Skip to main content

<ChartContainer>

component@pond-ts/chartssource

The top of the chart layout (react-timeseries-charts-style). Owns the shared x geometry: it collects each row's per-slot gutter widths, reserves each slot's max across rows (so the innermost axis aligns column-by-column and every row's plot left-aligns), and from the slot sums derives plotWidth and the shared time xScale. It renders its rows (separated by rowGap) then one TimeAxis at the bottom, aligned under the plots. Y axes are per-row (<YAxis>).

A width in pixels renders straight through; 'auto' (or an omitted width) measures the available width first — see ChartContainerProps.width and AutoWidthContainer.

Props

axisPanZoomboolean | 'none' | 'x' | 'y' | 'xy'

Which axis strips take gestures — the opt-in for grabbing an axis, and 'none' by default so no existing chart changes behaviour:

  • 'none' (or false, the default) — the strips are inert chrome.
  • 'x' — the <XAxis> strip pans on drag and zooms on wheel, exactly as the plot's own gestures do (same maths, same sign, same bounds / minDuration fences). Double-click returns to the declared range. A category axis has no continuous domain and stays inert.
  • 'y' — each <YAxis> gutter zooms that one axis on drag or wheel, double-click releasing it back to its fit. Report it to a scale UI with YAxisProps.onBoundsChange.
  • 'xy' (or true) — both.

Deliberately independent of panZoom, in both directions. A chart can scale its y axes without letting the plot capture vertical drags (which would fight a selection sweep), and an interactive plot does not hand its axes gestures nobody asked for. The one thing they share is the view itself: the x strip moves the same range the plot's pan does, and reports through onTimeRangeChange the same way.

The pairing to reach for on a time-series chart is panZoom="panZoom" axisPanZoom="xy" — drag the plot to pan, drag the x strip to pan, wheel either to zoom, and drag a y gutter to override its fit.

bandAlign'center' | 'end' | 'start'

Where the capped category block sits in the plot when maxBandWidth binds. Default 'start' — pack from the left, leaving the far side empty. 'center' and 'end' place it otherwise.

A no-op without maxBandWidth, or when the cap doesn't bind: the block fills the plot and there is no slack to place. (There is deliberately no 'fill' member — "fill" is what omitting maxBandWidth means, and a fill value alongside a pitch cap would be a contradiction rather than a choice.)

boundsreadonly [number, number]

Outer pan/zoom extent[min, max] (same units as range) the view can never move outside. Panning into an edge stops there (the window keeps its span); zooming out is capped at this width, so bounds is the zoom-out ceiling that pairs with the minDuration zoom-in floor. Omit for no limit (pan/zoom is unbounded). Constrains gestures (and any range routed through the container); seed range within it. On a trading-time axis the clamp is in wall-clock ms (a sensible outer limit; the per-session pan/zoom math already holds the trading span at each calendar edge).

calendarTradingCalendarLike

The high-level sugar for discontinuities: a trading calendar the container derives the provider from itself (calendar.discontinuities({ spacing })), so you don't wire the low-level prop. A @pond-ts/financial TradingCalendar satisfies the structural TradingCalendarLike shape (charts never imports that package). Combine with spacing. For the full option matrix (a bar period, a scoped range) use the low-level discontinuities prop instead. Only affects a time axis.

The provider is memoized on (calendar, spacing), so pass a stable calendar reference (build it once, not inline in JSX).

categoriesreadonly string[]

Make the x axis ordinal at the container level — one equal-width slot per name, in this order ([PND-IGNITECAT]).

Note this is a list of names (string[]), unlike <BarChart categories>, which takes { label, value } data. The container names the slots; the bar layer fills them.

Until this prop, the band scale was reachable only through a layer: <BarChart categories> (and a horizontal heat map) reported xKind: 'category', every other layer reported 'time' or 'value', and the container throws on a mix — so a line, a point or an envelope over categorical bars was not expressible at all. The workaround was to key every layer to a synthetic integer index and hand-supply the tick labels, which forfeits two features the ordinal axis already implements: <XAxis> label thinning (gated on a category axis with no custom ticks) and the maxBandWidth / bandAlign slot packing.

Declaring the categories here inverts that. The container owns the ordinal domain, so any value-keyed layer can live on it and both of those features keep working.

Keying a layer to the slots

The band scale's domain is numeric — slot i occupies [i, i+1], so its centre is i + 0.5. Key a ValueSeries there and the mark lands on the slot centre, which is also where <XAxis> puts the tick:

const line = ValueSeries.from(
  tickers.map((t, i) => ({ x: i + 0.5, target: t.target })),
  { key: 'x' },
);

<ChartContainer categories={tickers.map((t) => t.label)} width="auto">
  <ChartRow height={220}>
    <YAxis id="v" />
    <Layers>
      <BarChart categories={bars} />
      <LineChart series={line} column="target" axis="v" />
    </Layers>
  </ChartRow>
</ChartContainer>;

What still errors

  • A time-keyed layer. A TimeSeries has no slot to sit in; mixing one into an ordinal container is a hard error, as a mixed x-kind always was.
  • A category layer that disagrees. <BarChart categories> in an ordinal container must name the same list in the same order — this prop is authoritative, and a silent mismatch would draw bars under the wrong labels.

What declaring it costs

Setting this makes the x axis ordinal, and two container capabilities are defined only on a continuous x. Both were already true of an inferred category axis; they are stated here because this prop lets you opt a previously-continuous container into them:

  • x pan and zoom stop. panZoom keeps working on y (panY / zoomY), but the x half is gated off — sliding between named slots is not a gesture the axis has a meaning for.
  • range stops applying to x. The domain is [0, n], derived from the slot count, so an x range is a no-op rather than an error. Show a subset by passing fewer categories.
  • xScale stops applying. 'log' / 'symlog' describe how a continuous x spaces its values; ordinal slots are evenly spaced by definition, so the kind is ignored (as it already is on a time axis).

The hazard this cannot catch

A value-keyed layer is taken at its word. Anything reporting 'value' is read as slot coordinates, so a layer whose x means something else will draw — in the wrong place, silently. The sharpest instance is a horizontal categorical <BarChart>: its x is bar length, not a coordinate, so on an ordinal x it plots magnitudes as slot positions. Don't mix one into an ordinal container.

This is documented rather than enforced, and the reason is worth keeping: a guard was written for it, testing binCategories. That is the generic "my y is ordinal" channel, and a vertical heat map sets it too — so the guard rejected a ValueSeries grid with named columns on x, which is a wanted layout (ordinal rows plus ordinal columns is just a 2-D grid), with an error naming a <BarChart> that wasn't in the tree. Nothing on a layer source distinguishes "my x is a coordinate" from "my x is a magnitude", so there is no contradiction to detect — and a flag invented to carry it would buy a false sense of coverage while every other misuse stayed silent.

One more edge

categories={[]} is an ordinal axis with no slots yet, not a fallback to time. That is the useful reading for a loading state: the kind stays put when the data arrives, instead of flipping and rebuilding every scale mid-session.

Omit for the inferred behaviour: a container with only category layers still resolves its slots from them, exactly as before.

childrenReactNode
creatingAnnotationKindAnnotationKindtype@pond-ts/charts'region' | 'marker' | 'baseline'

The kind of an annotation, and of a creation tool.

| null

The armed annotation creation tool (the consumer's toolbar sets it), or null/omitted for idle. When set, the plot captures a create gesture — a preview tracks the pointer, and on release onCreate fires. The consumer then adds the mark, disarms (back to null), and selects it (spring-loaded); keep it set to place several. Requires editAnnotations.

cursor="crosshair" reticle y snapping. Default true — the crosshair centres on the nearest data point (the horizontal line snaps to that sample's value). false — the horizontal line + centre follow the pointer y freely, the value read as yScale.invert(pointerY). Either way the vertical line snaps its x to the data grid (so the time readout is clean), and both draw a full-height dashed vertical + full-width dashed horizontal line.

cursorCursorModeCursorModetype@pond-ts/charts'none' | 'line' | 'point' | 'inline' | 'flag' | 'crosshair' | 'region'

The in-chart cursor presentation for a row (the synced vertical line is shared across rows). Exclusive modes — pick one:

In-chart cursor presentation — the default for all rows (a row may override via <ChartRow cursor>). Default 'line' — the synced vertical line, with values surfaced outside the chart via onTrackerChanged. 'point' / 'inline' / 'flag' add per-series marks; 'none' hides it. 'region' shades the bucket under the pointer (needs cursorSequence). See CursorMode.

cursorFormatCursorFormatCursorFormattype@pond-ts/chartsstring | (value: number, ctx: { defaultText: string; grain: TimeGrain | undefined }) => string

How to format the cursor / marker readout on the x axis (ChartContainerProps.cursorFormat) — time or value kind. Either:

The cursor / marker readout format — the crosshair x pill, marker axis indicators, and annotation auto-labels — independent of the tick labels on both axis kinds: it does not disqualify the dateStyle ladder (time), and it never moves the tick labels (value). It beats an explicit <XAxis format> for the readout only — pill precedence is cursorFormat → axis format → container — so terse ticks can pair with a precise readout (+2.0σ labels, +1.83σ pill).

Omitted ⇒ the axis's own formatter. On a time axis that default is grain-aware: the readout formats at the axis's granularity, so a day-or-coarser axis reads a date (never a time-of-day) and a sub-day axis reads date + clock — a daily bar at a foreign-tz midnight no longer renders as 02 AM. On a value axis it is the tick formatter (timeFormat-shaped, else the d3 default).

A d3 specifier string formats uniformly (time specifier on a time axis, number specifier on a value axis); a function (value, { grain, defaultText }) => string receives the axis's resolved coarse TimeGrain (undefined on a value axis) and the default readout text, so it can branch on the zoom level and pass defaultText through for grains it doesn't override (no re-deriving the grain from the range). See CursorFormat. This is the independent readout channel; timeFormat owns the labels. (A category axis reads names, and a transformed axis's pill speaks its derived unit — neither consults cursorFormat.)

cursorSequenceSequenceSequenceclasspond-ts

An unbounded fixed-step grid definition used for alignment or aggregation.

| BoundedSequenceBoundedSequenceclasspond-ts

A finite ordered list of Interval buckets.

The bucketing for cursor="region" — the interval highlighted under the pointer. A pond Sequence (duration or calendar-aware — Sequence.every('1d'), Sequence.calendar('month')) is realized over the current view; a BoundedSequence (e.g. a TradingCalendar's sessionSequence() / barSequence()) is used as-is, so the band can track whole sessions. Either way the band maps through xScale, so on a trading-time axis the closed part of the bucket collapses. Ignored unless cursor="region".

Time axis only. A bucket is a time interval, so the region cursor is gated to a time x-axis — on a value axis (a horizontal histogram, a value-keyed chart) it's a no-op (highlighting a value band on a horizontal histogram would be a different, y-oriented cursor).

Pass a stable reference. The buckets are memoized on this value + the view range; a Sequence/BoundedSequence rebuilt inline every render re-realizes the buckets on each pointer move (harmless for a coarse day/session sequence, wasteful for a fine one over a wide view) — hoist it or useMemo it.

cursorTimeboolean

Show the cursor's time atop the in-chart readout (when a row's cursor draws one). Default false. Formatted by timeFormat to match the time axis.

discontinuitiesDiscontinuityProviderDiscontinuityProviderinterface@pond-ts/charts{ boundaries?: unknown; clampDown: unknown; clampUp: unknown; copy: unknown; distance: unknown; offset: unknown }

The structural discontinuity-provider surface scaleTradingTime consumes to collapse closed-market time. Charts declares this shape itself and never imports @pond-ts/financial — a TradingCalendar.discontinuities() provider satisfies it structurally, so the packages stay decoupled (trading-calendar RFC §6.1). Domain values are epoch-milliseconds.

A trading-calendar discontinuity provider — closed-market time (weekends, holidays, overnight, lunch breaks) collapsed. Supply it to turn the shared x axis into a trading-time axis: gaps disappear and time stays proportional within each session. A @pond-ts/financial TradingCalendar.discontinuities() satisfies this structurally (charts never imports that package). The low-level primitive: pass calendar.discontinuities() (or a { spacing, period } variant) directly. Only affects a time axis (ignored on a value axis). Takes precedence over calendar if both are given.

Pass a stable reference. The scale (and container frame) rebuild when this prop's identity changes, so memoize it — const disc = useMemo(() => calendar.discontinuities(), [calendar]) — rather than calling .discontinuities() inline in JSX, which would rebuild every render.

Accepts an explicit undefined (a cond ? provider : undefined toggle under exactOptionalPropertyTypes), same as omitting it.

Enter annotation-edit mode: suppresses the data cursor and makes editable annotations (those given an onChange) interactive — hovering one reveals its handles + highlights it, and dragging edits it. Default false. Pairs with each annotation's onChange (where the edit goes); this is the mode that turns the affordances on and gets the cursor out of the way.

gridboolean

Draw the reference gridlines behind the data. On a calendar (time) axis the verticals are the full grain populations — every day / month / aligned clock instant in view, each grain fading by its calendar density — not just the labelled ticks (the labels decorate the grid; they don't define it). Default true. Set false for a clean backdrop — session dividers (below) are independent and still draw when enabled.

heightnumber | 'auto'

Total height in CSS pixels, or 'auto' to fill the available height — the container-owned vertical layout ([PND-HEIGHT]). Omitted means the classic mode: rows declare pixel heights and the container's height is their sum.

With a height, the container renders as a flex column — the rows block flexes, the x-axis strip keeps its natural height at the bottom — and <ChartRow flex> rows (a bare <ChartRow> is flex={1}) divide whatever the browser says is left. That "whatever the browser says" is the point: the axis strip's height depends on its label, the theme's font size, whether the tick ladder is showing its calendar band row at the current grain, and how many marker pills stack — it is not a constant a caller could subtract, and every consumer who tried carried a wrong number (20, 24, and the recipe's 22 were all in the wild for one strip). CSS does the subtraction, so there is no number to know.

A single full-bleed chart is therefore zero arithmetic:

<ChartContainer width="auto" height="auto">
  <ChartRow>
    <YAxis id="v" />
    <Layers>…</Layers>
  </ChartRow>
</ChartContainer>

Fixed-height rows keep their pixels inside a managed container, and non-row children (a draggable splitter between two rows) take their natural space — so the resizable-panels shape becomes one flex row absorbing slack over one fixed row the drag resizes, with no reserved strip constant and no measuring hook.

'auto' measures with the same ResizeObserver as width="auto", gates the first paint until both needed dimensions exist, latches the last non-zero size while hidden, and — because a flex-column child's height defaults to its content — warns in dev when a measured dimension stays 0: the parent needs a definite height, or the deadlock is the default.

Cap the slot pitch on a category x axis, in CSS pixels ([PND-BANDPACK]). A band scale otherwise spreads its categories across the full plot width, so three categories in a 900px panel become three 300px bars and thirty become thirty 30px ones — the same chart in the same panel reading as two different charts depending on how many categories the data happened to return.

That is fine for a static chart with a known domain and wrong for a live one: when the category count moves over a session, bar width becomes a meaningless variable that moves on its own, and a reader can't compare the chart to what it looked like a minute ago or to the same chart on another screen. Capping the pitch keeps bar width constant and comparable, and the empty space left over is itself information — it shows the set is small.

Omitted ⇒ slots fill the plot (unchanged). When n × maxBandWidth exceeds the plot, the cap can't bind and the slots fill as before, so this degrades correctly as categories accumulate. Use bandAlign to say where the capped block sits.

This caps the slot, not the bar. <BarChart gap> still insets the bar within its slot, and the two compose — one knob for pitch, one for ink, neither doing the other's job. (Inverting gap against a measured plot width was the workaround this replaces for the width half; the packing half had no workaround at all.)

Vertical / x-axis categories only. A orientation="horizontal" categorical chart puts its categories on the y axis as unit slots, which is a different mechanism and is not capped by this.

Zoom-in floor — the minimum visible duration in ms. Default 1.

onCreate(spec: CreateSpecCreateSpectype@pond-ts/charts{ at: number; kind: 'marker' } | { axis: string; kind: 'baseline'; value: number } | { from: number; kind: 'region'; to: number }

What a completed create gesture reports to ContainerFrame.onCreate — the new mark's kind + position in axis units (+ the y-axis id for a baseline). (Which row a mark lands on is the consumer's call for now; multi-row routing is a follow-up.)

) => void

Fired when a create gesture completes (on release). See CreateSpec.

onDrawStats(frame: DrawStatsFrameDrawStatsFrameinterface@pond-ts/charts{ layers: readonly LayerDrawInfo[]; rowKey: symbol; totalDrawMs: number }

The per-repaint draw-stats frame handed to ContainerProps.onDrawStats. Fires once per row-canvas repaint (rows repaint independently, so a multi-row container fires one frame per row that painted), carrying that row's layers newest-drawn. The seam the dashboard A/B asked for (2026-07-21): read drawnCount vs sourceCount to see whether M4 engaged, an…

) => void

Draw-cost + decimation observability. Fires once per row-canvas repaint with a DrawStatsFrame — one LayerDrawInfo per layer in that row carrying its as, drawMs, and (for a decimating layer) sourceCount / drawnCount / decimated. Compare drawnCount to sourceCount to see whether M4 engaged; read drawMs for per-layer render cost. Omitted ⇒ no measurement — the render loop skips per-layer timing entirely, so this is zero-overhead when unused. Keep the callback cheap (it runs inside the draw frame); route it to a ref/store rather than doing React state work per frame.

onEditAnnotation(id: string) => void

Fired when a mark is double-clicked — the request to edit just that one (set its editing prop in response). Single click selects (inspect); double click edits. Works in any mode.

onHoverAnnotation(id: string | null) => void

Fired when the pointer enters an annotation (its id) or leaves it (null). Mirror it to a controlled hovered prop on each mark to sync hover both ways (e.g. a legend row ↔ the mark). Fires in any mode.

onRegionSelect(range: readonly [number, number]) => void

Makes the region cursor draggable: drag across the plot and the band extends bucket by bucket (snapping to cursorSequence points); on release this fires once with the selected [lo, hi] span, and the cursor reverts to the single-bucket highlight (it does not keep the range). Typical use — zoom the view to the returned span (the container doesn't zoom itself; that's the consumer's call), or map it onto a data subscription's range params.

The span is a neutral numeric pair in axis units — epoch ms on a time axis, the axis value (strike, distance, …) on a value axis — mirroring the polymorphic range input. A time consumer that wants a TimeRange builds one from the pair.

With no cursorSequence the region cursor is the degenerate case — it renders as a line on hover and the drag is freeform (raw [lo, hi], no bucket snapping); the same callback fires on release. Bucket snapping needs a cursorSequence, which is time-axis only (a time interval over a value domain is meaningless), so a value axis is always freeform. No-op unless cursor="region" on a time or value x-axis (a category axis is excluded — an ordinal-slot select is a different gesture).

onSelectAnnotation(id: string | null) => void

Fired when an annotation is clicked (its id), the plot is clicked empty (null), or a region is double-clicked (the shortcut into edit). The consumer holds the selected id and sets each mark's selected={id === sel}.

onTimeRangeChange(range: [number, number]) => void

Controlled view range — fires on pan/zoom with the new [start, end]. Wire it back to range for a controlled chart; omit for uncontrolled (the container holds the view internally). Uncontrolled + panZoom seeds the internal view from range whenever it isn't actively holding one — so toggling panZoom on, or a controlled→uncontrolled switch, starts from the current range, not the mount-time one. Once uncontrolled, later range changes are ignored so they can't fight the user's pan. To drive the range externally — or to follow a live sliding window — use controlled mode (this callback).

onTrackerChanged(info: TrackerInfoTrackerInfointerface@pond-ts/charts{ time: number; values: readonly TrackerSample[] }

The hover snapshot handed to onTrackerChanged — the cursor time + every series' value there, so a consumer can render the readout outside the chart.

| null) => void

Fires on pointer move with the hovered time + every series' value there (so you can render a readout outside the chart), and null on leave.

originnumber | 'data'

Label the x axis as offsets from a zero point instead of absolute values — the duration (elapsed-time) axis. A time axis reads 00:00 00:05 00:10 where it read 10:35 10:40 10:45; a value axis reads distance-from-the-origin (0 500 1000) where it read absolute distance.

  • 'data' — the start of the data (the union of the layers' x extents), so the labels are "since the beginning of the series" and stay put as you pan.
  • a number — an explicit zero point in axis units: a race gun, a trigger instant, a lap marker. Ticks before it read negative (-00:05 — the T-minus case).

Ticks are placed at round durations measured from the origin, not at the wall-clock boundaries the calendar ladder would pick — that's the difference between 00:00 00:05 00:10 and 00:01:43 00:06:43. Gridlines follow them, and so does the cursor pill (one grain finer, as ever: 00:05:12).

This is a labelling mode, not a data transform: range, an annotation's at, an onRegionSelect span, trackerPosition are all still absolute axis units. Ignored on a category axis. An explicit timeFormat / <XAxis format> still wins — on a time axis a d3 time specifier can only describe an instant, so it labels the underlying wall clock (the lever for stacking a wall-clock strip under a duration strip, on shared ticks); on a value axis a number specifier formats the offset. On a trading-calendar axis the durations are wall-clock, so ticks spanning a collapsed session gap sit unevenly — elapsed trading time is not implemented.

panZoomboolean | 'none' | 'pan' | 'panZoom' | 'panZoomX' | 'panZoomY' | 'panZoomXY'

Which pan/zoom gestures the plot captures:

  • 'none' (or false, the default) — neither; the plot doesn't capture drag or scroll.
  • 'pan' — drag to pan the time range, no wheel-zoom (scroll still scrolls the page).
  • 'panZoom' (or true) — drag to pan and wheel to zoom around the cursor.

The boolean form is the back-compat shorthand (true'panZoom', false'none'). Bound the reachable range with bounds (zoom-out / pan extent) and minDuration (zoom-in floor).

This prop is about the plot only. Gestures on the axis strips are a separate opt-in — see axisPanZoom — so turning pan/zoom on here does not silently make the axes grabbable.

rangeTimeRangeTimeRangeclasspond-ts

A time interval event key with inclusive start and end boundaries. Example: new TimeRange({ start, end }).

| readonly [number, number]

The shared x domain [begin, end] — a tuple, or a TimeRange (series.timeRange()). Units follow the data: epoch-ms for a time axis, the value units (distance, …) for a value axis. Omit to auto-fit to the rows' extents. The axis kind is never taken from here — it's inferred from the data — so a tuple stays a time domain on a time chart.

Which modifier a region-drag needs — set 'shift' when you also enable pan (panZoom="pan" or "panZoom") and want plain drag to pan, shift-drag to select. It's only enforced while pan is enabled (with pan off there's no gesture conflict, so shift is optional — either drag selects). Omitted ⇒ a region-drag preempts pan (drag always selects; document that precedence for users). Wheel-zoom is unaffected in every case.

rowGapnumber

Vertical space between rows in CSS pixels (not under the axis). Default 0.

sessionDividers'none' | 'labeled' | 'all'

Where to draw session dividers — the solid verticals at a trading calendar's collapse seams: boundaries that removed (closed-market) time actually precedes, not every session roll (only with a discontinuities / calendar provider). On a real exchange calendar every session open follows an overnight gap, so seams = session opens; a calendar of contiguous full-day sessions has seams only where days were excised (the weekend). Default 'none' — the hierarchical grid already marks the calendar structure at every zoom, so dividers are opt-in emphasis: 'all' draws one at every seam in view (the TradingView session-separator look, crowding lines fading out), 'labeled' only at seams the axis also labels. Dividers are independent of grid'all' + grid={false} is the separators-on-a-clean-plot look.

showAxisboolean

Auto-render the shared x axis under the rows. Default true. Set false for a bare plot (a sparkline), or when you place your own <XAxis> child (e.g. with a label, custom ticks, or on side="top"). Named showAxis (not axis) to avoid clashing with a layer's axis prop, which picks which <YAxis> it scales against — a different axis entirely.

snapboolean

Snap mode (the toolbar's "Snap"). Default true. When on, a dragged mark snaps to other marks' guidelines (their x-positions, within a few px) so spans align; off = free placement. (Snapping to the nearest data sample is not implemented — guideline alignment only.)

spacing'proportional' | 'uniform'

The trading axis metric, when a calendar is supplied (trading-calendar RFC Q7). 'proportional' (default) keeps time proportional within and across sessions — a half-day is half as wide. 'uniform' gives every session equal width (the TradingView ordinal look). Ignored without calendar (a low-level discontinuities provider already carries its own metric).

themeChartThemeChartThemeinterface@pond-ts/charts{ annotation?: { color: string; dash?: readonly number[]; depth: readonly [number, number, number]; fillOpacity: number; roles?: {}; spanEdge?: string }; area: { default: AreaStyle }; axis: { band?: { divider?: string; fill: string; label?:…

Visual styling for a chart, threaded through ChartContainer via context. Canvas has no CSS cascade into drawn pixels, so this typed object is the single styling channel for the drawn layers; DOM chrome (axis labels) derives from it too.

Visual theme for all rows; defaults to defaultTheme.

timeFormatAxisFormatAxisFormattype@pond-ts/chartsstring | (value: number) => string

How to format an axis's values — a d3 [format specifier] (https://github.com/d3/d3-format#locale_format) string, or a custom (value) => string function. Omit for the scale's d3 default.

Time-axis label formatting — a d3 time specifier string (e.g. '%H:%M') or a (epochMs) => string function (AxisFormat). A custom format owns the labels, so it opts the axis out of the dateStyle ladder (flat / stacked) by design. Omitted ⇒ the flat/stacked date style. To shape only the cursor readout while keeping a date style, use cursorFormat instead. (For back-compat this also shapes the readout when cursorFormat is absent.)

trackerPositionnumber | null

Controlled tracker position (epoch ms) — where to show the synced crosshair when this chart isn't the one under the pointer. A live local hover always wins over it, so this is a followed position, not a hard pin: supply it to drive the cursor from outside (a scrubber, a playback head, or — the main use — cross-chart sync). Maps through this chart's own xScale, so it lands at the right pixel even under a different zoom.

Multi-chart sync falls out of this plus onTrackerChanged: give every <ChartContainer> the same trackerPosition={sharedTime} and set sharedTime from each one's onTrackerChanged. The hovered chart favors its own pointer (and reports the time out); the others follow. Clear sharedTime to null on the group's onPointerLeave so the crosshair lifts when the pointer leaves every chart. (See the "Synced cursors across charts" story / dashboard guide.)

Omit or pass null (equivalent) for no controlled position — a hovered chart still tracks its pointer, a non-hovered one shows nothing. To force a chart to never show a cursor, use cursor="none", not trackerPosition. See onTrackerChanged.

widthnumber | 'auto'

Total width in CSS pixels (plot + axis gutters), or 'auto' to fill the available width — which is also what an omitted width means.

The canvas renderer needs real pixels to lay out ticks and slots before it draws, so 'auto' does not hand the canvas a percentage: the container renders a plain full-width box, measures it with a ResizeObserver, and mounts the chart at that pixel width, re-rendering as the box resizes. Nothing paints until a real width exists — a zero-width chart is degenerate, not empty — so an auto container renders an empty box for the first layout pass.

This is the responsive-width recipe moved inside the library, and it closes that recipe's sharpest edge by construction: the measured box is one the library owns, so it can never be the caller's padded or bordered box (whose border-box width overflows the chart by exactly the padding). Style your own wrapper around the container as freely as you like.

The parent needs a definite width. 'auto' measures a width: 100% box, so a parent whose own width comes from its content — a float, an inline-block, a grid auto track, a flex child without min-width: 0 — measures 0, and the chart is the content that would have given it a width. That is a standing deadlock, not a slow start: the chart stays blank with no error. Give the parent a width, a flex basis, or min-width: 0, or pass a number.

A container hidden by an ancestor's display: none is fine — it keeps the last width it measured and stays mounted, so a tab switch does not discard pan/zoom position, selection or hover.

Pass a number whenever the width is already known — a fixed-size panel, a print layout, a test. It skips the measure pass and paints on the first render.

xScale'linear' | 'log' | 'symlog'

How the value x axis maps data to pixels. Omitted ⇒ 'linear'.

'log' for a quantity spanning orders of magnitude — a power–duration curve is watts against 1s · 5s · 1m · 20m · 3h, which is unreadable on a linear x. 'symlog' is the same but linear through zero, for data that crosses it.

Ignored on a time or category axis, which have their own spacing rules.

Why this lives on the container and not on <XAxis scale>, which is where <YAxis scale>'s mirror would put it: the rows are stacked vertically, so a given pixel column has to mean the same x in every one of them — otherwise the stack doesn't line up and a cursor at one pixel reads a different value per row. The x scale and its domain are therefore shared by requirement, not by convention, and a shared thing is declared once by the thing that contains them. <YAxis> is the opposite for the same reason: each row carries its own quantity, so its scale must be per-row, which is why min / max / pad / scale belong to the axis.

That gives the test for what belongs here rather than on <XAxis>: does it define the mapping or the domain? origin, spacing, calendar and the viewport props all do, and sit here for the same reason. Every <XAxis> prop (format, label, side, ticks, align, …) does not — they style a scale the axis only draws, and putting a scale-defining prop among them would mean a registration round-trip to the component that already owns it.

(Had <XAxis> been mandatory in the declaration, the props would more naturally have lived there and x would mirror y — see [PND-XLOG].)