@pond-ts/charts 0.57.0 → 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 (73) hide show
  1. package/CHANGELOG.md +1070 -1
  2. package/dist/AreaChart.d.ts +12 -1
  3. package/dist/AreaChart.js +131 -13
  4. package/dist/BarChart.js +184 -30
  5. package/dist/BarList.d.ts +85 -5
  6. package/dist/BarList.js +25 -4
  7. package/dist/BoxList.d.ts +70 -3
  8. package/dist/BoxList.js +21 -7
  9. package/dist/BoxPlot.d.ts +2 -1
  10. package/dist/BoxPlot.js +101 -9
  11. package/dist/Candlestick.d.ts +13 -1
  12. package/dist/Candlestick.js +89 -3
  13. package/dist/ChartContainer.d.ts +36 -48
  14. package/dist/ChartContainer.js +465 -59
  15. package/dist/ChartRow.d.ts +9 -2
  16. package/dist/ChartRow.js +86 -12
  17. package/dist/HeatMap.d.ts +176 -0
  18. package/dist/HeatMap.js +344 -0
  19. package/dist/Layers.d.ts +5 -1
  20. package/dist/Layers.js +1014 -253
  21. package/dist/Legend.js +8 -4
  22. package/dist/LineChart.d.ts +18 -1
  23. package/dist/LineChart.js +165 -4
  24. package/dist/ListTable.d.ts +30 -3
  25. package/dist/ListTable.js +381 -23
  26. package/dist/ScatterChart.d.ts +3 -2
  27. package/dist/ScatterChart.js +68 -4
  28. package/dist/XAxis.js +40 -22
  29. package/dist/area.d.ts +34 -1
  30. package/dist/area.js +88 -1
  31. package/dist/bars.d.ts +57 -3
  32. package/dist/bars.js +237 -26
  33. package/dist/box.d.ts +2 -2
  34. package/dist/box.js +158 -40
  35. package/dist/brush.d.ts +142 -0
  36. package/dist/brush.js +179 -0
  37. package/dist/child-index.d.ts +27 -0
  38. package/dist/child-index.js +57 -0
  39. package/dist/context.d.ts +859 -33
  40. package/dist/cursors.d.ts +161 -0
  41. package/dist/cursors.js +503 -0
  42. package/dist/decimate.d.ts +78 -1
  43. package/dist/decimate.js +157 -0
  44. package/dist/heat.d.ts +163 -0
  45. package/dist/heat.js +659 -0
  46. package/dist/index.d.ts +11 -2
  47. package/dist/index.js +22 -0
  48. package/dist/line.d.ts +137 -0
  49. package/dist/line.js +328 -0
  50. package/dist/ohlc.d.ts +16 -1
  51. package/dist/ohlc.js +93 -4
  52. package/dist/scatter.d.ts +17 -9
  53. package/dist/scatter.js +221 -33
  54. package/dist/select.d.ts +13 -5
  55. package/dist/select.js +14 -6
  56. package/dist/selection-fixtures.d.ts +174 -0
  57. package/dist/selection-fixtures.js +569 -0
  58. package/dist/selection-stories.d.ts +73 -0
  59. package/dist/selection-stories.js +301 -0
  60. package/dist/selectors.d.ts +316 -0
  61. package/dist/selectors.js +391 -0
  62. package/dist/span.d.ts +122 -0
  63. package/dist/span.js +203 -0
  64. package/dist/sweep.d.ts +154 -0
  65. package/dist/sweep.js +282 -0
  66. package/dist/theme.d.ts +456 -5
  67. package/dist/theme.js +217 -41
  68. package/dist/tracker.d.ts +6 -0
  69. package/dist/tracker.js +6 -0
  70. package/dist/tradingAxis.fixture.d.ts +78 -0
  71. package/dist/tradingAxis.fixture.js +215 -0
  72. package/dist/useChartLegend.js +18 -3
  73. package/package.json +3 -3
@@ -0,0 +1,344 @@
1
+ import { useContext, useEffect, useMemo } from 'react';
2
+ import { ValueSeries } from 'pond-ts';
3
+ import { stacksFromColumns } from './data.js';
4
+ import { bandedColor, drawHeat, heatAt, heatValueExtent, } from './heat.js';
5
+ import { spansForLayer } from './span.js';
6
+ import { ContainerContext, LayersContext, } from './context.js';
7
+ import { useSlotKey } from './use-slot-key.js';
8
+ import { sweep2D } from './sweep.js';
9
+ /** Stable identity for "nothing in this set" — the resting case, so the layer
10
+ * doesn't rebuild its `entry` (and the canvas doesn't repaint) merely because
11
+ * the container handed out a fresh empty array. */
12
+ const NO_MARKS = [];
13
+ /**
14
+ * The cell identity of every member of `set` — the layer `id`, the bin `key`
15
+ * (or the stable per-bin `mark` where the series carries one) and the row
16
+ * `label`, which is the whole of what {@link drawHeat} matches on.
17
+ *
18
+ * Deliberately **not** filtered to this layer's own `id` — the draw matches on
19
+ * it anyway, and gates its per-bin scan on whether either set names this layer
20
+ * at all, so a component-side filter would buy nothing and add a second place
21
+ * the id rule lives. What this *does* drop is the `SelectInfo` presentation
22
+ * fields (`value`, `color`), which the draw has no business reading.
23
+ */
24
+ function marksOf(set) {
25
+ if (set.length === 0)
26
+ return NO_MARKS;
27
+ return set.map((m) => ({
28
+ id: m.id,
29
+ key: m.key,
30
+ label: m.label,
31
+ ...(m.mark !== undefined ? { mark: m.mark } : {}),
32
+ }));
33
+ }
34
+ /**
35
+ * A **heat-map draw layer**: a grid of cells, bins along x and the series'
36
+ * columns down y, colour carrying the aggregate ([PND-HEATMAP]).
37
+ *
38
+ * ```tsx
39
+ * // A stripe — one column.
40
+ * <HeatMap series={hourly} columns={['count']} colors={ramp} id="load" />
41
+ *
42
+ * // A grid — one column per row.
43
+ * <HeatMap series={byCity} columns={['London', 'Paris', 'Berlin']} colors={ramp} />
44
+ * ```
45
+ *
46
+ * **No reader of its own.** It builds on `stacksFromColumns`, whose output is
47
+ * already a heat map's data shape — bin spans, named rows, a row-major value
48
+ * grid. That covers all four shapes pond can express today (`TimeSeries` or
49
+ * `ValueSeries` × one column or many), and the stripe is simply `G === 1`, so
50
+ * there is one draw path rather than two.
51
+ *
52
+ * **The readout is the point.** A cell carries its value, so hover and click
53
+ * report it and the readout pill takes the cell's own colour. The bar-based
54
+ * workaround this replaces cannot: its bars are a constant-height column
55
+ * carrying no value, so the number has to be looked up out-of-band.
56
+ *
57
+ * **Styling.** Colour is data and comes from `colors`, not the theme. Geometry
58
+ * and the selected-cell treatment are borrowed from
59
+ * `theme.bar[as] ?? theme.bar.default` rather than a new `theme.heat` slot:
60
+ * `ChartTheme`'s slots are required, so adding one is breaking for every custom
61
+ * theme, and the M5 "theme tokens optional-with-default" gate has to land
62
+ * first. Borrowing defers that decision instead of pre-empting it.
63
+ *
64
+ * **Pair it with `<ChartContainer cursor="none">`.** The container's default is
65
+ * the shared vertical line, and on a grid that is a *second, weaker* cursor
66
+ * competing with the one that already works: the cell under the pointer takes an
67
+ * outline, which says both axes at once. The line says only x, and a heat map's
68
+ * x position is rarely the question. The pointer's own crosshair shape plus the
69
+ * cell outline is the whole affordance.
70
+ *
71
+ * **Not built:** a grouped two-level x axis, and cell value labels. The former
72
+ * is axis work that would serve bars equally; the latter is small and
73
+ * independent.
74
+ */
75
+ export function HeatMap({ series, columns, colors, domain, orientation = 'vertical', as: semantic, axis, gap = 0, scale = 'linear', noData = 'blank', decimate = true, id, index = 0, }) {
76
+ const container = useContext(ContainerContext);
77
+ if (container === null) {
78
+ throw new Error('<HeatMap> must be rendered inside a <ChartContainer>');
79
+ }
80
+ const layers = useContext(LayersContext);
81
+ if (layers === null) {
82
+ throw new Error('<HeatMap> must be rendered inside a <Layers>');
83
+ }
84
+ if (columns.length === 0) {
85
+ throw new Error('<HeatMap> needs at least one column (one row per column)');
86
+ }
87
+ // `columns`, `colors` and `domain` are **array** props, and the natural way to
88
+ // write every one of them is a fresh array per render — a JSX literal, a
89
+ // `.map()`, or a theme hook like the docs site's `useSequentialRamp()`. Keyed
90
+ // by identity they would rebuild the layer `entry` every render, hence a
91
+ // `registerLayer` every render: a repaint treadmill, not a noisy warning. So
92
+ // memoize on **content**, exactly as `<BarChart thresholds>` does. The joiner
93
+ // is NUL rather than a comma because a column name may contain a comma, and
94
+ // `['a,b']` must not key the same as `['a', 'b']`.
95
+ const columnsKey = columns.join('\u0000');
96
+ const colorsKey = colors.join('\u0000');
97
+ const domainKey = domain === undefined ? '' : `${domain[0]}\u0000${domain[1]}`;
98
+ const ss = useMemo(() => stacksFromColumns(series, columns), [series, columnsKey]);
99
+ const { bar } = container.theme;
100
+ const base = (semantic !== undefined ? bar[semantic] : undefined) ?? bar.default;
101
+ // The geometry comes from the BAR slot (a cell is a bar's slot with colour
102
+ // instead of height); the interaction states come from `theme.heat`, which
103
+ // exists because a bar's fill is free and a cell's fill is the datum — see
104
+ // `HeatStates`. Absent, the pre-states treatment is unchanged.
105
+ const heatStates = container.theme.heat;
106
+ const states = (semantic !== undefined ? heatStates?.[semantic] : undefined) ??
107
+ heatStates?.default;
108
+ const style = useMemo(() => ({
109
+ opacity: base.opacity,
110
+ highlight: base.highlight,
111
+ outlineWidth: base.outlineWidth,
112
+ gap,
113
+ minWidth: base.minWidth,
114
+ gridColor: container.theme.axis.grid,
115
+ ...(states !== undefined ? { states } : {}),
116
+ }), [base, gap, container.theme.axis.grid, states]);
117
+ // One colour domain across the whole grid, so rows are comparable.
118
+ const [lo, hi] = useMemo(() => domain ?? heatValueExtent(ss) ?? [0, 1], [domainKey, ss]);
119
+ const G = ss.groups.length;
120
+ const vertical = orientation === 'vertical';
121
+ // Colour is a function of the **value**, which is the layer's whole model —
122
+ // so the closure takes one. It also lets a decimated pixel column, which has
123
+ // no source `(b, g)`, be coloured by the same ramp.
124
+ const colorOf = useMemo(() => (value) => bandedColor(value, colors, lo, hi, scale), [colorsKey, lo, hi, scale]);
125
+ const selected = container.selected;
126
+ const hoveredMark = container.hovered;
127
+ // Both channels are **sets** — `selected` since [PND-MULTISEL], `hovered`
128
+ // since RFC A4.3 — and both reach `drawHeat` whole, which lights every cell a
129
+ // member names. Narrowed here only from `SelectInfo` down to the cell identity
130
+ // (`id` + `key`/`mark` + row `label`), so `heat.ts` stays free of the
131
+ // selection's presentation fields exactly as it is free of the theme.
132
+ const selection = useMemo(() => marksOf(selected), [selected]);
133
+ const hover = useMemo(() => marksOf(hoveredMark), [hoveredMark]);
134
+ // The selection's span entries, narrowed to this layer (interaction RFC
135
+ // A5.2). A heat map's labels vary per cell (the row names — the ordinal
136
+ // second dimension a span addresses via `rows`, RFC A5.3), so no label is
137
+ // passed and the `rows` channel rides through for `drawHeat` to test per
138
+ // cell. Reference-stable when empty, like the mark memos above.
139
+ const layerSpans = useMemo(() => spansForLayer(container.selectedSpans, id), [container.selectedSpans, id]);
140
+ const entry = useMemo(() => ({
141
+ layer: {
142
+ as: semantic,
143
+ // Inferred, exactly as BarChart does it — no axis-kind prop.
144
+ // Horizontal moves the bins to y, so x becomes the categories the
145
+ // columns name — which is the container's `'category'` kind, exactly as
146
+ // a categorical `<BarChart>` reports it.
147
+ xKind: vertical
148
+ ? series instanceof ValueSeries
149
+ ? 'value'
150
+ : 'time'
151
+ : 'category',
152
+ xExtent: () => vertical
153
+ ? ss.length === 0
154
+ ? null
155
+ : [ss.begin[0], ss.end[ss.length - 1]]
156
+ : [0, G],
157
+ yExtent: () => vertical
158
+ ? [0, G]
159
+ : ss.length === 0
160
+ ? null
161
+ : [ss.begin[0], ss.end[ss.length - 1]],
162
+ // Unit slots, one per column, labelled at each centre — on whichever
163
+ // axis they landed. `binCategories` is the y-axis channel and
164
+ // `xCategories` the x-axis one ([PND-HCAT]).
165
+ ...(vertical
166
+ ? { binCategories: () => ss.groups }
167
+ : { xCategories: () => ss.groups }),
168
+ // The x-scrub tracker samples along x, which is the bin axis only when
169
+ // vertical. A horizontal grid answers through `onHover` / `onSelect`
170
+ // instead, which resolve both axes — the same split the 2-D readout
171
+ // already forced.
172
+ sampleAt: (x) => {
173
+ if (!vertical)
174
+ return [];
175
+ // Every row's value at the cursor — the whole column of the grid,
176
+ // which is what an off-chart readout wants from a heat map.
177
+ for (let b = 0; b < ss.length; b += 1) {
178
+ if (x < ss.begin[b] || x > ss.end[b])
179
+ continue;
180
+ const out = [];
181
+ for (let g = 0; g < G; g += 1) {
182
+ const v = ss.values[b * G + g];
183
+ if (!Number.isFinite(v))
184
+ continue;
185
+ out.push({
186
+ x: (ss.begin[b] + ss.end[b]) / 2,
187
+ // `value` is where the cursor *draws* — `yScale(value)` — so
188
+ // it must be a y coordinate, and for a cell that is its row's
189
+ // centre, not its number. The number rides `readout`, which
190
+ // an off-chart consumer shows as `readout ?? value`. Without
191
+ // the split the dot would be placed at `yScale(anomaly)` on a
192
+ // unit-slot axis and land outside the plot entirely.
193
+ value: g + 0.5,
194
+ readout: v,
195
+ color: colorOf(v) ?? style.highlight,
196
+ label: ss.groups[g],
197
+ });
198
+ }
199
+ return out;
200
+ }
201
+ return [];
202
+ },
203
+ ...(id === undefined
204
+ ? {}
205
+ : {
206
+ hitTest: (px, py, xScale, yScale) => {
207
+ const hit = heatAt(ss, px, py, xScale, yScale, style.gap, style.minWidth, orientation);
208
+ if (hit === null)
209
+ return null;
210
+ const [b, g, begin, name, value] = hit;
211
+ const mark = ss.marks?.[b];
212
+ return {
213
+ id,
214
+ key: begin,
215
+ value,
216
+ color: colorOf(value) ?? style.highlight,
217
+ label: name,
218
+ ...(mark !== undefined ? { mark } : {}),
219
+ };
220
+ },
221
+ /**
222
+ * The **snapped rect** ([PND-INTERACT2D]). Both dimensions snap:
223
+ * x to whole bin columns (`sweep2D`'s cut, as every column mark
224
+ * does) and y **outward to whole row slots** — a vertical heat
225
+ * map's rows own the unit intervals `[g, g+1)` of its value axis
226
+ * (see `yExtent`), so a window is floored and ceiled onto them.
227
+ *
228
+ * Snapping both is what makes the selection a contiguous
229
+ * rectangle of cells, which in turn is why the region can carry
230
+ * **one** perimeter rather than a border per cell.
231
+ */
232
+ // A cell owns a position, not a column: the sweep is a rect, and
233
+ // the resting cursor is the small crosshair rather than a band.
234
+ //
235
+ // Gated on exactly what `beginSweep` gates on. `sweepsRect` is
236
+ // read at REST, where there is no session to ask, so declaring
237
+ // it unconditionally made a HORIZONTAL heat map suppress its
238
+ // row's cursor and paint a resting `+` for a rect gesture that
239
+ // can never start — `beginSweep` returns `null` there.
240
+ sweepsRect: vertical && ss.length > 0 && G > 0,
241
+ beginSweep: () => {
242
+ if (!vertical || ss.length === 0 || G === 0)
243
+ return null;
244
+ /** The window's covering row-slot run, snapped outward. */
245
+ const rowRun = (y0, y1) => {
246
+ const g0 = Math.max(0, Math.floor(y0));
247
+ const g1 = Math.min(G, Math.ceil(y1));
248
+ return [g0, g1 > g0 ? g1 : g0];
249
+ };
250
+ return sweep2D({
251
+ id,
252
+ begin: ss.begin,
253
+ end: ss.end,
254
+ length: ss.length,
255
+ spanFrom: 'bins',
256
+ materialize: (lo, hi, y0, y1) => {
257
+ const [g0, g1] = rowRun(y0, y1);
258
+ const out = [];
259
+ for (let b = lo; b < hi; b += 1) {
260
+ const mark = ss.marks?.[b];
261
+ for (let g = g0; g < g1; g += 1) {
262
+ const v = ss.values[b * G + g];
263
+ // A gap cell draws nothing and owns no membership —
264
+ // the rule `hitTest` already applies.
265
+ if (!Number.isFinite(v))
266
+ continue;
267
+ out.push({
268
+ id,
269
+ key: ss.begin[b],
270
+ value: v,
271
+ color: colorOf(v) ?? style.highlight,
272
+ label: ss.groups[g],
273
+ ...(mark !== undefined ? { mark } : {}),
274
+ });
275
+ }
276
+ }
277
+ return out;
278
+ },
279
+ // The brush draws THIS, not the pointer rectangle: the cut
280
+ // snaps to whole bins and whole rows, so a raw rect would
281
+ // promise a different set than the release delivers — and
282
+ // it disagrees most visibly at the moment the user is
283
+ // deciding where to let go.
284
+ snap: (lo, hi, y0, y1) => {
285
+ const [g0, g1] = rowRun(y0, y1);
286
+ return g1 > g0
287
+ ? { x: [ss.begin[lo], ss.end[hi - 1]], y: [g0, g1] }
288
+ : null;
289
+ },
290
+ // `rows` names the rows rather than numbering slots, so a
291
+ // committed selection survives a row reorder (RFC A5.3).
292
+ channels: (_hits, y0, y1) => {
293
+ const [g0, g1] = rowRun(y0, y1);
294
+ return { rows: ss.groups.slice(g0, g1) };
295
+ },
296
+ });
297
+ },
298
+ }),
299
+ draw: (ctx, xScale, yScale) => drawHeat(ctx, ss, xScale, yScale, style, colorOf, id,
300
+ // Both sets whole. Passing `selection[0]` here quietly showed one
301
+ // outline for a three-cell selection — the memos above were plural
302
+ // long before the draw was.
303
+ selection, hover, decimate, orientation, noData, layerSpans),
304
+ },
305
+ axisId: axis,
306
+ index,
307
+ }), [
308
+ ss,
309
+ G,
310
+ style,
311
+ colorOf,
312
+ decimate,
313
+ orientation,
314
+ noData,
315
+ vertical,
316
+ semantic,
317
+ series,
318
+ id,
319
+ axis,
320
+ index,
321
+ selection,
322
+ hover,
323
+ layerSpans,
324
+ ]);
325
+ const slot = useSlotKey();
326
+ useEffect(() => () => layers.unregisterLayer(slot), [layers, slot]);
327
+ useEffect(() => {
328
+ layers.registerLayer(slot, entry);
329
+ }, [layers, slot, entry]);
330
+ const { registerTrackerSource, unregisterTrackerSource } = container;
331
+ useEffect(() => () => unregisterTrackerSource(slot), [unregisterTrackerSource, slot]);
332
+ useEffect(() => {
333
+ registerTrackerSource(slot, entry.layer);
334
+ }, [registerTrackerSource, slot, entry.layer]);
335
+ const { registerSelectable, unregisterSelectable } = container;
336
+ useEffect(() => {
337
+ if (id === undefined)
338
+ return;
339
+ registerSelectable(slot);
340
+ return () => unregisterSelectable(slot);
341
+ }, [registerSelectable, unregisterSelectable, slot, id]);
342
+ return null;
343
+ }
344
+ //# sourceMappingURL=HeatMap.js.map
package/dist/Layers.d.ts CHANGED
@@ -16,7 +16,11 @@ export interface LayersProps {
16
16
  * others slots into place, not onto the top), and each layer keeps a stable,
17
17
  * id-keyed slot so a series/style update holds its position (no jump to the
18
18
  * front — the trap that bites live charts). Draw layers must be **direct
19
- * children** of `<Layers>` for the index to reach them.
19
+ * children** of `<Layers>` for the index to reach them — to group them
20
+ * conditionally, return a **keyed array**, not a `<>…</>`: a fragment takes no
21
+ * props, so the index stops there and the layers inside it all register at 0
22
+ * (dev warns about this, because a stable sort makes the resulting tie look
23
+ * correct until mount order and declaration order disagree).
20
24
  */
21
25
  export declare function Layers({ children }: LayersProps): import("react/jsx-runtime").JSX.Element;
22
26
  //# sourceMappingURL=Layers.d.ts.map