@pond-ts/charts 0.57.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1070 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.js +184 -30
- 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 +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/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +57 -3
- package/dist/bars.js +237 -26
- 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 +859 -33
- 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 +11 -2
- package/dist/index.js +22 -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/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 +456 -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/package.json +3 -3
package/dist/AreaChart.d.ts
CHANGED
|
@@ -14,6 +14,17 @@ export interface AreaChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
|
|
|
14
14
|
* single styling channel).
|
|
15
15
|
*/
|
|
16
16
|
as?: string;
|
|
17
|
+
/**
|
|
18
|
+
* **Opt in to selection** — see `<LineChart id>`; the currency is identical
|
|
19
|
+
* because the premise is ([PND-TRACESEL]): a click commits a **series-scoped**
|
|
20
|
+
* `SelectInfo` (`NaN` key/value plus a stable `mark`), a sweep commits a
|
|
21
|
+
* `SpanSelection` with **no marks**.
|
|
22
|
+
*
|
|
23
|
+
* What differs is only the **hit test**: an area is a filled region, so the
|
|
24
|
+
* pointer counts as on it when it lies **between the trace and the
|
|
25
|
+
* baseline** — the whole shape is the target, not the 1.5px edge.
|
|
26
|
+
*/
|
|
27
|
+
id?: string;
|
|
17
28
|
/**
|
|
18
29
|
* Which `<YAxis>` (by its `id`) this area scales against — picks the *scale*,
|
|
19
30
|
* where `as` picks the *style*. **Omitted ⇒ the row's default axis.**
|
|
@@ -140,6 +151,6 @@ export declare function resolveAreaBaseline(baseline: number | undefined, yScale
|
|
|
140
151
|
* </Layers>
|
|
141
152
|
* ```
|
|
142
153
|
*/
|
|
143
|
-
export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, baseline, curve, gaps, decimate, legend, index, }: AreaChartProps<S, VS>): null;
|
|
154
|
+
export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, id, baseline, curve, gaps, decimate, legend, index, }: AreaChartProps<S, VS>): null;
|
|
144
155
|
export {};
|
|
145
156
|
//# sourceMappingURL=AreaChart.d.ts.map
|
package/dist/AreaChart.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo } from 'react';
|
|
2
2
|
import { ValueSeries } from 'pond-ts';
|
|
3
3
|
import { assertNumericColumn, fromTimeSeries, fromValueSeries, } from './data.js';
|
|
4
|
-
import { areaExtent, drawArea } from './area.js';
|
|
4
|
+
import { areaExtent, areaHitIndex, areaStateStyle, drawArea } from './area.js';
|
|
5
|
+
import { drawPartitioned, plotExtentOf, strokeSpanEdges, } from './line.js';
|
|
6
|
+
import { sweepSpan } from './sweep.js';
|
|
5
7
|
import { resolveCurve } from './curve.js';
|
|
6
8
|
import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
7
|
-
import { ContainerContext, LayersContext } from './context.js';
|
|
9
|
+
import { ContainerContext, LayersContext, } from './context.js';
|
|
8
10
|
import { legendLabelFor, useLegendItems, } from './swatch.js';
|
|
9
11
|
import { useSlotKey } from './use-slot-key.js';
|
|
10
12
|
/** Read a d3 linear scale's domain lower bound (the axis floor) from the plain
|
|
@@ -54,7 +56,7 @@ function domainFloor(yScale) {
|
|
|
54
56
|
* </Layers>
|
|
55
57
|
* ```
|
|
56
58
|
*/
|
|
57
|
-
export function AreaChart({ series, column, readout, as: semantic, axis, baseline, curve, gaps = DEFAULT_GAP_MODE, decimate = true, legend, index = 0, }) {
|
|
59
|
+
export function AreaChart({ series, column, readout, as: semantic, axis, id, baseline, curve, gaps = DEFAULT_GAP_MODE, decimate = true, legend, index = 0, }) {
|
|
58
60
|
const container = useContext(ContainerContext);
|
|
59
61
|
if (container === null) {
|
|
60
62
|
throw new Error('<AreaChart> must be rendered inside a <ChartContainer>');
|
|
@@ -91,6 +93,45 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
|
|
|
91
93
|
// Faintness of the inferred dashed connectors (dashed / step) — theme-level,
|
|
92
94
|
// falling back to the shared default so a theme without it still renders faint.
|
|
93
95
|
const gapConnectorOpacity = container.theme.gap?.connectorOpacity ?? DEFAULT_GAP_CONNECTOR_OPACITY;
|
|
96
|
+
// ── The trace's interaction state ([PND-TRACESEL]) — see `<LineChart>` for
|
|
97
|
+
// the reasoning; this is the same derivation over `AreaStyle`'s channels.
|
|
98
|
+
const selectedEntries = container.selected;
|
|
99
|
+
const hoveredEntries = container.hovered;
|
|
100
|
+
// The committed spans, plus the **live** ones of a sweep in flight. A
|
|
101
|
+
// previewed span draws exactly as a committed one, so releasing changes
|
|
102
|
+
// nothing visually — the preview cannot promise a picture the commit does not
|
|
103
|
+
// deliver. The live channel wins while it is non-empty, because during a drag
|
|
104
|
+
// it IS the current answer.
|
|
105
|
+
const previewing = container.previewSpans.length > 0;
|
|
106
|
+
const allSpans = previewing
|
|
107
|
+
? container.previewSpans
|
|
108
|
+
: container.selectedSpans;
|
|
109
|
+
const traceState = useMemo(() => {
|
|
110
|
+
if (id === undefined)
|
|
111
|
+
return 'rest';
|
|
112
|
+
if (allSpans.some((sp) => sp.id === id))
|
|
113
|
+
return 'rest';
|
|
114
|
+
const mine = (e) => e.id === id;
|
|
115
|
+
if (selectedEntries.some(mine))
|
|
116
|
+
return 'selected';
|
|
117
|
+
if (hoveredEntries.some(mine))
|
|
118
|
+
return 'hover';
|
|
119
|
+
if (selectedEntries.length > 0 || allSpans.length > 0)
|
|
120
|
+
return 'dimmed';
|
|
121
|
+
return 'rest';
|
|
122
|
+
}, [id, selectedEntries, hoveredEntries, allSpans]);
|
|
123
|
+
// **`spanColor` only when this is the ONLY swept trace.** The hue is
|
|
124
|
+
// justified by identity not being in question inside a single series — but
|
|
125
|
+
// sweep two traces and both would go blue, so inside the window you could no
|
|
126
|
+
// longer tell them apart, which is the very thing the rule exists to prevent.
|
|
127
|
+
// With more than one, the window thickens and every trace keeps its colour.
|
|
128
|
+
const soleSpannedTrace = allSpans.length === 1;
|
|
129
|
+
const spanX = useMemo(() => {
|
|
130
|
+
if (id === undefined)
|
|
131
|
+
return null;
|
|
132
|
+
const mine = allSpans.find((sp) => sp.id === id);
|
|
133
|
+
return mine === undefined ? null : mine.x;
|
|
134
|
+
}, [id, allSpans]);
|
|
94
135
|
const entry = useMemo(() => ({
|
|
95
136
|
layer: {
|
|
96
137
|
as: semantic,
|
|
@@ -99,6 +140,36 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
|
|
|
99
140
|
// ValueSeries plots on a value axis, a TimeSeries on time.
|
|
100
141
|
xKind: series instanceof ValueSeries ? 'value' : 'time',
|
|
101
142
|
xExtent: () => cs.length === 0 ? null : [cs.x[0], cs.x[cs.length - 1]],
|
|
143
|
+
// ── Selection, gated on `id` ([PND-TRACESEL]). Same currency as
|
|
144
|
+
// `<LineChart>`; only `hitTest` differs, because a fill is not a stroke.
|
|
145
|
+
...(id === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: {
|
|
148
|
+
sweepsRect: false,
|
|
149
|
+
sweepAxis: 'x',
|
|
150
|
+
sweepSpanOnly: true,
|
|
151
|
+
hitTest: (px, py, xScale, yScale) => {
|
|
152
|
+
const i = areaHitIndex(cs, baseline, px, py, xScale, yScale);
|
|
153
|
+
if (i === null)
|
|
154
|
+
return null;
|
|
155
|
+
return {
|
|
156
|
+
id,
|
|
157
|
+
// Series-scoped, with a stable `mark` for identity — see
|
|
158
|
+
// `<LineChart>`'s hitTest for why both halves are needed.
|
|
159
|
+
key: NaN,
|
|
160
|
+
value: NaN,
|
|
161
|
+
color: style.fill,
|
|
162
|
+
label,
|
|
163
|
+
mark: label,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
beginSweep: () => cs.length === 0
|
|
167
|
+
? null
|
|
168
|
+
: sweepSpan({
|
|
169
|
+
id,
|
|
170
|
+
bounds: [cs.x[0], cs.x[cs.length - 1]],
|
|
171
|
+
}),
|
|
172
|
+
}),
|
|
102
173
|
sampleAt: (x) => {
|
|
103
174
|
// No readout past the data (tracker policy — nearest clamps to an
|
|
104
175
|
// endpoint outside the span); bounds from the columnar x axis.
|
|
@@ -151,16 +222,49 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
|
|
|
151
222
|
]
|
|
152
223
|
: [];
|
|
153
224
|
},
|
|
154
|
-
draw: (ctx, xScale, yScale) =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
225
|
+
draw: (ctx, xScale, yScale) => {
|
|
226
|
+
const fill = (st, alpha) => () => {
|
|
227
|
+
const prior = ctx.globalAlpha;
|
|
228
|
+
if (alpha !== 1)
|
|
229
|
+
ctx.globalAlpha = prior * alpha;
|
|
230
|
+
const out = drawAreaWith(st);
|
|
231
|
+
ctx.globalAlpha = prior;
|
|
232
|
+
return out;
|
|
233
|
+
};
|
|
234
|
+
if (spanX === null) {
|
|
235
|
+
const [st, alpha] = areaStateStyle(style, traceState);
|
|
236
|
+
return fill(st, alpha)();
|
|
237
|
+
}
|
|
238
|
+
// EXPERIMENT: annotation-register rules at the window's edges,
|
|
239
|
+
// underneath the trace ink (drawn first). See `strokeSpanEdges`.
|
|
240
|
+
//
|
|
241
|
+
// **Committed spans only.** While the drag is live the brush band
|
|
242
|
+
// already strokes its own edges at the same two x positions, so
|
|
243
|
+
// drawing these too put two rules a fraction of a pixel apart on each
|
|
244
|
+
// boundary — which read as one muddy smear rather than as either. The
|
|
245
|
+
// handoff is the honest reading anyway: the band is the gesture's
|
|
246
|
+
// mark and belongs to the drag; these preview the annotation you
|
|
247
|
+
// would get, and belong to the result.
|
|
248
|
+
if (!previewing)
|
|
249
|
+
strokeSpanEdges(ctx, [xScale(spanX[0]), xScale(spanX[1])], ctx.canvas.height, container.theme.annotation?.spanEdge ?? '#f0b26b');
|
|
250
|
+
const [outStyle, outAlpha] = areaStateStyle(style, 'dimmed');
|
|
251
|
+
const [inStyle] = areaStateStyle(style, 'selected');
|
|
252
|
+
return drawPartitioned(ctx, [xScale(spanX[0]), xScale(spanX[1])], plotExtentOf(ctx, xScale, yScale).height, fill(outStyle, outAlpha), fill(style.spanColor === undefined || !soleSpannedTrace
|
|
253
|
+
? inStyle
|
|
254
|
+
: { ...inStyle, color: style.spanColor, fill: style.spanColor }, 1), true, 0, plotExtentOf(ctx, xScale, yScale).width);
|
|
255
|
+
function drawAreaWith(st) {
|
|
256
|
+
return drawArea(ctx, cs, xScale, yScale, st,
|
|
257
|
+
// Omitted baseline rests on the axis floor (resolved late from the
|
|
258
|
+
// scale, so it tracks the auto-fit domain); a fixed baseline is used
|
|
259
|
+
// verbatim.
|
|
260
|
+
// A log axis has no position for zero — or anything at or below
|
|
261
|
+
// it — so an explicit out-of-domain `baseline` would scale to
|
|
262
|
+
// `NaN` and poison every coordinate in the fill path. Fall back
|
|
263
|
+
// to the axis floor, which is exactly what an omitted baseline
|
|
264
|
+
// already resolves to.
|
|
265
|
+
resolveAreaBaseline(baseline, yScale), curveFactory, gaps, gapConnectorOpacity, decimate);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
164
268
|
},
|
|
165
269
|
axisId: axis,
|
|
166
270
|
index,
|
|
@@ -178,6 +282,11 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
|
|
|
178
282
|
gapConnectorOpacity,
|
|
179
283
|
decimate,
|
|
180
284
|
axis,
|
|
285
|
+
id,
|
|
286
|
+
traceState,
|
|
287
|
+
spanX,
|
|
288
|
+
soleSpannedTrace,
|
|
289
|
+
previewing,
|
|
181
290
|
index,
|
|
182
291
|
]);
|
|
183
292
|
// A stable per-instance slot (see useSlotKey) keeps this layer's z-position
|
|
@@ -194,6 +303,15 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
|
|
|
194
303
|
useEffect(() => {
|
|
195
304
|
registerTrackerSource(slot, entry.layer);
|
|
196
305
|
}, [registerTrackerSource, slot, entry.layer]);
|
|
306
|
+
// Advertise selectability (only when an `id` was given) — see the same block
|
|
307
|
+
// in `LineChart.tsx` for why a trace was missing from this set.
|
|
308
|
+
const { registerSelectable, unregisterSelectable } = container;
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (id === undefined)
|
|
311
|
+
return;
|
|
312
|
+
registerSelectable(slot);
|
|
313
|
+
return () => unregisterSelectable(slot);
|
|
314
|
+
}, [registerSelectable, unregisterSelectable, slot, id]);
|
|
197
315
|
// And a legend row: the readout identity + the resolved area style (top line
|
|
198
316
|
// over the translucent fill), so a `<Legend>` swatch can never drift.
|
|
199
317
|
const legendRows = useMemo(() => {
|
package/dist/BarChart.js
CHANGED
|
@@ -2,10 +2,14 @@ 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
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
|
|
@@ -191,15 +195,21 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
191
195
|
: series instanceof ValueSeries
|
|
192
196
|
? 'value'
|
|
193
197
|
: '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
|
-
//
|
|
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.
|
|
201
211
|
const binBuckets = useMemo(() => {
|
|
202
|
-
if (orientation !== 'vertical'
|
|
212
|
+
if (orientation !== 'vertical')
|
|
203
213
|
return null;
|
|
204
214
|
const { begin, end, length } = shape.kind === 'single' ? shape.bs : shape.ss;
|
|
205
215
|
if (length === 0)
|
|
@@ -327,9 +337,35 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
327
337
|
const groups = shape.kind === 'stacked' ? shape.ss.groups : undefined;
|
|
328
338
|
const stackStyle = useMemo(() => {
|
|
329
339
|
const base = bar.default;
|
|
330
|
-
|
|
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;
|
|
331
356
|
return {
|
|
332
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
|
+
: {}),
|
|
333
369
|
opacity: base.opacity,
|
|
334
370
|
outlineWidth: base.outlineWidth,
|
|
335
371
|
// [PND-CATEMPH] Forward the themed emphasis so the category / horizontal
|
|
@@ -343,6 +379,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
343
379
|
...(base.emphasisOpacity !== undefined
|
|
344
380
|
? { emphasisOpacity: base.emphasisOpacity }
|
|
345
381
|
: {}),
|
|
382
|
+
...(base.dimmed !== undefined ? { dimmed: base.dimmed } : {}),
|
|
346
383
|
...(binColors !== undefined ? { binFills: binColors } : {}),
|
|
347
384
|
};
|
|
348
385
|
}, [bar, groups, colors, binColors]);
|
|
@@ -351,24 +388,32 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
351
388
|
// (id, key). Read here so a change re-registers the layer → the canvas repaints.
|
|
352
389
|
const selected = container.selected;
|
|
353
390
|
const hoveredMark = container.hovered;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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]);
|
|
372
417
|
const entry = useMemo(() => {
|
|
373
418
|
// ── Single-series, vertical: the original bar path, pixels unchanged. ──
|
|
374
419
|
if (shape.kind === 'single') {
|
|
@@ -403,7 +448,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
403
448
|
...(id === undefined
|
|
404
449
|
? {}
|
|
405
450
|
: {
|
|
406
|
-
hitTest: (px, py, xScale, yScale) => {
|
|
451
|
+
hitTest: (px, py, xScale, yScale, mode) => {
|
|
407
452
|
const baseline = resolveBarBaseline(yScale);
|
|
408
453
|
// No `gapPx` — the hit region is the bar's whole slot (its
|
|
409
454
|
// interval width, full plot height), not the inset rect the
|
|
@@ -412,6 +457,20 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
412
457
|
if (hit === null)
|
|
413
458
|
return null;
|
|
414
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
|
+
}
|
|
415
474
|
// The bar's stable `mark` (its own axis key) rides the
|
|
416
475
|
// selection, so the highlight match and a controlled echo key
|
|
417
476
|
// on the *sample* rather than on the `begin` edge — which on a
|
|
@@ -430,8 +489,43 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
430
489
|
...(stableMark !== undefined ? { mark: stableMark } : {}),
|
|
431
490
|
};
|
|
432
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
|
+
}),
|
|
433
527
|
}),
|
|
434
|
-
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder),
|
|
528
|
+
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder, layerSpans),
|
|
435
529
|
},
|
|
436
530
|
axisId: axis,
|
|
437
531
|
index,
|
|
@@ -490,8 +584,67 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
490
584
|
...(stableMark !== undefined ? { mark: stableMark } : {}),
|
|
491
585
|
};
|
|
492
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
|
+
},
|
|
493
646
|
}),
|
|
494
|
-
draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder),
|
|
647
|
+
draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder, layerSpans),
|
|
495
648
|
},
|
|
496
649
|
axisId: axis,
|
|
497
650
|
index,
|
|
@@ -513,6 +666,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
513
666
|
stackMinWidth,
|
|
514
667
|
selection,
|
|
515
668
|
hover,
|
|
669
|
+
layerSpans,
|
|
516
670
|
axis,
|
|
517
671
|
index,
|
|
518
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`.** */
|