@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
@@ -0,0 +1,569 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Sequence, TimeSeries } from 'pond-ts';
3
+ import { BarChart } from './BarChart.js';
4
+ import { BoxPlot } from './BoxPlot.js';
5
+ import { Candlestick } from './Candlestick.js';
6
+ import { HeatMap } from './HeatMap.js';
7
+ import { ScatterChart } from './ScatterChart.js';
8
+ import { H, calendarOf, sessionSeq, weekdaySessions, } from './tradingAxis.fixture.js';
9
+ const DAY = 86_400_000;
10
+ const D0 = Date.UTC(2026, 6, 1);
11
+ // ── Categorical ────────────────────────────────────────────────────────────
12
+ const SERVICES = [
13
+ { label: 'api', value: 2.7 },
14
+ { label: 'auth', value: 1.4 },
15
+ { label: 'cache', value: 0.6 },
16
+ { label: 'db', value: 3.2 },
17
+ { label: 'queue', value: 1.05 },
18
+ { label: 'search', value: 0.3 },
19
+ ];
20
+ const ERRORS = [
21
+ { label: 'api', value: 0.9 },
22
+ { label: 'auth', value: 0.2 },
23
+ { label: 'cache', value: 1.6 },
24
+ { label: 'db', value: 0.4 },
25
+ { label: 'queue', value: 1.1 },
26
+ { label: 'search', value: 0.8 },
27
+ ];
28
+ /** A `SelectInfo` for one category — what a legend chip / filter list hands in.
29
+ * On an ordinal axis the identity is the **mark** (the stable category name),
30
+ * not the slot index, which is the whole point of `mark` surviving a reorder. */
31
+ const svc = (label) => ({
32
+ id: 'svc',
33
+ key: 0,
34
+ value: 0,
35
+ color: '#000',
36
+ label,
37
+ mark: label,
38
+ });
39
+ export const categoricalBars = {
40
+ name: 'Categorical',
41
+ container: {},
42
+ axis: { id: 'v', min: 0, max: 3.6, label: '' },
43
+ renderLayer: (id) => _jsx(BarChart, { categories: SERVICES, id: id, gap: 6 }),
44
+ secondary: {
45
+ axis: { id: 'e', min: 0, max: 2, label: 'errors' },
46
+ renderLayer: (id) => (_jsx(BarChart, { categories: ERRORS, id: id, gap: 6, as: "warn" })),
47
+ },
48
+ picks: SERVICES.map((c) => ({ label: c.label, info: svc(c.label) })),
49
+ describe: (hit) => hit.mark ?? hit.label,
50
+ // No `sequence`: a time bucketing over ordinal slots is meaningless.
51
+ // No `rangeCursor`: it gates on a continuous x.
52
+ rangeCursor: false,
53
+ sweep: true,
54
+ };
55
+ // ── Time axis ──────────────────────────────────────────────────────────────
56
+ /** Thirty daily interval bars — a deterministic random-ish walk. */
57
+ const daily = () => new TimeSeries({
58
+ name: 'daily',
59
+ schema: [
60
+ { name: 'timeRange', kind: 'timeRange' },
61
+ { name: 'v', kind: 'number' },
62
+ ],
63
+ rows: Array.from({ length: 30 }, (_, i) => [
64
+ [D0 + i * DAY, D0 + (i + 1) * DAY],
65
+ 6 + 4 * Math.sin(i / 3) + 1.5 * Math.sin(i * 1.7),
66
+ ]),
67
+ });
68
+ /** A second daily series for the row-scoping story. */
69
+ const dailyErrors = () => new TimeSeries({
70
+ name: 'errs',
71
+ schema: [
72
+ { name: 'timeRange', kind: 'timeRange' },
73
+ { name: 'v', kind: 'number' },
74
+ ],
75
+ rows: Array.from({ length: 30 }, (_, i) => [
76
+ [D0 + i * DAY, D0 + (i + 1) * DAY],
77
+ 1 + Math.abs(Math.sin(i * 0.9)) * 1.4,
78
+ ]),
79
+ });
80
+ const isoDay = (t) => new Date(t).toISOString().slice(5, 10);
81
+ export const timeBars = {
82
+ name: 'BarChart',
83
+ container: { range: [D0, D0 + 30 * DAY] },
84
+ axis: { id: 'v', min: 0, max: 12, label: '' },
85
+ renderLayer: (id) => (_jsx(BarChart, { series: daily(), column: "v", axis: "v", id: id })),
86
+ secondary: {
87
+ axis: { id: 'e', min: 0, max: 3, label: 'errors' },
88
+ renderLayer: (id) => (_jsx(BarChart, { series: dailyErrors(), column: "v", axis: "e", id: id, as: "warn" })),
89
+ },
90
+ // A time hit's identity is its `key` (the bar's begin), so an external
91
+ // control picks by timestamp rather than by name.
92
+ picks: [0, 4, 9].map((i) => ({
93
+ label: isoDay(D0 + i * DAY),
94
+ info: {
95
+ id: 'svc',
96
+ key: D0 + i * DAY,
97
+ value: 0,
98
+ color: '#000',
99
+ label: 'v',
100
+ },
101
+ })),
102
+ describe: (hit) => isoDay(hit.key),
103
+ /**
104
+ * **The bucket must be coarser than the bar**, or the snap has nothing to do
105
+ * and the story silently demonstrates nothing: these are *daily* bars, so a
106
+ * `'1d'` sequence would bucket exactly one bar each and look identical to the
107
+ * freeform sweep. A week spans seven, which is visible. Anchored at the first
108
+ * bar so the buckets line up with the data rather than with the epoch.
109
+ */
110
+ sequence: () => Sequence.every('7d', { anchor: D0 }),
111
+ rangeCursor: true,
112
+ sweep: true,
113
+ };
114
+ // ── Stacked bars (many marks per bin) ──────────────────────────────────────
115
+ /** The stack's groups, bottom → top. **Four**, so the story walks the whole
116
+ * `defaultTheme` group ramp rather than the first few entries of it. */
117
+ const TIERS = ['web', 'api', 'db', 'edge'];
118
+ /** Twenty daily bins, four positive segments each. */
119
+ const stacked = () => new TimeSeries({
120
+ name: 'tiers',
121
+ schema: [
122
+ { name: 'timeRange', kind: 'timeRange' },
123
+ { name: 'web', kind: 'number' },
124
+ { name: 'api', kind: 'number' },
125
+ { name: 'db', kind: 'number' },
126
+ { name: 'edge', kind: 'number' },
127
+ ],
128
+ rows: Array.from({ length: 20 }, (_, i) => [
129
+ [D0 + i * DAY, D0 + (i + 1) * DAY],
130
+ 3 + 2 * Math.sin(i / 2),
131
+ 2 + 1.5 * Math.sin(i / 3 + 1),
132
+ 1.5 + Math.sin(i * 1.7),
133
+ 1.2 + Math.sin(i / 4 + 2),
134
+ ]),
135
+ });
136
+ /**
137
+ * **A stacked bar chart** — the column where a bin holds *many* marks.
138
+ *
139
+ * Every other column is one mark per x position, so "the mark under the
140
+ * pointer" and "the bin under the pointer" are the same thing and nothing
141
+ * distinguishes them. Here they come apart: a wide series carries no stable
142
+ * per-bin `mark`, so a segment's identity is the pair **`(key = bin begin,
143
+ * label = group)`** and one bin's three segments share a key. That is the
144
+ * axis this column exercises — click resolves to a segment, while a sweep
145
+ * materialises *every* drawn segment of every covered bin.
146
+ */
147
+ export const stackedBars = {
148
+ name: 'Stacked',
149
+ container: { range: [D0, D0 + 20 * DAY] },
150
+ axis: { id: 'v', min: 0, max: 14, label: '' },
151
+ renderLayer: (id) => (_jsx(BarChart, { series: stacked(), columns: TIERS, axis: "v", id: id })),
152
+ secondary: {
153
+ axis: { id: 'e', min: 0, max: 3, label: 'errors' },
154
+ renderLayer: (id) => (_jsx(BarChart, { series: dailyErrors(), column: "v", axis: "e", id: id, as: "warn" })),
155
+ },
156
+ // A segment is picked by `(key, label)` — `stackIndexMatches` falls to
157
+ // `labelsByKey` when the series carries no stable marks, so an external
158
+ // control naming only the day would match nothing.
159
+ picks: [
160
+ { day: 0, tier: 'web' },
161
+ { day: 4, tier: 'api' },
162
+ { day: 9, tier: 'db' },
163
+ ].map(({ day, tier }) => ({
164
+ label: `${isoDay(D0 + day * DAY)}·${tier}`,
165
+ info: {
166
+ id: 'svc',
167
+ key: D0 + day * DAY,
168
+ value: 0,
169
+ color: '#000',
170
+ label: tier,
171
+ },
172
+ })),
173
+ /** `describeEntries` renders a span's endpoints through this with a
174
+ * `{ key }`-only cast, so a missing label is a real case here rather than
175
+ * defensive noise — a span names an x range, not a group. */
176
+ describe: (hit) => hit.label === undefined
177
+ ? isoDay(hit.key)
178
+ : `${isoDay(hit.key)}·${hit.label}`,
179
+ sequence: () => Sequence.every('7d', { anchor: D0 }),
180
+ rangeCursor: true,
181
+ sweep: true,
182
+ };
183
+ // ── Box plots (a mark with internal structure) ─────────────────────────────
184
+ const BOX_BASE = Date.UTC(2026, 6, 1, 12, 0, 0);
185
+ const BOX_BUCKET = 600_000; // 10 minutes
186
+ const BOX_COUNT = 10;
187
+ /**
188
+ * Ten percentile buckets through pond's own `aggregate` — the reducers do the
189
+ * quantiles, the fixture just reads the columns (the idiom `Box.stories.tsx`
190
+ * established). Deterministic samples, so the boxes are stable across runs.
191
+ */
192
+ const boxes = (phase = 0) => {
193
+ const STEP = 5_000; // one raw sample / 5s
194
+ const N = (BOX_COUNT * BOX_BUCKET) / STEP;
195
+ const rows = Array.from({ length: N }, (_, i) => [
196
+ BOX_BASE + i * STEP,
197
+ 60 +
198
+ 18 * Math.sin(i / 70 + phase) +
199
+ 9 * Math.sin(i * 1.3) +
200
+ 5 * Math.sin(i * 2.7) +
201
+ 3 * Math.sin(i * 0.7),
202
+ ]);
203
+ return new TimeSeries({
204
+ name: 'raw',
205
+ schema: [
206
+ { name: 'time', kind: 'time' },
207
+ { name: 'latency', kind: 'number' },
208
+ ],
209
+ rows,
210
+ }).aggregate(Sequence.every('10m'), {
211
+ p5: { from: 'latency', using: 'p5' },
212
+ p25: { from: 'latency', using: 'p25' },
213
+ p50: { from: 'latency', using: 'p50' },
214
+ p75: { from: 'latency', using: 'p75' },
215
+ p95: { from: 'latency', using: 'p95' },
216
+ });
217
+ };
218
+ /** **Local** time, because the axis renders local — a UTC caption beside a
219
+ * local axis reads as an off-by-hours bug in the hit test, which is exactly
220
+ * the kind of false lead a review story must not plant. */
221
+ const clock = (t) => new Date(t).toTimeString().slice(0, 5);
222
+ /**
223
+ * **A box plot column** — the first fixture whose mark has *internal
224
+ * structure*. Everything before it is one filled rect per x; a box is a body,
225
+ * a median rule and (depending on `shape`) stems and caps, and `hitTest` is
226
+ * rect containment over that composite. So the question this column asks is
227
+ * **what counts as the mark for hit purposes**, and the answer is allowed to
228
+ * differ by shape — which is why it ships as two columns rather than one.
229
+ *
230
+ * It sweeps like any bar column. A box is an **aggregation** — it owns one
231
+ * `[begin, end)` interval of the key axis — so the fact that its ink floats
232
+ * between two quantiles instead of rising from the baseline says nothing about
233
+ * which column the mark occupies. The sweep cuts columns, not ink.
234
+ */
235
+ const boxFixture = (name, shape, note) => ({
236
+ name,
237
+ container: { range: [BOX_BASE, BOX_BASE + BOX_COUNT * BOX_BUCKET] },
238
+ axis: { id: 'v', min: 0, max: 110, label: note },
239
+ renderLayer: (id) => (_jsx(BoxPlot, { series: boxes(), lower: "p5", q1: "p25", median: "p50", q3: "p75", upper: "p95", axis: "v", shape: shape, gap: 10, id: id })),
240
+ secondary: {
241
+ axis: { id: 'e', min: 0, max: 110, label: 'p95 (b)' },
242
+ renderLayer: (id) => (_jsx(BoxPlot, { series: boxes(2.1), lower: "p5", q1: "p25", median: "p50", q3: "p75", upper: "p95", axis: "e", shape: shape, gap: 10, as: "warn", id: id })),
243
+ },
244
+ // A box's `key` is its `x` (the bucket begin), same identity rule the
245
+ // time-axis bar column uses.
246
+ picks: [0, 3, 7].map((i) => ({
247
+ label: clock(BOX_BASE + i * BOX_BUCKET),
248
+ info: {
249
+ id: 'svc',
250
+ key: BOX_BASE + i * BOX_BUCKET,
251
+ value: 0,
252
+ color: '#000',
253
+ label: 'latency',
254
+ },
255
+ })),
256
+ describe: (hit) => clock(hit.key),
257
+ sweep: true,
258
+ /** Three 10-minute boxes per bucket — coarser than the mark, so the snap has
259
+ * something to do (the lesson `timeBars` learned with its `'1d'`). */
260
+ sequence: () => Sequence.every('30m', { anchor: BOX_BASE }),
261
+ rangeCursor: true,
262
+ });
263
+ /** The default shape: a q1→q3 body with thin stems and end caps. The stems are
264
+ * the interesting part for selection — they are drawn ink far from the body. */
265
+ export const boxWhisker = boxFixture('BoxWhisker', 'whisker', 'whisker');
266
+ /** The candlestick look: a light outer bar over the full range, a darker inner
267
+ * body, no stems. The mark is one solid rect, so the hittable area is the
268
+ * whole p5→p95 span rather than a body plus two thin stems. */
269
+ export const boxSolid = boxFixture('BoxSolid', 'solid', 'solid');
270
+ // ── Candlesticks (a mark whose HUE is its meaning) ─────────────────────────
271
+ /** Twenty daily OHLC bars, deterministic, with real direction changes so both
272
+ * the rising and falling colours are on screen in every state. */
273
+ const ohlc = () => {
274
+ const rows = [];
275
+ let px = 100;
276
+ for (let i = 0; i < 20; i += 1) {
277
+ const drift = 2.2 * Math.sin(i / 2.4) + 1.1 * Math.sin(i * 1.7);
278
+ const open = px;
279
+ const close = px + drift;
280
+ const wick = 0.8 + Math.abs(Math.sin(i * 2.3));
281
+ rows.push([
282
+ D0 + i * DAY,
283
+ open,
284
+ Math.max(open, close) + wick,
285
+ Math.min(open, close) - wick,
286
+ close,
287
+ ]);
288
+ px = close;
289
+ }
290
+ return new TimeSeries({
291
+ name: 'ohlc',
292
+ schema: [
293
+ { name: 'time', kind: 'time' },
294
+ { name: 'open', kind: 'number' },
295
+ { name: 'high', kind: 'number' },
296
+ { name: 'low', kind: 'number' },
297
+ { name: 'close', kind: 'number' },
298
+ ],
299
+ rows,
300
+ });
301
+ };
302
+ /**
303
+ * **A candlestick column** — the mark whose *hue is its meaning*.
304
+ *
305
+ * Every other column is free to recolour on selection: a bar swaps its fill, a
306
+ * box rotates its whole tint ladder. A candle cannot. Rising vs falling is the
307
+ * first thing anyone reads off it, and that read lives in exactly the channel
308
+ * the other marks use to announce state — so this column exists to check that
309
+ * a selected candle still says which way the price went.
310
+ *
311
+ * It sweeps like the rest: a candle owns one `[x, xEnd)` column, same as a box.
312
+ */
313
+ export const candles = {
314
+ name: 'Candlestick',
315
+ container: { range: [D0, D0 + 20 * DAY] },
316
+ axis: { id: 'v', min: 88, max: 116, label: '' },
317
+ renderLayer: (id) => _jsx(Candlestick, { series: ohlc(), axis: "v", gap: 6, id: id }),
318
+ secondary: {
319
+ axis: { id: 'e', min: 88, max: 116, label: 'bar' },
320
+ renderLayer: (id) => (_jsx(Candlestick, { series: ohlc(), axis: "e", gap: 6, variant: "bar", id: id })),
321
+ },
322
+ picks: [0, 5, 11].map((i) => ({
323
+ label: isoDay(D0 + i * DAY),
324
+ info: {
325
+ id: 'svc',
326
+ key: D0 + i * DAY,
327
+ value: 0,
328
+ color: '#000',
329
+ label: 'close',
330
+ },
331
+ })),
332
+ describe: (hit) => isoDay(hit.key),
333
+ sweep: true,
334
+ sequence: () => Sequence.every('5d', { anchor: D0 }),
335
+ rangeCursor: true,
336
+ };
337
+ // ── The 2-D family: scatter and heat map ──────────────────────────────────
338
+ /** Forty points on a time axis — a scatter's marks live at `(x, y)`, not in a
339
+ * column, which is the whole distinction this pair of columns exists to make. */
340
+ const points = () => new TimeSeries({
341
+ name: 'pts',
342
+ schema: [
343
+ { name: 'time', kind: 'time' },
344
+ { name: 'v', kind: 'number' },
345
+ { name: 'w', kind: 'number' },
346
+ ],
347
+ rows: Array.from({ length: 40 }, (_, i) => [
348
+ D0 + i * (DAY / 2),
349
+ 6 + 3.4 * Math.sin(i / 3.1) + 1.8 * Math.sin(i * 2.3),
350
+ 1 + Math.abs(Math.sin(i * 1.7)),
351
+ ]),
352
+ });
353
+ /** Three rows × twenty daily bins — a grid of cells. */
354
+ const grid = () => new TimeSeries({
355
+ name: 'grid',
356
+ schema: [
357
+ { name: 'timeRange', kind: 'timeRange' },
358
+ { name: 'low', kind: 'number' },
359
+ { name: 'mid', kind: 'number' },
360
+ { name: 'high', kind: 'number' },
361
+ ],
362
+ rows: Array.from({ length: 20 }, (_, i) => [
363
+ [D0 + i * DAY, D0 + (i + 1) * DAY],
364
+ 2 + 2 * Math.sin(i / 2.2),
365
+ 5 + 3 * Math.sin(i / 3.3 + 1),
366
+ 8 + 3 * Math.sin(i / 1.9 + 2),
367
+ ]),
368
+ });
369
+ const HEAT_RAMP = ['#e0f2f1', '#7FC8BF', '#2A9D8F', '#1F7A6F'];
370
+ /**
371
+ * **A scatter column** — the first mark that does *not* own a column.
372
+ *
373
+ * Every fixture before this one is an interval on the key axis: a bar, a
374
+ * stacked bin, a box, a candle. A scatter point is a position, `(x, y)`, with
375
+ * no span either side of it — so "the marks between here and there" is a
376
+ * question about a **region**, not about a run of columns, and `sweep1D`'s
377
+ * two binary searches have nothing to cut.
378
+ *
379
+ * Hence `sweep: false`, and hence [PND-INTERACT2D] rather than a quick wiring
380
+ * job: the 2-D rect is a different gesture, not the same one on a new layer.
381
+ */
382
+ export const scatterPoints = {
383
+ name: 'Scatter',
384
+ container: { range: [D0, D0 + 20 * DAY] },
385
+ axis: { id: 'v', min: 0, max: 12, label: '' },
386
+ renderLayer: (id) => (_jsx(ScatterChart, { series: points(), column: "v", axis: "v", id: id })),
387
+ secondary: {
388
+ axis: { id: 'e', min: 0, max: 3, label: 'w' },
389
+ renderLayer: (id) => (_jsx(ScatterChart, { series: points(), column: "w", axis: "e", id: id, as: "warn" })),
390
+ },
391
+ picks: [0, 8, 17].map((i) => ({
392
+ label: isoDay(D0 + i * (DAY / 2)),
393
+ info: {
394
+ id: 'svc',
395
+ key: D0 + i * (DAY / 2),
396
+ value: 0,
397
+ color: '#000',
398
+ label: 'v',
399
+ },
400
+ })),
401
+ describe: (hit) => isoDay(hit.key),
402
+ // A **2-D** sweep: the drag cuts a free rect, and the committed span carries
403
+ // a `y` window alongside `x` ([PND-INTERACT2D]).
404
+ sweep: true,
405
+ rangeCursor: true,
406
+ };
407
+ /**
408
+ * **A heat-map column** — a *grid* of cells, `(bin × row)`.
409
+ *
410
+ * A heat map's bins do own columns, so an x-only sweep would be well defined —
411
+ * and it would also be the wrong gesture. Selecting every row of a covered bin
412
+ * ignores the y dimension the mark exists to show, and the rect that reads it
413
+ * is [PND-INTERACT2D]'s, so this declares `sweep: false` rather than shipping
414
+ * the half of the gesture that happens to be easy.
415
+ */
416
+ export const heatGrid = {
417
+ name: 'HeatMap',
418
+ container: { range: [D0, D0 + 20 * DAY] },
419
+ axis: { id: 'v', min: 0, max: 3, label: '' },
420
+ renderLayer: (id) => (_jsx(HeatMap, { series: grid(), columns: ['low', 'mid', 'high'], colors: HEAT_RAMP, axis: "v", id: id })),
421
+ secondary: {
422
+ axis: { id: 'e', min: 0, max: 3, label: 'again' },
423
+ renderLayer: (id) => (_jsx(HeatMap, { series: grid(), columns: ['low', 'mid', 'high'], colors: HEAT_RAMP, axis: "e", id: id })),
424
+ },
425
+ // A cell's identity is `(key = bin begin, label = row)`, the stack's rule.
426
+ picks: [
427
+ { day: 2, row: 'low' },
428
+ { day: 7, row: 'mid' },
429
+ { day: 13, row: 'high' },
430
+ ].map(({ day, row }) => ({
431
+ label: `${isoDay(D0 + day * DAY)}·${row}`,
432
+ info: {
433
+ id: 'svc',
434
+ key: D0 + day * DAY,
435
+ value: 0,
436
+ color: '#000',
437
+ label: row,
438
+ },
439
+ })),
440
+ describe: (hit) => hit.label === undefined
441
+ ? isoDay(hit.key)
442
+ : `${isoDay(hit.key)}·${hit.label}`,
443
+ // A **2-D** sweep, snapped in both dimensions — bins on x, rows on y — so a
444
+ // capture is always a contiguous rectangle of cells ([PND-INTERACT2D]).
445
+ sweep: true,
446
+ rangeCursor: true,
447
+ };
448
+ // ── Trading sessions (a discontinuous time axis) ───────────────────────────
449
+ /** Six weekday sessions (09:30–16:00 UTC) from a Monday — five overnight seams
450
+ * plus a **weekend** one, which is the widest collapse in view. */
451
+ const SESSIONS = weekdaySessions(6);
452
+ const MON_OPEN = SESSIONS[0].open;
453
+ /** Hourly interval bars inside each session, the last one clipped at the close.
454
+ * Seven bars per session (09:30 … 15:30), so a block is countable by eye. */
455
+ const sessionBars = (amp, phase) => {
456
+ const rows = [];
457
+ let i = 0;
458
+ for (const s of SESSIONS) {
459
+ for (let t = s.open; t < s.close; t += H, i++) {
460
+ rows.push([
461
+ [t, Math.min(t + H, s.close)],
462
+ 6 + amp * Math.sin(i / 3 + phase) + 1.2 * Math.sin(i * 1.7),
463
+ ]);
464
+ }
465
+ }
466
+ return new TimeSeries({
467
+ name: 'session',
468
+ schema: [
469
+ { name: 'timeRange', kind: 'timeRange' },
470
+ { name: 'v', kind: 'number' },
471
+ ],
472
+ rows,
473
+ });
474
+ };
475
+ const stamp = (t) => new Date(t).toISOString().slice(5, 16).replace('T', ' ');
476
+ /**
477
+ * **A trading-time axis** — closed-market time collapsed, session dividers
478
+ * drawn. Selection on a *continuous* axis never has to ask where a block's
479
+ * edges fall relative to the data's own structure; here it does, because the
480
+ * axis has seams and a wall-clock bucketing knows nothing about them.
481
+ *
482
+ * `sessionDividers: 'all'` is part of the fixture rather than of one story:
483
+ * every cell in this column is about the session grid, so the grid should be
484
+ * visible in all of them.
485
+ */
486
+ export const tradingSessions = {
487
+ name: 'TradingSessions',
488
+ container: {
489
+ /**
490
+ * Wider than the other columns — 42 bars across 6 sessions.
491
+ *
492
+ * **The x labels still crowd, and that is a defect this column found.**
493
+ * The trading axis budgets `TRADING_TICK_PX` (65px) of plot per tick and
494
+ * picks the finest grain that fits; here that is 12 hours. But a `00:00`
495
+ * anchor is not *inside* any session, so the collapse relocates it to the
496
+ * session open (09:30) — 2.5h into a 6.5h session, ~33px from that
497
+ * session's `12:00` label. The budget bounds spacing in wall-clock time
498
+ * and the axis draws in trading time, so it does not bound what it thinks
499
+ * it bounds. Widening moves the labels from overlapping to merely
500
+ * abutting, which is why 820 rather than the shared 640.
501
+ *
502
+ * Left visible on purpose: it is orthogonal to selection, and papering
503
+ * over it (fewer sessions, one bar per session) would cost the weekend
504
+ * seam the crossing story is built on.
505
+ */
506
+ width: 820,
507
+ range: [MON_OPEN, SESSIONS[SESSIONS.length - 1].close],
508
+ // The high-level sugar; the container derives the provider itself. Built
509
+ // once at module scope because the prop must be a *stable* reference.
510
+ calendar: calendarOf(SESSIONS),
511
+ sessionDividers: 'all',
512
+ },
513
+ axis: { id: 'v', min: 0, max: 12, label: '' },
514
+ renderLayer: (id) => (_jsx(BarChart, { series: sessionBars(4, 0), column: "v", axis: "v", id: id })),
515
+ secondary: {
516
+ axis: { id: 'e', min: 0, max: 12, label: 'errors' },
517
+ renderLayer: (id) => (_jsx(BarChart, { series: sessionBars(2.2, 1.1), column: "v", axis: "e", id: id, as: "warn" })),
518
+ },
519
+ picks: [0, 3, 8].map((i) => ({
520
+ label: stamp(MON_OPEN + i * H),
521
+ info: {
522
+ id: 'svc',
523
+ key: MON_OPEN + i * H,
524
+ value: 0,
525
+ color: '#000',
526
+ label: 'v',
527
+ },
528
+ })),
529
+ describe: (hit) => stamp(hit.key),
530
+ // The column's ordinary `SweepWithSequence` cell snaps to whole sessions —
531
+ // the same bucketing `sessions.conforming` names, so the generated story and
532
+ // the session pair below agree about what "conforming" means.
533
+ sequence: () => sessionSeq(SESSIONS),
534
+ rangeCursor: true,
535
+ sweep: true,
536
+ sessions: {
537
+ conforming: () => sessionSeq(SESSIONS),
538
+ /**
539
+ * **Noon-thirty to noon-thirty.** A wall-clock day bucket anchored inside
540
+ * the session, so no boundary can coincide with an open or a close: every
541
+ * block takes the afternoon of one session and the morning of the next.
542
+ * 12:30 is deliberately a *bar* edge — the block straddling a seam is the
543
+ * subject, and a bucket boundary bisecting a bar would be a second,
544
+ * unrelated question.
545
+ */
546
+ crossing: () => Sequence.every('1d', { anchor: MON_OPEN + 3 * H }),
547
+ },
548
+ };
549
+ /** Every column of the matrix, in tree order. */
550
+ export const FIXTURES = [
551
+ categoricalBars,
552
+ timeBars,
553
+ stackedBars,
554
+ boxWhisker,
555
+ boxSolid,
556
+ candles,
557
+ scatterPoints,
558
+ heatGrid,
559
+ tradingSessions,
560
+ ];
561
+ /** Shared caption styling, so every cell reads identically. */
562
+ export const caption = {
563
+ font: '13px system-ui',
564
+ color: '#667',
565
+ marginTop: 4,
566
+ maxWidth: 640,
567
+ };
568
+ export { DAY, D0 };
569
+ //# sourceMappingURL=selection-fixtures.js.map
@@ -0,0 +1,73 @@
1
+ import type { StoryObj } from '@storybook/react-vite';
2
+ import { type ChartFixture } from './selection-fixtures.js';
3
+ /**
4
+ * **The selection matrix's rows** — one feature set, rendered against every
5
+ * chart fixture (`selection-fixtures.tsx` supplies the columns).
6
+ *
7
+ * Every story here is generated from one definition, so a difference between
8
+ * two columns is a difference in the **library**, not in how someone happened
9
+ * to write the story. That is what makes walking the matrix a review technique
10
+ * rather than a gallery — see CLAUDE.md → "Storybook stories".
11
+ *
12
+ * Stories whose subject is *not* chart-type dependent (the deprecation shim,
13
+ * for instance) deliberately live in one column only; running them against
14
+ * every fixture would test nothing new.
15
+ */
16
+ type Story = StoryObj;
17
+ /**
18
+ * The `<Selector>` feature set for one chart type. `<RangeCursor>` is mounted
19
+ * only where the fixture declares it draws — on an ordinal axis it does not,
20
+ * and mounting one there costs the row its cursor entirely.
21
+ */
22
+ export interface SelectorStories {
23
+ MountedAtContainer: Story;
24
+ MountedInRow: Story;
25
+ NoSelector: Story;
26
+ ControlledNoSelector: Story;
27
+ ModifiersReported: Story;
28
+ HoverOnly: Story;
29
+ BareSelector: Story;
30
+ }
31
+ export declare function makeSelectorStories(fx: ChartFixture): SelectorStories;
32
+ /**
33
+ * The `<MultiSelector>` feature set for one chart type. `SweepWithSequence` is
34
+ * generated only where the fixture declares a `sequence` — an ordinal axis has
35
+ * no time bucketing, so that cell is a **gap in the matrix rather than a story
36
+ * that quietly does nothing**.
37
+ *
38
+ * The whole set is `null` for a fixture whose layer cannot sweep (`sweep:
39
+ * false`): with no `beginSweep` a mounted `<MultiSelector>` has neither a drag
40
+ * nor a resting preview, so every cell would render a chart that ignores the
41
+ * component it is named after.
42
+ */
43
+ export interface MultiSelectorStories {
44
+ SweepMarks: Story;
45
+ ClickStillSelectsOne: Story;
46
+ LivePreviewDuringDrag: Story;
47
+ SweepAdditive: Story;
48
+ DemoteOnEdit: Story;
49
+ /** Only where the fixture declares a `sequence` — see the factory's doc. */
50
+ SweepWithSequence?: Story;
51
+ }
52
+ export declare function makeMultiSelectorStories(fx: ChartFixture): MultiSelectorStories | null;
53
+ /**
54
+ * **The two session-break cells** — generated only for a fixture whose axis
55
+ * collapses closed-market time (`fx.sessions`).
56
+ *
57
+ * Every other cell in the matrix runs on an axis with no internal structure,
58
+ * where a snap block is just an interval. A trading-time axis has **seams**,
59
+ * and a wall-clock bucketing knows nothing about them — so "what does a
60
+ * selection block do at a session break?" becomes a real question with two
61
+ * answers worth seeing side by side. Both stories draw `sessionDividers`, so
62
+ * the grid a block either respects or ignores is on screen.
63
+ *
64
+ * These live in one column by construction: there is nothing to compare them
65
+ * against in a column whose axis has no seams.
66
+ */
67
+ export interface SessionStories {
68
+ SequenceConformsToSessions: Story;
69
+ SequenceCrossesSessions: Story;
70
+ }
71
+ export declare function makeSessionStories(fx: ChartFixture): SessionStories | null;
72
+ export {};
73
+ //# sourceMappingURL=selection-stories.d.ts.map