@pond-ts/charts 0.48.0 → 0.49.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +215 -1
  2. package/dist/AreaChart.d.ts +18 -1
  3. package/dist/AreaChart.js +23 -2
  4. package/dist/BandChart.d.ts +21 -2
  5. package/dist/BandChart.js +68 -9
  6. package/dist/BarChart.d.ts +12 -1
  7. package/dist/BarChart.js +34 -1
  8. package/dist/BoxPlot.d.ts +18 -1
  9. package/dist/BoxPlot.js +60 -3
  10. package/dist/Candlestick.d.ts +8 -1
  11. package/dist/Candlestick.js +40 -6
  12. package/dist/ChartContainer.d.ts +23 -13
  13. package/dist/ChartContainer.js +86 -32
  14. package/dist/ChartRow.js +22 -3
  15. package/dist/Layers.js +37 -14
  16. package/dist/Legend.d.ts +62 -0
  17. package/dist/Legend.js +169 -0
  18. package/dist/LineChart.d.ts +20 -1
  19. package/dist/LineChart.js +23 -2
  20. package/dist/ScatterChart.d.ts +8 -1
  21. package/dist/ScatterChart.js +24 -1
  22. package/dist/XAxis.js +9 -2
  23. package/dist/YAxis.d.ts +9 -1
  24. package/dist/YAxis.js +27 -6
  25. package/dist/annotations.d.ts +21 -3
  26. package/dist/annotations.js +36 -15
  27. package/dist/area.d.ts +2 -1
  28. package/dist/area.js +29 -4
  29. package/dist/band.d.ts +2 -1
  30. package/dist/band.js +18 -1
  31. package/dist/bars.js +8 -1
  32. package/dist/box.d.ts +14 -1
  33. package/dist/box.js +56 -2
  34. package/dist/context.d.ts +51 -4
  35. package/dist/culling.d.ts +165 -0
  36. package/dist/culling.js +286 -0
  37. package/dist/data.d.ts +3 -1
  38. package/dist/decimate.d.ts +193 -0
  39. package/dist/decimate.js +359 -0
  40. package/dist/format.d.ts +20 -11
  41. package/dist/index.d.ts +6 -0
  42. package/dist/index.js +6 -0
  43. package/dist/line.d.ts +2 -1
  44. package/dist/line.js +38 -3
  45. package/dist/ohlc.js +6 -1
  46. package/dist/scatter.js +42 -7
  47. package/dist/swatch.d.ts +104 -0
  48. package/dist/swatch.js +96 -0
  49. package/dist/theme.d.ts +27 -0
  50. package/dist/theme.js +12 -0
  51. package/dist/useChartLegend.d.ts +106 -0
  52. package/dist/useChartLegend.js +122 -0
  53. package/dist/yticks.d.ts +20 -0
  54. package/dist/yticks.js +28 -0
  55. package/package.json +3 -3
package/dist/BoxPlot.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ValueSeries } from 'pond-ts';
3
3
  import { boxFromTimeSeries, boxFromValueSeries } from './data.js';
4
- import { boxExtent, boxIndexAtTime, drawBox, isFiniteBox, } from './box.js';
4
+ import { boxAt, boxExtent, boxIndexAtTime, drawBox, isFiniteBox, } from './box.js';
5
5
  import { ContainerContext, LayersContext, } from './context.js';
6
+ import { legendLabelFor, useLegendItems, } from './swatch.js';
6
7
  import { useSlotKey } from './use-slot-key.js';
7
8
  /** Whisker collapse floor (px) — a too-thin box still draws a 1px mark. */
8
9
  const MIN_BOX_WIDTH_PX = 1;
@@ -37,7 +38,7 @@ const MIN_BOX_WIDTH_PX = 1;
37
38
  * </Layers>
38
39
  * ```
39
40
  */
40
- export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, axis, gap = 0, shape = 'whisker', showMedian = true, offset = 0, capWidth, index = 0, }) {
41
+ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, axis, gap = 0, shape = 'whisker', showMedian = true, offset = 0, capWidth, id, legend, index = 0, }) {
41
42
  const container = useContext(ContainerContext);
42
43
  if (container === null) {
43
44
  throw new Error('<BoxPlot> must be rendered inside a <ChartContainer>');
@@ -60,6 +61,16 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
60
61
  const qLabel = useMemo(() => {
61
62
  return (col, role) => semantic !== undefined ? `${semantic} ${role}` : (col ?? role);
62
63
  }, [semantic]);
64
+ // The series identity for selection/legend: the `as` role, else the range
65
+ // columns as a span (matches the legend row's label).
66
+ const label = semantic ?? `${lower}–${upper}`;
67
+ // Current selection / hover narrowed to this layer's box key (its `x`), or
68
+ // `null` — matched by the series `id`, so a change re-registers the layer and
69
+ // the canvas repaints the outline. A no-`id` layer never matches.
70
+ const sel = container.selected;
71
+ const hov = container.hovered;
72
+ const selectedKey = id !== undefined && sel !== null && sel.id === id ? sel.key : null;
73
+ const hoveredKey = id !== undefined && hov !== null && hov.id === id ? hov.key : null;
63
74
  const entry = useMemo(() => ({
64
75
  layer: {
65
76
  yExtent: () => boxExtent(bx),
@@ -116,7 +127,22 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
116
127
  lines,
117
128
  };
118
129
  },
119
- draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian, offset, capWidth),
130
+ // Selection hit-test (opt-in via `id`, like Bar/Scatter): the first box
131
+ // whose bounding rect contains the click. A box is a discrete interval
132
+ // mark, so this is rect-containment (`boxAt`) — not the continuous
133
+ // nearest-point threshold. `key` is the box's `x`; `value` its `upper`.
134
+ ...(id === undefined
135
+ ? {}
136
+ : {
137
+ hitTest: (px, py, xScale, yScale) => {
138
+ const hit = boxAt(bx, px, py, xScale, yScale, gap, MIN_BOX_WIDTH_PX, offset);
139
+ if (hit === null)
140
+ return null;
141
+ const [, begin, value] = hit;
142
+ return { id, key: begin, value, color: style.whisker, label };
143
+ },
144
+ }),
145
+ draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian, offset, capWidth, selectedKey, hoveredKey),
120
146
  },
121
147
  axisId: axis,
122
148
  index,
@@ -136,6 +162,10 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
136
162
  showMedian,
137
163
  offset,
138
164
  capWidth,
165
+ id,
166
+ label,
167
+ selectedKey,
168
+ hoveredKey,
139
169
  axis,
140
170
  index,
141
171
  ]);
@@ -146,6 +176,15 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
146
176
  useEffect(() => {
147
177
  layers.registerLayer(slot, entry);
148
178
  }, [layers, slot, entry]);
179
+ // Advertise selectability (only when an `id` was given) — powers the
180
+ // container's "wired but nothing selectable" dev-warn, like Bar/Scatter.
181
+ const { registerSelectable, unregisterSelectable } = container;
182
+ useEffect(() => {
183
+ if (id === undefined)
184
+ return;
185
+ registerSelectable(slot);
186
+ return () => unregisterSelectable(slot);
187
+ }, [registerSelectable, unregisterSelectable, slot, id]);
149
188
  // Also a tracker source: the container fans in the box quantiles at the cursor
150
189
  // for the (outside-the-chart) readout.
151
190
  const { registerTrackerSource, unregisterTrackerSource } = container;
@@ -153,6 +192,24 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
153
192
  useEffect(() => {
154
193
  registerTrackerSource(slot, entry.layer);
155
194
  }, [registerTrackerSource, slot, entry.layer]);
195
+ // And a legend row: the series identity (`as`, else the range columns as a
196
+ // span) + the resolved whisker style, so a `<Legend>` swatch can never drift.
197
+ const legendRows = useMemo(() => {
198
+ const name = legendLabelFor(legend, semantic ?? `${lower}–${upper}`);
199
+ return name === null
200
+ ? null
201
+ : [
202
+ {
203
+ label: name,
204
+ swatch: {
205
+ kind: 'box',
206
+ whisker: style.whisker,
207
+ whiskerWidth: style.whiskerWidth,
208
+ },
209
+ },
210
+ ];
211
+ }, [legend, semantic, lower, upper, style]);
212
+ useLegendItems(container, slot, index, legendRows);
156
213
  return null;
157
214
  }
158
215
  /** Append a tracker sample for a quantile, skipping a non-finite value. */
@@ -56,6 +56,13 @@ export interface CandlestickProps<S extends SeriesSchema> {
56
56
  * for a compact legend; the full quote is opt-in for a dense hover readout.
57
57
  */
58
58
  showOHLC?: boolean;
59
+ /**
60
+ * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
61
+ * row's display name. **Omitted ⇒ a row named by the layer's readout
62
+ * identity** (`as` ?? the `close` column). The swatch is the resolved
63
+ * up/down candle pair.
64
+ */
65
+ legend?: boolean | string;
59
66
  /**
60
67
  * @internal Declaration position among the `<Layers>` children, injected by
61
68
  * `Layers` so z-order follows JSX order. Do not set.
@@ -89,5 +96,5 @@ export interface CandlestickProps<S extends SeriesSchema> {
89
96
  * </Layers>
90
97
  * ```
91
98
  */
92
- export declare function Candlestick<S extends SeriesSchema>({ series, open, high, low, close, as: semantic, axis, variant, colorBy, gap, showOHLC, index, }: CandlestickProps<S>): null;
99
+ export declare function Candlestick<S extends SeriesSchema>({ series, open, high, low, close, as: semantic, axis, variant, colorBy, gap, showOHLC, legend, index, }: CandlestickProps<S>): null;
93
100
  //# sourceMappingURL=Candlestick.d.ts.map
@@ -2,6 +2,7 @@ import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ohlcFromTimeSeries } from './data.js';
3
3
  import { drawCandles, isFiniteOhlc, ohlcExtent, ohlcIndexAtTime, resolveCandleStyle, } from './ohlc.js';
4
4
  import { ContainerContext, LayersContext, } from './context.js';
5
+ import { legendLabelFor, useLegendItems, } from './swatch.js';
5
6
  import { useSlotKey } from './use-slot-key.js';
6
7
  /**
7
8
  * A first-class OHLC **candlestick** draw layer — the financial sibling of
@@ -30,7 +31,7 @@ import { useSlotKey } from './use-slot-key.js';
30
31
  * </Layers>
31
32
  * ```
32
33
  */
33
- export function Candlestick({ series, open = 'open', high = 'high', low = 'low', close = 'close', as: semantic, axis, variant = 'candle', colorBy = 'direction', gap = 0, showOHLC = false, index = 0, }) {
34
+ export function Candlestick({ series, open = 'open', high = 'high', low = 'low', close = 'close', as: semantic, axis, variant = 'candle', colorBy = 'direction', gap = 0, showOHLC = false, legend, index = 0, }) {
34
35
  const container = useContext(ContainerContext);
35
36
  if (container === null) {
36
37
  throw new Error('<Candlestick> must be rendered inside a <ChartContainer>');
@@ -70,11 +71,15 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
70
71
  }
71
72
  // Opt-in full quote: four value pills (body colour for open/close, wick
72
73
  // colour for the high/low extremes). Each is a value-only axis pill.
74
+ // With a semantic `as`, each reads under the series name + role
75
+ // (`SPY high`) — BoxPlot's qLabel convention, so two quoted series
76
+ // don't merge readout keys on the bare role words (F-charts-8 §3).
77
+ const role = (r) => semantic !== undefined ? `${semantic} ${r}` : r;
73
78
  const samples = [
74
- { x: at, value: ohlc.high[i], color: wick, label: 'high' },
75
- { x: at, value: ohlc.open[i], color: body, label: 'open' },
76
- { x: at, value: ohlc.close[i], color: body, label: 'close' },
77
- { x: at, value: ohlc.low[i], color: wick, label: 'low' },
79
+ { x: at, value: ohlc.high[i], color: wick, label: role('high') },
80
+ { x: at, value: ohlc.open[i], color: body, label: role('open') },
81
+ { x: at, value: ohlc.close[i], color: body, label: role('close') },
82
+ { x: at, value: ohlc.low[i], color: wick, label: role('low') },
78
83
  ];
79
84
  return samples;
80
85
  },
@@ -82,7 +87,18 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
82
87
  },
83
88
  axisId: axis,
84
89
  index,
85
- }), [ohlc, style, label, variant, colorBy, gap, showOHLC, axis, index]);
90
+ }), [
91
+ ohlc,
92
+ style,
93
+ label,
94
+ semantic,
95
+ variant,
96
+ colorBy,
97
+ gap,
98
+ showOHLC,
99
+ axis,
100
+ index,
101
+ ]);
86
102
  // Stable per-instance slot (see useSlotKey): keeps this candle layer's
87
103
  // z-position + identity across prop updates; the injected index drives the sort.
88
104
  const slot = useSlotKey();
@@ -97,6 +113,24 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
97
113
  useEffect(() => {
98
114
  registerTrackerSource(slot, entry.layer);
99
115
  }, [registerTrackerSource, slot, entry.layer]);
116
+ // And a legend row: the series identity + the resolved up/down pair, so a
117
+ // `<Legend>` swatch can never drift from the drawn candles.
118
+ const legendRows = useMemo(() => {
119
+ const name = legendLabelFor(legend, label);
120
+ return name === null
121
+ ? null
122
+ : [
123
+ {
124
+ label: name,
125
+ swatch: {
126
+ kind: 'candle',
127
+ up: style.rising.body,
128
+ down: style.falling.body,
129
+ },
130
+ },
131
+ ];
132
+ }, [legend, label, style]);
133
+ useLegendItems(container, slot, index, legendRows);
100
134
  return null;
101
135
  }
102
136
  //# sourceMappingURL=Candlestick.js.map
@@ -300,21 +300,31 @@ export interface ChartContainerProps {
300
300
  */
301
301
  timeFormat?: AxisFormat;
302
302
  /**
303
- * The **cursor / marker readout** format — the crosshair time pill, marker
303
+ * The **cursor / marker readout** format — the crosshair x pill, marker
304
304
  * axis indicators, and annotation auto-labels — **independent of the tick
305
- * labels**, so it does **not** disqualify the `dateStyle` ladder.
306
- * **Omitted a grain-aware default**: the readout formats at the axis's own
307
- * granularity, so a day-or-coarser axis reads a **date** (never a
308
- * time-of-day) and a sub-day axis reads date + clock a daily bar at a
309
- * foreign-tz midnight no longer renders as `02 AM`.
305
+ * labels** on both axis kinds: it does **not** disqualify the `dateStyle`
306
+ * ladder (time), and it never moves the tick labels (value). It beats an
307
+ * explicit `<XAxis format>` for the **readout only** pill precedence is
308
+ * `cursorFormat axis format container` so terse ticks can pair with a
309
+ * precise readout (`+2.0σ` labels, `+1.83σ` pill).
310
310
  *
311
- * A d3 time-specifier **string** formats uniformly at every zoom; a
312
- * **function** `(epochMs, { grain, defaultText }) => string` receives the
313
- * axis's resolved coarse {@link TimeGrain} and the grain-aware default text,
314
- * so it can branch on the zoom level and pass `defaultText` through for
315
- * grains it doesn't override (no re-deriving the grain from the range). See
316
- * {@link CursorFormat}. This is the independent readout channel;
317
- * {@link timeFormat} owns the labels.
311
+ * **Omitted the axis's own formatter.** On a time axis that default is
312
+ * grain-aware: the readout formats at the axis's granularity, so a
313
+ * day-or-coarser axis reads a **date** (never a time-of-day) and a sub-day
314
+ * axis reads date + clock a daily bar at a foreign-tz midnight no longer
315
+ * renders as `02 AM`. On a value axis it is the tick formatter
316
+ * ({@link timeFormat}-shaped, else the d3 default).
317
+ *
318
+ * A d3 specifier **string** formats uniformly (time specifier on a time
319
+ * axis, number specifier on a value axis); a **function**
320
+ * `(value, { grain, defaultText }) => string` receives the axis's resolved
321
+ * coarse {@link TimeGrain} (`undefined` on a value axis) and the default
322
+ * readout text, so it can branch on the zoom level and pass `defaultText`
323
+ * through for grains it doesn't override (no re-deriving the grain from the
324
+ * range). See {@link CursorFormat}. This is the independent readout channel;
325
+ * {@link timeFormat} owns the labels. (A category axis reads names, and a
326
+ * `transform`ed axis's pill speaks its derived unit — neither consults
327
+ * `cursorFormat`.)
318
328
  */
319
329
  cursorFormat?: CursorFormat;
320
330
  /** Visual theme for all rows; defaults to {@link defaultTheme}. */
@@ -151,6 +151,20 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
151
151
  });
152
152
  }, []);
153
153
  const annotations = useMemo(() => Array.from(annotationMap.values()), [annotationMap]);
154
+ // Legend rows register here (label + resolved swatch per layer, see
155
+ // useLegendItem) so a `<Legend>` anywhere in the container can enumerate
156
+ // every layer across rows. Same per-instance-slot discipline as the sources.
157
+ const [legendItems, setLegendItems] = useState(() => new Map());
158
+ const registerLegendItem = useCallback((key, item) => setLegendItems((m) => new Map(m).set(key, item)), []);
159
+ const unregisterLegendItem = useCallback((key) => {
160
+ setLegendItems((m) => {
161
+ if (!m.has(key))
162
+ return m;
163
+ const next = new Map(m);
164
+ next.delete(key);
165
+ return next;
166
+ });
167
+ }, []);
154
168
  // The shared x scale's kind, **inferred from the registered layers**: a
155
169
  // ValueSeries row plots on a value axis, a TimeSeries on time. A container
156
170
  // has one shared x (the synced cursor's whole point), so the rows must agree
@@ -243,8 +257,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
243
257
  if (!warnedNoSelectableRef.current) {
244
258
  warnedNoSelectableRef.current = true;
245
259
  console.warn('[pond-charts] `selected`/`onSelect` is set but no layer has an `id` — ' +
246
- 'nothing is selectable. Give a <BarChart>/<ScatterChart> an `id` to ' +
247
- 'make it interactive (an `id` gates selection + hover).');
260
+ 'nothing is selectable. Give a <BarChart>/<ScatterChart>/<BoxPlot> an ' +
261
+ '`id` to make it interactive (an `id` gates selection + hover).');
248
262
  }
249
263
  }
250
264
  else {
@@ -309,13 +323,19 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
309
323
  // (t0/t1); otherwise the auto-fit extent (→ [0, 1] before any layer registers,
310
324
  // the two-pass settle). This is what the scale + cursor + axis read.
311
325
  const [d0, d1] = explicitDomain !== undefined ? [t0, t1] : (autoExtent ?? [0, 1]);
312
- // The shared x scale + the formatter for its ticks / cursor readout, built
313
- // together so each branch keeps its concrete scale type (no casts): a value
314
- // axis is a `scaleLinear` formatted by `resolveAxisFormat`, time is a
315
- // `scaleTime` formatted by d3's multi-scale `resolveTimeFormat`. `formatTime`
316
- // is the one formatter <TimeAxis> + the cursor readout share, so a tick and
317
- // the cursor read identically. (The `formatTime` name predates the value axis
318
- // on a value axis it formats the value, not a time.)
326
+ // The shared x scale + its two formatting channels, built together so each
327
+ // branch keeps its concrete scale type (no casts): a value axis is a
328
+ // `scaleLinear` formatted by `resolveAxisFormat`, time is a `scaleTime`
329
+ // formatted by d3's multi-scale `resolveTimeFormat`.
330
+ //
331
+ // - `formatTime` is the **label** channel what `<XAxis>` ticks fall back to
332
+ // shaped by `timeFormat` only, never `cursorFormat` (so a readout format
333
+ // can't move the tick labels). (The name predates the value axis — on a
334
+ // value axis it formats the value, not a time.)
335
+ // - `formatReadout` is the **readout** channel — the crosshair pill, marker
336
+ // indicators, and annotation auto-labels — defined only when `cursorFormat`
337
+ // is set; consumers read `formatReadout ?? <their label formatter>`, which
338
+ // is how "the readout matches the axis" stays the default.
319
339
  // The trading-time provider only applies to a **time** axis — a value axis is
320
340
  // always a plain `scaleLinear`. Gate it once here so the scale branch AND the
321
341
  // frame (which pan/zoom read) agree: on a value axis the provider is dropped,
@@ -342,35 +362,52 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
342
362
  const xTickCount = resolvedKind === 'time'
343
363
  ? Math.max(2, Math.floor(plotWidth / TRADING_TICK_PX))
344
364
  : TIME_TICK_COUNT;
345
- const { xScale, formatTime } = useMemo(() => {
365
+ const { xScale, formatTime, formatReadout } = useMemo(() => {
346
366
  if (resolvedKind === 'category') {
347
367
  // Ordinal column-domain axis: a band scale over the category slots. The
348
368
  // domain is **always** `[0, n]` (one unit slot per category) — NOT the
349
369
  // resolved `[d0, d1]`: a category axis ignores an explicit `range` (its
350
370
  // slots are absolute `0..n`, matching `categoryStack`), so an out-of-`[0,n]`
351
371
  // range can't silently offset the labels from the bars. The pixel mapping
352
- // stays linear; the formatter is the category-name lookup.
372
+ // stays linear; the formatter is the category-name lookup. A category
373
+ // reads by **name** — `cursorFormat` has nothing to format, so the
374
+ // readout channel stays unset.
353
375
  const cats = categories ?? [];
354
376
  const s = scaleBand(cats).domain([0, cats.length]).range([0, plotWidth]);
355
- return { xScale: s, formatTime: (v) => s.label(v) };
356
- }
357
- if (resolvedKind === 'value') {
358
- const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
359
377
  return {
360
378
  xScale: s,
361
- formatTime: resolveAxisFormat(s, xTickCount, timeFormat),
379
+ formatTime: (v) => s.label(v),
380
+ formatReadout: undefined,
362
381
  };
363
382
  }
364
- // The cursor / marker / annotation **readout** formatter. `cursorFormat`
365
- // wins (the independent readout channel — it never disqualifies the date
366
- // style); else a container `timeFormat` (back-compat: shapes the readout
367
- // too, and already opts labels out of the ladder); else the scale's
368
- // **grain-aware** default (a day-or-coarser axis reads a date, not a
369
- // time-of-day the F-charts-7 `02 AM` fix), never d3's multi-scale default.
383
+ if (resolvedKind === 'value') {
384
+ const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
385
+ const labels = resolveAxisFormat(s, xTickCount, timeFormat);
386
+ // The value-axis readout channel: a `cursorFormat` **string** is a d3
387
+ // *number* specifier here (resolved through the linear scale, exactly as
388
+ // a tick format would be); a **function** gets `grain: undefined` (no
389
+ // time grain to hand over) and the label formatter's text as its
390
+ // pass-through default.
391
+ const readout = typeof cursorFormat === 'function'
392
+ ? (v) => cursorFormat(v, { grain: undefined, defaultText: labels(v) })
393
+ : cursorFormat !== undefined
394
+ ? resolveAxisFormat(s, xTickCount, cursorFormat)
395
+ : undefined;
396
+ return { xScale: s, formatTime: labels, formatReadout: readout };
397
+ }
398
+ // Time axis, label channel: a container `timeFormat` when set (it owns the
399
+ // labels and opts them out of the ladder); else the scale's **grain-aware**
400
+ // default (a day-or-coarser axis reads a date, not a time-of-day — the
401
+ // F-charts-7 `02 AM` fix), never d3's multi-scale default.
402
+ const timeLabels = (s) => timeFormat !== undefined
403
+ ? resolveTimeFormat(s, xTickCount, timeFormat)
404
+ : s.readoutFormat(xTickCount);
405
+ // Time axis, readout channel — only when `cursorFormat` is set (otherwise
406
+ // the readout falls back to the label channel at the consumer). A
407
+ // **function** gets the axis's resolved coarse grain and the grain-aware
408
+ // default text per instant, so it can branch on zoom and pass the default
409
+ // through. A **string** formats uniformly (d3 time specifier).
370
410
  const timeReadout = (s) => {
371
- // A `cursorFormat` **function** gets the axis's resolved coarse grain and
372
- // the grain-aware default text per instant, so it can branch on zoom and
373
- // pass the default through. A **string** formats uniformly (d3 specifier).
374
411
  if (typeof cursorFormat === 'function') {
375
412
  const grain = s.grain(xTickCount);
376
413
  const def = s.readoutFormat(xTickCount);
@@ -379,11 +416,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
379
416
  if (cursorFormat !== undefined) {
380
417
  return resolveTimeFormat(s, xTickCount, cursorFormat);
381
418
  }
382
- // No cursorFormat: a container timeFormat still shapes the readout
383
- // (back-compat); else the scale's grain-aware default.
384
- return timeFormat !== undefined
385
- ? resolveTimeFormat(s, xTickCount, timeFormat)
386
- : s.readoutFormat(xTickCount);
419
+ return undefined;
387
420
  };
388
421
  if (xDiscontinuities !== undefined) {
389
422
  // Trading-time axis: closed-market gaps collapse, time proportional within
@@ -393,7 +426,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
393
426
  const s = scaleTradingTime(xDiscontinuities)
394
427
  .domain([d0, d1])
395
428
  .range([0, plotWidth]);
396
- return { xScale: s, formatTime: timeReadout(s) };
429
+ return {
430
+ xScale: s,
431
+ formatTime: timeLabels(s),
432
+ formatReadout: timeReadout(s),
433
+ };
397
434
  }
398
435
  // Plain continuous time axis: the same trading-time scale over the
399
436
  // gap-free identity provider, so it runs the same logical tick ladder
@@ -404,7 +441,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
404
441
  const s = scaleTradingTime(identityProvider())
405
442
  .domain([d0, d1])
406
443
  .range([0, plotWidth]);
407
- return { xScale: s, formatTime: timeReadout(s) };
444
+ return {
445
+ xScale: s,
446
+ formatTime: timeLabels(s),
447
+ formatReadout: timeReadout(s),
448
+ };
408
449
  }, [
409
450
  resolvedKind,
410
451
  categories,
@@ -527,6 +568,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
527
568
  onHoverAnnotation,
528
569
  onEditAnnotation,
529
570
  formatTime,
571
+ formatReadout,
530
572
  xFormatCustom: timeFormat !== undefined,
531
573
  xTickCount,
532
574
  registerTrackerSource,
@@ -535,6 +577,10 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
535
577
  unregisterSelectable,
536
578
  registerAnnotation,
537
579
  unregisterAnnotation,
580
+ registerLegendItem,
581
+ unregisterLegendItem,
582
+ legendItems,
583
+ rowOrder: rowKeys,
538
584
  annotations,
539
585
  labelLanes,
540
586
  xScale,
@@ -584,6 +630,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
584
630
  onHoverAnnotation,
585
631
  onEditAnnotation,
586
632
  formatTime,
633
+ formatReadout,
587
634
  timeFormat,
588
635
  xTickCount,
589
636
  registerTrackerSource,
@@ -592,6 +639,10 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
592
639
  unregisterSelectable,
593
640
  registerAnnotation,
594
641
  unregisterAnnotation,
642
+ registerLegendItem,
643
+ unregisterLegendItem,
644
+ legendItems,
645
+ rowKeys,
595
646
  annotations,
596
647
  labelLanes,
597
648
  xScale,
@@ -610,6 +661,9 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
610
661
  display: 'flex',
611
662
  flexDirection: 'column',
612
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',
613
667
  }, children: children }), showAxis && _jsx(TimeAxis, {})] }) }));
614
668
  }
615
669
  //# sourceMappingURL=ChartContainer.js.map
package/dist/ChartRow.js CHANGED
@@ -3,6 +3,7 @@ import { Children, cloneElement, isValidElement, useCallback, useContext, useEff
3
3
  import { scaleLinear } from 'd3-scale';
4
4
  import { resolveYDomain } from './domain.js';
5
5
  import { resolveAxisFormat } from './format.js';
6
+ import { resolveYTickCount } from './yticks.js';
6
7
  import { placeAxisSlots } from './slots.js';
7
8
  import { useSlotKey } from './use-slot-key.js';
8
9
  import { YAxis } from './YAxis.js';
@@ -45,6 +46,7 @@ function axisSpecEqual(a, b) {
45
46
  a.pad === b.pad &&
46
47
  a.labelPlacement === b.labelPlacement &&
47
48
  a.index === b.index &&
49
+ a.tickCount === b.tickCount &&
48
50
  Object.is(a.format, b.format) &&
49
51
  numberArraysEqual(a.tickValues, b.tickValues));
50
52
  }
@@ -175,6 +177,7 @@ export function ChartRow({ height, cursor, children }) {
175
177
  labelPlacement: 'rotated',
176
178
  format: undefined,
177
179
  tickValues: undefined,
180
+ tickCount: undefined,
178
181
  index: 0,
179
182
  },
180
183
  ], [realAxes]);
@@ -233,17 +236,31 @@ export function ChartRow({ height, cursor, children }) {
233
236
  }
234
237
  return map;
235
238
  }, [effectiveAxes, layerList, height, defaultAxisId, topHeader]);
239
+ // Resolved auto-tick count per axis — explicit `<YAxis tickCount>` else
240
+ // height-derived (see resolveYTickCount). The single source the `<YAxis>`
241
+ // labels, the readout formatter (below), and the `Layers` gridlines all read,
242
+ // so label / readout / gridline stay on one `ticks(count)`.
243
+ const tickCounts = useMemo(() => {
244
+ const map = new Map();
245
+ for (const ax of effectiveAxes) {
246
+ map.set(ax.id, resolveYTickCount(height, ax.tickCount));
247
+ }
248
+ return map;
249
+ }, [effectiveAxes, height]);
236
250
  // A value formatter per axis (its `format` resolved against its scale) — shared
237
251
  // by the axis tick labels and the cursor readout so a value reads the same.
252
+ // Calibrated to the axis's resolved tick count (density), like the labels.
238
253
  const formats = useMemo(() => {
239
254
  const map = new Map();
240
255
  for (const ax of effectiveAxes) {
241
256
  const sc = yScales.get(ax.id);
242
- if (sc)
243
- map.set(ax.id, resolveAxisFormat(sc, AXIS_TICK_COUNT, ax.format));
257
+ if (sc) {
258
+ const count = tickCounts.get(ax.id) ?? AXIS_TICK_COUNT;
259
+ map.set(ax.id, resolveAxisFormat(sc, count, ax.format));
260
+ }
244
261
  }
245
262
  return map;
246
- }, [effectiveAxes, yScales]);
263
+ }, [effectiveAxes, yScales, tickCounts]);
247
264
  // Explicit tick values per axis (axes that set `<YAxis ticks>`) — so Layers
248
265
  // draws the row's gridlines at the same positions the axis labels, instead of
249
266
  // d3's auto-picked ticks.
@@ -271,6 +288,7 @@ export function ChartRow({ height, cursor, children }) {
271
288
  yScales,
272
289
  formats,
273
290
  tickValues,
291
+ tickCounts,
274
292
  axisSides,
275
293
  defaultAxisId,
276
294
  axisSlots,
@@ -287,6 +305,7 @@ export function ChartRow({ height, cursor, children }) {
287
305
  yScales,
288
306
  formats,
289
307
  tickValues,
308
+ tickCounts,
290
309
  axisSides,
291
310
  defaultAxisId,
292
311
  axisSlots,