@pond-ts/charts 0.57.0 → 0.59.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/API.md +576 -0
- package/CHANGELOG.md +1213 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.d.ts +56 -7
- package/dist/BarChart.js +263 -39
- 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 +36 -48
- package/dist/ChartContainer.js +465 -59
- package/dist/ChartRow.d.ts +9 -2
- package/dist/ChartRow.js +176 -14
- 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 +58 -2
- package/dist/YAxis.js +3 -1
- package/dist/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +67 -6
- package/dist/bars.js +250 -35
- 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 +870 -39
- package/dist/cursors.d.ts +161 -0
- package/dist/cursors.js +503 -0
- package/dist/data.d.ts +38 -0
- package/dist/data.js +43 -0
- package/dist/decimate.d.ts +78 -1
- package/dist/decimate.js +157 -0
- package/dist/format.d.ts +15 -0
- package/dist/format.js +16 -1
- package/dist/heat.d.ts +163 -0
- package/dist/heat.js +659 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +27 -0
- 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/range.d.ts +14 -1
- package/dist/range.js +24 -3
- 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 +510 -5
- package/dist/theme.js +217 -41
- 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/dist/yticks.d.ts +3 -0
- package/dist/yticks.js +104 -0
- package/package.json +6 -5
package/dist/BarChart.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo } from 'react';
|
|
2
2
|
import { Interval, ValueSeries } from 'pond-ts';
|
|
3
|
-
import { barsFromTimeSeries, barsFromBins, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
|
|
3
|
+
import { barsFromTimeSeries, barsFromBins, barsFromValueSeries, categoryStack, categoryStacks, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
|
|
4
4
|
import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, normalizeThresholds, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
|
|
5
|
+
import { spansForLayer } from './span.js';
|
|
5
6
|
import { isDev } from './dev.js';
|
|
6
7
|
import { ContainerContext, LayersContext, } from './context.js';
|
|
8
|
+
import { sweep1D } from './sweep.js';
|
|
7
9
|
import { legendLabelFor, useLegendItems, } from './swatch.js';
|
|
8
10
|
import { useSlotKey } from './use-slot-key.js';
|
|
11
|
+
/** Stable "nothing selected" identity for the narrowed mark lists below. */
|
|
12
|
+
const EMPTY_MARKS = [];
|
|
9
13
|
/**
|
|
10
14
|
* A bar / histogram draw layer. In its simplest form, one rectangle per event
|
|
11
15
|
* spanning the key's `[begin, end]` from the axis baseline to a numeric
|
|
@@ -56,7 +60,7 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
56
60
|
* </Layers>
|
|
57
61
|
* ```
|
|
58
62
|
*/
|
|
59
|
-
export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, thresholds, bandColors, orientation = 'vertical', ordinal = false, id, axis, gap, decimate = true, legend, index = 0, }) {
|
|
63
|
+
export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, thresholds, bandColors, orientation = 'vertical', ordinal = false, id, axis, gap, maxBarWidth, decimate = true, legend, index = 0, }) {
|
|
60
64
|
const container = useContext(ContainerContext);
|
|
61
65
|
if (container === null) {
|
|
62
66
|
throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
|
|
@@ -74,8 +78,11 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
74
78
|
throw new Error('<BarChart> needs exactly one of `series`, `bins`, or `categories`');
|
|
75
79
|
}
|
|
76
80
|
if (categories !== undefined) {
|
|
77
|
-
if (column !== undefined
|
|
78
|
-
throw new Error('<BarChart categories> takes no `column
|
|
81
|
+
if (column !== undefined) {
|
|
82
|
+
throw new Error('<BarChart categories> takes no `column` — a single-value datum carries its own `value`, and a stacked one names its groups with `columns`');
|
|
83
|
+
}
|
|
84
|
+
if (columns !== undefined && columns.length === 0) {
|
|
85
|
+
throw new Error('<BarChart categories> with `columns` needs at least one group name');
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
const isMap = series instanceof Map;
|
|
@@ -101,10 +108,21 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
101
108
|
// horizontal bar) so one oriented draw path covers it.
|
|
102
109
|
const shape = useMemo(() => {
|
|
103
110
|
if (categories !== undefined) {
|
|
104
|
-
// Categorical row-read: one unit-slot bar per category
|
|
105
|
-
//
|
|
111
|
+
// Categorical row-read: one unit-slot bar per category, drawn on the
|
|
112
|
+
// container's band scale. The reused stacked geometry — only the axis
|
|
106
113
|
// (band scale + labels) is new.
|
|
107
|
-
|
|
114
|
+
//
|
|
115
|
+
// With `columns` it is a real multi-group stack ([PND-CATSTACK]): same
|
|
116
|
+
// slots, same `marks`, `G > 1`. Because `marks` is indexed by BIN, one
|
|
117
|
+
// selection entry naming `(id, mark)` matches every segment of a bar —
|
|
118
|
+
// which is the property that made the hand-composed workaround's
|
|
119
|
+
// "recedes from the waist up" bug inexpressible here.
|
|
120
|
+
return {
|
|
121
|
+
kind: 'stacked',
|
|
122
|
+
ss: columns !== undefined
|
|
123
|
+
? categoryStacks(categories, columns)
|
|
124
|
+
: categoryStack(categories),
|
|
125
|
+
};
|
|
108
126
|
}
|
|
109
127
|
if (bins !== undefined) {
|
|
110
128
|
const cols = columns ?? (column !== undefined ? [column] : undefined);
|
|
@@ -191,15 +209,21 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
191
209
|
: series instanceof ValueSeries
|
|
192
210
|
? 'value'
|
|
193
211
|
: 'time';
|
|
194
|
-
// The bars' `[begin, end)` spans as pond `Interval`s — the
|
|
195
|
-
// buckets (a region drag snaps bar by bar; a
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// cursor
|
|
200
|
-
//
|
|
212
|
+
// The bars' `[begin, end)` spans as pond `Interval`s — the shared snap
|
|
213
|
+
// buckets (a region drag snaps bar by bar; a `<MultiSelector>` sweep's band
|
|
214
|
+
// extends bar by bar). Published for any **vertical** bar layer: a
|
|
215
|
+
// horizontal chart puts the value/count on x (snapping it is meaningless).
|
|
216
|
+
// On a **category** axis the buckets are the unit slots `[i, i+1)` — the
|
|
217
|
+
// region cursor still ignores them (its band gates on a continuous axis),
|
|
218
|
+
// but the sweep's band needs them to snap to the slots' **outer edges**:
|
|
219
|
+
// the band scale's `invert` snaps a pixel to the slot *centre*, so a
|
|
220
|
+
// freeform sweep band ran centre-to-centre while capture and the committed
|
|
221
|
+
// span snapped outward (RFC A7.6's edge rule) — the band disagreed with
|
|
222
|
+
// what release would select. Memoized off the shape alone, so a hover /
|
|
223
|
+
// selection change (which rebuilds the layer entry) doesn't re-allocate
|
|
224
|
+
// the intervals.
|
|
201
225
|
const binBuckets = useMemo(() => {
|
|
202
|
-
if (orientation !== 'vertical'
|
|
226
|
+
if (orientation !== 'vertical')
|
|
203
227
|
return null;
|
|
204
228
|
const { begin, end, length } = shape.kind === 'single' ? shape.bs : shape.ss;
|
|
205
229
|
if (length === 0)
|
|
@@ -240,6 +264,19 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
240
264
|
// The stacked path's bar-thickness floor comes from `bar.default` (not the `as`
|
|
241
265
|
// role — `as` is single-series only), matching how `gapPx` sources its default.
|
|
242
266
|
const stackMinWidth = bar.default.minWidth;
|
|
267
|
+
// Same sourcing as `gapPx`: the prop wins, else the theme token, else
|
|
268
|
+
// uncapped ([PND-BARWIDTH]). `bar.default` rather than the `as` role for the
|
|
269
|
+
// stacked ceiling, for the reason above.
|
|
270
|
+
const maxWidthPx = maxBarWidth ?? bar.default.maxWidth;
|
|
271
|
+
// The single-series draw takes a `BarStyle` straight from the theme, so the
|
|
272
|
+
// prop override is applied by shadowing the token — same relationship `gap`
|
|
273
|
+
// has, and with the prop absent the role's own `maxWidth` (if any) already
|
|
274
|
+
// rides along untouched. Only the INK path gets this: `barSlotRect` (the hit
|
|
275
|
+
// region) stays the whole slot, so a narrow capped bar keeps a full-width
|
|
276
|
+
// target — the deliberate ink/hit split `gapPx` already relies on.
|
|
277
|
+
const singleDrawStyle = useMemo(() => maxBarWidth !== undefined
|
|
278
|
+
? { ...singleStyle, maxWidth: maxBarWidth }
|
|
279
|
+
: singleStyle, [singleStyle, maxBarWidth]);
|
|
243
280
|
// ── Threshold ladder ([PND-BANDBAR2]) ────────────────────────────────────
|
|
244
281
|
// Resolved once here rather than per bar per frame: normalize the breakpoints
|
|
245
282
|
// (sort, drop non-finite), then pair them with `bandColors` → the role's
|
|
@@ -327,9 +364,62 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
327
364
|
const groups = shape.kind === 'stacked' ? shape.ss.groups : undefined;
|
|
328
365
|
const stackStyle = useMemo(() => {
|
|
329
366
|
const base = bar.default;
|
|
330
|
-
|
|
367
|
+
// The theme's **group ramp** — multi-group only, since a ramp exists to
|
|
368
|
+
// tell groups apart and `G === 1` (every categorical and single-series
|
|
369
|
+
// chart runs this same path) has nothing to tell apart. Resolution per
|
|
370
|
+
// group: `colors` → a role named after the group → the ramp → `fill`.
|
|
371
|
+
const multi = (groups?.length ?? 0) > 1;
|
|
372
|
+
const ramp = multi ? base.groups : undefined;
|
|
373
|
+
const rampDim = multi ? base.groupsDimmed : undefined;
|
|
374
|
+
const rampHover = multi ? base.groupsHover : undefined;
|
|
375
|
+
const at = (r, i) => r[i % r.length];
|
|
376
|
+
const fills = (groups ?? []).map((g, i) => colors?.[g] ??
|
|
377
|
+
bar[g]?.fill ??
|
|
378
|
+
(ramp !== undefined ? at(ramp, i) : base.fill));
|
|
379
|
+
// A ramp entry the call site overrode is no longer the ramp's colour, so
|
|
380
|
+
// its receded counterpart would be wrong — the whole ramp only means
|
|
381
|
+
// anything when it is the ramp that painted it. This gates the *derived*
|
|
382
|
+
// companions (`dimmedFills` / `hoverFills`), which need a counterpart per
|
|
383
|
+
// group and cannot invent one for an arbitrary call-site colour.
|
|
384
|
+
const ramped = ramp !== undefined && colors === undefined;
|
|
385
|
+
// `groupColored` derives nothing — it only says "a selected segment keeps
|
|
386
|
+
// its own fill rather than taking the flat `highlight`", because the colour
|
|
387
|
+
// is meaning-carrying.
|
|
388
|
+
//
|
|
389
|
+
// So the condition is exactly **"do the fills actually differ"**, read off
|
|
390
|
+
// the resolved `fills` rather than inferred from anything upstream of them.
|
|
391
|
+
// Both cheaper inferences are wrong, and each was shipped in turn:
|
|
392
|
+
//
|
|
393
|
+
// - Gating on `ramped` (the ramp painted it) meant a stack with `colors`
|
|
394
|
+
// collapsed BOTH segments of a selected bar to one `highlight` blue —
|
|
395
|
+
// losing the segment distinction exactly where the reader is looking. A
|
|
396
|
+
// call site naming its groups' colours is *more* deliberate than a
|
|
397
|
+
// fallback ramp, not less. Found building [PND-CATSTACK].
|
|
398
|
+
// - Gating on `groups.length > 1` (my fix for that) is wrong in the other
|
|
399
|
+
// direction: a multi-group stack under a theme with **no** group ramp, no
|
|
400
|
+
// `colors` and no per-group roles resolves every fill to `base.fill`, so
|
|
401
|
+
// claiming the colour carries meaning suppresses the highlight and leaves
|
|
402
|
+
// *nothing* — selection becomes invisible. `estelaTheme` is exactly that
|
|
403
|
+
// theme, and it ships. Found by Layer-2 review, which is the only way it
|
|
404
|
+
// could have been: every story and test renders `defaultTheme`, whose ramp
|
|
405
|
+
// makes the two gates indistinguishable.
|
|
406
|
+
//
|
|
407
|
+
// Reading `fills` also handles the degenerate `colors` map that assigns one
|
|
408
|
+
// colour to every group: nothing is distinguished, so the highlight applies.
|
|
409
|
+
const groupColoured = new Set(fills).size > 1;
|
|
331
410
|
return {
|
|
332
411
|
fills,
|
|
412
|
+
...(groupColoured ? { groupColored: true } : {}),
|
|
413
|
+
...(ramped && rampDim !== undefined
|
|
414
|
+
? {
|
|
415
|
+
dimmedFills: (groups ?? []).map((_g, i) => at(rampDim, i)),
|
|
416
|
+
}
|
|
417
|
+
: {}),
|
|
418
|
+
...(ramped && rampHover !== undefined
|
|
419
|
+
? {
|
|
420
|
+
hoverFills: (groups ?? []).map((_g, i) => at(rampHover, i)),
|
|
421
|
+
}
|
|
422
|
+
: {}),
|
|
333
423
|
opacity: base.opacity,
|
|
334
424
|
outlineWidth: base.outlineWidth,
|
|
335
425
|
// [PND-CATEMPH] Forward the themed emphasis so the category / horizontal
|
|
@@ -343,32 +433,42 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
343
433
|
...(base.emphasisOpacity !== undefined
|
|
344
434
|
? { emphasisOpacity: base.emphasisOpacity }
|
|
345
435
|
: {}),
|
|
436
|
+
...(base.dimmed !== undefined ? { dimmed: base.dimmed } : {}),
|
|
437
|
+
...(maxWidthPx !== undefined ? { maxWidth: maxWidthPx } : {}),
|
|
346
438
|
...(binColors !== undefined ? { binFills: binColors } : {}),
|
|
347
439
|
};
|
|
348
|
-
}, [bar, groups, colors, binColors]);
|
|
440
|
+
}, [bar, groups, colors, binColors, maxWidthPx]);
|
|
349
441
|
// The current selection / hover, narrowed to the identity the highlight match
|
|
350
442
|
// needs. For a stack that's (id, key, label = group); the single path uses just
|
|
351
443
|
// (id, key). Read here so a change re-registers the layer → the canvas repaints.
|
|
352
444
|
const selected = container.selected;
|
|
353
445
|
const hoveredMark = container.hovered;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
446
|
+
// The selection is a set ([PND-MULTISEL]); narrow each member to the identity
|
|
447
|
+
// the draw path matches on. `EMPTY_MARKS` keeps the no-selection case
|
|
448
|
+
// reference-stable so it doesn't re-identify the layer entry each render.
|
|
449
|
+
const selection = useMemo(() => selected.length === 0
|
|
450
|
+
? EMPTY_MARKS
|
|
451
|
+
: selected.map((m) => ({
|
|
452
|
+
id: m.id,
|
|
453
|
+
key: m.key,
|
|
454
|
+
label: m.label,
|
|
455
|
+
...(m.mark !== undefined ? { mark: m.mark } : {}),
|
|
456
|
+
})), [selected]);
|
|
457
|
+
const hover = useMemo(() => hoveredMark.length === 0
|
|
458
|
+
? EMPTY_MARKS
|
|
459
|
+
: hoveredMark.map((m) => ({
|
|
460
|
+
id: m.id,
|
|
461
|
+
key: m.key,
|
|
462
|
+
label: m.label,
|
|
463
|
+
...(m.mark !== undefined ? { mark: m.mark } : {}),
|
|
464
|
+
})), [hoveredMark]);
|
|
465
|
+
// The selection's span entries, narrowed to this layer (interaction RFC
|
|
466
|
+
// A5.2). On the single-series path every mark shares one label, so a span's
|
|
467
|
+
// `rows` channel resolves here (once) rather than per bar; the stacked path's
|
|
468
|
+
// labels vary per segment (group / category), so `rows` rides through for
|
|
469
|
+
// `drawStacks` to test. Empty (and reference-stable) when no span names us —
|
|
470
|
+
// this layer neither re-registers nor repaints for other layers' spans.
|
|
471
|
+
const layerSpans = useMemo(() => spansForLayer(container.selectedSpans, id, shape.kind === 'single' ? label : undefined), [container.selectedSpans, id, shape.kind, label]);
|
|
372
472
|
const entry = useMemo(() => {
|
|
373
473
|
// ── Single-series, vertical: the original bar path, pixels unchanged. ──
|
|
374
474
|
if (shape.kind === 'single') {
|
|
@@ -403,7 +503,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
403
503
|
...(id === undefined
|
|
404
504
|
? {}
|
|
405
505
|
: {
|
|
406
|
-
hitTest: (px, py, xScale, yScale) => {
|
|
506
|
+
hitTest: (px, py, xScale, yScale, mode) => {
|
|
407
507
|
const baseline = resolveBarBaseline(yScale);
|
|
408
508
|
// No `gapPx` — the hit region is the bar's whole slot (its
|
|
409
509
|
// interval width, full plot height), not the inset rect the
|
|
@@ -412,6 +512,20 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
412
512
|
if (hit === null)
|
|
413
513
|
return null;
|
|
414
514
|
const [bi, begin, value] = hit;
|
|
515
|
+
// A CLICK narrows the slot to the bar's drawn ink
|
|
516
|
+
// vertically (the x keeps the slot, so the gap between
|
|
517
|
+
// columns is still not a dead channel). Slots tile the
|
|
518
|
+
// whole plot, so without this a click could never resolve
|
|
519
|
+
// to null — and that null IS the deselect path (RFC §7's
|
|
520
|
+
// empty commit). Hover keeps the full slot: the highlight
|
|
521
|
+
// tracks continuously like the readout (#582).
|
|
522
|
+
if (mode === 'select') {
|
|
523
|
+
const yValue = yScale(value);
|
|
524
|
+
const yBase = yScale(baseline);
|
|
525
|
+
if (py < Math.min(yValue, yBase) ||
|
|
526
|
+
py > Math.max(yValue, yBase))
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
415
529
|
// The bar's stable `mark` (its own axis key) rides the
|
|
416
530
|
// selection, so the highlight match and a controlled echo key
|
|
417
531
|
// on the *sample* rather than on the `begin` edge — which on a
|
|
@@ -430,8 +544,43 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
430
544
|
...(stableMark !== undefined ? { mark: stableMark } : {}),
|
|
431
545
|
};
|
|
432
546
|
},
|
|
547
|
+
// The <MultiSelector> sweep's range query (RFC A7.6): bars
|
|
548
|
+
// are sorted, non-overlapping intervals, so the covered set
|
|
549
|
+
// is a contiguous run — sweep1D's two binary searches. Each
|
|
550
|
+
// materialised hit is EXACTLY what hitTest reports for that
|
|
551
|
+
// bar, so a swept bar and a clicked bar are the same currency.
|
|
552
|
+
beginSweep: () => bs.length === 0
|
|
553
|
+
? null
|
|
554
|
+
: sweep1D({
|
|
555
|
+
id,
|
|
556
|
+
begin: bs.begin,
|
|
557
|
+
end: bs.end,
|
|
558
|
+
length: bs.length,
|
|
559
|
+
// A gap bar (non-finite value) owns no membership.
|
|
560
|
+
selectable: (i) => Number.isFinite(bs.y[i]),
|
|
561
|
+
materialize: (lo, hi) => {
|
|
562
|
+
const out = [];
|
|
563
|
+
for (let i = lo; i < hi; i += 1) {
|
|
564
|
+
const v = bs.y[i];
|
|
565
|
+
if (!Number.isFinite(v))
|
|
566
|
+
continue;
|
|
567
|
+
const stableMark = bs.marks?.[i];
|
|
568
|
+
out.push({
|
|
569
|
+
id,
|
|
570
|
+
key: bs.begin[i],
|
|
571
|
+
value: v,
|
|
572
|
+
color: binColors?.[i] ?? singleStyle.fill,
|
|
573
|
+
label,
|
|
574
|
+
...(stableMark !== undefined
|
|
575
|
+
? { mark: stableMark }
|
|
576
|
+
: {}),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
},
|
|
581
|
+
}),
|
|
433
582
|
}),
|
|
434
|
-
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale,
|
|
583
|
+
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleDrawStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder, layerSpans),
|
|
435
584
|
},
|
|
436
585
|
axisId: axis,
|
|
437
586
|
index,
|
|
@@ -469,7 +618,20 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
469
618
|
? {}
|
|
470
619
|
: {
|
|
471
620
|
hitTest: (px, py, xScale, yScale) => {
|
|
472
|
-
|
|
621
|
+
// The cap reaches the hit rect ONLY for a real stack, where the
|
|
622
|
+
// rect is what resolves *which segment* was hit and so must be
|
|
623
|
+
// the drawn one. A single-series chart has one segment per slot
|
|
624
|
+
// and nothing to disambiguate, so narrowing its target buys
|
|
625
|
+
// nothing and costs clickability.
|
|
626
|
+
//
|
|
627
|
+
// This path serves single-series **horizontal** charts as well as
|
|
628
|
+
// stacks (see the branch comment above), which is how the prop's
|
|
629
|
+
// documented guarantee — "a single-series bar hit-tests its whole
|
|
630
|
+
// slot" — was true only of vertical ones. Found by Layer-2 review;
|
|
631
|
+
// the fix is to make the guarantee orientation-independent rather
|
|
632
|
+
// than to narrow the claim, since the reason for the split is
|
|
633
|
+
// segment disambiguation and that is a property of the *stack*.
|
|
634
|
+
const hit = stackAt(ss, px, py, orientation, xScale, yScale, gapPx, stackMinWidth, ss.groups.length > 1 ? maxWidthPx : undefined);
|
|
473
635
|
if (hit === null)
|
|
474
636
|
return null;
|
|
475
637
|
const [bi, g, begin, name, value] = hit;
|
|
@@ -490,8 +652,67 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
490
652
|
...(stableMark !== undefined ? { mark: stableMark } : {}),
|
|
491
653
|
};
|
|
492
654
|
},
|
|
655
|
+
// The sweep, on the binned/stacked/categorical path — **either
|
|
656
|
+
// orientation** ([PND-HSWEEP]). The session is identical, because
|
|
657
|
+
// `sweep1D` cuts in KEY-axis units and does not care which screen
|
|
658
|
+
// axis produced them; the bins are `ss.begin`/`ss.end` whichever
|
|
659
|
+
// way the chart is drawn. What differs is only where the gesture
|
|
660
|
+
// reads the pointer, which `sweepAxis` declares.
|
|
661
|
+
//
|
|
662
|
+
// The cut stays 1-D on a horizontal chart, deliberately: a
|
|
663
|
+
// vertical bar's sweep ignores the value axis (drag anywhere
|
|
664
|
+
// horizontally, take whole columns), so its transpose ignores it
|
|
665
|
+
// too. A rect here would be value-filtering — a capability the
|
|
666
|
+
// vertical chart has never had.
|
|
667
|
+
//
|
|
668
|
+
// A covered bin materialises every drawn segment (finite,
|
|
669
|
+
// non-zero — the marks hitTest can hit), assembled exactly as
|
|
670
|
+
// hitTest assembles them.
|
|
671
|
+
sweepAxis: vertical ? 'x' : 'y',
|
|
672
|
+
beginSweep: () => {
|
|
673
|
+
const G = ss.groups.length;
|
|
674
|
+
if (ss.length === 0 || G === 0)
|
|
675
|
+
return null;
|
|
676
|
+
const drawn = (b, g) => {
|
|
677
|
+
const v = ss.values[b * G + g];
|
|
678
|
+
return Number.isFinite(v) && v !== 0;
|
|
679
|
+
};
|
|
680
|
+
return sweep1D({
|
|
681
|
+
id,
|
|
682
|
+
begin: ss.begin,
|
|
683
|
+
end: ss.end,
|
|
684
|
+
length: ss.length,
|
|
685
|
+
selectable: (b) => {
|
|
686
|
+
for (let g = 0; g < G; g += 1)
|
|
687
|
+
if (drawn(b, g))
|
|
688
|
+
return true;
|
|
689
|
+
return false;
|
|
690
|
+
},
|
|
691
|
+
materialize: (lo, hi) => {
|
|
692
|
+
const out = [];
|
|
693
|
+
for (let b = lo; b < hi; b += 1) {
|
|
694
|
+
const stableMark = ss.marks?.[b];
|
|
695
|
+
for (let g = 0; g < G; g += 1) {
|
|
696
|
+
if (!drawn(b, g))
|
|
697
|
+
continue;
|
|
698
|
+
out.push({
|
|
699
|
+
id,
|
|
700
|
+
key: ss.begin[b],
|
|
701
|
+
value: ss.values[b * G + g],
|
|
702
|
+
color: stackStyle.binFills?.[b] ?? stackStyle.fills[g],
|
|
703
|
+
label: stableMark ?? ss.groups[g],
|
|
704
|
+
...(stableMark !== undefined
|
|
705
|
+
? { mark: stableMark }
|
|
706
|
+
: {}),
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return out;
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
},
|
|
493
714
|
}),
|
|
494
|
-
draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder),
|
|
715
|
+
draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder, layerSpans),
|
|
495
716
|
},
|
|
496
717
|
axisId: axis,
|
|
497
718
|
index,
|
|
@@ -503,6 +724,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
503
724
|
categoryLabels,
|
|
504
725
|
orientation,
|
|
505
726
|
singleStyle,
|
|
727
|
+
singleDrawStyle,
|
|
506
728
|
stackStyle,
|
|
507
729
|
binColors,
|
|
508
730
|
bandLadder,
|
|
@@ -511,8 +733,10 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
511
733
|
gapPx,
|
|
512
734
|
decimate,
|
|
513
735
|
stackMinWidth,
|
|
736
|
+
maxWidthPx,
|
|
514
737
|
selection,
|
|
515
738
|
hover,
|
|
739
|
+
layerSpans,
|
|
516
740
|
axis,
|
|
517
741
|
index,
|
|
518
742
|
]);
|
package/dist/BarList.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 BarListColumn, 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 BarList source doors share. */
|
|
7
8
|
export interface BarListCommon<R extends ListRow = ListRow> {
|
|
@@ -53,13 +54,92 @@ export interface BarListCommon<R extends ListRow = ListRow> {
|
|
|
53
54
|
/** Observe a toggle (`expanded` is the row's **new** state). */
|
|
54
55
|
onExpandToggle?: (key: string, expanded: boolean) => void;
|
|
55
56
|
/**
|
|
56
|
-
* The selected row
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* The selected row(s), marked with an inset edge in the annotation (marks)
|
|
58
|
+
* register — selection is a user's mark, not data. Consumer-owned state:
|
|
59
|
+
* pair with {@link onRowClick}. `null` / omitted ⇒ none.
|
|
60
|
+
*
|
|
61
|
+
* **Accepts one key or a set**, the same union {@link hovered} takes
|
|
62
|
+
* ([PND-INTERACTCONF] / RFC `interaction.md` A3.1 — the list family speaks
|
|
63
|
+
* the canvas's interaction vocabulary, not a parallel one). Plural because
|
|
64
|
+
* a range of rows can be selected at once; passing a bare key still means
|
|
65
|
+
* exactly what it looks like.
|
|
66
|
+
*
|
|
67
|
+
* The library applies **no set arithmetic** — it renders what you hand back.
|
|
59
68
|
*/
|
|
60
|
-
selected?: string | null;
|
|
61
|
-
/** Row click (rows show
|
|
69
|
+
selected?: string | readonly string[] | null;
|
|
70
|
+
/** Row click (rows show the pointer affordance only when provided). */
|
|
62
71
|
onRowClick?: (row: R) => void;
|
|
72
|
+
/**
|
|
73
|
+
* **Plural select** — the list's answer to `<MultiSelector>`, and how a user
|
|
74
|
+
* produces a multi-row {@link selected}.
|
|
75
|
+
*
|
|
76
|
+
* Fires with the rows the gesture took plus its modifiers:
|
|
77
|
+
*
|
|
78
|
+
* - a **click** reports `[row]` — so this is a strict *superset* of
|
|
79
|
+
* {@link onRowClick}, the way `<MultiSelector>` is of `<Selector>`;
|
|
80
|
+
* - a **drag across rows** reports the whole inclusive run, in display
|
|
81
|
+
* order.
|
|
82
|
+
*
|
|
83
|
+
* **Mounting it is what enables the drag** (interaction RFC A4.2 rule 1 —
|
|
84
|
+
* the same rule that makes a bare `<MultiSelector />` enable the canvas
|
|
85
|
+
* sweep). A list with only `onRowClick` behaves exactly as it always has.
|
|
86
|
+
*
|
|
87
|
+
* **Crossing into another row is what makes it a range**, not a pixel slop:
|
|
88
|
+
* a row is tall and discrete, so a press-and-release on one row is always a
|
|
89
|
+
* click, and a horizontal wobble — which on a stack of rows means nothing —
|
|
90
|
+
* can never commit one. While the drag runs, the covered rows light as
|
|
91
|
+
* *hovered*: that is the live preview of what releasing would take, and it
|
|
92
|
+
* out-ranks {@link hovered} for the duration without touching it.
|
|
93
|
+
*
|
|
94
|
+
* **The library holds no state and applies no set arithmetic.** You get the
|
|
95
|
+
* run and the modifiers; you decide whether to replace or union, and feed
|
|
96
|
+
* the result back through {@link selected}:
|
|
97
|
+
*
|
|
98
|
+
* ```tsx
|
|
99
|
+
* onRowSelect={(rows, m) =>
|
|
100
|
+
* setSel((cur) => {
|
|
101
|
+
* const keys = rows.map((r) => r.key);
|
|
102
|
+
* return m.additive ? [...new Set([...cur, ...keys])] : keys;
|
|
103
|
+
* })
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*
|
|
107
|
+
* `modifiers.additive` is the platform-idiomatic add chord already resolved
|
|
108
|
+
* (⌘ on macOS, Ctrl elsewhere). **`shiftKey` is reported but carries no
|
|
109
|
+
* built-in meaning** — an ordinal range is a gesture here, not a modifier
|
|
110
|
+
* (see `SelectModifiers`), so a shift-click extend is yours to define if you
|
|
111
|
+
* want one.
|
|
112
|
+
*/
|
|
113
|
+
onRowSelect?: (rows: readonly R[], modifiers: SelectModifiers) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Controlled **hover-highlight** — the transiently lit row key(s), or `null`.
|
|
116
|
+
* **Omitted ⇒ uncontrolled** (the list tracks its own pointer, as it always
|
|
117
|
+
* has). The hover analog of {@link selected}: pass it to light rows from
|
|
118
|
+
* _outside_ the list — the chart bar the pointer is on, a map segment, a
|
|
119
|
+
* sibling list.
|
|
120
|
+
*
|
|
121
|
+
* **Accepts one key or a set**, the same union `<Selector hovered>`
|
|
122
|
+
* takes ([PND-INTERACTCONF] / RFC `interaction.md` A3.1 — the list family
|
|
123
|
+
* speaks the canvas's interaction vocabulary, not a parallel one). Plural
|
|
124
|
+
* because a sweep lights several marks at once; a plain pointer-over carries
|
|
125
|
+
* 0 or 1, so passing a bare key still means exactly what it looks like.
|
|
126
|
+
*
|
|
127
|
+
* The library applies **no set arithmetic** — it reports what the pointer is
|
|
128
|
+
* over and renders what you hand back.
|
|
129
|
+
*/
|
|
130
|
+
hovered?: string | readonly string[] | null;
|
|
131
|
+
/**
|
|
132
|
+
* Fires when the pointer enters a row (with that row) or leaves every row
|
|
133
|
+
* (`null`) — the hover analog of `onRowClick`, and the list's half of the
|
|
134
|
+
* bidirectional channel: mirror it out to light the matching chart bar,
|
|
135
|
+
* pairing with {@link hovered} to sync hover both ways.
|
|
136
|
+
*
|
|
137
|
+
* Notification only (fires controlled or uncontrolled) and **deduped by row
|
|
138
|
+
* key**, so it reports a row transition, not every pointer move. Moving from
|
|
139
|
+
* one row straight to the next reports the new row — no `null` in between;
|
|
140
|
+
* `null` means the pointer genuinely left the rows.
|
|
141
|
+
*/
|
|
142
|
+
onHover?: (row: R | null) => void;
|
|
63
143
|
/** Each bar line's height in px. **Omitted ⇒ `8`.** */
|
|
64
144
|
barHeight?: number;
|
|
65
145
|
/** Rule between rows (`theme.axis.grid`). **Omitted ⇒ `true`.** */
|
package/dist/BarList.js
CHANGED
|
@@ -36,7 +36,7 @@ export function BarList(props) {
|
|
|
36
36
|
// One normalized view of the union — `isSeriesSource` is the runtime
|
|
37
37
|
// narrowing; the doors are mutually exclusive by construction now.
|
|
38
38
|
const source = props;
|
|
39
|
-
const { rows, series, label, columns, domain, sortBy, sortDirection = 'desc', sort, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, markers, barHeight = 8, divided, baseline, theme = defaultTheme, } = source;
|
|
39
|
+
const { rows, series, label, columns, domain, sortBy, sortDirection = 'desc', sort, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, onRowSelect, hovered, onHover, markers, barHeight = 8, divided, baseline, theme = defaultTheme, } = source;
|
|
40
40
|
// A runtime guard for JS consumers and `any`-typed call sites — the
|
|
41
41
|
// props union makes both branches unreachable from typed TS, but a
|
|
42
42
|
// silently-ignored source prop is a worse failure than a throw.
|
|
@@ -55,9 +55,30 @@ export function BarList(props) {
|
|
|
55
55
|
frac: listFraction(m.value, scale),
|
|
56
56
|
...(m.label !== undefined ? { label: m.label } : {}),
|
|
57
57
|
})), [markers, scale]);
|
|
58
|
-
return (_jsx(ListTable, { rows: sorted, kind: "bar", 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) => {
|
|
58
|
+
return (_jsx(ListTable, { rows: sorted, kind: "bar", 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) => {
|
|
59
59
|
const style = theme.bar[col.as ?? 'default'] ?? theme.bar.default;
|
|
60
60
|
const frac = listFraction(row.values[col.column], scale);
|
|
61
|
+
// **The fill's state treatment — decorative, never load-bearing.**
|
|
62
|
+
// The band and the rail have already said "selected"; this only
|
|
63
|
+
// agrees with them. It is skipped entirely on a multi-metric row
|
|
64
|
+
// because there the fill IS the metric's identity, and recolouring
|
|
65
|
+
// it would trade a distinction the reader needs for one they
|
|
66
|
+
// already have (`ChartTheme.list`'s channel rule).
|
|
67
|
+
//
|
|
68
|
+
// Which is also why nothing below may be the *only* signal: strip
|
|
69
|
+
// this block and a selected row still reads as selected.
|
|
70
|
+
const soleMetric = columns.length === 1;
|
|
71
|
+
const fill = state.selected && soleMetric
|
|
72
|
+
? style.highlight
|
|
73
|
+
: state.dimmed
|
|
74
|
+
? (style.dimmed ?? style.fill)
|
|
75
|
+
: style.fill;
|
|
76
|
+
// A `dimmed` token carries its own alpha (`rgba(…,0.32)`), so
|
|
77
|
+
// multiplying `opacity` on top of it would dim twice. Fall back to
|
|
78
|
+
// the raw 0.32 only when the theme names no dimmed colour.
|
|
79
|
+
const fillOpacity = state.dimmed && style.dimmed === undefined
|
|
80
|
+
? style.opacity * 0.32
|
|
81
|
+
: style.opacity;
|
|
61
82
|
return (_jsxs("div", { "data-list-track": col.column, style: {
|
|
62
83
|
position: 'relative',
|
|
63
84
|
height: barHeight,
|
|
@@ -75,8 +96,8 @@ export function BarList(props) {
|
|
|
75
96
|
bottom: 0,
|
|
76
97
|
left: 0,
|
|
77
98
|
width: `${frac * 100}%`,
|
|
78
|
-
background:
|
|
79
|
-
opacity:
|
|
99
|
+
background: fill,
|
|
100
|
+
opacity: fillOpacity,
|
|
80
101
|
borderRadius: barHeight / 2,
|
|
81
102
|
} }))] }, `${ci} ${col.column}`));
|
|
82
103
|
}) })) }));
|