@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.
- package/CHANGELOG.md +1218 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.d.ts +84 -9
- package/dist/BarChart.js +295 -40
- package/dist/BarList.d.ts +85 -5
- package/dist/BarList.js +25 -4
- package/dist/BoxList.d.ts +70 -3
- package/dist/BoxList.js +21 -7
- package/dist/BoxPlot.d.ts +2 -1
- package/dist/BoxPlot.js +101 -9
- package/dist/Candlestick.d.ts +13 -1
- package/dist/Candlestick.js +89 -3
- package/dist/ChartContainer.d.ts +79 -48
- package/dist/ChartContainer.js +482 -60
- package/dist/ChartRow.d.ts +9 -2
- package/dist/ChartRow.js +86 -12
- package/dist/HeatMap.d.ts +176 -0
- package/dist/HeatMap.js +344 -0
- package/dist/Layers.d.ts +5 -1
- package/dist/Layers.js +1014 -253
- package/dist/Legend.js +8 -4
- package/dist/LineChart.d.ts +18 -1
- package/dist/LineChart.js +165 -4
- package/dist/ListTable.d.ts +30 -3
- package/dist/ListTable.js +381 -23
- package/dist/ScatterChart.d.ts +3 -2
- package/dist/ScatterChart.js +68 -4
- package/dist/XAxis.js +40 -22
- package/dist/YAxis.d.ts +28 -1
- package/dist/YAxis.js +24 -2
- package/dist/annotations.d.ts +74 -0
- package/dist/annotations.js +97 -7
- package/dist/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +178 -5
- package/dist/bars.js +504 -46
- package/dist/box.d.ts +2 -2
- package/dist/box.js +158 -40
- package/dist/brush.d.ts +142 -0
- package/dist/brush.js +179 -0
- package/dist/child-index.d.ts +27 -0
- package/dist/child-index.js +57 -0
- package/dist/context.d.ts +871 -36
- package/dist/cursors.d.ts +161 -0
- package/dist/cursors.js +503 -0
- package/dist/decimate.d.ts +78 -1
- package/dist/decimate.js +157 -0
- package/dist/heat.d.ts +163 -0
- package/dist/heat.js +659 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +25 -2
- package/dist/line.d.ts +137 -0
- package/dist/line.js +328 -0
- package/dist/ohlc.d.ts +16 -1
- package/dist/ohlc.js +93 -4
- package/dist/scatter.d.ts +17 -9
- package/dist/scatter.js +221 -33
- package/dist/select.d.ts +13 -5
- package/dist/select.js +14 -6
- package/dist/selection-fixtures.d.ts +174 -0
- package/dist/selection-fixtures.js +569 -0
- package/dist/selection-stories.d.ts +73 -0
- package/dist/selection-stories.js +301 -0
- package/dist/selectors.d.ts +316 -0
- package/dist/selectors.js +391 -0
- package/dist/span.d.ts +122 -0
- package/dist/span.js +203 -0
- package/dist/sweep.d.ts +154 -0
- package/dist/sweep.js +282 -0
- package/dist/theme.d.ts +517 -11
- package/dist/theme.js +220 -39
- package/dist/tracker.d.ts +6 -0
- package/dist/tracker.js +6 -0
- package/dist/tradingAxis.fixture.d.ts +78 -0
- package/dist/tradingAxis.fixture.js +215 -0
- package/dist/useChartLegend.js +18 -3
- 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
|