@pond-ts/charts 0.48.1 → 0.50.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 (56) hide show
  1. package/CHANGELOG.md +225 -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 +29 -1
  9. package/dist/BoxPlot.js +61 -3
  10. package/dist/Candlestick.d.ts +21 -1
  11. package/dist/Candlestick.js +42 -7
  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 +15 -1
  33. package/dist/box.js +71 -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 +231 -0
  39. package/dist/decimate.js +478 -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.d.ts +2 -1
  46. package/dist/ohlc.js +23 -2
  47. package/dist/scatter.js +42 -7
  48. package/dist/swatch.d.ts +104 -0
  49. package/dist/swatch.js +96 -0
  50. package/dist/theme.d.ts +27 -0
  51. package/dist/theme.js +12 -0
  52. package/dist/useChartLegend.d.ts +106 -0
  53. package/dist/useChartLegend.js +122 -0
  54. package/dist/yticks.d.ts +20 -0
  55. package/dist/yticks.js +28 -0
  56. package/package.json +3 -3
package/dist/ohlc.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { barSpanPx } from './range.js';
2
+ import { visibleSpanRange } from './culling.js';
3
+ import { decimateOhlc } from './decimate.js';
2
4
  /** Default body width as a fraction of the candle slot when the style omits one. */
3
5
  const DEFAULT_BODY_WIDTH = 0.8;
4
6
  /** Minimum body height in px so a doji (open === close) still shows a mark. */
@@ -88,9 +90,28 @@ export function resolveCandleStyle(style, open, close, colorBy) {
88
90
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
89
91
  * beyond the `barSpanPx` tuple.
90
92
  */
91
- export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1) {
93
+ export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1, decimate = true) {
92
94
  const bodyFraction = style.bodyWidth ?? DEFAULT_BODY_WIDTH;
93
- for (let i = 0; i < ohlc.length; i += 1) {
95
+ // Viewport cull first (Phase 2): the [vStart, vEnd) candles whose span overlaps
96
+ // the window (+1 each side). Full range when `xScale` has no domain (a stub).
97
+ let [vStart, vEnd] = visibleSpanRange(ohlc.x, ohlc.xEnd, ohlc.length, xScale);
98
+ // M4 candle decimation (Phase 5): once the *visible* candles are denser than ~2
99
+ // per device pixel, replace them with per-column **aggregate candles**
100
+ // (open=first, high=max, low=min, close=last — a coarser-timeframe candle;
101
+ // {@link decimateOhlc}). Gate on the visible count, NOT `ohlc.length`: a candle's
102
+ // width is its slot, so decimating when only a handful are on screen (deep zoom
103
+ // into a large series) would re-slot each to a 1px sliver. `decimateOhlc` no-ops
104
+ // (returns the same object) below the visible-density threshold or on a
105
+ // domainless scale, leaving the loop-bound cull above.
106
+ const decimated = decimate !== false
107
+ ? decimateOhlc(ohlc, xScale, ctx, 2, vEnd - vStart)
108
+ : ohlc;
109
+ if (decimated !== ohlc) {
110
+ ohlc = decimated; // aggregate candles are already the visible set
111
+ vStart = 0;
112
+ vEnd = ohlc.length;
113
+ }
114
+ for (let i = vStart; i < vEnd; i += 1) {
94
115
  if (!isFiniteOhlc(ohlc, i))
95
116
  continue;
96
117
  const open = ohlc.open[i];
package/dist/scatter.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { visiblePointRange } from './culling.js';
1
2
  /**
2
3
  * Scatter geometry + the canvas draw — pure, like {@link drawLine} /
3
4
  * {@link drawBand}, so the recording-mock tests assert the op sequence and the
@@ -5,11 +6,13 @@
5
6
  *
6
7
  * A scatter plots one mark per finite point at `(xScale(x), yScale(y))`, sized
7
8
  * + coloured by the resolved {@link ResolvedEncoding} (data-driven radius /
8
- * colour) over the style's base. All three of `drawScatter`, `scatterExtent`,
9
- * and {@link hitTestScatter} are **O(N)** in the point count (a single pass; no
10
- * spatial index a chart row holds far fewer points than a dense line, and a
11
- * click happens at human cadence). If a scatter ever needs 100k+ points this is
12
- * the place to add a coarse x-bucket index; today the linear walk is the right
9
+ * colour) over the style's base. `drawScatter` culls to the visible x-window
10
+ * first (Phase 2 see the draw loop), so a pan/zoom repaint is O(visible), not
11
+ * O(N); `scatterExtent` and {@link hitTestScatter} still walk the full series
12
+ * (**O(N)** the y-extent must see every point and a click happens at human
13
+ * cadence). No spatial index a chart row holds far fewer points than a dense
14
+ * line. If a scatter ever needs 100k+ points *and* a hot hit-test this is the
15
+ * place to add a coarse x-bucket index; today the linear walk is the right
13
16
  * tradeoff.
14
17
  */
15
18
  /** A non-finite y (the gap signal) means "no point here" — skip it everywhere. */
@@ -123,7 +126,39 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
123
126
  let selPy = 0;
124
127
  let selR = 0;
125
128
  let selHit = false;
126
- for (let i = 0; i < cs.length; i += 1) {
129
+ // Viewport culling (Phase 2): draw only the marks in the visible x-window
130
+ // (+1 each side). The loop keeps the **original** index `i`, so the index-keyed
131
+ // accessors (`colorAt`/`radiusAt`/`keyAt`/`labelAt`) and the selection match
132
+ // stay correct — a subarray would renumber them. Full range when `xScale` has
133
+ // no domain (a test stub). A selected point outside the window isn't drawn (its
134
+ // ring would be off-screen anyway).
135
+ //
136
+ // Radius-aware pad (the follow-up #499 flagged): the ±1 margin is in *index*
137
+ // space, but a mark's **radius** can reach the plot from further out — a dense
138
+ // scatter of visible-size bubbles would otherwise drop an edge bubble whose
139
+ // centre is >1 sample off-screen while its disc overlaps the edge (a flicker
140
+ // under pan). So we make two window calls: pass 1 is the plain window; scan it
141
+ // for the max drawn radius; pass 2 re-expands by that radius plus `|offsetPx|`
142
+ // (the pixel nudge shifts marks in px space, so it widens the reach too). The
143
+ // common small-radius frame re-expands by a few pixels — usually the same
144
+ // window. Interval marks (bars/candles/boxes) don't need this — their width
145
+ // *is* their x-span (`visibleSpanRange` captures it exactly); only sub-pixel
146
+ // `minWidth`/`gapPx` rounding can poke past the edge, which the ±1 margin
147
+ // absorbs and which only bites when zoomed out (where culling barely narrows).
148
+ const [w0Start, w0End] = visiblePointRange(cs.x, cs.length, xScale);
149
+ let maxR = 0;
150
+ for (let i = w0Start; i < w0End; i += 1) {
151
+ if (isPoint(cs, i)) {
152
+ const r = encoding.radiusAt(i);
153
+ if (r > maxR)
154
+ maxR = r;
155
+ }
156
+ }
157
+ const pad = maxR + Math.abs(offsetPx);
158
+ const [vStart, vEnd] = pad > 0
159
+ ? visiblePointRange(cs.x, cs.length, xScale, pad)
160
+ : [w0Start, w0End];
161
+ for (let i = vStart; i < vEnd; i += 1) {
127
162
  if (!isPoint(cs, i))
128
163
  continue;
129
164
  // `offsetPx` nudges the whole scatter in pixel space (zoom-stable) — for
@@ -162,7 +197,7 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
162
197
  ctx.fillStyle = style.label;
163
198
  ctx.font = `${font.size}px ${font.family}`;
164
199
  ctx.textBaseline = 'middle';
165
- for (let i = 0; i < cs.length; i += 1) {
200
+ for (let i = vStart; i < vEnd; i += 1) {
166
201
  if (!isPoint(cs, i))
167
202
  continue;
168
203
  const text = labelAt(i);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `<Legend>` support — the swatch vocabulary, the per-layer registration hook,
3
+ * and the pure ordering/dedup pipeline the component renders from.
4
+ *
5
+ * The mechanism ([PND-LEGEND], #508 item 2): each draw layer registers its
6
+ * **resolved** style as a {@link SwatchSpec} alongside its readout identity, so
7
+ * a legend can never drift from the plot — the property an app-side legend
8
+ * only gets by manually sharing a palette. The legend renders the registry;
9
+ * layers self-describe.
10
+ */
11
+ import { type ContainerFrame } from './context.js';
12
+ /**
13
+ * A legend item's **swatch** — the layer's resolved style, in the mark's own
14
+ * vocabulary (a line shows stroke + dash, a band shows its translucent fill, a
15
+ * candle shows its up/down pair). Resolved means post-theme: the exact values
16
+ * the canvas draws with, so the swatch and the mark can never disagree.
17
+ */
18
+ export type SwatchSpec = {
19
+ readonly kind: 'line';
20
+ readonly color: string;
21
+ readonly width: number;
22
+ readonly dash?: readonly number[] | undefined;
23
+ } | {
24
+ readonly kind: 'area';
25
+ readonly line: string;
26
+ readonly fill: string;
27
+ readonly fillOpacity: number;
28
+ } | {
29
+ readonly kind: 'band';
30
+ readonly fill: string;
31
+ readonly opacity: number;
32
+ } | {
33
+ readonly kind: 'scatter';
34
+ readonly color: string;
35
+ readonly radius: number;
36
+ readonly outline?: string | undefined;
37
+ } | {
38
+ readonly kind: 'box';
39
+ readonly whisker: string;
40
+ readonly whiskerWidth: number;
41
+ } | {
42
+ readonly kind: 'bar';
43
+ readonly fill: string;
44
+ } | {
45
+ readonly kind: 'candle';
46
+ readonly up: string;
47
+ readonly down: string;
48
+ };
49
+ /**
50
+ * One row as a layer registers it (and as `<Legend items>` accepts it):
51
+ * the display `label` (the layer's readout identity `as ?? column`, or its
52
+ * `legend="name"` override), the resolved {@link SwatchSpec}, and — when the
53
+ * layer is interactive — its selection `id`.
54
+ */
55
+ export interface LegendItemInput {
56
+ readonly label: string;
57
+ readonly swatch: SwatchSpec;
58
+ /** The layer's selection identity (its `id` prop) — gates the legend's
59
+ * default hover/select interactions, and keys dedup ahead of `label`. */
60
+ readonly id?: string | undefined;
61
+ }
62
+ /** A registered legend row: the input plus its place in the chart (chart row,
63
+ * declaration index, position within the layer), which drives display order. */
64
+ export interface LegendItemSpec extends LegendItemInput {
65
+ readonly rowKey: symbol;
66
+ readonly index: number;
67
+ /** Position within a multi-row layer (a stacked bar registers one row per
68
+ * column, in stack order) — `0` for the single-row marks. */
69
+ readonly subIndex: number;
70
+ }
71
+ /**
72
+ * Order + dedup the registered rows for display: chart-row order first (the
73
+ * container's top-to-bottom `rowOrder`), declaration `index` within a row —
74
+ * the existing z-order convention — then `subIndex` (stack order within a
75
+ * multi-row layer), then label as a final stable tiebreak. Dedup keys on
76
+ * `(id ?? label, subIndex)` — the A2.2 selection model makes `id` the series
77
+ * identity (a theme-role label can repeat), and the `subIndex` component
78
+ * keeps a **multi-group layer's** rows distinct (its groups all share the
79
+ * layer's `id`; without it a stacked bar with an `id` would collapse to one
80
+ * row). Two single-row layers still collapse on a shared identity (both are
81
+ * `subIndex 0`) — the first row in display order stands, exactly as the
82
+ * tracker readout merges keys.
83
+ */
84
+ export declare function orderLegendItems(items: Iterable<LegendItemSpec>, rowOrder: readonly symbol[]): LegendItemSpec[];
85
+ /**
86
+ * Resolve a layer's `legend` prop + identity into what it registers:
87
+ * `false` ⇒ `null` (opt out — no row); a string ⇒ that display name; omitted /
88
+ * `true` ⇒ the layer's own readout identity. Kept as a helper so every mark
89
+ * resolves the prop identically.
90
+ */
91
+ export declare function legendLabelFor(legend: boolean | string | undefined, identity: string): string | null;
92
+ /**
93
+ * Register this layer instance's legend row(s) — one for the single-row marks,
94
+ * one per column for a stacked bar — keyed off the layer's per-instance
95
+ * `slot` (row *i* registers under a derived per-index key, so the layer's rows
96
+ * live and die together); unregister all on unmount. Pass `rows: null` (or
97
+ * `[]`) to register nothing (the layer opted out via `legend={false}`).
98
+ * `rows` must be **memoised by the caller** (like `useRegisterAnnotation`'s
99
+ * `xs`) so the effect re-runs only when a swatch / label genuinely changes,
100
+ * not every render. Reads the enclosing chart row's key itself — a layer
101
+ * outside a `<ChartRow>` (impossible today) simply doesn't register.
102
+ */
103
+ export declare function useLegendItems(container: ContainerFrame, slot: symbol, index: number, rows: readonly LegendItemInput[] | null): void;
104
+ //# sourceMappingURL=swatch.d.ts.map
package/dist/swatch.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `<Legend>` support — the swatch vocabulary, the per-layer registration hook,
3
+ * and the pure ordering/dedup pipeline the component renders from.
4
+ *
5
+ * The mechanism ([PND-LEGEND], #508 item 2): each draw layer registers its
6
+ * **resolved** style as a {@link SwatchSpec} alongside its readout identity, so
7
+ * a legend can never drift from the plot — the property an app-side legend
8
+ * only gets by manually sharing a palette. The legend renders the registry;
9
+ * layers self-describe.
10
+ */
11
+ import { useContext, useEffect, useRef } from 'react';
12
+ import { RowContext } from './context.js';
13
+ /**
14
+ * Order + dedup the registered rows for display: chart-row order first (the
15
+ * container's top-to-bottom `rowOrder`), declaration `index` within a row —
16
+ * the existing z-order convention — then `subIndex` (stack order within a
17
+ * multi-row layer), then label as a final stable tiebreak. Dedup keys on
18
+ * `(id ?? label, subIndex)` — the A2.2 selection model makes `id` the series
19
+ * identity (a theme-role label can repeat), and the `subIndex` component
20
+ * keeps a **multi-group layer's** rows distinct (its groups all share the
21
+ * layer's `id`; without it a stacked bar with an `id` would collapse to one
22
+ * row). Two single-row layers still collapse on a shared identity (both are
23
+ * `subIndex 0`) — the first row in display order stands, exactly as the
24
+ * tracker readout merges keys.
25
+ */
26
+ export function orderLegendItems(items, rowOrder) {
27
+ const rowPos = new Map();
28
+ rowOrder.forEach((k, i) => rowPos.set(k, i));
29
+ const sorted = [...items].sort((a, b) => (rowPos.get(a.rowKey) ?? rowOrder.length) -
30
+ (rowPos.get(b.rowKey) ?? rowOrder.length) ||
31
+ a.index - b.index ||
32
+ a.subIndex - b.subIndex ||
33
+ a.label.localeCompare(b.label));
34
+ const seen = new Set();
35
+ const out = [];
36
+ for (const it of sorted) {
37
+ const key = `${it.id ?? it.label} ${it.subIndex}`;
38
+ if (seen.has(key))
39
+ continue;
40
+ seen.add(key);
41
+ out.push(it);
42
+ }
43
+ return out;
44
+ }
45
+ /**
46
+ * Resolve a layer's `legend` prop + identity into what it registers:
47
+ * `false` ⇒ `null` (opt out — no row); a string ⇒ that display name; omitted /
48
+ * `true` ⇒ the layer's own readout identity. Kept as a helper so every mark
49
+ * resolves the prop identically.
50
+ */
51
+ export function legendLabelFor(legend, identity) {
52
+ if (legend === false)
53
+ return null;
54
+ return typeof legend === 'string' && legend.length > 0 ? legend : identity;
55
+ }
56
+ /**
57
+ * Register this layer instance's legend row(s) — one for the single-row marks,
58
+ * one per column for a stacked bar — keyed off the layer's per-instance
59
+ * `slot` (row *i* registers under a derived per-index key, so the layer's rows
60
+ * live and die together); unregister all on unmount. Pass `rows: null` (or
61
+ * `[]`) to register nothing (the layer opted out via `legend={false}`).
62
+ * `rows` must be **memoised by the caller** (like `useRegisterAnnotation`'s
63
+ * `xs`) so the effect re-runs only when a swatch / label genuinely changes,
64
+ * not every render. Reads the enclosing chart row's key itself — a layer
65
+ * outside a `<ChartRow>` (impossible today) simply doesn't register.
66
+ */
67
+ export function useLegendItems(container, slot, index, rows) {
68
+ const rowFrame = useContext(RowContext);
69
+ const rowKey = rowFrame?.rowKey;
70
+ const { registerLegendItem, unregisterLegendItem } = container;
71
+ // Stable per-subIndex child keys, grown lazily; `slot` itself keys row 0 so
72
+ // the common single-row mark registers exactly one symbol.
73
+ const childKeys = useRef([slot]);
74
+ useEffect(() => {
75
+ const keys = childKeys.current;
76
+ return () => keys.forEach((k) => unregisterLegendItem(k));
77
+ }, [unregisterLegendItem]);
78
+ useEffect(() => {
79
+ const keys = childKeys.current;
80
+ const want = rowKey === undefined ? [] : (rows ?? []);
81
+ while (keys.length < want.length) {
82
+ keys.push(Symbol(`legend-${keys.length}`));
83
+ }
84
+ want.forEach((row, i) => registerLegendItem(keys[i], {
85
+ ...row,
86
+ rowKey: rowKey,
87
+ index,
88
+ subIndex: i,
89
+ }));
90
+ // Drop rows beyond the current count (a stack that lost a column).
91
+ for (let i = want.length; i < keys.length; i += 1) {
92
+ unregisterLegendItem(keys[i]);
93
+ }
94
+ }, [registerLegendItem, unregisterLegendItem, slot, rows, rowKey, index]);
95
+ }
96
+ //# sourceMappingURL=swatch.js.map
package/dist/theme.d.ts CHANGED
@@ -179,6 +179,33 @@ export interface ChartTheme {
179
179
  readonly color: string;
180
180
  readonly fillOpacity: number;
181
181
  readonly depth: readonly [number, number, number];
182
+ /**
183
+ * **Optional per-role overrides** — a small map from a role name to its
184
+ * `color` (and optionally `fillOpacity`), so distinct marks can be styled
185
+ * at once without splitting the whole register: a `<Baseline role="atm">`
186
+ * green, a `<Marker role="ref">` in another hue, each still drawn through
187
+ * the shared {@link depth} ramp. A mark's `role` resolves
188
+ * `roles[role] ?? { color, fillOpacity }` (an unknown/unset role is the
189
+ * base register). Colour stays a **theme** concern — there is no per-mark
190
+ * colour prop (the one-styling-channel discipline).
191
+ */
192
+ readonly roles?: {
193
+ readonly [role: string]: {
194
+ readonly color: string;
195
+ readonly fillOpacity?: number;
196
+ };
197
+ };
198
+ };
199
+ /**
200
+ * The **`<Legend>` card** — background, border, and label text of the
201
+ * in-chart series key. **Optional**: when absent the legend derives from
202
+ * existing tokens (`chip.background`, `axis.grid`, `axis.label`), so a
203
+ * hand-built theme keeps compiling and reads coherently without opting in.
204
+ */
205
+ readonly legend?: {
206
+ readonly background: string;
207
+ readonly border: string;
208
+ readonly text: string;
182
209
  };
183
210
  }
184
211
  /** A resolved line style: stroke colour + width (px). */
package/dist/theme.js CHANGED
@@ -131,6 +131,12 @@ export const defaultTheme = {
131
131
  fillOpacity: 0.1,
132
132
  depth: [1, 0.7, 0.4],
133
133
  },
134
+ // The in-chart series key: chip-white card, gridline border, axis-label text.
135
+ legend: {
136
+ background: '#ffffff',
137
+ border: '#e2e8f0',
138
+ text: '#64748b',
139
+ },
134
140
  };
135
141
  /**
136
142
  * The estela theme — estela's real `@estela/ui` palette as *one theme*, on its
@@ -271,5 +277,11 @@ export const estelaTheme = {
271
277
  fillOpacity: 0.1,
272
278
  depth: [1, 0.7, 0.4],
273
279
  },
280
+ // The in-chart series key on the dark ground: deep panel, abyss-line border.
281
+ legend: {
282
+ background: '#0B4E58', // --es-deep (the chip panel)
283
+ border: '#1B6B75',
284
+ text: '#B7D9DD',
285
+ },
274
286
  };
275
287
  //# sourceMappingURL=theme.js.map
@@ -0,0 +1,106 @@
1
+ import { type ContainerFrame } from './context.js';
2
+ import type { SelectInfo } from './context.js';
3
+ import { type LegendItemInput, type SwatchSpec } from './swatch.js';
4
+ /** One legend **item** as {@link useChartLegend} serves it — a series entry:
5
+ * the registered identity + resolved swatch, plus its live interaction
6
+ * state. (Items are grouped into {@link LegendRow}s by chart row.) */
7
+ export interface LegendItem extends LegendItemInput {
8
+ /** This item's series is the container's current selection (id-keyed). */
9
+ readonly selected: boolean;
10
+ /** This item's series is the container's current hover (id-keyed). */
11
+ readonly hovered: boolean;
12
+ }
13
+ /** One **chart row's** group of legend {@link LegendItem}s — the grouping
14
+ * matches the chart's `<ChartRow>` layout, so a legend can mirror the rows
15
+ * (a flat legend is `rows.flatMap((r) => r.items)`). */
16
+ export interface LegendRow {
17
+ /** The chart row these items belong to (its stable per-row key). */
18
+ readonly rowKey: symbol;
19
+ /** The row's items, in display order (declaration → stack position). */
20
+ readonly items: readonly LegendItem[];
21
+ }
22
+ /** What {@link useChartLegend} returns — the data shape + the sync verbs a
23
+ * custom-rendered legend needs. */
24
+ export interface ChartLegend {
25
+ /** The registered items **grouped by chart row** (top-to-bottom), each
26
+ * group's items in declaration → stack order, deduped by
27
+ * `(id ?? label, stack position)`. A flat list is
28
+ * `rows.flatMap((r) => r.items)`; a row-scoped legend has one entry. */
29
+ readonly rows: readonly LegendRow[];
30
+ /**
31
+ * The container's reserved axis gutters in px — how far the **plot** is
32
+ * inset from the chart box on each side. A custom legend laid out above /
33
+ * below the chart pads by `gutters.left` (and `gutters.right`) to align
34
+ * with the plot instead of the y-axis column.
35
+ */
36
+ readonly gutters: {
37
+ readonly left: number;
38
+ readonly right: number;
39
+ };
40
+ /**
41
+ * The **cursor's x position** in axis units — epoch ms on a time axis, the
42
+ * axis value on a value axis (the `TrackerInfo.time` convention) — or
43
+ * `null` when no cursor is live. The values-in-the-legend seam: with a
44
+ * row's identity and this instant, look up each series' value at the
45
+ * cursor (`series.nearest(cursorTime)`), falling back to the latest sample
46
+ * when `null` — a "current or cursor value" readout per row. Live: the
47
+ * frame rebuilds as the cursor moves, so a hook consumer re-renders with
48
+ * it.
49
+ */
50
+ readonly cursorTime: number | null;
51
+ /**
52
+ * Echo a row hover into the container's `hovered` channel (the same one an
53
+ * in-plot hover drives — marks light up where they support it, and
54
+ * `onHover` fires); `null` clears. Id-gated: a row without an `id` is a
55
+ * no-op (there is no series identity to point at).
56
+ */
57
+ hover(row: LegendItemInput | null): void;
58
+ /**
59
+ * Toggle the container selection to this row's series (fires `onSelect`;
60
+ * clears when the row is already selected — the `<Legend>` click
61
+ * semantics). Id-gated no-op for rows without an `id`.
62
+ */
63
+ select(row: LegendItemInput): void;
64
+ }
65
+ /** The series-scoped {@link SelectInfo} a legend interaction reports: no
66
+ * sample under it, so `key`/`value` are deliberately `NaN` (see the
67
+ * provenance note on {@link SelectInfo.key}); `color` is the swatch's
68
+ * primary colour. */
69
+ export declare function seriesSelectInfo(row: LegendItemInput): SelectInfo;
70
+ /** The swatch's primary colour — what a series-scoped `SelectInfo` reports. */
71
+ export declare function swatchColor(s: SwatchSpec): string;
72
+ /** Build the {@link ChartLegend} from a frame — the pure core `<Legend>` and
73
+ * {@link useChartLegend} share, so a custom-rendered legend and the built-in
74
+ * card can never disagree about rows or sync semantics. Pass a `rowKey` to
75
+ * **scope** the rows to a single chart row (the layers registered under it);
76
+ * omit it for the whole container. */
77
+ export declare function buildChartLegend(container: ContainerFrame, rowKey?: symbol): ChartLegend;
78
+ /**
79
+ * **Headless legend** — the registry `<Legend>` renders, as data, plus the
80
+ * hover/select verbs already wired to the chart. For consumers whose legend
81
+ * is a design of its own (a horizontal strip, a ticker-compare pair with the
82
+ * secondary dimmed, values-in-the-legend): render from `rows` (items grouped
83
+ * by chart row; a flat legend is `rows.flatMap((r) => r.items)`), call
84
+ * `hover`/`select` from your item handlers, and read each item's
85
+ * `selected`/`hovered` state back — the same contract the built-in card uses,
86
+ * because both are built by {@link buildChartLegend}.
87
+ *
88
+ * **Readout integration:** an item's `label` is the layer's readout identity —
89
+ * the same string the tracker's `onTrackerChanged` samples carry — so merging
90
+ * live cursor values into your legend is a label-keyed join, no extra
91
+ * plumbing.
92
+ *
93
+ * **Scope follows placement:** called at the container level it enumerates
94
+ * **all** rows; called inside a `<Layers>` (i.e. under a `<ChartRow>`) it
95
+ * **scopes to that row's** layers — a per-row legend needs no prop, just
96
+ * placement, the same way annotations scope to the row they sit in.
97
+ *
98
+ * **Placement:** it reads the frame, so it works anywhere under the
99
+ * `<ChartContainer>`. To render the markup *outside* the chart's box, portal
100
+ * it out (`createPortal`) — context flows through portals.
101
+ *
102
+ * Must be under a `<ChartContainer>`; throws otherwise (for a fully detached
103
+ * key, `<Legend items>` renders explicit rows without a chart).
104
+ */
105
+ export declare function useChartLegend(): ChartLegend;
106
+ //# sourceMappingURL=useChartLegend.d.ts.map
@@ -0,0 +1,122 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import { ContainerContext, RowContext, } from './context.js';
3
+ import { orderLegendItems, } from './swatch.js';
4
+ /** The series-scoped {@link SelectInfo} a legend interaction reports: no
5
+ * sample under it, so `key`/`value` are deliberately `NaN` (see the
6
+ * provenance note on {@link SelectInfo.key}); `color` is the swatch's
7
+ * primary colour. */
8
+ export function seriesSelectInfo(row) {
9
+ return {
10
+ id: row.id,
11
+ key: NaN,
12
+ value: NaN,
13
+ color: swatchColor(row.swatch),
14
+ label: row.label,
15
+ };
16
+ }
17
+ /** The swatch's primary colour — what a series-scoped `SelectInfo` reports. */
18
+ export function swatchColor(s) {
19
+ switch (s.kind) {
20
+ case 'line':
21
+ return s.color;
22
+ case 'area':
23
+ return s.line;
24
+ case 'band':
25
+ return s.fill;
26
+ case 'scatter':
27
+ return s.color;
28
+ case 'box':
29
+ return s.whisker;
30
+ case 'bar':
31
+ return s.fill;
32
+ case 'candle':
33
+ return s.up;
34
+ }
35
+ }
36
+ /** Build the {@link ChartLegend} from a frame — the pure core `<Legend>` and
37
+ * {@link useChartLegend} share, so a custom-rendered legend and the built-in
38
+ * card can never disagree about rows or sync semantics. Pass a `rowKey` to
39
+ * **scope** the rows to a single chart row (the layers registered under it);
40
+ * omit it for the whole container. */
41
+ export function buildChartLegend(container, rowKey) {
42
+ const scoped = Array.from(container.legendItems.values()).filter((it) => rowKey === undefined || it.rowKey === rowKey);
43
+ // Ordered + deduped specs (chart-row first), each carrying its `rowKey`;
44
+ // group consecutive same-row specs into a LegendRow, mapping spec → item.
45
+ const rows = [];
46
+ for (const spec of orderLegendItems(scoped, container.rowOrder)) {
47
+ const item = {
48
+ label: spec.label,
49
+ swatch: spec.swatch,
50
+ ...(spec.id !== undefined ? { id: spec.id } : {}),
51
+ selected: spec.id !== undefined && container.selected?.id === spec.id,
52
+ hovered: spec.id !== undefined && container.hovered?.id === spec.id,
53
+ };
54
+ const last = rows[rows.length - 1];
55
+ if (last !== undefined && last.rowKey === spec.rowKey) {
56
+ last.items.push(item);
57
+ }
58
+ else {
59
+ rows.push({ rowKey: spec.rowKey, items: [item] });
60
+ }
61
+ }
62
+ // The cursor pixel → axis units, exactly as the tracker fan-in resolves it
63
+ // (in-bounds guard included, so an off-plot cursor reads as "no cursor").
64
+ const cursorTime = container.cursorX !== null &&
65
+ container.cursorX >= 0 &&
66
+ container.cursorX <= container.plotWidth
67
+ ? +container.xScale.invert(container.cursorX)
68
+ : null;
69
+ return {
70
+ rows,
71
+ gutters: { left: container.leftGutter, right: container.rightGutter },
72
+ cursorTime,
73
+ hover: (row) => {
74
+ if (row === null)
75
+ return container.setHovered(null);
76
+ if (row.id !== undefined)
77
+ container.setHovered(seriesSelectInfo(row));
78
+ },
79
+ select: (row) => {
80
+ if (row.id === undefined)
81
+ return;
82
+ container.select(container.selected?.id === row.id ? null : seriesSelectInfo(row));
83
+ },
84
+ };
85
+ }
86
+ /**
87
+ * **Headless legend** — the registry `<Legend>` renders, as data, plus the
88
+ * hover/select verbs already wired to the chart. For consumers whose legend
89
+ * is a design of its own (a horizontal strip, a ticker-compare pair with the
90
+ * secondary dimmed, values-in-the-legend): render from `rows` (items grouped
91
+ * by chart row; a flat legend is `rows.flatMap((r) => r.items)`), call
92
+ * `hover`/`select` from your item handlers, and read each item's
93
+ * `selected`/`hovered` state back — the same contract the built-in card uses,
94
+ * because both are built by {@link buildChartLegend}.
95
+ *
96
+ * **Readout integration:** an item's `label` is the layer's readout identity —
97
+ * the same string the tracker's `onTrackerChanged` samples carry — so merging
98
+ * live cursor values into your legend is a label-keyed join, no extra
99
+ * plumbing.
100
+ *
101
+ * **Scope follows placement:** called at the container level it enumerates
102
+ * **all** rows; called inside a `<Layers>` (i.e. under a `<ChartRow>`) it
103
+ * **scopes to that row's** layers — a per-row legend needs no prop, just
104
+ * placement, the same way annotations scope to the row they sit in.
105
+ *
106
+ * **Placement:** it reads the frame, so it works anywhere under the
107
+ * `<ChartContainer>`. To render the markup *outside* the chart's box, portal
108
+ * it out (`createPortal`) — context flows through portals.
109
+ *
110
+ * Must be under a `<ChartContainer>`; throws otherwise (for a fully detached
111
+ * key, `<Legend items>` renders explicit rows without a chart).
112
+ */
113
+ export function useChartLegend() {
114
+ const container = useContext(ContainerContext);
115
+ if (container === null) {
116
+ throw new Error('useChartLegend() must be used inside a <ChartContainer>');
117
+ }
118
+ // A RowContext in scope (rendered inside a <Layers>) narrows to that row.
119
+ const rowKey = useContext(RowContext)?.rowKey;
120
+ return useMemo(() => buildChartLegend(container, rowKey), [container, rowKey]);
121
+ }
122
+ //# sourceMappingURL=useChartLegend.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The **y-axis auto-tick count** — the `count` a `<YAxis>`'s labels and the
3
+ * row's gridlines both pass to `scale.ticks(count)` / `tickFormat(count)`, so
4
+ * a label and its gridline stay on the same instants (the alignment the two
5
+ * hardcoded `5`s in `YAxis` + `Layers` used to hold by agreeing).
6
+ *
7
+ * Height-derived by default — a short strip gets fewer ticks than a tall row,
8
+ * so a 72px histogram lane no longer crushes 5 labels into the space a 380px
9
+ * row uses (the #508 vol-surface friction). This mirrors the trading-time x
10
+ * axis, whose count is width-derived (0.44.1). An explicit `<YAxis tickCount>`
11
+ * overrides the derivation; explicit `<YAxis ticks>` bypasses this entirely.
12
+ */
13
+ /**
14
+ * Resolve a y-axis's auto-tick count: the explicit `tickCount` when given,
15
+ * else `floor(height / Y_TICK_PX)` floored at 2 (a drawable minimum even on a
16
+ * pre-layout zero height). `ticks(count)` treats it as a target and returns
17
+ * nice 1-2-5 values near it, so a larger count on a tall row is exactly right.
18
+ */
19
+ export declare function resolveYTickCount(height: number, explicit?: number | undefined): number;
20
+ //# sourceMappingURL=yticks.d.ts.map
package/dist/yticks.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The **y-axis auto-tick count** — the `count` a `<YAxis>`'s labels and the
3
+ * row's gridlines both pass to `scale.ticks(count)` / `tickFormat(count)`, so
4
+ * a label and its gridline stay on the same instants (the alignment the two
5
+ * hardcoded `5`s in `YAxis` + `Layers` used to hold by agreeing).
6
+ *
7
+ * Height-derived by default — a short strip gets fewer ticks than a tall row,
8
+ * so a 72px histogram lane no longer crushes 5 labels into the space a 380px
9
+ * row uses (the #508 vol-surface friction). This mirrors the trading-time x
10
+ * axis, whose count is width-derived (0.44.1). An explicit `<YAxis tickCount>`
11
+ * overrides the derivation; explicit `<YAxis ticks>` bypasses this entirely.
12
+ */
13
+ /** Target px of row height per y tick. A y label is one line, so this is the
14
+ * vertical breathing room between gridlines — a touch tighter than the x
15
+ * axis's per-tick budget, since stacked numbers need less room than dates. */
16
+ const Y_TICK_PX = 48;
17
+ /**
18
+ * Resolve a y-axis's auto-tick count: the explicit `tickCount` when given,
19
+ * else `floor(height / Y_TICK_PX)` floored at 2 (a drawable minimum even on a
20
+ * pre-layout zero height). `ticks(count)` treats it as a target and returns
21
+ * nice 1-2-5 values near it, so a larger count on a tall row is exactly right.
22
+ */
23
+ export function resolveYTickCount(height, explicit) {
24
+ if (explicit !== undefined)
25
+ return Math.max(1, Math.floor(explicit));
26
+ return Math.max(2, Math.floor(height / Y_TICK_PX));
27
+ }
28
+ //# sourceMappingURL=yticks.js.map