@pond-ts/charts 0.41.0 → 0.43.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 +157 -6
- package/dist/BarChart.d.ts +139 -54
- package/dist/BarChart.js +301 -104
- package/dist/CategoryAxis.d.ts +16 -0
- package/dist/CategoryAxis.js +19 -0
- package/dist/ChartContainer.d.ts +99 -6
- package/dist/ChartContainer.js +157 -5
- package/dist/Layers.js +119 -12
- package/dist/ScatterChart.d.ts +15 -3
- package/dist/ScatterChart.js +23 -5
- package/dist/XAxis.js +41 -2
- package/dist/annotations.d.ts +38 -1
- package/dist/annotations.js +68 -25
- package/dist/bandScale.d.ts +57 -0
- package/dist/bandScale.js +67 -0
- package/dist/bars.d.ts +113 -5
- package/dist/bars.js +189 -10
- package/dist/context.d.ts +122 -31
- package/dist/data.d.ts +174 -1
- package/dist/data.js +214 -0
- package/dist/grid.d.ts +14 -0
- package/dist/grid.js +36 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +12 -1
- package/dist/scatter.d.ts +9 -7
- package/dist/scatter.js +12 -8
- package/dist/theme.d.ts +7 -0
- package/dist/theme.js +1 -0
- package/dist/tracker.d.ts +37 -0
- package/dist/tracker.js +77 -6
- package/dist/tradingTimeScale.d.ts +97 -0
- package/dist/tradingTimeScale.js +152 -0
- package/dist/viewport.d.ts +23 -0
- package/dist/viewport.js +51 -0
- package/package.json +3 -3
package/dist/ChartContainer.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { scaleLinear, scaleTime } from 'd3-scale';
|
|
4
|
+
import { scaleTradingTime, } from './tradingTimeScale.js';
|
|
5
|
+
import { scaleBand } from './bandScale.js';
|
|
6
|
+
import { Sequence } from 'pond-ts';
|
|
4
7
|
import { ContainerContext, } from './context.js';
|
|
5
8
|
import { maxSlotWidths, sum } from './slots.js';
|
|
6
9
|
import { computeLabelLanes } from './annotations.js';
|
|
@@ -31,7 +34,7 @@ function normalizeRange(range) {
|
|
|
31
34
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
32
35
|
* (`<YAxis>`).
|
|
33
36
|
*/
|
|
34
|
-
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, children, }) {
|
|
37
|
+
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, discontinuities, calendar, spacing, children, }) {
|
|
35
38
|
// The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
|
|
36
39
|
// ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
|
|
37
40
|
// is the placeholder while auto-fitting.
|
|
@@ -77,6 +80,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
77
80
|
// still cursor stays put while a live window slides under it; a controlled
|
|
78
81
|
// `trackerPosition` resolves to a pixel below.
|
|
79
82
|
const [hoverX, setHoverX] = useState(null);
|
|
83
|
+
// The region-cursor drag anchor (epoch ms) — set on press, cleared on release.
|
|
84
|
+
const [regionAnchor, setRegionAnchor] = useState(null);
|
|
80
85
|
// The free-form crosshair also needs the pointer's y + which row (row-specific,
|
|
81
86
|
// unlike the shared x). One state object so a move updates both atomically.
|
|
82
87
|
const [hoverPoint, setHoverPoint] = useState(null);
|
|
@@ -99,6 +104,29 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
99
104
|
return next;
|
|
100
105
|
});
|
|
101
106
|
}, []);
|
|
107
|
+
// Selectable-layer registry: an id-bearing Bar/Scatter registers here (keyed
|
|
108
|
+
// by its slot) so the container knows whether *any* series is selectable. Only
|
|
109
|
+
// used to power the dev-warn below — selection resolution itself walks the
|
|
110
|
+
// rows' layers, not this set. Backed by a **ref** (the synchronous source of
|
|
111
|
+
// truth) mirrored to state: a child layer's register effect runs before this
|
|
112
|
+
// parent's dev-warn effect in the same commit, so the ref is already settled
|
|
113
|
+
// there (reading state would lag a render). State only triggers the re-check.
|
|
114
|
+
const selectableRef = useRef(new Set());
|
|
115
|
+
const [selectableKeys, setSelectableKeys] = useState(selectableRef.current);
|
|
116
|
+
const registerSelectable = useCallback((key) => {
|
|
117
|
+
if (selectableRef.current.has(key))
|
|
118
|
+
return;
|
|
119
|
+
selectableRef.current = new Set(selectableRef.current).add(key);
|
|
120
|
+
setSelectableKeys(selectableRef.current);
|
|
121
|
+
}, []);
|
|
122
|
+
const unregisterSelectable = useCallback((key) => {
|
|
123
|
+
if (!selectableRef.current.has(key))
|
|
124
|
+
return;
|
|
125
|
+
const next = new Set(selectableRef.current);
|
|
126
|
+
next.delete(key);
|
|
127
|
+
selectableRef.current = next;
|
|
128
|
+
setSelectableKeys(next);
|
|
129
|
+
}, []);
|
|
102
130
|
// Annotations register here so the container can do what a mark can't in
|
|
103
131
|
// isolation: draw its guide line across other rows, order regions, serve snap
|
|
104
132
|
// targets. Keyed by per-instance slot key (same discipline as the sources).
|
|
@@ -127,11 +155,31 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
127
155
|
else if (kind !== s.xKind) {
|
|
128
156
|
throw new Error(`ChartContainer: rows mix x-axis kinds ('${kind}' and '${s.xKind}'). ` +
|
|
129
157
|
`A container has one shared x axis — every row must plot the same ` +
|
|
130
|
-
`kind (all time-keyed,
|
|
158
|
+
`kind (all time-keyed, all value-keyed, or all category).`);
|
|
131
159
|
}
|
|
132
160
|
}
|
|
133
161
|
return kind ?? 'time';
|
|
134
162
|
}, [sources]);
|
|
163
|
+
// A `'category'` container's ordered category names — the ordinal axis domain.
|
|
164
|
+
// Every category layer must agree on the same list (a mix is an error, like the
|
|
165
|
+
// kind), so the shared band scale has one authoritative slot order. `null` when
|
|
166
|
+
// no category layer has registered (or the kind isn't category).
|
|
167
|
+
const categories = useMemo(() => {
|
|
168
|
+
let cats = null;
|
|
169
|
+
for (const s of sources.values()) {
|
|
170
|
+
const c = s.xCategories?.() ?? null;
|
|
171
|
+
if (c === null)
|
|
172
|
+
continue;
|
|
173
|
+
if (cats === null)
|
|
174
|
+
cats = c;
|
|
175
|
+
else if (cats.length !== c.length || cats.some((v, i) => v !== c[i])) {
|
|
176
|
+
throw new Error(`ChartContainer: category rows disagree on the axis categories. ` +
|
|
177
|
+
`Every category layer in one container must share the same ordered ` +
|
|
178
|
+
`column set (got [${cats.join(', ')}] and [${c.join(', ')}]).`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return cats;
|
|
182
|
+
}, [sources]);
|
|
135
183
|
// Auto-fit extent — the union of the layers' x extents — used as the domain
|
|
136
184
|
// when no explicit `range` is given. (Same source registry as the kind; the
|
|
137
185
|
// two-pass register→resolve applies.)
|
|
@@ -174,6 +222,26 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
174
222
|
if (!controlledSelectionRef.current)
|
|
175
223
|
setInternalSelected(hit);
|
|
176
224
|
}, []);
|
|
225
|
+
// Dev-warn: selection is wired (`selected` and/or `onSelect`) but no layer
|
|
226
|
+
// carries an `id`, so nothing is selectable — `id` gates interactivity, so a
|
|
227
|
+
// consumer who forgot it gets a silent no-op click without this nudge. Fires
|
|
228
|
+
// once per wired-but-empty transition (guarded by a ref); child layers
|
|
229
|
+
// register before this parent effect runs, so the set is settled here.
|
|
230
|
+
const selectionWired = controlledSelection || onSelect !== undefined;
|
|
231
|
+
const warnedNoSelectableRef = useRef(false);
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (selectionWired && selectableRef.current.size === 0) {
|
|
234
|
+
if (!warnedNoSelectableRef.current) {
|
|
235
|
+
warnedNoSelectableRef.current = true;
|
|
236
|
+
console.warn('[pond-charts] `selected`/`onSelect` is set but no layer has an `id` — ' +
|
|
237
|
+
'nothing is selectable. Give a <BarChart>/<ScatterChart> an `id` to ' +
|
|
238
|
+
'make it interactive (an `id` gates selection + hover).');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
warnedNoSelectableRef.current = false;
|
|
243
|
+
}
|
|
244
|
+
}, [selectionWired, selectableKeys]);
|
|
177
245
|
// Hover-highlight: the transient mark under the pointer (distinct from the
|
|
178
246
|
// committed selection). Controlled (`hovered` prop) or uncontrolled (internal),
|
|
179
247
|
// mirroring selection; `onHover` notifies in both modes. Deduped by key+label
|
|
@@ -197,8 +265,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
197
265
|
const same = prev === hit ||
|
|
198
266
|
(prev !== null &&
|
|
199
267
|
hit !== null &&
|
|
200
|
-
prev.
|
|
201
|
-
prev.
|
|
268
|
+
prev.id === hit.id &&
|
|
269
|
+
prev.key === hit.key);
|
|
202
270
|
if (same)
|
|
203
271
|
return;
|
|
204
272
|
lastHoverRef.current = hit;
|
|
@@ -239,7 +307,34 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
239
307
|
// is the one formatter <TimeAxis> + the cursor readout share, so a tick and
|
|
240
308
|
// the cursor read identically. (The `formatTime` name predates the value axis
|
|
241
309
|
// — on a value axis it formats the value, not a time.)
|
|
310
|
+
// The trading-time provider only applies to a **time** axis — a value axis is
|
|
311
|
+
// always a plain `scaleLinear`. Gate it once here so the scale branch AND the
|
|
312
|
+
// frame (which pan/zoom read) agree: on a value axis the provider is dropped,
|
|
313
|
+
// so interactions use continuous value math, not trading-time math.
|
|
314
|
+
// Resolve the trading-time provider: the low-level `discontinuities` prop wins;
|
|
315
|
+
// otherwise derive it from the high-level `calendar` sugar at the chosen
|
|
316
|
+
// `spacing`. Memoized on `(calendar, spacing)` so a stable calendar yields a
|
|
317
|
+
// stable provider (the scale + frame only rebuild when it actually changes) —
|
|
318
|
+
// pan/zoom read the same provider identity as the low-level path would. Gated
|
|
319
|
+
// on a time axis so a value-axis chart never calls `calendar.discontinuities`.
|
|
320
|
+
const calendarProvider = useMemo(() => resolvedKind === 'time' &&
|
|
321
|
+
discontinuities === undefined &&
|
|
322
|
+
calendar !== undefined
|
|
323
|
+
? calendar.discontinuities(spacing ? { spacing } : undefined)
|
|
324
|
+
: undefined, [resolvedKind, discontinuities, calendar, spacing]);
|
|
325
|
+
const xDiscontinuities = resolvedKind === 'time' ? (discontinuities ?? calendarProvider) : undefined;
|
|
242
326
|
const { xScale, formatTime } = useMemo(() => {
|
|
327
|
+
if (resolvedKind === 'category') {
|
|
328
|
+
// Ordinal column-domain axis: a band scale over the category slots. The
|
|
329
|
+
// domain is **always** `[0, n]` (one unit slot per category) — NOT the
|
|
330
|
+
// resolved `[d0, d1]`: a category axis ignores an explicit `range` (its
|
|
331
|
+
// slots are absolute `0..n`, matching `categoryStack`), so an out-of-`[0,n]`
|
|
332
|
+
// range can't silently offset the labels from the bars. The pixel mapping
|
|
333
|
+
// stays linear; the formatter is the category-name lookup.
|
|
334
|
+
const cats = categories ?? [];
|
|
335
|
+
const s = scaleBand(cats).domain([0, cats.length]).range([0, plotWidth]);
|
|
336
|
+
return { xScale: s, formatTime: (v) => s.label(v) };
|
|
337
|
+
}
|
|
243
338
|
if (resolvedKind === 'value') {
|
|
244
339
|
const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
|
|
245
340
|
return {
|
|
@@ -247,17 +342,58 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
247
342
|
formatTime: resolveAxisFormat(s, TIME_TICK_COUNT, timeFormat),
|
|
248
343
|
};
|
|
249
344
|
}
|
|
345
|
+
if (xDiscontinuities !== undefined) {
|
|
346
|
+
// Trading-time axis: closed-market gaps collapse, time proportional within
|
|
347
|
+
// sessions. Same tickFormat surface as scaleTime, so the readout is shared.
|
|
348
|
+
const s = scaleTradingTime(xDiscontinuities)
|
|
349
|
+
.domain([d0, d1])
|
|
350
|
+
.range([0, plotWidth]);
|
|
351
|
+
return {
|
|
352
|
+
xScale: s,
|
|
353
|
+
formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
250
356
|
const s = scaleTime().domain([d0, d1]).range([0, plotWidth]);
|
|
251
357
|
return {
|
|
252
358
|
xScale: s,
|
|
253
359
|
formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
|
|
254
360
|
};
|
|
255
|
-
}, [
|
|
361
|
+
}, [
|
|
362
|
+
resolvedKind,
|
|
363
|
+
categories,
|
|
364
|
+
d0,
|
|
365
|
+
d1,
|
|
366
|
+
plotWidth,
|
|
367
|
+
timeFormat,
|
|
368
|
+
xDiscontinuities,
|
|
369
|
+
]);
|
|
256
370
|
// The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
|
|
257
371
|
// if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
|
|
258
372
|
// briefly stale until the next pointer move — rare, and the bounds check below
|
|
259
373
|
// hides an out-of-plot crosshair meanwhile.
|
|
260
374
|
const cursorX = resolveCursorX(trackerPosition, hoverX, xScale);
|
|
375
|
+
// `cursor="region"` buckets: realize the `cursorSequence` over the current view
|
|
376
|
+
// (a `Sequence` → `.bounded`; a `BoundedSequence` used as-is), so the band can
|
|
377
|
+
// find the interval under the pointer. Memoized on the sequence + view range;
|
|
378
|
+
// a coarse sequence (days / sessions) is a handful of intervals.
|
|
379
|
+
// A `Sequence` bucket is a **time** interval, so gate it to a time axis — on a
|
|
380
|
+
// value axis (a horizontal histogram, a value-keyed chart) the value domain is
|
|
381
|
+
// not epoch-ms, so realizing time buckets over it is meaningless (it would
|
|
382
|
+
// shade the whole plot). Region cursor is time-axis only.
|
|
383
|
+
const cursorBuckets = useMemo(() => {
|
|
384
|
+
if (cursorSequence === undefined || resolvedKind !== 'time')
|
|
385
|
+
return undefined;
|
|
386
|
+
if (!(cursorSequence instanceof Sequence))
|
|
387
|
+
return cursorSequence.intervals();
|
|
388
|
+
// `bounded` (sample 'begin') drops a partial *leading* bucket — the one that
|
|
389
|
+
// contains the view start begins before it. Widen the realized range back by
|
|
390
|
+
// one bucket width so that covering bucket is included (a coarse calendar
|
|
391
|
+
// unit is bounded at ~a year; a fixed step uses its own width).
|
|
392
|
+
const back = cursorSequence.kind() === 'fixed'
|
|
393
|
+
? cursorSequence.stepMs()
|
|
394
|
+
: 366 * 86_400_000;
|
|
395
|
+
return cursorSequence.bounded({ start: d0 - back, end: d1 }).intervals();
|
|
396
|
+
}, [cursorSequence, d0, d1, resolvedKind]);
|
|
261
397
|
// Emit { time, values } for an outside readout — recomputed as the cursor moves
|
|
262
398
|
// *or* the window slides under it (xScale change → new time at the same pixel).
|
|
263
399
|
// Out of the plot (null, or a controlled trackerPosition d3 extrapolated past
|
|
@@ -298,6 +434,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
298
434
|
cursorRowKey: hoverPoint?.rowKey ?? null,
|
|
299
435
|
setHoverY,
|
|
300
436
|
crosshairSnap,
|
|
437
|
+
cursorBuckets,
|
|
438
|
+
regionAnchor,
|
|
439
|
+
setRegionAnchor,
|
|
440
|
+
onRegionSelect,
|
|
441
|
+
regionSelectModifier,
|
|
301
442
|
draggingKey,
|
|
302
443
|
setDragging,
|
|
303
444
|
selected: selectedValue,
|
|
@@ -316,12 +457,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
316
457
|
formatTime,
|
|
317
458
|
registerTrackerSource,
|
|
318
459
|
unregisterTrackerSource,
|
|
460
|
+
registerSelectable,
|
|
461
|
+
unregisterSelectable,
|
|
319
462
|
registerAnnotation,
|
|
320
463
|
unregisterAnnotation,
|
|
321
464
|
annotations,
|
|
322
465
|
labelLanes,
|
|
323
466
|
xScale,
|
|
324
467
|
xKind: resolvedKind,
|
|
468
|
+
discontinuities: xDiscontinuities,
|
|
325
469
|
panZoom,
|
|
326
470
|
minDuration,
|
|
327
471
|
applyRange,
|
|
@@ -343,6 +487,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
343
487
|
hoverPoint,
|
|
344
488
|
setHoverY,
|
|
345
489
|
crosshairSnap,
|
|
490
|
+
cursorBuckets,
|
|
491
|
+
regionAnchor,
|
|
492
|
+
setRegionAnchor,
|
|
493
|
+
onRegionSelect,
|
|
494
|
+
regionSelectModifier,
|
|
346
495
|
draggingKey,
|
|
347
496
|
setDragging,
|
|
348
497
|
selectedValue,
|
|
@@ -361,12 +510,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
361
510
|
formatTime,
|
|
362
511
|
registerTrackerSource,
|
|
363
512
|
unregisterTrackerSource,
|
|
513
|
+
registerSelectable,
|
|
514
|
+
unregisterSelectable,
|
|
364
515
|
registerAnnotation,
|
|
365
516
|
unregisterAnnotation,
|
|
366
517
|
annotations,
|
|
367
518
|
labelLanes,
|
|
368
519
|
xScale,
|
|
369
520
|
resolvedKind,
|
|
521
|
+
xDiscontinuities,
|
|
370
522
|
panZoom,
|
|
371
523
|
minDuration,
|
|
372
524
|
applyRange,
|
package/dist/Layers.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { Canvas } from './Canvas.js';
|
|
4
|
-
import { drawGrid } from './grid.js';
|
|
5
|
-
import {
|
|
4
|
+
import { drawGrid, drawDividers, thinPixels } from './grid.js';
|
|
5
|
+
import { TimeRange } from 'pond-ts';
|
|
6
|
+
import { cursorParts, bandRect, regionSpan } from './tracker.js';
|
|
6
7
|
import { resolveSelection } from './select.js';
|
|
7
|
-
import { panRange, zoomRange } from './viewport.js';
|
|
8
|
+
import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
|
|
8
9
|
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
9
10
|
import { ContainerContext, LayersContext, RowContext, } from './context.js';
|
|
10
|
-
/** Gridline tick count
|
|
11
|
+
/** Gridline tick count. **Must match the axis label counts** (`XAxis`
|
|
12
|
+
* `TICK_COUNT`, `ChartContainer` `TIME_TICK_COUNT`, `YAxis`) — the grid, the
|
|
13
|
+
* session dividers, and the axis labels are all derived from `ticks(count)`, so
|
|
14
|
+
* they only line up while the counts agree. Kept at 5 across all four. */
|
|
11
15
|
const GRID_TICKS = 5;
|
|
16
|
+
/** Minimum px between session dividers — thins dense collapse points (e.g. a
|
|
17
|
+
* daily chart where every candle is a new session) so the axis never crowds. */
|
|
18
|
+
const MIN_DIVIDER_PX = 40;
|
|
12
19
|
/** Wheel-zoom sensitivity: `factor = exp(deltaY * k)` (one ~100px notch ≈ ±15%). */
|
|
13
20
|
const ZOOM_SENSITIVITY = 0.0015;
|
|
14
21
|
/** Pointer slop (px): a drag must exceed this before it pans, and a click within
|
|
@@ -63,11 +70,31 @@ export function Layers({ children }) {
|
|
|
63
70
|
// Explicit `<YAxis ticks>` drive the gridlines too, so they align with the
|
|
64
71
|
// axis labels; otherwise d3 auto-picks (the default).
|
|
65
72
|
const explicitY = tickValues.get(defaultAxisId);
|
|
66
|
-
|
|
73
|
+
// A category axis draws no vertical gridlines — a line through each bar
|
|
74
|
+
// centre reads as noise; the bars are the structure.
|
|
75
|
+
const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(GRID_TICKS);
|
|
76
|
+
const xTicks = xTickVals.map((d) => xScale(+d));
|
|
67
77
|
const yTicks = gridY
|
|
68
78
|
? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
|
|
69
79
|
: [];
|
|
70
80
|
drawGrid(ctx, xTicks, yTicks, w, h, gridColor, gridDash);
|
|
81
|
+
// Session dividers: solid verticals at the trading calendar's collapse
|
|
82
|
+
// points (session/day opens), where closed time was removed from the axis.
|
|
83
|
+
// Draw them at the axis ticks that are collapse points — the same
|
|
84
|
+
// calendar-coarsened instants the axis labels — so a divider sits under
|
|
85
|
+
// each date/month/year label, not at every session (which crowds).
|
|
86
|
+
const disc = container.discontinuities;
|
|
87
|
+
if (disc?.boundaries) {
|
|
88
|
+
const [d0, d1] = container.timeRange;
|
|
89
|
+
// Call as a method (not a detached reference) so a class-based provider
|
|
90
|
+
// whose `boundaries` reads `this` keeps its receiver.
|
|
91
|
+
const collapse = new Set(disc.boundaries(d0, d1));
|
|
92
|
+
const bx = xTickVals
|
|
93
|
+
.filter((t) => collapse.has(+t))
|
|
94
|
+
.map((t) => xScale(+t));
|
|
95
|
+
const dividerColor = container.theme.axis.sessionDivider ?? gridColor;
|
|
96
|
+
drawDividers(ctx, thinPixels(bx, MIN_DIVIDER_PX), h, dividerColor);
|
|
97
|
+
}
|
|
71
98
|
for (const entry of layers) {
|
|
72
99
|
const yScale = yScales.get(entry.axisId ?? defaultAxisId);
|
|
73
100
|
if (yScale === undefined)
|
|
@@ -83,6 +110,8 @@ export function Layers({ children }) {
|
|
|
83
110
|
background,
|
|
84
111
|
gridColor,
|
|
85
112
|
gridDash,
|
|
113
|
+
container.discontinuities,
|
|
114
|
+
container.timeRange,
|
|
86
115
|
]);
|
|
87
116
|
// Interaction overlay: the cursor marks live on a DOM/SVG overlay above the
|
|
88
117
|
// data, so hovering never repaints the data canvas (whose `draw` doesn't depend
|
|
@@ -231,7 +260,29 @@ export function Layers({ children }) {
|
|
|
231
260
|
}
|
|
232
261
|
return;
|
|
233
262
|
}
|
|
234
|
-
|
|
263
|
+
// Region-cursor drag-select (opt-in via `onRegionSelect`, time axis only):
|
|
264
|
+
// anchor the selection at the press; the band then extends as the pointer
|
|
265
|
+
// moves (bucket by bucket with a sequence, freeform without), and release
|
|
266
|
+
// commits the range. A `regionSelectModifier` (only while `panZoom` is on)
|
|
267
|
+
// gates it behind the key so plain drag can still pan; otherwise it preempts
|
|
268
|
+
// pan (returns before the pan is armed below).
|
|
269
|
+
if (c.cursor === 'region' && c.onRegionSelect && c.xKind === 'time') {
|
|
270
|
+
const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
|
|
271
|
+
if (!needsShift || e.shiftKey) {
|
|
272
|
+
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
|
|
273
|
+
c.setRegionAnchor(+c.xScale.invert(px));
|
|
274
|
+
c.setHoverX(px);
|
|
275
|
+
try {
|
|
276
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* ignore */
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Modifier required but not held → fall through to pan.
|
|
284
|
+
}
|
|
285
|
+
if (!c.panZoom || c.xKind === 'category')
|
|
235
286
|
return;
|
|
236
287
|
const r = c.timeRange;
|
|
237
288
|
// Arm a potential pan: record the anchor, but DON'T capture the pointer or
|
|
@@ -261,6 +312,13 @@ export function Layers({ children }) {
|
|
|
261
312
|
c.setHoverX(px); // share the preview x so other rows draw a guide there
|
|
262
313
|
return;
|
|
263
314
|
}
|
|
315
|
+
// Region drag in progress: just track the pointer x (the band spans from the
|
|
316
|
+
// anchor bucket to here); no pan, no hover hit-test.
|
|
317
|
+
if (c.regionAnchor !== null) {
|
|
318
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
319
|
+
c.setHoverX(Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left)));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
264
322
|
// A pan is only live while a button is held. A move with no buttons means
|
|
265
323
|
// the press already ended without us seeing the pointerup — which the
|
|
266
324
|
// deferred-capture path allows: an uncommitted (sub-slop) potential-pan
|
|
@@ -296,9 +354,17 @@ export function Layers({ children }) {
|
|
|
296
354
|
/* ignore (synthetic / already-released pointer) */
|
|
297
355
|
}
|
|
298
356
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
357
|
+
if (c.discontinuities) {
|
|
358
|
+
// Trading-time axis: pan by an equal amount of *trading* time so the
|
|
359
|
+
// drag feels uniform across collapsed gaps (a raw-ms shift jumps).
|
|
360
|
+
const fraction = c.plotWidth > 0 ? -dx / c.plotWidth : 0;
|
|
361
|
+
c.applyRange(panRangeTrading(drag.startRange, fraction, c.discontinuities));
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
const span = drag.startRange[1] - drag.startRange[0];
|
|
365
|
+
const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
|
|
366
|
+
c.applyRange(panRange(drag.startRange, dt));
|
|
367
|
+
}
|
|
302
368
|
return; // tracker suppressed during a pan
|
|
303
369
|
}
|
|
304
370
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
@@ -338,6 +404,22 @@ export function Layers({ children }) {
|
|
|
338
404
|
}, []);
|
|
339
405
|
const handlePointerUp = useCallback((e) => {
|
|
340
406
|
const c = containerRef.current;
|
|
407
|
+
// End a region drag: commit the anchor→pointer span as a one-shot range,
|
|
408
|
+
// then clear the anchor (the cursor reverts to the single-bucket highlight).
|
|
409
|
+
if (c.regionAnchor !== null) {
|
|
410
|
+
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
|
|
411
|
+
const span = regionSpan(c.cursorBuckets ?? [], c.regionAnchor, +c.xScale.invert(px));
|
|
412
|
+
c.setRegionAnchor(null);
|
|
413
|
+
try {
|
|
414
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
/* ignore */
|
|
418
|
+
}
|
|
419
|
+
if (span)
|
|
420
|
+
c.onRegionSelect?.(new TimeRange({ start: span.start, end: span.end }));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
341
423
|
if (c.creating !== null) {
|
|
342
424
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
343
425
|
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
|
|
@@ -405,6 +487,10 @@ export function Layers({ children }) {
|
|
|
405
487
|
c.setHoverY(null, null);
|
|
406
488
|
return;
|
|
407
489
|
}
|
|
490
|
+
// Cancel a region-drag on leave (no commit) — a safety net for the rare case
|
|
491
|
+
// where the pointer capture didn't take, so the anchor can't get stuck.
|
|
492
|
+
if (c.regionAnchor !== null)
|
|
493
|
+
c.setRegionAnchor(null);
|
|
408
494
|
c.setHoverX(null);
|
|
409
495
|
c.setHoverY(null, null);
|
|
410
496
|
c.setHovered(null);
|
|
@@ -444,14 +530,19 @@ export function Layers({ children }) {
|
|
|
444
530
|
return;
|
|
445
531
|
const onWheel = (e) => {
|
|
446
532
|
const c = containerRef.current;
|
|
447
|
-
if (!c.panZoom)
|
|
533
|
+
if (!c.panZoom || c.xKind === 'category')
|
|
448
534
|
return;
|
|
449
535
|
e.preventDefault();
|
|
450
536
|
const rect = el.getBoundingClientRect();
|
|
451
537
|
const localX = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
|
|
452
538
|
const pivot = +c.xScale.invert(localX);
|
|
453
539
|
const factor = Math.exp(e.deltaY * ZOOM_SENSITIVITY);
|
|
454
|
-
c.applyRange(
|
|
540
|
+
c.applyRange(c.discontinuities
|
|
541
|
+
? // minDuration is the zoom-in floor; on a trading-time axis it caps
|
|
542
|
+
// the minimum visible *trading* time (ms of open-market time) rather
|
|
543
|
+
// than wall-clock ms — the sensible meaning for this axis.
|
|
544
|
+
zoomRangeTrading(c.timeRange, pivot, factor, c.discontinuities, c.minDuration)
|
|
545
|
+
: zoomRange(c.timeRange, pivot, factor, c.minDuration));
|
|
455
546
|
};
|
|
456
547
|
el.addEventListener('wheel', onWheel, { passive: false });
|
|
457
548
|
return () => el.removeEventListener('wheel', onWheel);
|
|
@@ -532,6 +623,22 @@ export function Layers({ children }) {
|
|
|
532
623
|
side: axisSides.get(defaultAxisId) ?? 'left',
|
|
533
624
|
};
|
|
534
625
|
})();
|
|
626
|
+
// `region` cursor (time axis only): shade the span under the pointer. With a
|
|
627
|
+
// `cursorSequence` the band snaps to the bucket (and extends bucket by bucket
|
|
628
|
+
// under a drag); with none it's the **freeform** case — a bare hover draws a
|
|
629
|
+
// plain line (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges
|
|
630
|
+
// map through `xScale`, so on a trading-time axis the band crops to live time.
|
|
631
|
+
const regionActive = parts.band && container.xKind === 'time';
|
|
632
|
+
const band = regionActive && cursorTime !== null
|
|
633
|
+
? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
|
|
634
|
+
: null;
|
|
635
|
+
// Degenerate region cursor (no sequence, not mid-drag): a plain vertical line.
|
|
636
|
+
const regionLine = regionActive &&
|
|
637
|
+
container.cursorBuckets === undefined &&
|
|
638
|
+
container.regionAnchor === null &&
|
|
639
|
+
cursorX !== null &&
|
|
640
|
+
cursorX >= 0 &&
|
|
641
|
+
cursorX <= plotWidth;
|
|
535
642
|
// Cross-row guide lines: the x-positions of annotations on the OTHER rows
|
|
536
643
|
// (markers + region edges), so a mark on one row reads against this row's data +
|
|
537
644
|
// the shared x axis. A mark's own row skips itself; baselines cast no vertical
|
|
@@ -599,7 +706,7 @@ export function Layers({ children }) {
|
|
|
599
706
|
top: 0,
|
|
600
707
|
left: 0,
|
|
601
708
|
pointerEvents: 'none',
|
|
602
|
-
}, children: [parts.line &&
|
|
709
|
+
}, children: [band !== null && (_jsx("rect", { x: band.x0, y: 0, width: band.x1 - band.x0, height: row.height, fill: cursorColor, opacity: 0.12 })), regionLine && cursorX !== null && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.line &&
|
|
603
710
|
cursorX !== null &&
|
|
604
711
|
cursorX >= 0 &&
|
|
605
712
|
cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
|
package/dist/ScatterChart.d.ts
CHANGED
|
@@ -15,6 +15,17 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
15
15
|
* exception), not a per-component style override.
|
|
16
16
|
*/
|
|
17
17
|
as?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The **stable series identity** for selection + hover. **Optional, and it
|
|
20
|
+
* gates interactivity:** the scatter is selectable/hoverable only when given an
|
|
21
|
+
* `id` — omit it and the points render + read out but can't be clicked (a click
|
|
22
|
+
* on them reads as empty space ⇒ deselect). Distinct from `as` (a theme role
|
|
23
|
+
* that can repeat): `id` must be unique among the selectable layers, and it is
|
|
24
|
+
* the key the controlled `selected` echo, dedup, and (later) multi-select all
|
|
25
|
+
* match on — so a selection survives a data update where a sample `key` goes
|
|
26
|
+
* stale.
|
|
27
|
+
*/
|
|
28
|
+
id?: string;
|
|
18
29
|
/**
|
|
19
30
|
* Which `<YAxis>` (by its `id`) this scatter scales against — picks the
|
|
20
31
|
* *scale*, where `as` picks the *style*. **Omitted ⇒ the row's default axis.**
|
|
@@ -66,8 +77,9 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
66
77
|
* (`sampleAt`), and that sample flows to the container's `onTrackerChanged` —
|
|
67
78
|
* the nearest-point readout. Scatter reuses the shared tracker rather than
|
|
68
79
|
* adding a separate `onNearest` channel, so a scatter reads out exactly like a
|
|
69
|
-
* line. Click selection hit-tests each point's disc (`hitTest`)
|
|
70
|
-
* point (matching
|
|
80
|
+
* line. Click selection hit-tests each point's disc (`hitTest`) — **opt-in via
|
|
81
|
+
* `id`**; the selected point (matching the selection's series `id` and the sample
|
|
82
|
+
* `key`) gets a highlight ring. Without an `id` the scatter is display-only.
|
|
71
83
|
*
|
|
72
84
|
* ```tsx
|
|
73
85
|
* <Layers>
|
|
@@ -80,5 +92,5 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
80
92
|
* </Layers>
|
|
81
93
|
* ```
|
|
82
94
|
*/
|
|
83
|
-
export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
|
|
95
|
+
export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
|
|
84
96
|
//# sourceMappingURL=ScatterChart.d.ts.map
|
package/dist/ScatterChart.js
CHANGED
|
@@ -16,8 +16,9 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
16
16
|
* (`sampleAt`), and that sample flows to the container's `onTrackerChanged` —
|
|
17
17
|
* the nearest-point readout. Scatter reuses the shared tracker rather than
|
|
18
18
|
* adding a separate `onNearest` channel, so a scatter reads out exactly like a
|
|
19
|
-
* line. Click selection hit-tests each point's disc (`hitTest`)
|
|
20
|
-
* point (matching
|
|
19
|
+
* line. Click selection hit-tests each point's disc (`hitTest`) — **opt-in via
|
|
20
|
+
* `id`**; the selected point (matching the selection's series `id` and the sample
|
|
21
|
+
* `key`) gets a highlight ring. Without an `id` the scatter is display-only.
|
|
21
22
|
*
|
|
22
23
|
* ```tsx
|
|
23
24
|
* <Layers>
|
|
@@ -30,7 +31,7 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
30
31
|
* </Layers>
|
|
31
32
|
* ```
|
|
32
33
|
*/
|
|
33
|
-
export function ScatterChart({ series, column, as: semantic, axis, radius, color, label, index = 0, }) {
|
|
34
|
+
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, index = 0, }) {
|
|
34
35
|
const container = useContext(ContainerContext);
|
|
35
36
|
if (container === null) {
|
|
36
37
|
throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
|
|
@@ -101,8 +102,15 @@ export function ScatterChart({ series, column, as: semantic, axis, radius, color
|
|
|
101
102
|
},
|
|
102
103
|
];
|
|
103
104
|
},
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
// `id` gates interactivity: only an id-bearing layer wires a hitTest, so
|
|
106
|
+
// a no-id scatter is display-only (a click on it resolves to empty space).
|
|
107
|
+
// Omit the key entirely when there's no id (exactOptionalPropertyTypes).
|
|
108
|
+
...(id === undefined
|
|
109
|
+
? {}
|
|
110
|
+
: {
|
|
111
|
+
hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel),
|
|
112
|
+
}),
|
|
113
|
+
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id),
|
|
106
114
|
},
|
|
107
115
|
axisId: axis,
|
|
108
116
|
index,
|
|
@@ -112,6 +120,7 @@ export function ScatterChart({ series, column, as: semantic, axis, radius, color
|
|
|
112
120
|
column,
|
|
113
121
|
style,
|
|
114
122
|
seriesLabel,
|
|
123
|
+
id,
|
|
115
124
|
encoding,
|
|
116
125
|
keyAt,
|
|
117
126
|
labelAt,
|
|
@@ -134,6 +143,15 @@ export function ScatterChart({ series, column, as: semantic, axis, radius, color
|
|
|
134
143
|
useEffect(() => {
|
|
135
144
|
registerTrackerSource(slot, entry.layer);
|
|
136
145
|
}, [registerTrackerSource, slot, entry.layer]);
|
|
146
|
+
// Advertise selectability (only when an `id` was given) so the container can
|
|
147
|
+
// warn if selection is wired but nothing is selectable.
|
|
148
|
+
const { registerSelectable, unregisterSelectable } = container;
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (id === undefined)
|
|
151
|
+
return;
|
|
152
|
+
registerSelectable(slot);
|
|
153
|
+
return () => unregisterSelectable(slot);
|
|
154
|
+
}, [registerSelectable, unregisterSelectable, slot, id]);
|
|
137
155
|
return null;
|
|
138
156
|
}
|
|
139
157
|
//# sourceMappingURL=ScatterChart.js.map
|