@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
package/dist/BarChart.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo } from 'react';
|
|
2
2
|
import { Interval, ValueSeries } from 'pond-ts';
|
|
3
3
|
import { barsFromTimeSeries, barsFromBins, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
|
|
4
|
-
import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
|
|
4
|
+
import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, normalizeThresholds, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
|
|
5
|
+
import { spansForLayer } from './span.js';
|
|
6
|
+
import { isDev } from './dev.js';
|
|
5
7
|
import { ContainerContext, LayersContext, } from './context.js';
|
|
8
|
+
import { sweep1D } from './sweep.js';
|
|
6
9
|
import { legendLabelFor, useLegendItems, } from './swatch.js';
|
|
7
10
|
import { useSlotKey } from './use-slot-key.js';
|
|
11
|
+
/** Stable "nothing selected" identity for the narrowed mark lists below. */
|
|
12
|
+
const EMPTY_MARKS = [];
|
|
8
13
|
/**
|
|
9
14
|
* A bar / histogram draw layer. In its simplest form, one rectangle per event
|
|
10
15
|
* spanning the key's `[begin, end]` from the axis baseline to a numeric
|
|
@@ -24,14 +29,21 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
24
29
|
* domain spans zero, or on the axis floor when an explicit `<YAxis min>` sits
|
|
25
30
|
* above zero (see {@link resolveBarBaseline}).
|
|
26
31
|
*
|
|
27
|
-
* **Baseline (stacked).** A stack is **cumulative from value 0** —
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* An explicit `<YAxis min>` **above** 0 is therefore unsupported
|
|
31
|
-
* would hide the bottom of the cumulative column; only the
|
|
32
|
-
* draws (clipped cleanly at the plot floor, as any bar
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* **Baseline (stacked).** A stack is **cumulative from value 0** — so its value
|
|
33
|
+
* axis **must include 0**. The auto-fit guarantees this:
|
|
34
|
+
* {@link stackValueExtent} returns `[minNegativeTotal, maxPositiveTotal]`, both
|
|
35
|
+
* seeded at `0`. An explicit `<YAxis min>` **above** 0 is therefore unsupported
|
|
36
|
+
* for a stack — it would hide the bottom of the cumulative column; only the
|
|
37
|
+
* portion above the floor draws (clipped cleanly at the plot floor, as any bar
|
|
38
|
+
* below an explicit floor is).
|
|
39
|
+
*
|
|
40
|
+
* **Signed stacks are supported** ([PND-SIGNSTACK]): each bin keeps two running
|
|
41
|
+
* totals, so positive segments stack **up** from the zero line and negative
|
|
42
|
+
* ones stack **down** from it — the signed histogram (net flow by category,
|
|
43
|
+
* inflow/outflow, buy/sell pressure by venue). A **zero** segment is still
|
|
44
|
+
* skipped, having no extent to draw or hit-test. This changed in the
|
|
45
|
+
* threshold-banding wave: negative segments were previously dropped outright
|
|
46
|
+
* and silently, so a mixed-sign series rendered as an all-positive chart.
|
|
35
47
|
*
|
|
36
48
|
* **Interaction (opt-in via `id`).** Hover lights the bar / segment under the
|
|
37
49
|
* cursor (hit-tested by pixel rect, so it works in both orientations); click
|
|
@@ -48,7 +60,7 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
48
60
|
* </Layers>
|
|
49
61
|
* ```
|
|
50
62
|
*/
|
|
51
|
-
export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, 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, decimate = true, legend, index = 0, }) {
|
|
52
64
|
const container = useContext(ContainerContext);
|
|
53
65
|
if (container === null) {
|
|
54
66
|
throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
|
|
@@ -183,15 +195,21 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
183
195
|
: series instanceof ValueSeries
|
|
184
196
|
? 'value'
|
|
185
197
|
: 'time';
|
|
186
|
-
// The bars' `[begin, end)` spans as pond `Interval`s — the
|
|
187
|
-
// buckets (a region drag snaps bar by bar; a
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
// cursor
|
|
192
|
-
//
|
|
198
|
+
// The bars' `[begin, end)` spans as pond `Interval`s — the shared snap
|
|
199
|
+
// buckets (a region drag snaps bar by bar; a `<MultiSelector>` sweep's band
|
|
200
|
+
// extends bar by bar). Published for any **vertical** bar layer: a
|
|
201
|
+
// horizontal chart puts the value/count on x (snapping it is meaningless).
|
|
202
|
+
// On a **category** axis the buckets are the unit slots `[i, i+1)` — the
|
|
203
|
+
// region cursor still ignores them (its band gates on a continuous axis),
|
|
204
|
+
// but the sweep's band needs them to snap to the slots' **outer edges**:
|
|
205
|
+
// the band scale's `invert` snaps a pixel to the slot *centre*, so a
|
|
206
|
+
// freeform sweep band ran centre-to-centre while capture and the committed
|
|
207
|
+
// span snapped outward (RFC A7.6's edge rule) — the band disagreed with
|
|
208
|
+
// what release would select. Memoized off the shape alone, so a hover /
|
|
209
|
+
// selection change (which rebuilds the layer entry) doesn't re-allocate
|
|
210
|
+
// the intervals.
|
|
193
211
|
const binBuckets = useMemo(() => {
|
|
194
|
-
if (orientation !== 'vertical'
|
|
212
|
+
if (orientation !== 'vertical')
|
|
195
213
|
return null;
|
|
196
214
|
const { begin, end, length } = shape.kind === 'single' ? shape.bs : shape.ss;
|
|
197
215
|
if (length === 0)
|
|
@@ -232,17 +250,136 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
232
250
|
// The stacked path's bar-thickness floor comes from `bar.default` (not the `as`
|
|
233
251
|
// role — `as` is single-series only), matching how `gapPx` sources its default.
|
|
234
252
|
const stackMinWidth = bar.default.minWidth;
|
|
253
|
+
// ── Threshold ladder ([PND-BANDBAR2]) ────────────────────────────────────
|
|
254
|
+
// Resolved once here rather than per bar per frame: normalize the breakpoints
|
|
255
|
+
// (sort, drop non-finite), then pair them with `bandColors` → the role's
|
|
256
|
+
// `BarStyle.bands`. Everything that can go wrong with the pairing is a
|
|
257
|
+
// *silent* wrong-looking chart, so each case dev-warns — this feature exists
|
|
258
|
+
// because a quietly-unbanded bar was the workaround's failure mode.
|
|
259
|
+
// Value-compare the two array props rather than relying on their identity.
|
|
260
|
+
// `thresholds={[1, 2]}` inline is the documented usage and the shape every
|
|
261
|
+
// story and doc example uses — and a fresh array each render would rebuild
|
|
262
|
+
// the ladder, hence the layer `entry` below, hence a `registerLayer` call
|
|
263
|
+
// **every render**. That is a repaint treadmill, not just a noisy warning.
|
|
264
|
+
// The same value-compare-on-registration reasoning `<YAxis ticks>` already
|
|
265
|
+
// applies.
|
|
266
|
+
const thresholdKey = thresholds === undefined ? '' : thresholds.join(',');
|
|
267
|
+
const bandColorKey = bandColors === undefined ? '' : bandColors.join(',');
|
|
268
|
+
const bandLadder = useMemo(() => {
|
|
269
|
+
const steps = normalizeThresholds(thresholds);
|
|
270
|
+
if (steps === null) {
|
|
271
|
+
if (isDev && thresholds !== undefined && thresholds.length > 0) {
|
|
272
|
+
console.warn('<BarChart thresholds>: no usable breakpoints, so no banding was ' +
|
|
273
|
+
'applied — each must be finite and greater than zero. Bars draw ' +
|
|
274
|
+
'in the flat fill.');
|
|
275
|
+
}
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
// Some, but not all, entries dropped. Silently banding on a subset of what
|
|
279
|
+
// the caller wrote is exactly the class of quiet wrongness this feature is
|
|
280
|
+
// meant to remove, so say so.
|
|
281
|
+
if (isDev && thresholds !== undefined && steps.length < thresholds.length) {
|
|
282
|
+
console.warn(`<BarChart thresholds>: dropped ${thresholds.length - steps.length} ` +
|
|
283
|
+
'breakpoint(s) that were not finite and greater than zero. The ' +
|
|
284
|
+
'ladder is walked on the magnitude and mirrored onto whichever side ' +
|
|
285
|
+
'of zero a bar is on, so a negative breakpoint has no meaning; ' +
|
|
286
|
+
`banding on [${steps.join(', ')}].`);
|
|
287
|
+
}
|
|
288
|
+
const want = steps.length + 1;
|
|
289
|
+
const supplied = bandColors ?? singleStyle.bands;
|
|
290
|
+
if (supplied === undefined || supplied.length === 0) {
|
|
291
|
+
if (isDev) {
|
|
292
|
+
console.warn(`<BarChart thresholds>: ${steps.length} breakpoint(s) need ${want} ` +
|
|
293
|
+
'band colours, but neither `bandColors` nor the theme role’s ' +
|
|
294
|
+
'`BarStyle.bands` supplies any. Bars draw in the flat fill.');
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
if (supplied.length < want && isDev) {
|
|
299
|
+
console.warn(`<BarChart thresholds>: ${steps.length} breakpoint(s) need ${want} ` +
|
|
300
|
+
`band colours but only ${supplied.length} were supplied; bands ` +
|
|
301
|
+
'above the last colour fall back to the flat fill.');
|
|
302
|
+
}
|
|
303
|
+
// Pad a short ladder with the flat fill so the draw path can index freely.
|
|
304
|
+
const resolved = supplied.length >= want
|
|
305
|
+
? supplied.slice(0, want)
|
|
306
|
+
: [
|
|
307
|
+
...supplied,
|
|
308
|
+
...Array.from({ length: want - supplied.length }, () => singleStyle.fill),
|
|
309
|
+
];
|
|
310
|
+
return { thresholds: steps, colors: resolved };
|
|
311
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `thresholdKey` /
|
|
312
|
+
// `bandColorKey` are the value-compared stand-ins for the array props.
|
|
313
|
+
}, [thresholdKey, bandColorKey, singleStyle]);
|
|
314
|
+
// Conflicts between the ladder and the shapes it can't apply to. In an effect
|
|
315
|
+
// so a re-render doesn't re-log; each fires once per genuinely new pairing.
|
|
316
|
+
const multiGroup = shape.kind === 'stacked' && shape.ss.groups.length > 1;
|
|
317
|
+
const hasLadder = bandLadder !== undefined;
|
|
318
|
+
const hasBinColors = binColors !== undefined;
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!isDev || !hasLadder)
|
|
321
|
+
return;
|
|
322
|
+
if (hasBinColors) {
|
|
323
|
+
console.warn('<BarChart>: `thresholds` and `binColors` are both set. They are two ' +
|
|
324
|
+
'answers to “what colour is this bar”; `binColors` wins as the more ' +
|
|
325
|
+
'specific one, and the threshold bands are ignored.');
|
|
326
|
+
}
|
|
327
|
+
if (multiGroup) {
|
|
328
|
+
console.warn('<BarChart>: `thresholds` is ignored on a multi-group stack — a ' +
|
|
329
|
+
'segment that is already one slice of a total has no defined ' +
|
|
330
|
+
'banding. Threshold bands apply to single-value bars (`series` / ' +
|
|
331
|
+
'`bins` / `categories`), in either orientation.');
|
|
332
|
+
}
|
|
333
|
+
}, [hasLadder, hasBinColors, multiGroup]);
|
|
235
334
|
// Stacked style: per-group fills (colors override → theme role → default),
|
|
236
335
|
// plus the shared opacity / outline from the default bar style. Memoized on the
|
|
237
336
|
// groups + colours so a selection change doesn't rebuild it.
|
|
238
337
|
const groups = shape.kind === 'stacked' ? shape.ss.groups : undefined;
|
|
239
338
|
const stackStyle = useMemo(() => {
|
|
240
339
|
const base = bar.default;
|
|
241
|
-
|
|
340
|
+
// The theme's **group ramp** — multi-group only, since a ramp exists to
|
|
341
|
+
// tell groups apart and `G === 1` (every categorical and single-series
|
|
342
|
+
// chart runs this same path) has nothing to tell apart. Resolution per
|
|
343
|
+
// group: `colors` → a role named after the group → the ramp → `fill`.
|
|
344
|
+
const multi = (groups?.length ?? 0) > 1;
|
|
345
|
+
const ramp = multi ? base.groups : undefined;
|
|
346
|
+
const rampDim = multi ? base.groupsDimmed : undefined;
|
|
347
|
+
const rampHover = multi ? base.groupsHover : undefined;
|
|
348
|
+
const at = (r, i) => r[i % r.length];
|
|
349
|
+
const fills = (groups ?? []).map((g, i) => colors?.[g] ??
|
|
350
|
+
bar[g]?.fill ??
|
|
351
|
+
(ramp !== undefined ? at(ramp, i) : base.fill));
|
|
352
|
+
// A ramp entry the call site overrode is no longer the ramp's colour, so
|
|
353
|
+
// its receded counterpart would be wrong — the whole ramp only means
|
|
354
|
+
// anything when it is the ramp that painted it.
|
|
355
|
+
const ramped = ramp !== undefined && colors === undefined;
|
|
242
356
|
return {
|
|
243
357
|
fills,
|
|
358
|
+
...(ramped ? { groupColored: true } : {}),
|
|
359
|
+
...(ramped && rampDim !== undefined
|
|
360
|
+
? {
|
|
361
|
+
dimmedFills: (groups ?? []).map((_g, i) => at(rampDim, i)),
|
|
362
|
+
}
|
|
363
|
+
: {}),
|
|
364
|
+
...(ramped && rampHover !== undefined
|
|
365
|
+
? {
|
|
366
|
+
hoverFills: (groups ?? []).map((_g, i) => at(rampHover, i)),
|
|
367
|
+
}
|
|
368
|
+
: {}),
|
|
244
369
|
opacity: base.opacity,
|
|
245
370
|
outlineWidth: base.outlineWidth,
|
|
371
|
+
// [PND-CATEMPH] Forward the themed emphasis so the category / horizontal
|
|
372
|
+
// path can read the same `fill → hover → highlight` channel every other
|
|
373
|
+
// bar does, instead of accepting those theme values and ignoring them.
|
|
374
|
+
highlight: base.highlight,
|
|
375
|
+
...(base.hover !== undefined ? { hover: base.hover } : {}),
|
|
376
|
+
...(base.selectedOutline !== undefined
|
|
377
|
+
? { selectedOutline: base.selectedOutline }
|
|
378
|
+
: {}),
|
|
379
|
+
...(base.emphasisOpacity !== undefined
|
|
380
|
+
? { emphasisOpacity: base.emphasisOpacity }
|
|
381
|
+
: {}),
|
|
382
|
+
...(base.dimmed !== undefined ? { dimmed: base.dimmed } : {}),
|
|
246
383
|
...(binColors !== undefined ? { binFills: binColors } : {}),
|
|
247
384
|
};
|
|
248
385
|
}, [bar, groups, colors, binColors]);
|
|
@@ -251,24 +388,32 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
251
388
|
// (id, key). Read here so a change re-registers the layer → the canvas repaints.
|
|
252
389
|
const selected = container.selected;
|
|
253
390
|
const hoveredMark = container.hovered;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
391
|
+
// The selection is a set ([PND-MULTISEL]); narrow each member to the identity
|
|
392
|
+
// the draw path matches on. `EMPTY_MARKS` keeps the no-selection case
|
|
393
|
+
// reference-stable so it doesn't re-identify the layer entry each render.
|
|
394
|
+
const selection = useMemo(() => selected.length === 0
|
|
395
|
+
? EMPTY_MARKS
|
|
396
|
+
: selected.map((m) => ({
|
|
397
|
+
id: m.id,
|
|
398
|
+
key: m.key,
|
|
399
|
+
label: m.label,
|
|
400
|
+
...(m.mark !== undefined ? { mark: m.mark } : {}),
|
|
401
|
+
})), [selected]);
|
|
402
|
+
const hover = useMemo(() => hoveredMark.length === 0
|
|
403
|
+
? EMPTY_MARKS
|
|
404
|
+
: hoveredMark.map((m) => ({
|
|
405
|
+
id: m.id,
|
|
406
|
+
key: m.key,
|
|
407
|
+
label: m.label,
|
|
408
|
+
...(m.mark !== undefined ? { mark: m.mark } : {}),
|
|
409
|
+
})), [hoveredMark]);
|
|
410
|
+
// The selection's span entries, narrowed to this layer (interaction RFC
|
|
411
|
+
// A5.2). On the single-series path every mark shares one label, so a span's
|
|
412
|
+
// `rows` channel resolves here (once) rather than per bar; the stacked path's
|
|
413
|
+
// labels vary per segment (group / category), so `rows` rides through for
|
|
414
|
+
// `drawStacks` to test. Empty (and reference-stable) when no span names us —
|
|
415
|
+
// this layer neither re-registers nor repaints for other layers' spans.
|
|
416
|
+
const layerSpans = useMemo(() => spansForLayer(container.selectedSpans, id, shape.kind === 'single' ? label : undefined), [container.selectedSpans, id, shape.kind, label]);
|
|
272
417
|
const entry = useMemo(() => {
|
|
273
418
|
// ── Single-series, vertical: the original bar path, pixels unchanged. ──
|
|
274
419
|
if (shape.kind === 'single') {
|
|
@@ -303,7 +448,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
303
448
|
...(id === undefined
|
|
304
449
|
? {}
|
|
305
450
|
: {
|
|
306
|
-
hitTest: (px, py, xScale, yScale) => {
|
|
451
|
+
hitTest: (px, py, xScale, yScale, mode) => {
|
|
307
452
|
const baseline = resolveBarBaseline(yScale);
|
|
308
453
|
// No `gapPx` — the hit region is the bar's whole slot (its
|
|
309
454
|
// interval width, full plot height), not the inset rect the
|
|
@@ -312,6 +457,20 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
312
457
|
if (hit === null)
|
|
313
458
|
return null;
|
|
314
459
|
const [bi, begin, value] = hit;
|
|
460
|
+
// A CLICK narrows the slot to the bar's drawn ink
|
|
461
|
+
// vertically (the x keeps the slot, so the gap between
|
|
462
|
+
// columns is still not a dead channel). Slots tile the
|
|
463
|
+
// whole plot, so without this a click could never resolve
|
|
464
|
+
// to null — and that null IS the deselect path (RFC §7's
|
|
465
|
+
// empty commit). Hover keeps the full slot: the highlight
|
|
466
|
+
// tracks continuously like the readout (#582).
|
|
467
|
+
if (mode === 'select') {
|
|
468
|
+
const yValue = yScale(value);
|
|
469
|
+
const yBase = yScale(baseline);
|
|
470
|
+
if (py < Math.min(yValue, yBase) ||
|
|
471
|
+
py > Math.max(yValue, yBase))
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
315
474
|
// The bar's stable `mark` (its own axis key) rides the
|
|
316
475
|
// selection, so the highlight match and a controlled echo key
|
|
317
476
|
// on the *sample* rather than on the `begin` edge — which on a
|
|
@@ -330,8 +489,43 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
330
489
|
...(stableMark !== undefined ? { mark: stableMark } : {}),
|
|
331
490
|
};
|
|
332
491
|
},
|
|
492
|
+
// The <MultiSelector> sweep's range query (RFC A7.6): bars
|
|
493
|
+
// are sorted, non-overlapping intervals, so the covered set
|
|
494
|
+
// is a contiguous run — sweep1D's two binary searches. Each
|
|
495
|
+
// materialised hit is EXACTLY what hitTest reports for that
|
|
496
|
+
// bar, so a swept bar and a clicked bar are the same currency.
|
|
497
|
+
beginSweep: () => bs.length === 0
|
|
498
|
+
? null
|
|
499
|
+
: sweep1D({
|
|
500
|
+
id,
|
|
501
|
+
begin: bs.begin,
|
|
502
|
+
end: bs.end,
|
|
503
|
+
length: bs.length,
|
|
504
|
+
// A gap bar (non-finite value) owns no membership.
|
|
505
|
+
selectable: (i) => Number.isFinite(bs.y[i]),
|
|
506
|
+
materialize: (lo, hi) => {
|
|
507
|
+
const out = [];
|
|
508
|
+
for (let i = lo; i < hi; i += 1) {
|
|
509
|
+
const v = bs.y[i];
|
|
510
|
+
if (!Number.isFinite(v))
|
|
511
|
+
continue;
|
|
512
|
+
const stableMark = bs.marks?.[i];
|
|
513
|
+
out.push({
|
|
514
|
+
id,
|
|
515
|
+
key: bs.begin[i],
|
|
516
|
+
value: v,
|
|
517
|
+
color: binColors?.[i] ?? singleStyle.fill,
|
|
518
|
+
label,
|
|
519
|
+
...(stableMark !== undefined
|
|
520
|
+
? { mark: stableMark }
|
|
521
|
+
: {}),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
},
|
|
526
|
+
}),
|
|
333
527
|
}),
|
|
334
|
-
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors),
|
|
528
|
+
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder, layerSpans),
|
|
335
529
|
},
|
|
336
530
|
axisId: axis,
|
|
337
531
|
index,
|
|
@@ -390,8 +584,67 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
390
584
|
...(stableMark !== undefined ? { mark: stableMark } : {}),
|
|
391
585
|
};
|
|
392
586
|
},
|
|
587
|
+
// The sweep, on the binned/stacked/categorical path — **either
|
|
588
|
+
// orientation** ([PND-HSWEEP]). The session is identical, because
|
|
589
|
+
// `sweep1D` cuts in KEY-axis units and does not care which screen
|
|
590
|
+
// axis produced them; the bins are `ss.begin`/`ss.end` whichever
|
|
591
|
+
// way the chart is drawn. What differs is only where the gesture
|
|
592
|
+
// reads the pointer, which `sweepAxis` declares.
|
|
593
|
+
//
|
|
594
|
+
// The cut stays 1-D on a horizontal chart, deliberately: a
|
|
595
|
+
// vertical bar's sweep ignores the value axis (drag anywhere
|
|
596
|
+
// horizontally, take whole columns), so its transpose ignores it
|
|
597
|
+
// too. A rect here would be value-filtering — a capability the
|
|
598
|
+
// vertical chart has never had.
|
|
599
|
+
//
|
|
600
|
+
// A covered bin materialises every drawn segment (finite,
|
|
601
|
+
// non-zero — the marks hitTest can hit), assembled exactly as
|
|
602
|
+
// hitTest assembles them.
|
|
603
|
+
sweepAxis: vertical ? 'x' : 'y',
|
|
604
|
+
beginSweep: () => {
|
|
605
|
+
const G = ss.groups.length;
|
|
606
|
+
if (ss.length === 0 || G === 0)
|
|
607
|
+
return null;
|
|
608
|
+
const drawn = (b, g) => {
|
|
609
|
+
const v = ss.values[b * G + g];
|
|
610
|
+
return Number.isFinite(v) && v !== 0;
|
|
611
|
+
};
|
|
612
|
+
return sweep1D({
|
|
613
|
+
id,
|
|
614
|
+
begin: ss.begin,
|
|
615
|
+
end: ss.end,
|
|
616
|
+
length: ss.length,
|
|
617
|
+
selectable: (b) => {
|
|
618
|
+
for (let g = 0; g < G; g += 1)
|
|
619
|
+
if (drawn(b, g))
|
|
620
|
+
return true;
|
|
621
|
+
return false;
|
|
622
|
+
},
|
|
623
|
+
materialize: (lo, hi) => {
|
|
624
|
+
const out = [];
|
|
625
|
+
for (let b = lo; b < hi; b += 1) {
|
|
626
|
+
const stableMark = ss.marks?.[b];
|
|
627
|
+
for (let g = 0; g < G; g += 1) {
|
|
628
|
+
if (!drawn(b, g))
|
|
629
|
+
continue;
|
|
630
|
+
out.push({
|
|
631
|
+
id,
|
|
632
|
+
key: ss.begin[b],
|
|
633
|
+
value: ss.values[b * G + g],
|
|
634
|
+
color: stackStyle.binFills?.[b] ?? stackStyle.fills[g],
|
|
635
|
+
label: stableMark ?? ss.groups[g],
|
|
636
|
+
...(stableMark !== undefined
|
|
637
|
+
? { mark: stableMark }
|
|
638
|
+
: {}),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
},
|
|
393
646
|
}),
|
|
394
|
-
draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover),
|
|
647
|
+
draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder, layerSpans),
|
|
395
648
|
},
|
|
396
649
|
axisId: axis,
|
|
397
650
|
index,
|
|
@@ -405,6 +658,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
405
658
|
singleStyle,
|
|
406
659
|
stackStyle,
|
|
407
660
|
binColors,
|
|
661
|
+
bandLadder,
|
|
408
662
|
label,
|
|
409
663
|
id,
|
|
410
664
|
gapPx,
|
|
@@ -412,6 +666,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
412
666
|
stackMinWidth,
|
|
413
667
|
selection,
|
|
414
668
|
hover,
|
|
669
|
+
layerSpans,
|
|
415
670
|
axis,
|
|
416
671
|
index,
|
|
417
672
|
]);
|
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
|
}) })) }));
|