@pond-ts/charts 0.56.2 → 0.58.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 (78) hide show
  1. package/CHANGELOG.md +1218 -1
  2. package/dist/AreaChart.d.ts +12 -1
  3. package/dist/AreaChart.js +131 -13
  4. package/dist/BarChart.d.ts +84 -9
  5. package/dist/BarChart.js +295 -40
  6. package/dist/BarList.d.ts +85 -5
  7. package/dist/BarList.js +25 -4
  8. package/dist/BoxList.d.ts +70 -3
  9. package/dist/BoxList.js +21 -7
  10. package/dist/BoxPlot.d.ts +2 -1
  11. package/dist/BoxPlot.js +101 -9
  12. package/dist/Candlestick.d.ts +13 -1
  13. package/dist/Candlestick.js +89 -3
  14. package/dist/ChartContainer.d.ts +79 -48
  15. package/dist/ChartContainer.js +482 -60
  16. package/dist/ChartRow.d.ts +9 -2
  17. package/dist/ChartRow.js +86 -12
  18. package/dist/HeatMap.d.ts +176 -0
  19. package/dist/HeatMap.js +344 -0
  20. package/dist/Layers.d.ts +5 -1
  21. package/dist/Layers.js +1014 -253
  22. package/dist/Legend.js +8 -4
  23. package/dist/LineChart.d.ts +18 -1
  24. package/dist/LineChart.js +165 -4
  25. package/dist/ListTable.d.ts +30 -3
  26. package/dist/ListTable.js +381 -23
  27. package/dist/ScatterChart.d.ts +3 -2
  28. package/dist/ScatterChart.js +68 -4
  29. package/dist/XAxis.js +40 -22
  30. package/dist/YAxis.d.ts +28 -1
  31. package/dist/YAxis.js +24 -2
  32. package/dist/annotations.d.ts +74 -0
  33. package/dist/annotations.js +97 -7
  34. package/dist/area.d.ts +34 -1
  35. package/dist/area.js +88 -1
  36. package/dist/bars.d.ts +178 -5
  37. package/dist/bars.js +504 -46
  38. package/dist/box.d.ts +2 -2
  39. package/dist/box.js +158 -40
  40. package/dist/brush.d.ts +142 -0
  41. package/dist/brush.js +179 -0
  42. package/dist/child-index.d.ts +27 -0
  43. package/dist/child-index.js +57 -0
  44. package/dist/context.d.ts +871 -36
  45. package/dist/cursors.d.ts +161 -0
  46. package/dist/cursors.js +503 -0
  47. package/dist/decimate.d.ts +78 -1
  48. package/dist/decimate.js +157 -0
  49. package/dist/heat.d.ts +163 -0
  50. package/dist/heat.js +659 -0
  51. package/dist/index.d.ts +13 -4
  52. package/dist/index.js +25 -2
  53. package/dist/line.d.ts +137 -0
  54. package/dist/line.js +328 -0
  55. package/dist/ohlc.d.ts +16 -1
  56. package/dist/ohlc.js +93 -4
  57. package/dist/scatter.d.ts +17 -9
  58. package/dist/scatter.js +221 -33
  59. package/dist/select.d.ts +13 -5
  60. package/dist/select.js +14 -6
  61. package/dist/selection-fixtures.d.ts +174 -0
  62. package/dist/selection-fixtures.js +569 -0
  63. package/dist/selection-stories.d.ts +73 -0
  64. package/dist/selection-stories.js +301 -0
  65. package/dist/selectors.d.ts +316 -0
  66. package/dist/selectors.js +391 -0
  67. package/dist/span.d.ts +122 -0
  68. package/dist/span.js +203 -0
  69. package/dist/sweep.d.ts +154 -0
  70. package/dist/sweep.js +282 -0
  71. package/dist/theme.d.ts +517 -11
  72. package/dist/theme.js +220 -39
  73. package/dist/tracker.d.ts +6 -0
  74. package/dist/tracker.js +6 -0
  75. package/dist/tradingAxis.fixture.d.ts +78 -0
  76. package/dist/tradingAxis.fixture.js +215 -0
  77. package/dist/useChartLegend.js +18 -3
  78. package/package.json +3 -3
package/dist/BoxList.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type ReactNode } from 'react';
2
2
  import type { SeriesSchema, ValueSeriesSchema } from 'pond-ts';
3
3
  import { type BoxListColumn, type ListCellSpec, type ListMarker, type ListRow, type ListSortDirection } from './list.js';
4
4
  import { type ListRowsSource, type ListSeriesSource } from './list-source.js';
5
+ import type { SelectModifiers } from './context.js';
5
6
  import { type ChartTheme } from './theme.js';
6
7
  /** The props both BoxList source doors share. */
7
8
  export interface BoxListCommon<R extends ListRow = ListRow> {
@@ -41,10 +42,76 @@ export interface BoxListCommon<R extends ListRow = ListRow> {
41
42
  defaultExpanded?: readonly string[];
42
43
  /** Observe a toggle (`expanded` is the row's new state). */
43
44
  onExpandToggle?: (key: string, expanded: boolean) => void;
44
- /** The selected row's `key` (inset accent edge). Pair with `onRowClick`. */
45
- selected?: string | null;
46
- /** Row click (also gates the hover affordance). */
45
+ /**
46
+ * The selected row(s), marked with an inset edge in the annotation (marks)
47
+ * register selection is a user's mark, not data. Consumer-owned state:
48
+ * pair with {@link onRowClick}. `null` / omitted ⇒ none.
49
+ *
50
+ * **Accepts one key or a set**, the same union {@link hovered} takes
51
+ * ([PND-INTERACTCONF] / RFC `interaction.md` A3.1 — the list family speaks
52
+ * the canvas's interaction vocabulary, not a parallel one). Plural because
53
+ * a range of rows can be selected at once; passing a bare key still means
54
+ * exactly what it looks like.
55
+ *
56
+ * The library applies **no set arithmetic** — it renders what you hand back.
57
+ */
58
+ selected?: string | readonly string[] | null;
59
+ /** Row click (also gates the pointer affordance). */
47
60
  onRowClick?: (row: R) => void;
61
+ /**
62
+ * **Plural select** — the list's answer to `<MultiSelector>`, and how a user
63
+ * produces a multi-row {@link selected}.
64
+ *
65
+ * Fires with the rows the gesture took plus its modifiers:
66
+ *
67
+ * - a **click** reports `[row]` — so this is a strict *superset* of
68
+ * {@link onRowClick}, the way `<MultiSelector>` is of `<Selector>`;
69
+ * - a **drag across rows** reports the whole inclusive run, in display
70
+ * order.
71
+ *
72
+ * **Mounting it is what enables the drag** (interaction RFC A4.2 rule 1 —
73
+ * the same rule that makes a bare `<MultiSelector />` enable the canvas
74
+ * sweep). A list with only `onRowClick` behaves exactly as it always has.
75
+ *
76
+ * **Crossing into another row is what makes it a range**, not a pixel slop:
77
+ * a row is tall and discrete, so a press-and-release on one row is always a
78
+ * click, and a horizontal wobble — which on a stack of rows means nothing —
79
+ * can never commit one. While the drag runs, the covered rows light as
80
+ * *hovered*: that is the live preview of what releasing would take, and it
81
+ * out-ranks {@link hovered} for the duration without touching it.
82
+ *
83
+ * **The library holds no state and applies no set arithmetic.** You get the
84
+ * run and the modifiers; you decide whether to replace or union, and feed
85
+ * the result back through {@link selected}:
86
+ *
87
+ * ```tsx
88
+ * onRowSelect={(rows, m) =>
89
+ * setSel((cur) => {
90
+ * const keys = rows.map((r) => r.key);
91
+ * return m.additive ? [...new Set([...cur, ...keys])] : keys;
92
+ * })
93
+ * }
94
+ * ```
95
+ *
96
+ * `modifiers.additive` is the platform-idiomatic add chord already resolved
97
+ * (⌘ on macOS, Ctrl elsewhere). **`shiftKey` is reported but carries no
98
+ * built-in meaning** — an ordinal range is a gesture here, not a modifier
99
+ * (see `SelectModifiers`), so a shift-click extend is yours to define if you
100
+ * want one.
101
+ */
102
+ onRowSelect?: (rows: readonly R[], modifiers: SelectModifiers) => void;
103
+ /**
104
+ * Controlled **hover-highlight** — the lit row key(s), or `null`; **omitted
105
+ * ⇒ uncontrolled**. Accepts one key or a set, the same union
106
+ * `<Selector hovered>` takes, so an external hover (a chart mark, a map
107
+ * segment) lights rows from outside. See `<BarList>`'s `hovered` for the
108
+ * full note.
109
+ */
110
+ hovered?: string | readonly string[] | null;
111
+ /** Hover out: the entered row, or `null` on leaving the rows — deduped by
112
+ * row key, fires controlled or uncontrolled. Pair with {@link hovered} to
113
+ * sync hover both ways. */
114
+ onHover?: (row: R | null) => void;
48
115
  /** Each box line's height in px. **Omitted ⇒ `10`.** */
49
116
  barHeight?: number;
50
117
  /** Rule between rows. **Omitted ⇒ `true`.** */
package/dist/BoxList.js CHANGED
@@ -35,7 +35,7 @@ export function BoxList(props) {
35
35
  // One normalized view of the union — `isSeriesSource` is the runtime
36
36
  // narrowing; the doors are mutually exclusive by construction now.
37
37
  const source = props;
38
- const { rows, series, label, columns, domain, sortBy, sortDirection = 'desc', sort, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, markers, barHeight = 10, divided, baseline = true, theme = defaultTheme, } = source;
38
+ const { rows, series, label, columns, domain, sortBy, sortDirection = 'desc', sort, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, onRowSelect, hovered, onHover, markers, barHeight = 10, divided, baseline = true, theme = defaultTheme, } = source;
39
39
  // A runtime guard for JS consumers and `any`-typed call sites — the
40
40
  // props union makes both branches unreachable from typed TS, but a
41
41
  // silently-ignored source prop is a worse failure than a throw.
@@ -58,13 +58,10 @@ export function BoxList(props) {
58
58
  frac: listFraction(m.value, scale),
59
59
  ...(m.label !== undefined ? { label: m.label } : {}),
60
60
  })), [markers, scale]);
61
- return (_jsx(ListTable, { rows: sorted, kind: "box", markers: resolvedMarkers, before: before, after: after, renderExpanded: renderExpanded, defaultExpanded: defaultExpanded, onExpandToggle: onExpandToggle, selected: selected, onRowClick: onRowClick, divided: divided, baseline: baseline, theme: theme, renderGlyphs: (row) => (_jsx(_Fragment, { children: columns.map((col, ci) => (_jsx(BoxLine
62
- // Index-qualified so two lines over the same quantile names
63
- // (say, styled differently) never collide.
64
- , { row: row, col: col, scale: scale, height: barHeight, style: theme.box[col.as ?? 'default'] ?? theme.box.default, ink: listInk(theme), fontSize: theme.font.size }, `${ci} ${col.lower} ${col.upper}`))) })) }));
61
+ return (_jsx(ListTable, { rows: sorted, kind: "box", markers: resolvedMarkers, before: before, after: after, renderExpanded: renderExpanded, defaultExpanded: defaultExpanded, onExpandToggle: onExpandToggle, selected: selected, onRowClick: onRowClick, onRowSelect: onRowSelect, hovered: hovered, onHover: onHover, divided: divided, baseline: baseline, theme: theme, renderGlyphs: (row, state) => (_jsx(_Fragment, { children: columns.map((col, ci) => (_jsx(BoxLine, { dimmed: state.dimmed, row: row, col: col, scale: scale, height: barHeight, style: theme.box[col.as ?? 'default'] ?? theme.box.default, ink: listInk(theme), fontSize: theme.font.size }, `${ci} ${col.lower} ${col.upper}`))) })) }));
65
62
  }
66
63
  /** One horizontal box line: range band → body → median → current tick + label. */
67
- function BoxLine({ row, col, scale, height, style, ink, fontSize, }) {
64
+ function BoxLine({ row, col, scale, height, style, ink, fontSize, dimmed, }) {
68
65
  const at = (name) => name === undefined ? null : listFraction(row.values[name], scale);
69
66
  const lo = at(col.lower);
70
67
  const hi = at(col.upper);
@@ -77,6 +74,21 @@ function BoxLine({ row, col, scale, height, style, ink, fontSize, }) {
77
74
  ? col.format(raw)
78
75
  : null;
79
76
  const pct = (f) => `${f * 100}%`;
77
+ /**
78
+ * The dim multiplier for this line's **marks** — body, median, tick.
79
+ *
80
+ * Not the range band below it: that is the row's *scale*, the thing that
81
+ * makes one row comparable with the next, and receding it alongside the
82
+ * marks is exactly the mistake `ChartTheme.list`'s track rule names. It is
83
+ * the box list's track.
84
+ *
85
+ * **Selection is deliberately absent here.** Rule 2 is that band + rail
86
+ * read as selected with no help from the fill, and a box has four inks
87
+ * (whisker, body, median, tick) rather than the one a bar has — "the fill
88
+ * goes blue" has no single referent. So a selected box row is signalled by
89
+ * its chrome alone, which the rule says is sufficient by design.
90
+ */
91
+ const dim = dimmed ? 0.32 : 1;
80
92
  // The row keeps its slot height even when everything is missing — a gap
81
93
  // reads as an empty line, not a collapsed row.
82
94
  return (_jsxs("div", { "data-list-boxline": "", style: { position: 'relative', height: height + 4, margin: '2px 0' }, children: [lo !== null && hi !== null && (_jsx("div", { "data-list-range": "", style: {
@@ -95,7 +107,7 @@ function BoxLine({ row, col, scale, height, style, ink, fontSize, }) {
95
107
  left: pct(q1),
96
108
  width: pct(Math.max(q3 - q1, 0)),
97
109
  background: style.fill,
98
- opacity: Math.min(style.fillOpacity * 2, 1),
110
+ opacity: Math.min(style.fillOpacity * 2, 1) * dim,
99
111
  borderRadius: 1,
100
112
  } })), med !== null && (_jsx("div", { "data-list-median": "", style: {
101
113
  position: 'absolute',
@@ -104,6 +116,7 @@ function BoxLine({ row, col, scale, height, style, ink, fontSize, }) {
104
116
  left: `calc(${pct(med)} - ${style.medianWidth / 2}px)`,
105
117
  width: style.medianWidth,
106
118
  background: style.median,
119
+ opacity: dim,
107
120
  } })), tick !== null && (_jsxs(_Fragment, { children: [_jsx("div", { "data-list-tick": "", style: {
108
121
  position: 'absolute',
109
122
  top: 0,
@@ -112,6 +125,7 @@ function BoxLine({ row, col, scale, height, style, ink, fontSize, }) {
112
125
  width: 3,
113
126
  background: style.stroke,
114
127
  borderRadius: 1,
128
+ opacity: dim,
115
129
  } }), label !== null && (_jsx("span", { "data-list-value": "", style: {
116
130
  position: 'absolute',
117
131
  left: `calc(${pct(tick)} + 8px)`,
package/dist/BoxPlot.d.ts CHANGED
@@ -66,7 +66,8 @@ export interface BoxPlotCommon<S extends SeriesSchema = SeriesSchema, VS extends
66
66
  * contract `<BarChart>` / `<ScatterChart>` carry. With an `id`, a click on a
67
67
  * box (body or whisker — a range-only bid→ask segment included) selects it
68
68
  * (`selected`/`onSelect`) and pointer-over lights it (`hovered`/`onHover`);
69
- * the box matching the selection's `(id, key)` outlines. **Omitted
69
+ * **every** box matching a selection member's `(id, key)` outlines, so a
70
+ * multi-mark `selected` set lights all of it. **Omitted ⇒
70
71
  * display-only** (a click resolves to empty space). `key` is the box's `x`
71
72
  * (its `begin`).
72
73
  */
package/dist/BoxPlot.js CHANGED
@@ -1,12 +1,38 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
- import { ValueSeries } from 'pond-ts';
2
+ import { Interval, ValueSeries } from 'pond-ts';
3
+ import { sweep1D } from './sweep.js';
3
4
  import { boxFromTimeSeries, boxFromValueSeries } from './data.js';
4
5
  import { boxAt, boxExtent, boxIndexAtTime, drawBox, isFiniteBox, } from './box.js';
6
+ import { spansForLayer } from './span.js';
5
7
  import { ContainerContext, LayersContext, } from './context.js';
6
8
  import { legendLabelFor, useLegendItems, } from './swatch.js';
7
9
  import { useSlotKey } from './use-slot-key.js';
8
10
  /** Whisker collapse floor (px) — a too-thin box still draws a 1px mark. */
9
11
  const MIN_BOX_WIDTH_PX = 1;
12
+ /** Stable identity for "no keys of ours in this set" — the resting case, so the
13
+ * layer doesn't re-register (and the canvas doesn't repaint) every time some
14
+ * *other* layer's selection changes. */
15
+ const NO_KEYS = [];
16
+ /**
17
+ * The keys of every member of `set` naming this layer (`m.id === id`) — the
18
+ * narrowing that keeps `box.ts` free of the selection identity, exactly as
19
+ * `barAt` / `boxAt` keep it free of the theme.
20
+ *
21
+ * A box's identity within its series is its `x` (its `begin`), so the key is the
22
+ * whole match; a `SelectInfo.mark` is `undefined` for a box (see its docs) and
23
+ * has nothing to add here. Linear over the set — see `includesKey` in `box.ts`.
24
+ */
25
+ function keysOf(set, id) {
26
+ if (id === undefined || set.length === 0)
27
+ return NO_KEYS;
28
+ const out = [];
29
+ for (let i = 0; i < set.length; i += 1) {
30
+ const m = set[i];
31
+ if (m.id === id)
32
+ out.push(m.key);
33
+ }
34
+ return out.length === 0 ? NO_KEYS : out;
35
+ }
10
36
  /**
11
37
  * A discrete box-and-whisker draw layer — the bar-chart analog of the variance
12
38
  * band. Reads **pre-computed quantile columns** of `series` (typically a
@@ -64,13 +90,42 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
64
90
  // The series identity for selection/legend: the `as` role, else the range
65
91
  // columns as a span (matches the legend row's label).
66
92
  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.
93
+ // Current selection / hover narrowed to this layer's box keys (each box's
94
+ // `x`) — matched by the series `id`, so a change re-registers the layer and
95
+ // the canvas repaints the outlines. A no-`id` layer never matches.
70
96
  const sel = container.selected;
71
97
  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;
98
+ // **Every** selected key belonging to this series, not just the first: the
99
+ // container's `selected` has been a set since [PND-MULTISEL] and `hovered`
100
+ // since RFC A4.3, and a draw path that reads `[0]` silently drops the rest —
101
+ // a consumer pinning three boxes saw one outline and no error.
102
+ const selectedKeys = useMemo(() => keysOf(sel, id), [sel, id]);
103
+ const hoveredKeys = useMemo(() => keysOf(hov, id), [hov, id]);
104
+ // The selection's span entries, narrowed to this layer (interaction RFC
105
+ // A5.2). Every box shares one label (the series label), so a span's `rows`
106
+ // channel resolves here once; `x`/`y` ride through for the draw's per-box
107
+ // test. Reference-stable when empty — see `keysOf`.
108
+ const layerSpans = useMemo(() => spansForLayer(container.selectedSpans, id, label), [container.selectedSpans, id, label]);
109
+ // The box's **column** — its `[x, xEnd)` slot, published as snap buckets
110
+ // exactly as a bar layer publishes its bins. A box *is* a bar that isn't
111
+ // grounded to the axis: an aggregation owning one interval of the key axis,
112
+ // drawn floating between two quantiles instead of rising from the baseline.
113
+ // That the ink doesn't reach the axis says nothing about which column the
114
+ // mark owns, so the region cursor snaps to boxes and a sweep cuts them
115
+ // slot-edge to slot-edge, same as bars (RFC A7.6's edge rule).
116
+ //
117
+ // Memoized off the shape alone, so a hover / selection change (which
118
+ // rebuilds the layer entry) doesn't re-allocate the intervals.
119
+ const binBuckets = useMemo(() => {
120
+ if (bx.length === 0)
121
+ return null;
122
+ const out = new Array(bx.length);
123
+ for (let i = 0; i < bx.length; i += 1) {
124
+ const b = bx.x[i];
125
+ out[i] = new Interval({ value: b, start: b, end: bx.xEnd[i] });
126
+ }
127
+ return out;
128
+ }, [bx]);
74
129
  const entry = useMemo(() => ({
75
130
  layer: {
76
131
  as: semantic,
@@ -79,6 +134,7 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
79
134
  // infers the shared x kind from its layers.
80
135
  xKind: isValue ? 'value' : 'time',
81
136
  xExtent: () => bx.length === 0 ? null : [bx.x[0], bx.xEnd[bx.length - 1]],
137
+ ...(binBuckets !== null ? { binIntervals: () => binBuckets } : {}),
82
138
  sampleAt: (x) => {
83
139
  // The readout reads the box **under the cursor** (boxIndexAtTime — span
84
140
  // containment, not nearest-by-begin which flips past a wide box's
@@ -142,13 +198,48 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
142
198
  const [, begin, value] = hit;
143
199
  return { id, key: begin, value, color: style.whisker, label };
144
200
  },
201
+ // The `<MultiSelector>` sweep's range query, identical in shape
202
+ // to the bar layer's: boxes are sorted, non-overlapping key
203
+ // intervals, so the covered set is a contiguous run and
204
+ // `sweep1D`'s two binary searches find it. Each materialised hit
205
+ // is EXACTLY what `hitTest` reports for that box, so a swept box
206
+ // and a clicked box are the same currency.
207
+ beginSweep: () => bx.length === 0
208
+ ? null
209
+ : sweep1D({
210
+ id,
211
+ begin: bx.x,
212
+ end: bx.xEnd,
213
+ length: bx.length,
214
+ // A gap box (its present quantiles not all finite) draws
215
+ // nothing and owns no membership — the same rule
216
+ // `hitTest` and the flag already apply.
217
+ selectable: (i) => isFiniteBox(bx, i),
218
+ materialize: (lo, hi) => {
219
+ const out = [];
220
+ for (let i = lo; i < hi; i += 1) {
221
+ if (!isFiniteBox(bx, i))
222
+ continue;
223
+ out.push({
224
+ id,
225
+ key: bx.x[i],
226
+ // `upper`, matching what `hitTest` reports.
227
+ value: bx.upper[i],
228
+ color: style.whisker,
229
+ label,
230
+ });
231
+ }
232
+ return out;
233
+ },
234
+ }),
145
235
  }),
146
- draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian, offset, capWidth, selectedKey, hoveredKey, decimate),
236
+ draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian, offset, capWidth, selectedKeys, hoveredKeys, decimate, layerSpans),
147
237
  },
148
238
  axisId: axis,
149
239
  index,
150
240
  }), [
151
241
  bx,
242
+ binBuckets,
152
243
  isValue,
153
244
  series,
154
245
  lower,
@@ -165,8 +256,9 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
165
256
  capWidth,
166
257
  id,
167
258
  label,
168
- selectedKey,
169
- hoveredKey,
259
+ selectedKeys,
260
+ hoveredKeys,
261
+ layerSpans,
170
262
  decimate,
171
263
  axis,
172
264
  index,
@@ -77,6 +77,18 @@ export interface CandlestickProps<S extends SeriesSchema> {
77
77
  * up/down candle pair.
78
78
  */
79
79
  legend?: boolean | string;
80
+ /**
81
+ * Stable series identity — **gates selection + hover**, the same id-gated
82
+ * contract `<BarChart>` / `<BoxPlot>` / `<ScatterChart>` carry. With an `id`,
83
+ * a click inside a candle's slot selects it (`selected`/`onSelect`),
84
+ * pointer-over lights it (`hovered`/`onHover`), and a `<MultiSelector>` can
85
+ * sweep a run of candles. **Omitted ⇒ display-only.** `key` is the candle's
86
+ * `x` (its slot begin).
87
+ *
88
+ * The state cues never touch the candle's colour — see {@link CandleStyle}
89
+ * for why a candle is the one mark whose hue cannot carry its state.
90
+ */
91
+ id?: string;
80
92
  /**
81
93
  * @internal Declaration position among the `<Layers>` children, injected by
82
94
  * `Layers` so z-order follows JSX order. Do not set.
@@ -110,5 +122,5 @@ export interface CandlestickProps<S extends SeriesSchema> {
110
122
  * </Layers>
111
123
  * ```
112
124
  */
113
- export declare function Candlestick<S extends SeriesSchema>({ series, open, high, low, close, as: semantic, axis, variant, colorBy, gap, showOHLC, decimate, legend, index, }: CandlestickProps<S>): null;
125
+ export declare function Candlestick<S extends SeriesSchema>({ series, open, high, low, close, as: semantic, axis, variant, colorBy, gap, showOHLC, decimate, id, legend, index, }: CandlestickProps<S>): null;
114
126
  //# sourceMappingURL=Candlestick.d.ts.map
@@ -1,9 +1,26 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
+ import { Interval } from 'pond-ts';
2
3
  import { ohlcFromTimeSeries } from './data.js';
3
- import { drawCandles, isFiniteOhlc, ohlcExtent, ohlcIndexAtTime, resolveCandleStyle, } from './ohlc.js';
4
+ import { drawCandles, isFiniteOhlc, ohlcAt, ohlcExtent, ohlcIndexAtTime, resolveCandleStyle, } from './ohlc.js';
4
5
  import { ContainerContext, LayersContext, } from './context.js';
5
6
  import { legendLabelFor, useLegendItems, } from './swatch.js';
6
7
  import { useSlotKey } from './use-slot-key.js';
8
+ import { sweep1D } from './sweep.js';
9
+ const NO_KEYS = [];
10
+ /** This layer's keys within a selection / hover set — see `<BoxPlot>`'s twin.
11
+ * Reference-stable when empty, so an untouched set doesn't rebuild the layer. */
12
+ function keysOf(set, id) {
13
+ if (id === undefined || set.length === 0)
14
+ return NO_KEYS;
15
+ const out = [];
16
+ for (let i = 0; i < set.length; i += 1) {
17
+ const m = set[i];
18
+ if (m.id === id)
19
+ out.push(m.key);
20
+ }
21
+ return out.length === 0 ? NO_KEYS : out;
22
+ }
23
+ import { spansForLayer } from './span.js';
7
24
  /**
8
25
  * A first-class OHLC **candlestick** draw layer — the financial sibling of
9
26
  * {@link BoxPlot}. Reads four price columns (`open`/`high`/`low`/`close`) of
@@ -31,7 +48,7 @@ import { useSlotKey } from './use-slot-key.js';
31
48
  * </Layers>
32
49
  * ```
33
50
  */
34
- export function Candlestick({ series, open = 'open', high = 'high', low = 'low', close = 'close', as: semantic, axis, variant = 'candle', colorBy = 'direction', gap = 0, showOHLC = false, decimate = true, legend, index = 0, }) {
51
+ export function Candlestick({ series, open = 'open', high = 'high', low = 'low', close = 'close', as: semantic, axis, variant = 'candle', colorBy = 'direction', gap = 0, showOHLC = false, decimate = true, id, legend, index = 0, }) {
35
52
  const container = useContext(ContainerContext);
36
53
  if (container === null) {
37
54
  throw new Error('<Candlestick> must be rendered inside a <ChartContainer>');
@@ -47,12 +64,32 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
47
64
  // Series identity for the readout (the `as` role, else the close column name) —
48
65
  // the primary `close` pill keys on this, like every other layer.
49
66
  const label = semantic ?? close;
67
+ // Current selection / hover narrowed to this layer's candle keys (each
68
+ // candle's `x`), and the selection's span entries — the same three channels
69
+ // the bar and box layers narrow. A no-`id` layer never matches.
70
+ const selectedKeys = useMemo(() => keysOf(container.selected, id), [container.selected, id]);
71
+ const hoveredKeys = useMemo(() => keysOf(container.hovered, id), [container.hovered, id]);
72
+ const layerSpans = useMemo(() => spansForLayer(container.selectedSpans, id, label), [container.selectedSpans, id, label]);
73
+ // A candle owns one `[x, xEnd)` column of the key axis — an aggregation, the
74
+ // same as a box — so it publishes its slots as snap buckets and the region
75
+ // cursor / sweep band land on candle edges rather than centre-to-centre.
76
+ const binBuckets = useMemo(() => {
77
+ if (ohlc.length === 0)
78
+ return null;
79
+ const out = new Array(ohlc.length);
80
+ for (let i = 0; i < ohlc.length; i += 1) {
81
+ const b = ohlc.x[i];
82
+ out[i] = new Interval({ value: b, start: b, end: ohlc.xEnd[i] });
83
+ }
84
+ return out;
85
+ }, [ohlc]);
50
86
  const entry = useMemo(() => ({
51
87
  layer: {
52
88
  as: semantic,
53
89
  yExtent: () => ohlcExtent(ohlc),
54
90
  xKind: 'time',
55
91
  xExtent: () => ohlc.length === 0 ? null : [ohlc.x[0], ohlc.xEnd[ohlc.length - 1]],
92
+ ...(binBuckets !== null ? { binIntervals: () => binBuckets } : {}),
56
93
  sampleAt: (time) => {
57
94
  // The readout reads the candle **under the cursor** (containment span,
58
95
  // not nearest-by-begin), anchored at the slot centre. Outside every
@@ -84,7 +121,51 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
84
121
  ];
85
122
  return samples;
86
123
  },
87
- draw: (ctx, xScale, yScale) => drawCandles(ctx, ohlc, xScale, yScale, style, variant, colorBy, gap, undefined, decimate),
124
+ ...(id === undefined
125
+ ? {}
126
+ : {
127
+ // Rect containment over the candle's slot (`ohlcAt`) — a candle
128
+ // is a discrete interval mark, so this is the box's rule, not
129
+ // the continuous nearest-point one. `key` is its `x`, `value`
130
+ // its `close` (the price the default readout reports).
131
+ hitTest: (px, py, xScale, yScale) => {
132
+ const hit = ohlcAt(ohlc, px, py, xScale, yScale, gap, 1);
133
+ if (hit === null)
134
+ return null;
135
+ const [i, begin, value] = hit;
136
+ const { body } = resolveCandleStyle(style, ohlc.open[i], ohlc.close[i], colorBy);
137
+ return { id, key: begin, value, color: body, label };
138
+ },
139
+ // Candles are sorted, non-overlapping columns, so a sweep is
140
+ // `sweep1D`'s two binary searches — identical to the bar and box
141
+ // layers, and each materialised hit is exactly what `hitTest`
142
+ // reports for that candle.
143
+ beginSweep: () => ohlc.length === 0
144
+ ? null
145
+ : sweep1D({
146
+ id,
147
+ begin: ohlc.x,
148
+ end: ohlc.xEnd,
149
+ length: ohlc.length,
150
+ selectable: (i) => isFiniteOhlc(ohlc, i),
151
+ materialize: (lo, hi) => {
152
+ const out = [];
153
+ for (let i = lo; i < hi; i += 1) {
154
+ if (!isFiniteOhlc(ohlc, i))
155
+ continue;
156
+ out.push({
157
+ id,
158
+ key: ohlc.x[i],
159
+ value: ohlc.close[i],
160
+ color: resolveCandleStyle(style, ohlc.open[i], ohlc.close[i], colorBy).body,
161
+ label,
162
+ });
163
+ }
164
+ return out;
165
+ },
166
+ }),
167
+ }),
168
+ draw: (ctx, xScale, yScale) => drawCandles(ctx, ohlc, xScale, yScale, style, variant, colorBy, gap, undefined, decimate, selectedKeys, hoveredKeys, layerSpans),
88
169
  },
89
170
  axisId: axis,
90
171
  index,
@@ -100,6 +181,11 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
100
181
  decimate,
101
182
  axis,
102
183
  index,
184
+ id,
185
+ binBuckets,
186
+ selectedKeys,
187
+ hoveredKeys,
188
+ layerSpans,
103
189
  ]);
104
190
  // Stable per-instance slot (see useSlotKey): keeps this candle layer's
105
191
  // z-position + identity across prop updates; the injected index drives the sort.