@pond-ts/charts 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.50.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.51.0...HEAD
12
+ [0.51.0]: https://github.com/pond-ts/pond/compare/v0.50.0...v0.51.0
12
13
  [0.50.0]: https://github.com/pond-ts/pond/compare/v0.49.0...v0.50.0
13
14
  [0.49.0]: https://github.com/pond-ts/pond/compare/v0.48.1...v0.49.0
14
15
  [0.48.1]: https://github.com/pond-ts/pond/compare/v0.48.0...v0.48.1
@@ -50,6 +51,117 @@ and type-level changes; patch bumps are strictly additive.
50
51
 
51
52
  ## [Unreleased]
52
53
 
54
+ ## [0.51.0] — 2026-07-22
55
+
56
+ ### Changed
57
+
58
+ - **charts:** **`trackerPosition` is now a _followed_ position, not a hard pin —
59
+ enabling cross-chart cursor sync.** A live local hover wins over
60
+ `trackerPosition`, so the chart under the pointer shows its own cursor while
61
+ any chart without a local pointer follows the controlled time (mapped through
62
+ its own `xScale`, so it's correct across different zooms). This makes
63
+ **multi-chart dashboard cursor sync** fall out of the plain props: give every
64
+ `<ChartContainer>` the same `trackerPosition={sharedTime}` and set `sharedTime`
65
+ from each one's `onTrackerChanged` (clear it to `null` on the group's
66
+ `onPointerLeave`) — no "which chart is active" bookkeeping. **Behaviour
67
+ change:** previously a numeric `trackerPosition` overrode local hover, and
68
+ `trackerPosition={null}` force-hid the cursor; now `null` and `undefined` are
69
+ equivalent ("no controlled position") and a hovered chart always tracks its
70
+ pointer. To force a chart to never show a cursor, use `cursor="none"`. See the
71
+ "Synced cursors across charts" story. No type change (`number | null`).
72
+ - **charts:** **Line / area draw is ~3× faster on stroke-bound frames**
73
+ (PND-AFFINE / PND-GRADX; 2026-07 external-bench profile). When the curve is
74
+ linear and both scales are affine (every y axis is `scaleLinear`; x is
75
+ `scaleLinear` / `scaleTime` / the gap-free default time axis), `drawLine` and
76
+ `drawArea` now map points with an inline `k·v + b` over the typed arrays
77
+ instead of a per-point d3-scale closure + d3-shape generator — a **visually
78
+ identical** draw (guarded by the decimation pixel-identity and per-layer
79
+ visual-regression e2e). A real-gap trading-time axis, or a non-linear curve,
80
+ transparently keeps the exact d3 path. Measured on a JS-only micro-bench
81
+ (`scripts/perf-affine.mjs`): line 1M 60.4 → 19.0 ms (3.2×), area 1M 132 →
82
+ 38 ms (3.5×). Separately, the area fill gradient's full-series value extent is
83
+ now memoized per column buffer, so a y-zoom / pan repaint no longer re-walks
84
+ the whole series to find the gradient span. No API change.
85
+ - **charts:** **A y-zoom / y-autorange repaint no longer re-decimates line /
86
+ area layers** (PND-DECKEY; same 2026-07 profile, finding 3). The M4
87
+ decimation output is a pure function of the source data, x-domain, device
88
+ width, threshold, and session breaks — it never reads the y-scale — so it is
89
+ identical across every y-only frame. `drawLine` / `drawArea` now memoize the
90
+ cull+decimate result per source series (one entry, keyed on the x-scale
91
+ object + width + threshold + breaks), so a y-zoom / live y-autorange frame
92
+ reuses the prior polyline instead of re-binning O(N) points; a pan / x-zoom
93
+ mints a fresh x-scale and correctly recomputes. Measured
94
+ (`scripts/perf-deckey.mjs`): the ~5 ms/frame decimation walk at 1M points is
95
+ eliminated on every y-only frame. No API change.
96
+
97
+ ### Added
98
+
99
+ - **charts:** **`<BarChart decimate>` — dense column charts now decimate**
100
+ (PND-MARKDEC; 2026-07 profile, finding 4 — "column dead by 5M"). **Default
101
+ `true`**: once the visible **single-series** bars are denser than ~2 per device
102
+ pixel (each slot < ~1px), they're drawn as one per-column **envelope** rect —
103
+ the exact painted union `[min(value, baseline), max(value, baseline)]` of each
104
+ pixel column — instead of every bar, so a 100k–5M-bar column chart stays
105
+ interactive. **Visually lossless** at that density (a perf knob, not a style);
106
+ interaction still reads the source bars (`barAt`), and the per-bar
107
+ selection/hover highlight is suppressed only when decimated (a <1px bar's ring
108
+ isn't visible anyway). `decimate={false}` draws every bar; `{ threshold }`
109
+ tunes the samples-per-pixel factor. No-op for a stacked / multi-group
110
+ histogram (the low-count categorical path). `drawBars` now returns
111
+ `LayerDrawStats` (visible via `onDrawStats`). Measured
112
+ (`scripts/perf-markdec.mjs`, JS-only): the bar draw at 5M points drops
113
+ 485 → 26 ms (18.9×), 100k drops 9.7 → 0.9 ms (10.7×) — with the larger
114
+ rasterization win on top, browser-side.
115
+ - **charts:** **`panZoom` is now a three-way mode + a `bounds` extent.**
116
+ `<ChartContainer panZoom>` takes `'none'` / `'pan'` / `'panZoom'` (drag-only
117
+ vs. drag+wheel), with the old boolean kept as shorthand (`true` ⇒ `'panZoom'`,
118
+ `false` ⇒ `'none'`) — so existing charts are unchanged. A new `bounds`
119
+ (`[min, max]`) prop fences pan/zoom to an **outer** extent (panning into an
120
+ edge stops there keeping its span; zoom-out is capped at the whole span), the
121
+ companion
122
+ to the existing `minDuration` zoom-in floor — together they pin the reachable
123
+ window between an inner and outer bound. On a trading-time axis `bounds`
124
+ clamps in wall-clock ms. Purely additive; no type narrowing.
125
+ - **charts:** **Draw-cost + decimation observability** — `<ChartContainer
126
+ onDrawStats>` (PND-DECOBS; dashboard A/B friction, 2026-07-21). Fires a
127
+ `DrawStatsFrame` once per row-canvas repaint (keyed by an opaque `rowKey` for
128
+ multi-row attribution), one `LayerDrawInfo` per layer carrying its `as`,
129
+ measured `drawMs`, and — for a decimating layer (line / area / band / candle /
130
+ box) — `sourceCount` / `drawnCount` / `decimated`.
131
+ Compare `drawnCount` to `sourceCount` to see whether M4 engaged; read `drawMs`
132
+ for per-layer render cost. **Zero-overhead when unused** — the render loop
133
+ skips per-layer timing entirely unless a consumer subscribes. New exports:
134
+ `DrawStatsFrame`, `LayerDrawInfo`.
135
+
136
+ ### Fixed
137
+
138
+ - **charts:** hovering no longer repaints the row data canvas on every cursor
139
+ mousemove. The container frame minted a fresh `timeRange` array identity per
140
+ rebuild (and the frame rebuilds per cursor move), which the Layers draw
141
+ callback — depending on `container.timeRange` — read as a domain change:
142
+ each hover frame re-fired the canvas draw effect, including per-layer M4
143
+ re-decimation (measured 105 repaints per 122 mousemove events; with
144
+ `decimate` off, hover fell to ~10 fps). The tuple is now identity-stable on
145
+ its endpoints, restoring the SVG-overlay cursor contract (0 repaints, full
146
+ frame rate while hovering). Found running uPlot's bench protocol against
147
+ pond-charts; guarded by a new hover-sweep perf invariant in
148
+ `e2e/perf-invariants.spec.ts`.
149
+ - **charts:** hovering no longer re-renders cursor-independent components
150
+ (both `YAxis`, `Bar`/`Box`). The cursor position was a `ContainerFrame`
151
+ field, so every mousemove re-identified the whole (~50-field) frame and
152
+ re-rendered **all** its context consumers — even ones that never read the
153
+ cursor. The per-move cursor state (`cursorX`/`cursorY`/`cursorRowKey`) now
154
+ lives in a dedicated `CursorContext`; the frame stays identity-stable across
155
+ a hover, so only the genuine cursor consumers (the `Layers` overlay,
156
+ `XAxis` crosshair pill, `Legend` values) re-render. Measured: 4 → 2 React
157
+ commits per mousemove, ~25% less hover script time on the uPlot-bench
158
+ workload (the win scales with axis/row count). No API change — the split
159
+ types are internal (`PND-HOVCTX`, follow-up to the repaint fix above).
160
+ - **charts:** corrected the `@pond-ts/charts` package-header doc comment, which
161
+ described a "chunked Path2D cache" render stage that was explored and
162
+ **deferred**, never built (it doesn't help the pan case, which re-decimates
163
+ every frame). The stale comment had misled a consumer's perf investigation.
164
+
53
165
  ## [0.50.0] — 2026-07-21
54
166
 
55
167
  ### Added
package/dist/AreaChart.js CHANGED
@@ -59,6 +59,7 @@ export function AreaChart({ series, column, as: semantic, axis, baseline, curve,
59
59
  const gapConnectorOpacity = container.theme.gap?.connectorOpacity ?? DEFAULT_GAP_CONNECTOR_OPACITY;
60
60
  const entry = useMemo(() => ({
61
61
  layer: {
62
+ as: semantic,
62
63
  yExtent: () => areaExtent(cs, baseline),
63
64
  // The container infers the shared x scale's kind from its layers — a
64
65
  // ValueSeries plots on a value axis, a TimeSeries on time.
package/dist/BandChart.js CHANGED
@@ -49,6 +49,7 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, dec
49
49
  }, [semantic]);
50
50
  const entry = useMemo(() => ({
51
51
  layer: {
52
+ as: semantic,
52
53
  yExtent: () => bandExtent(bs),
53
54
  // The container infers the shared x scale's kind from its layers — a
54
55
  // ValueSeries plots on a value axis, a TimeSeries on time.
@@ -2,6 +2,7 @@ import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
3
  import { type BinRecord, type CategoryDatum } from './data.js';
4
4
  import { type Orientation } from './bars.js';
5
+ import type { DecimateOption } from './decimate.js';
5
6
  export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
6
7
  /**
7
8
  * The source series. Provide **exactly one** of `series` or `bins`.
@@ -120,6 +121,18 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
120
121
  * would invert collapses to the style's `minWidth`.
121
122
  */
122
123
  gap?: number;
124
+ /**
125
+ * **M4 column decimation** (charts decimator wave). **Omitted ⇒ `true`**: once
126
+ * the visible bars are denser than ~2 per device pixel (each slot < ~1px), the
127
+ * **single-series** bars are drawn as one per-column **envelope** rect — the
128
+ * exact painted union `[min, max]` of each pixel column's bars — instead of every
129
+ * bar, so a 100k-bar column chart stays interactive. It is **visually lossless**
130
+ * at that density (a perf knob, not a style); interaction still reads the source
131
+ * bars. Pass `false` to always draw every bar, or `{ threshold }` to tune the
132
+ * samples-per-pixel factor. **No-op for a stacked / multi-group histogram** (the
133
+ * categorical case is low-count; only the single-series path decimates).
134
+ */
135
+ decimate?: DecimateOption;
123
136
  /**
124
137
  * This layer's `<Legend>` row(s): `false` ⇒ none (opt out), a string ⇒ the
125
138
  * display name of a **one-row** layer. **Omitted ⇒** the single path (and a
@@ -180,5 +193,5 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
180
193
  * </Layers>
181
194
  * ```
182
195
  */
183
- export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation, ordinal, id, axis, gap, legend, index, }: BarChartProps<S, VS>): null;
196
+ export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation, ordinal, id, axis, gap, decimate, legend, index, }: BarChartProps<S, VS>): null;
184
197
  //# sourceMappingURL=BarChart.d.ts.map
package/dist/BarChart.js CHANGED
@@ -48,7 +48,7 @@ import { useSlotKey } from './use-slot-key.js';
48
48
  * </Layers>
49
49
  * ```
50
50
  */
51
- export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation = 'vertical', ordinal = false, id, axis, gap, legend, index = 0, }) {
51
+ export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation = 'vertical', ordinal = false, id, axis, gap, decimate = true, legend, index = 0, }) {
52
52
  const container = useContext(ContainerContext);
53
53
  if (container === null) {
54
54
  throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
@@ -226,6 +226,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
226
226
  const bs = shape.bs;
227
227
  return {
228
228
  layer: {
229
+ as: semantic,
229
230
  yExtent: () => barExtent(bs),
230
231
  xKind: binAxisKind,
231
232
  xExtent: () => bs.length === 0 ? null : [bs.begin[0], bs.end[bs.length - 1]],
@@ -266,7 +267,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
266
267
  };
267
268
  },
268
269
  }),
269
- draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover),
270
+ draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate),
270
271
  },
271
272
  axisId: axis,
272
273
  index,
@@ -279,6 +280,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
279
280
  const vertical = orientation === 'vertical';
280
281
  return {
281
282
  layer: {
283
+ as: semantic,
282
284
  // Horizontal puts the value on the shared x (always 'value'); vertical
283
285
  // keeps the bin axis on x. The bin axis on the *other* side is a linear
284
286
  // numeric scale either way (time ms label via <YAxis ticks>).
@@ -336,6 +338,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
336
338
  label,
337
339
  id,
338
340
  gapPx,
341
+ decimate,
339
342
  stackMinWidth,
340
343
  selection,
341
344
  hover,
package/dist/BoxPlot.js CHANGED
@@ -73,6 +73,7 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
73
73
  const hoveredKey = id !== undefined && hov !== null && hov.id === id ? hov.key : null;
74
74
  const entry = useMemo(() => ({
75
75
  layer: {
76
+ as: semantic,
76
77
  yExtent: () => boxExtent(bx),
77
78
  // A ValueSeries plots on a value axis, a TimeSeries on time; the container
78
79
  // infers the shared x kind from its layers.
@@ -49,6 +49,7 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
49
49
  const label = semantic ?? close;
50
50
  const entry = useMemo(() => ({
51
51
  layer: {
52
+ as: semantic,
52
53
  yExtent: () => ohlcExtent(ohlc),
53
54
  xKind: 'time',
54
55
  xExtent: () => ohlc.length === 0 ? null : [ohlc.x[0], ohlc.xEnd[ohlc.length - 1]],
@@ -2,7 +2,7 @@ import { type ReactNode } from 'react';
2
2
  import { type DiscontinuityProvider, type TradingCalendarLike } from './tradingTimeScale.js';
3
3
  import { Sequence, BoundedSequence } from 'pond-ts';
4
4
  import type { TimeRange } from 'pond-ts';
5
- import { type AnnotationKind, type CreateSpec, type CursorMode, type SelectInfo, type TrackerInfo } from './context.js';
5
+ import { type AnnotationKind, type CreateSpec, type CursorMode, type SelectInfo, type TrackerInfo, type DrawStatsFrame } from './context.js';
6
6
  import { type AxisFormat, type CursorFormat } from './format.js';
7
7
  import { type ChartTheme } from './theme.js';
8
8
  export interface ChartContainerProps {
@@ -94,9 +94,25 @@ export interface ChartContainerProps {
94
94
  */
95
95
  showAxis?: boolean;
96
96
  /**
97
- * Controlled tracker position (epoch ms) — pins the synced crosshair across
98
- * rows. Omit for uncontrolled (the chart tracks the pointer itself); pass
99
- * `null` to force it hidden. See {@link onTrackerChanged}.
97
+ * Controlled tracker position (epoch ms) — where to show the synced crosshair
98
+ * **when this chart isn't the one under the pointer**. A live local hover
99
+ * always wins over it, so this is a *followed* position, not a hard pin:
100
+ * supply it to drive the cursor from outside (a scrubber, a playback head, or
101
+ * — the main use — **cross-chart sync**). Maps through this chart's own
102
+ * `xScale`, so it lands at the right pixel even under a different zoom.
103
+ *
104
+ * **Multi-chart sync** falls out of this plus {@link onTrackerChanged}: give
105
+ * every `<ChartContainer>` the same `trackerPosition={sharedTime}` and set
106
+ * `sharedTime` from each one's `onTrackerChanged`. The hovered chart favors its
107
+ * own pointer (and reports the time out); the others follow. Clear `sharedTime`
108
+ * to `null` on the group's `onPointerLeave` so the crosshair lifts when the
109
+ * pointer leaves every chart. (See the "Synced cursors across charts" story /
110
+ * dashboard guide.)
111
+ *
112
+ * **Omit or pass `null`** (equivalent) for no controlled position — a hovered
113
+ * chart still tracks its pointer, a non-hovered one shows nothing. To force a
114
+ * chart to *never* show a cursor, use `cursor="none"`, not `trackerPosition`.
115
+ * See {@link onTrackerChanged}.
100
116
  */
101
117
  trackerPosition?: number | null;
102
118
  /**
@@ -154,9 +170,10 @@ export interface ChartContainerProps {
154
170
  onRegionSelect?: (range: readonly [number, number]) => void;
155
171
  /**
156
172
  * Which modifier a region-drag needs — set `'shift'` when you also enable
157
- * `panZoom` and want **plain drag to pan, shift-drag to select**. It's only
158
- * enforced while `panZoom` is on (with pan off there's no gesture conflict, so
159
- * shift is optional — either drag selects). **Omitted** ⇒ a region-drag
173
+ * **pan** (`panZoom="pan"` or `"panZoom"`) and want **plain drag to pan,
174
+ * shift-drag to select**. It's only enforced while pan is enabled (with pan
175
+ * off there's no gesture conflict, so shift is optional — either drag
176
+ * selects). **Omitted** ⇒ a region-drag
160
177
  * **preempts** pan (drag always selects; document that precedence for users).
161
178
  * Wheel-zoom is unaffected in every case.
162
179
  */
@@ -166,6 +183,17 @@ export interface ChartContainerProps {
166
183
  * you can render a readout outside the chart), and `null` on leave.
167
184
  */
168
185
  onTrackerChanged?: (info: TrackerInfo | null) => void;
186
+ /**
187
+ * Draw-cost + decimation observability. Fires **once per row-canvas repaint**
188
+ * with a {@link DrawStatsFrame} — one {@link LayerDrawInfo} per layer in that
189
+ * row carrying its `as`, `drawMs`, and (for a decimating layer) `sourceCount`
190
+ * / `drawnCount` / `decimated`. Compare `drawnCount` to `sourceCount` to see
191
+ * whether M4 engaged; read `drawMs` for per-layer render cost. **Omitted ⇒ no
192
+ * measurement** — the render loop skips per-layer timing entirely, so this is
193
+ * zero-overhead when unused. Keep the callback cheap (it runs inside the draw
194
+ * frame); route it to a ref/store rather than doing React state work per frame.
195
+ */
196
+ onDrawStats?: (frame: DrawStatsFrame) => void;
169
197
  /**
170
198
  * Controlled selection — the selected mark (echo the `onSelect` arg back), or
171
199
  * `null`. **Omitted ⇒ uncontrolled** (a click on a selectable layer manages it
@@ -212,10 +240,32 @@ export interface ChartContainerProps {
212
240
  */
213
241
  onHover?: (hit: SelectInfo | null) => void;
214
242
  /**
215
- * Enable pan/zoom: drag the plot to pan the time range, wheel to zoom around
216
- * the cursor. **Default off** — so it doesn't capture drag/scroll unless asked.
243
+ * Which pan/zoom gestures the plot captures:
244
+ *
245
+ * - `'none'` (or `false`, the **default**) — neither; the plot doesn't capture
246
+ * drag or scroll.
247
+ * - `'pan'` — drag to pan the time range, **no** wheel-zoom (scroll still
248
+ * scrolls the page).
249
+ * - `'panZoom'` (or `true`) — drag to pan **and** wheel to zoom around the
250
+ * cursor.
251
+ *
252
+ * The boolean form is the back-compat shorthand (`true` ⇒ `'panZoom'`,
253
+ * `false` ⇒ `'none'`). Bound the reachable range with {@link bounds}
254
+ * (zoom-out / pan extent) and {@link minDuration} (zoom-in floor).
255
+ */
256
+ panZoom?: boolean | 'none' | 'pan' | 'panZoom';
257
+ /**
258
+ * **Outer pan/zoom extent** — `[min, max]` (same units as {@link range}) the
259
+ * view can never move outside. Panning into an edge stops there (the window
260
+ * keeps its span); zooming out is capped at this width, so `bounds` is the
261
+ * zoom-**out** ceiling that pairs with the {@link minDuration} zoom-**in**
262
+ * floor. **Omit for no limit** (pan/zoom is unbounded). Constrains gestures
263
+ * (and any range routed through the container); seed {@link range} within it.
264
+ * On a trading-time axis the clamp is in wall-clock ms (a sensible outer
265
+ * limit; the per-session pan/zoom math already holds the trading span at each
266
+ * calendar edge).
217
267
  */
218
- panZoom?: boolean;
268
+ bounds?: readonly [number, number];
219
269
  /**
220
270
  * Controlled view range — fires on pan/zoom with the new `[start, end]`. Wire
221
271
  * it back to `range` for a controlled chart; omit for uncontrolled (the
@@ -340,5 +390,5 @@ export interface ChartContainerProps {
340
390
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
341
391
  * (`<YAxis>`).
342
392
  */
343
- export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
393
+ export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
344
394
  //# sourceMappingURL=ChartContainer.d.ts.map
@@ -4,10 +4,11 @@ import { scaleLinear } from 'd3-scale';
4
4
  import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
5
5
  import { scaleBand } from './bandScale.js';
6
6
  import { Sequence } from 'pond-ts';
7
- import { ContainerContext, } from './context.js';
7
+ import { ContainerContext, CursorContext, } from './context.js';
8
8
  import { maxSlotWidths, sum } from './slots.js';
9
9
  import { computeLabelLanes } from './annotations.js';
10
10
  import { resolveCursorX, DEFAULT_CURSOR_MODE } from './tracker.js';
11
+ import { clampToBounds } from './viewport.js';
11
12
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
12
13
  import { TimeAxis } from './TimeAxis.js';
13
14
  import { defaultTheme } from './theme.js';
@@ -43,7 +44,15 @@ function normalizeRange(range) {
43
44
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
44
45
  * (`<YAxis>`).
45
46
  */
46
- export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
47
+ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
48
+ // Normalize the `panZoom` mode (boolean shorthand or the three-way string)
49
+ // into the two gesture flags the event surface reads. `true` ⇒ both; `'pan'`
50
+ // ⇒ drag only; `false`/`'none'` ⇒ neither. Zoom implies pan (there is no
51
+ // zoom-without-pan mode), so `interactive` (holds an internal view) tracks
52
+ // whichever is on.
53
+ const panEnabled = panZoom === true || panZoom === 'pan' || panZoom === 'panZoom';
54
+ const zoomEnabled = panZoom === true || panZoom === 'panZoom';
55
+ const interactive = panEnabled || zoomEnabled;
47
56
  // The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
48
57
  // ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
49
58
  // is the placeholder while auto-fitting.
@@ -57,7 +66,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
57
66
  seed[0],
58
67
  seed[1],
59
68
  ]);
60
- const uncontrolled = panZoom && onTimeRangeChange === undefined;
69
+ const uncontrolled = interactive && onTimeRangeChange === undefined;
61
70
  // While the internal view isn't in use (not uncontrolled), keep it synced to
62
71
  // the prop — so *entering* uncontrolled pan/zoom (toggling panZoom on, or a
63
72
  // controlled→uncontrolled switch) starts from the current range, not the
@@ -78,12 +87,20 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
78
87
  useLayoutEffect(() => {
79
88
  onRangeRef.current = onTimeRangeChange;
80
89
  });
90
+ // Latest `bounds` in a ref too, so `applyRange` clamps to the current extent
91
+ // while staying identity-stable (it's a frame field + a gesture callback dep).
92
+ const boundsRef = useRef(bounds);
93
+ useLayoutEffect(() => {
94
+ boundsRef.current = bounds;
95
+ });
81
96
  const applyRange = useCallback((range) => {
97
+ const b = boundsRef.current;
98
+ const next = b ? clampToBounds(range, b) : range;
82
99
  const cb = onRangeRef.current;
83
100
  if (cb)
84
- cb(range);
101
+ cb(next);
85
102
  else
86
- setInternalRange(range);
103
+ setInternalRange(next);
87
104
  }, []);
88
105
  // Cross-row tracker. We store the cursor's plot-pixel x (not a timestamp), so a
89
106
  // still cursor stays put while a live window slides under it; a controlled
@@ -222,6 +239,18 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
222
239
  }, [sources]);
223
240
  const onTrackerRef = useRef(onTrackerChanged);
224
241
  onTrackerRef.current = onTrackerChanged;
242
+ // Draw-stats sink: hold the latest `onDrawStats` in a ref and expose a *stable*
243
+ // reporter that reads it, so an inline arrow doesn't re-identify the context
244
+ // (which would thrash every row's draw memo). The reporter is `undefined` when
245
+ // there's no subscriber — the signal for `Layers` to skip per-layer timing
246
+ // entirely (zero overhead when unused). Its identity flips only when the
247
+ // presence of `onDrawStats` toggles, not on every render.
248
+ const onDrawStatsRef = useRef(onDrawStats);
249
+ onDrawStatsRef.current = onDrawStats;
250
+ const hasDrawStats = onDrawStats !== undefined;
251
+ const reportDrawStats = useMemo(() => hasDrawStats
252
+ ? (frame) => onDrawStatsRef.current?.(frame)
253
+ : undefined, [hasDrawStats]);
225
254
  // Selection: controlled (`selected` prop) or uncontrolled (internal). A click
226
255
  // on a selectable layer calls `select()` after hit-testing; `onSelect` notifies
227
256
  // in both modes, the internal state is managed only when uncontrolled. The full
@@ -531,8 +560,28 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
531
560
  // Pack overlapping top-flag labels (markers + regions) into stacked lanes so
532
561
  // close-in-x labels don't collide; chips read their lane back off the frame.
533
562
  const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey, plotWidth), [annotations, xScale, draggingKey]);
563
+ // The frame's `[d0, d1]` tuple, identity-stable on the endpoints. The frame
564
+ // memo rebuilds whenever any of its (many) fields change — a `hovered`
565
+ // transition, a selection, an annotation edit, a range change — so an inline
566
+ // `[d0, d1]` literal there would mint a fresh array on any such rebuild, and
567
+ // every draw callback listing `container.timeRange` in its deps (Layers'
568
+ // data-canvas draw) would read that as a domain change and replot the row
569
+ // canvas. Memoizing on the endpoints keeps the draw stable across those
570
+ // unrelated rebuilds. (Cursor *position* no longer rebuilds the frame at all —
571
+ // it lives in `cursorFrame` below, [PND-HOVCTX] — but the tuple stays a memo
572
+ // to hold the line for every other rebuild path.)
573
+ const timeRangeTuple = useMemo(() => [d0, d1], [d0, d1]);
574
+ // The per-move cursor state, split into its own context so a mousemove
575
+ // re-identifies only this small object — not the ~50-field frame below, which
576
+ // stays stable across hovers so `YAxis` / `Bar` / `Box` don't re-render. See
577
+ // [PND-HOVCTX] / {@link CursorContext}.
578
+ const cursorFrame = useMemo(() => ({
579
+ cursorX,
580
+ cursorY: hoverPoint?.y ?? null,
581
+ cursorRowKey: hoverPoint?.rowKey ?? null,
582
+ }), [cursorX, hoverPoint]);
534
583
  const frame = useMemo(() => ({
535
- timeRange: [d0, d1],
584
+ timeRange: timeRangeTuple,
536
585
  width,
537
586
  theme: theme ?? defaultTheme,
538
587
  plotWidth,
@@ -541,16 +590,14 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
541
590
  leftGutter,
542
591
  rightGutter,
543
592
  rowGap,
544
- cursorX,
545
593
  setHoverX,
546
- cursorY: hoverPoint?.y ?? null,
547
- cursorRowKey: hoverPoint?.rowKey ?? null,
548
594
  setHoverY,
549
595
  crosshairSnap,
550
596
  cursorBuckets,
551
597
  regionAnchor,
552
598
  setRegionAnchor,
553
599
  onRegionSelect,
600
+ reportDrawStats,
554
601
  regionSelectModifier,
555
602
  draggingKey,
556
603
  setDragging,
@@ -588,15 +635,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
588
635
  discontinuities: xDiscontinuities,
589
636
  grid,
590
637
  sessionDividers,
591
- panZoom,
638
+ panEnabled,
639
+ zoomEnabled,
592
640
  minDuration,
593
641
  applyRange,
594
642
  registerGutter,
595
643
  registerRow,
596
644
  firstRowKey,
597
645
  }), [
598
- d0,
599
- d1,
646
+ timeRangeTuple,
600
647
  width,
601
648
  theme,
602
649
  plotWidth,
@@ -605,14 +652,13 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
605
652
  leftGutter,
606
653
  rightGutter,
607
654
  rowGap,
608
- cursorX,
609
- hoverPoint,
610
655
  setHoverY,
611
656
  crosshairSnap,
612
657
  cursorBuckets,
613
658
  regionAnchor,
614
659
  setRegionAnchor,
615
660
  onRegionSelect,
661
+ reportDrawStats,
616
662
  regionSelectModifier,
617
663
  draggingKey,
618
664
  setDragging,
@@ -650,20 +696,21 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
650
696
  xDiscontinuities,
651
697
  grid,
652
698
  sessionDividers,
653
- panZoom,
699
+ panEnabled,
700
+ zoomEnabled,
654
701
  minDuration,
655
702
  applyRange,
656
703
  registerGutter,
657
704
  registerRow,
658
705
  firstRowKey,
659
706
  ]);
660
- return (_jsx(ContainerContext.Provider, { value: frame, children: _jsxs("div", { style: { width: `${width}px` }, children: [_jsx("div", { style: {
661
- display: 'flex',
662
- flexDirection: 'column',
663
- gap: `${rowGap}px`,
664
- // The positioned ancestor for overlay chrome (`<Legend>`): the
665
- // card anchors to the rows block, never the axis strip below.
666
- position: 'relative',
667
- }, children: children }), showAxis && _jsx(TimeAxis, {})] }) }));
707
+ return (_jsx(ContainerContext.Provider, { value: frame, children: _jsx(CursorContext.Provider, { value: cursorFrame, children: _jsxs("div", { style: { width: `${width}px` }, children: [_jsx("div", { style: {
708
+ display: 'flex',
709
+ flexDirection: 'column',
710
+ gap: `${rowGap}px`,
711
+ // The positioned ancestor for overlay chrome (`<Legend>`): the
712
+ // card anchors to the rows block, never the axis strip below.
713
+ position: 'relative',
714
+ }, children: children }), showAxis && _jsx(TimeAxis, {})] }) }) }));
668
715
  }
669
716
  //# sourceMappingURL=ChartContainer.js.map