@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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.48.0",
42
- "pond-ts": "^0.48.0",
41
+ "@pond-ts/react": "^0.49.0",
42
+ "pond-ts": "^0.49.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {