@pond-ts/charts 0.38.0 → 0.39.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 +57 -1
- package/dist/ChartContainer.d.ts +11 -1
- package/dist/ChartContainer.js +20 -2
- package/dist/Layers.js +135 -32
- package/dist/XAxis.js +66 -16
- package/dist/annotations.d.ts +16 -7
- package/dist/annotations.js +104 -38
- package/dist/context.d.ts +38 -4
- package/dist/tracker.js +4 -3
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
|
|
|
8
8
|
them all. Pre-1.0: minor bumps may include new features and type-level changes;
|
|
9
9
|
patch bumps are strictly additive.
|
|
10
10
|
|
|
11
|
-
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.
|
|
11
|
+
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...HEAD
|
|
12
|
+
[0.39.0]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...v0.39.0
|
|
12
13
|
[0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
|
|
13
14
|
[0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
|
|
14
15
|
[0.36.0]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...v0.36.0
|
|
@@ -34,6 +35,61 @@ patch bumps are strictly additive.
|
|
|
34
35
|
[0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
|
|
35
36
|
[0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
|
|
36
37
|
|
|
38
|
+
## [Unreleased]
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **Charts — click-to-select an annotation now works while `panZoom` is on.** A
|
|
43
|
+
_selectable but non-editable_ `<Region>` / `<Marker>` (one with no `onChange`)
|
|
44
|
+
lets its press bubble to the plot so a drag can pan _through_ it. The plot
|
|
45
|
+
captured the pointer on press to start the pan, and the browser then retargeted
|
|
46
|
+
the resulting `click` onto the plot (Pointer Events spec: a captured pointer's
|
|
47
|
+
compatibility mouse events fire on the capture target) — silently dropping the
|
|
48
|
+
mark's `onSelectAnnotation`. The plot now **defers** its pan pointer-capture
|
|
49
|
+
until the pointer actually moves past the drag slop, so a click (no drag) leaves
|
|
50
|
+
the pointer on the mark and its select fires, while a press-drag still pans
|
|
51
|
+
through and the tracker still hides once the pan commits. Resolves the
|
|
52
|
+
browser-dependent finding deferred from #308; adds
|
|
53
|
+
`e2e/annotations-panzoom.spec.ts`, the first real-pointer-event behavior e2e for
|
|
54
|
+
the annotation layer. (#309)
|
|
55
|
+
|
|
56
|
+
## [0.39.0] — 2026-07-03
|
|
57
|
+
|
|
58
|
+
A `@pond-ts/charts` release: the **crosshair reticle + annotation-layout** wave,
|
|
59
|
+
driven by the Tidal terminal. `pond-ts`, `@pond-ts/react`, and `@pond-ts/fit`
|
|
60
|
+
carry no code changes — republished in lock-step (peer ranges widen to `^0.39.0`).
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- `@pond-ts/charts`: **`<ChartContainer crosshairSnap>`** (default `true`) — the
|
|
65
|
+
`cursor="crosshair"` reticle centres on the nearest data point; `false` gives a
|
|
66
|
+
**free** reticle whose horizontal line + value follow the pointer y
|
|
67
|
+
(`yScale.invert`), while the vertical line still snaps its x to the data grid
|
|
68
|
+
for a clean time readout.
|
|
69
|
+
- `@pond-ts/charts`: **coincident marker labels merge** — labelled `<Marker>`s at
|
|
70
|
+
the same x fold into one chip (`"a, b, c"`) instead of stacking; their x-axis
|
|
71
|
+
indicator pills dedup to one.
|
|
72
|
+
- `@pond-ts/charts`: **x-axis indicator pills lane-stack** when they'd overlap
|
|
73
|
+
(each connector lengthens to its lane).
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- `@pond-ts/charts`: **`cursor="crosshair"` is now a single reticle** — a
|
|
78
|
+
full-height dashed vertical + full-width dashed horizontal line + a centre dot +
|
|
79
|
+
one value pill, with the time pill connected to the vertical line. (Was
|
|
80
|
+
per-series dots + on-axis pills; the per-series readout stays on `flag` /
|
|
81
|
+
`inline`.)
|
|
82
|
+
- `@pond-ts/charts`: top-flag **labels pack per row** — a label only contends with
|
|
83
|
+
labels in its own row's top space (a bottom-row label no longer dodges a
|
|
84
|
+
top-row one at the same x). A dragged mark is excluded from the pack, so static
|
|
85
|
+
marks hold their lanes as it crosses them (no phantom lane swaps). A marker's
|
|
86
|
+
staff now hangs from the top of its (stacked) flag.
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- `@pond-ts/charts`: the crosshair x-axis pill and marker pills read the axis's
|
|
91
|
+
own formatter (a value-axis / off-boundary time no longer shows a raw number).
|
|
92
|
+
|
|
37
93
|
## [0.38.0] — 2026-07-03
|
|
38
94
|
|
|
39
95
|
A `@pond-ts/charts` release: **axis-edge value indicators + the crosshair
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -82,6 +82,16 @@ export interface ChartContainerProps {
|
|
|
82
82
|
* axis.
|
|
83
83
|
*/
|
|
84
84
|
cursorTime?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* `cursor="crosshair"` reticle **y** snapping. **Default `true`** — the
|
|
87
|
+
* crosshair centres on the nearest **data point** (the horizontal line snaps to
|
|
88
|
+
* that sample's value). `false` — the horizontal line + centre follow the
|
|
89
|
+
* pointer **y** freely, the value read as `yScale.invert(pointerY)`. Either way
|
|
90
|
+
* the vertical line snaps its **x** to the data grid (so the time readout is
|
|
91
|
+
* clean), and both draw a full-height dashed vertical + full-width dashed
|
|
92
|
+
* horizontal line.
|
|
93
|
+
*/
|
|
94
|
+
crosshairSnap?: boolean;
|
|
85
95
|
/**
|
|
86
96
|
* Enter **annotation-edit mode**: suppresses the data cursor and makes editable
|
|
87
97
|
* annotations (those given an `onChange`) interactive — hovering one reveals its
|
|
@@ -145,5 +155,5 @@ export interface ChartContainerProps {
|
|
|
145
155
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
146
156
|
* (`<YAxis>`).
|
|
147
157
|
*/
|
|
148
|
-
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
158
|
+
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
149
159
|
//# sourceMappingURL=ChartContainer.d.ts.map
|
package/dist/ChartContainer.js
CHANGED
|
@@ -31,7 +31,7 @@ function normalizeRange(range) {
|
|
|
31
31
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
32
32
|
* (`<YAxis>`).
|
|
33
33
|
*/
|
|
34
|
-
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorTime = false, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, children, }) {
|
|
34
|
+
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, 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, }) {
|
|
35
35
|
// The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
|
|
36
36
|
// ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
|
|
37
37
|
// is the placeholder while auto-fitting.
|
|
@@ -77,6 +77,13 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
77
77
|
// still cursor stays put while a live window slides under it; a controlled
|
|
78
78
|
// `trackerPosition` resolves to a pixel below.
|
|
79
79
|
const [hoverX, setHoverX] = useState(null);
|
|
80
|
+
// The free-form crosshair also needs the pointer's y + which row (row-specific,
|
|
81
|
+
// unlike the shared x). One state object so a move updates both atomically.
|
|
82
|
+
const [hoverPoint, setHoverPoint] = useState(null);
|
|
83
|
+
const setHoverY = useCallback((y, rowKey) => setHoverPoint(y === null || rowKey === null ? null : { y, rowKey }), []);
|
|
84
|
+
// The actively-dragged annotation — excluded from the lane packers so static
|
|
85
|
+
// marks hold their lanes while it crosses them (see ContainerFrame.draggingKey).
|
|
86
|
+
const [draggingKey, setDragging] = useState(null);
|
|
80
87
|
// Draw layers register as tracker sources; on hover we fan in their values at
|
|
81
88
|
// the cursor and hand them out via onTrackerChanged (held in a ref so an
|
|
82
89
|
// inline callback doesn't churn the frame). This powers a readout rendered
|
|
@@ -256,7 +263,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
256
263
|
}, [cursorX, xScale, sources, plotWidth]);
|
|
257
264
|
// Pack overlapping top-flag labels (markers + regions) into stacked lanes so
|
|
258
265
|
// close-in-x labels don't collide; chips read their lane back off the frame.
|
|
259
|
-
const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v)), [annotations, xScale]);
|
|
266
|
+
const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey), [annotations, xScale, draggingKey]);
|
|
260
267
|
const frame = useMemo(() => ({
|
|
261
268
|
timeRange: [d0, d1],
|
|
262
269
|
width,
|
|
@@ -269,6 +276,12 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
269
276
|
rowGap,
|
|
270
277
|
cursorX,
|
|
271
278
|
setHoverX,
|
|
279
|
+
cursorY: hoverPoint?.y ?? null,
|
|
280
|
+
cursorRowKey: hoverPoint?.rowKey ?? null,
|
|
281
|
+
setHoverY,
|
|
282
|
+
crosshairSnap,
|
|
283
|
+
draggingKey,
|
|
284
|
+
setDragging,
|
|
272
285
|
selected: selectedValue,
|
|
273
286
|
select,
|
|
274
287
|
hovered,
|
|
@@ -309,6 +322,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
309
322
|
rightGutter,
|
|
310
323
|
rowGap,
|
|
311
324
|
cursorX,
|
|
325
|
+
hoverPoint,
|
|
326
|
+
setHoverY,
|
|
327
|
+
crosshairSnap,
|
|
328
|
+
draggingKey,
|
|
329
|
+
setDragging,
|
|
312
330
|
selectedValue,
|
|
313
331
|
select,
|
|
314
332
|
hovered,
|
package/dist/Layers.js
CHANGED
|
@@ -234,17 +234,23 @@ export function Layers({ children }) {
|
|
|
234
234
|
if (!c.panZoom)
|
|
235
235
|
return;
|
|
236
236
|
const r = c.timeRange;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
237
|
+
// Arm a potential pan: record the anchor, but DON'T capture the pointer or
|
|
238
|
+
// hide the tracker yet. Capturing on press retargets the eventual `click`
|
|
239
|
+
// to the plot (Pointer Events spec: a captured pointer's compatibility
|
|
240
|
+
// mouse events fire on the capture target) — which silently swallows a
|
|
241
|
+
// click-select on a *selectable but non-editable* mark, whose press bubbles
|
|
242
|
+
// up to here (its DragArea deliberately lets a non-edit press through so a
|
|
243
|
+
// pan can read past it). The pan commits — capture + hide tracker — only
|
|
244
|
+
// once the pointer moves past the slop (handlePointerMove); a press that
|
|
245
|
+
// stays put is a click, and leaving the pointer on the mark lets its
|
|
246
|
+
// onClick fire. Pan-through (drag starting on a non-editable mark) is
|
|
247
|
+
// unaffected: the first move past the slop still reaches this handler and
|
|
248
|
+
// captures then.
|
|
249
|
+
dragRef.current = {
|
|
250
|
+
startX: e.clientX,
|
|
251
|
+
startRange: [r[0], r[1]],
|
|
252
|
+
captured: false,
|
|
253
|
+
};
|
|
248
254
|
}, []);
|
|
249
255
|
const handlePointerMove = useCallback((e) => {
|
|
250
256
|
const c = containerRef.current;
|
|
@@ -255,6 +261,16 @@ export function Layers({ children }) {
|
|
|
255
261
|
c.setHoverX(px); // share the preview x so other rows draw a guide there
|
|
256
262
|
return;
|
|
257
263
|
}
|
|
264
|
+
// A pan is only live while a button is held. A move with no buttons means
|
|
265
|
+
// the press already ended without us seeing the pointerup — which the
|
|
266
|
+
// deferred-capture path allows: an uncommitted (sub-slop) potential-pan
|
|
267
|
+
// released *outside* the plot never captured, so its pointerup fires
|
|
268
|
+
// off-plot and never reaches handlePointerUp. Drop the stale dragRef here
|
|
269
|
+
// (and fall through to hover) so it can't fire a phantom pan on re-entry.
|
|
270
|
+
// A genuine pan-in-progress always has buttons !== 0, so this never cuts
|
|
271
|
+
// one short — including the press-leave-then-return-still-holding case.
|
|
272
|
+
if (dragRef.current && e.buttons === 0)
|
|
273
|
+
dragRef.current = null;
|
|
258
274
|
const drag = dragRef.current;
|
|
259
275
|
if (drag) {
|
|
260
276
|
// Pan from the start range by the total drag — right → earlier (−dt).
|
|
@@ -263,21 +279,61 @@ export function Layers({ children }) {
|
|
|
263
279
|
// the view nor shifts the scale the click then hit-tests against.
|
|
264
280
|
if (Math.abs(dx) <= DRAG_SLOP)
|
|
265
281
|
return;
|
|
282
|
+
// First move past the slop ⇒ this is a real pan, not a click. Commit it
|
|
283
|
+
// now (deferred from press, see handlePointerDown): hide the tracker and
|
|
284
|
+
// claim the pointer so the pan keeps tracking outside the plot. Capturing
|
|
285
|
+
// here — after the click/select decision is already moot — is what keeps a
|
|
286
|
+
// tap-select on a non-editable mark working while a drag still pans.
|
|
287
|
+
if (!drag.captured) {
|
|
288
|
+
drag.captured = true;
|
|
289
|
+
c.setHoverX(null); // hide the tracker while panning
|
|
290
|
+
c.setHoverY(null, null);
|
|
291
|
+
c.setHovered(null); // and drop any hover-highlight
|
|
292
|
+
try {
|
|
293
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* ignore (synthetic / already-released pointer) */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
266
299
|
const span = drag.startRange[1] - drag.startRange[0];
|
|
267
300
|
const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
|
|
268
301
|
c.applyRange(panRange(drag.startRange, dt));
|
|
269
302
|
return; // tracker suppressed during a pan
|
|
270
303
|
}
|
|
271
304
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
272
|
-
const
|
|
305
|
+
const r = rowRef.current;
|
|
306
|
+
const rawX = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
|
|
307
|
+
const py = Math.max(0, Math.min(r.height, e.clientY - rect.top));
|
|
308
|
+
// Crosshair with `crosshairSnap` (default): snap the shared vertical line to
|
|
309
|
+
// the nearest sample's x, so the reticle centres on a real data point (and
|
|
310
|
+
// stays aligned across rows on a shared grid). Free mode keeps the raw x.
|
|
311
|
+
// Crosshair always snaps the shared vertical line (and so the x-time pill)
|
|
312
|
+
// to the nearest sample's x — the reticle rides the data grid on x, giving a
|
|
313
|
+
// clean time readout (a raw pointer time formats to unreadable sub-second
|
|
314
|
+
// precision). `crosshairSnap` only governs the *y* (snap to the value vs a
|
|
315
|
+
// free horizontal line at the pointer). This is ChartIQ's model.
|
|
316
|
+
let px = rawX;
|
|
317
|
+
if ((r.cursor ?? c.cursor) === 'crosshair') {
|
|
318
|
+
const t = +c.xScale.invert(rawX);
|
|
319
|
+
for (const entry of r.layers) {
|
|
320
|
+
if (entry.layer.cursorFlag)
|
|
321
|
+
continue;
|
|
322
|
+
const s = entry.layer.sampleAt(t)[0];
|
|
323
|
+
if (s !== undefined) {
|
|
324
|
+
px = c.xScale(s.x);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
273
329
|
c.setHoverX(px);
|
|
330
|
+
c.setHoverY(py, r.rowKey);
|
|
274
331
|
// Hover-highlight: hit-test the row's selectable layers (Bar) under the
|
|
275
332
|
// pointer and set the hovered mark. Deduped in the container, so the data
|
|
276
333
|
// canvas repaints only on a mark transition — not every move (the move just
|
|
277
334
|
// slides the SVG cursor). A row with no selectable layer (line/area/band)
|
|
278
|
-
// resolves to null → a no-op.
|
|
279
|
-
const
|
|
280
|
-
const hit = resolveSelection(r.layers, px, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
|
|
335
|
+
// resolves to null → a no-op. Uses the raw pointer, not the snapped x.
|
|
336
|
+
const hit = resolveSelection(r.layers, rawX, py, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
|
|
281
337
|
c.setHovered(hit);
|
|
282
338
|
}, []);
|
|
283
339
|
const handlePointerUp = useCallback((e) => {
|
|
@@ -323,13 +379,18 @@ export function Layers({ children }) {
|
|
|
323
379
|
}
|
|
324
380
|
return;
|
|
325
381
|
}
|
|
326
|
-
|
|
382
|
+
const drag = dragRef.current;
|
|
383
|
+
if (drag) {
|
|
327
384
|
dragRef.current = null;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
385
|
+
// Only release if the pan actually committed + captured (a click never
|
|
386
|
+
// captured, so there's nothing to release).
|
|
387
|
+
if (drag.captured) {
|
|
388
|
+
try {
|
|
389
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
/* ignore */
|
|
393
|
+
}
|
|
333
394
|
}
|
|
334
395
|
}
|
|
335
396
|
}, []);
|
|
@@ -341,9 +402,11 @@ export function Layers({ children }) {
|
|
|
341
402
|
drawFromRef.current = null;
|
|
342
403
|
setDrawFrom(null);
|
|
343
404
|
c.setHoverX(null);
|
|
405
|
+
c.setHoverY(null, null);
|
|
344
406
|
return;
|
|
345
407
|
}
|
|
346
408
|
c.setHoverX(null);
|
|
409
|
+
c.setHoverY(null, null);
|
|
347
410
|
c.setHovered(null);
|
|
348
411
|
}, []);
|
|
349
412
|
// Click selection: ignore the click that ends a drag/pan (moved past a few px),
|
|
@@ -427,6 +490,48 @@ export function Layers({ children }) {
|
|
|
427
490
|
const timeX = parts.chip === 'flag' && trackerSamples.length > 0
|
|
428
491
|
? trackerSamples[0].px
|
|
429
492
|
: cursorX;
|
|
493
|
+
// Crosshair reticle (`chip: 'axis'`): a single centre for THIS row — the
|
|
494
|
+
// horizontal line + centre dot + value pill anchor to `center.py` (the vertical
|
|
495
|
+
// line is the shared `cursorX`, drawn in every row). Snap: the sample nearest
|
|
496
|
+
// the pointer y in the hovered row (or the first sample when nothing's hovered,
|
|
497
|
+
// e.g. a pinned demo — so every row shows a reticle then). Free: the raw pointer
|
|
498
|
+
// y in the hovered row, its value via `yScale.invert`. `null` ⇒ vertical only.
|
|
499
|
+
const cursorInBounds = cursorX !== null && cursorX >= 0 && cursorX <= plotWidth;
|
|
500
|
+
const reticle = (() => {
|
|
501
|
+
if (parts.chip !== 'axis' || !cursorInBounds)
|
|
502
|
+
return null;
|
|
503
|
+
const hoveredRow = container.cursorRowKey === row.rowKey;
|
|
504
|
+
const cy = container.cursorY;
|
|
505
|
+
if (container.crosshairSnap) {
|
|
506
|
+
if (trackerSamples.length === 0)
|
|
507
|
+
return null;
|
|
508
|
+
const pick = hoveredRow && cy !== null
|
|
509
|
+
? trackerSamples.reduce((a, b) => Math.abs(b.py - cy) < Math.abs(a.py - cy) ? b : a)
|
|
510
|
+
: container.cursorRowKey === null
|
|
511
|
+
? trackerSamples[0]
|
|
512
|
+
: null;
|
|
513
|
+
return pick
|
|
514
|
+
? {
|
|
515
|
+
py: pick.py,
|
|
516
|
+
value: pick.value,
|
|
517
|
+
format: pick.format,
|
|
518
|
+
side: pick.side,
|
|
519
|
+
}
|
|
520
|
+
: null;
|
|
521
|
+
}
|
|
522
|
+
// Free reticle — the raw pointer y in the hovered row.
|
|
523
|
+
if (!hoveredRow || cy === null)
|
|
524
|
+
return null;
|
|
525
|
+
const ys = yScales.get(defaultAxisId);
|
|
526
|
+
if (ys === undefined)
|
|
527
|
+
return null;
|
|
528
|
+
return {
|
|
529
|
+
py: cy,
|
|
530
|
+
value: ys.invert(cy),
|
|
531
|
+
format: formats.get(defaultAxisId) ?? String,
|
|
532
|
+
side: axisSides.get(defaultAxisId) ?? 'left',
|
|
533
|
+
};
|
|
534
|
+
})();
|
|
430
535
|
// Cross-row guide lines: the x-positions of annotations on the OTHER rows
|
|
431
536
|
// (markers + region edges), so a mark on one row reads against this row's data +
|
|
432
537
|
// the shared x axis. A mark's own row skips itself; baselines cast no vertical
|
|
@@ -500,7 +605,9 @@ export function Layers({ children }) {
|
|
|
500
605
|
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' &&
|
|
501
606
|
trackerSamples.map((s, i) => s.py > flagBase ? (_jsx("line", { x1: s.px, y1: flagBase, x2: s.px, y2: s.py, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `staff-${i}`)) : null), parts.chip === 'flag' &&
|
|
502
607
|
trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.chip === 'axis' &&
|
|
503
|
-
|
|
608
|
+
cursorX !== null &&
|
|
609
|
+
cursorX >= 0 &&
|
|
610
|
+
cursorX <= plotWidth && (_jsxs(_Fragment, { children: [_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), reticle && (_jsxs(_Fragment, { children: [_jsx("line", { x1: 0, y1: Math.round(reticle.py), x2: plotWidth, y2: Math.round(reticle.py), stroke: cursorColor, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), _jsx("circle", { cx: cursorX, cy: reticle.py, r: 3, fill: cursorColor, stroke: background, strokeWidth: background ? 1 : 0 })] }))] })), parts.dots &&
|
|
504
611
|
trackerSamples.map((s, i) => (_jsx("circle", { cx: s.px, cy: s.py, r: 3, fill: s.color, stroke: background, strokeWidth: background ? 1 : 0 }, `dot-${i}`)))] }), showTime && timeX !== null && cursorTime !== null && (_jsx("div", { style: {
|
|
505
612
|
...chipStyle,
|
|
506
613
|
background: 'transparent',
|
|
@@ -529,16 +636,12 @@ export function Layers({ children }) {
|
|
|
529
636
|
right: flip ? `${plotWidth - s.px + 8}px` : undefined,
|
|
530
637
|
color: s.color,
|
|
531
638
|
}, children: s.format(s.value) }, i));
|
|
532
|
-
}),
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
transform: 'translateY(-50%)',
|
|
539
|
-
...axisPillX(s.side, plotWidth),
|
|
540
|
-
}, children: s.format(s.value) }, `ytag-${i}`));
|
|
541
|
-
}), parts.chip === 'flag' &&
|
|
639
|
+
}), reticle && (_jsx("div", { style: {
|
|
640
|
+
...axisPillStyle(container.theme, cursorColor),
|
|
641
|
+
top: `${Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, reticle.py))}px`,
|
|
642
|
+
transform: 'translateY(-50%)',
|
|
643
|
+
...axisPillX(reticle.side, plotWidth),
|
|
644
|
+
}, children: reticle.format(reticle.value) })), parts.chip === 'flag' &&
|
|
542
645
|
cursorX !== null &&
|
|
543
646
|
trackerSamples.map((s, i) => (
|
|
544
647
|
// The flag flies from the top of its staff — chip top at the staff top
|
package/dist/XAxis.js
CHANGED
|
@@ -49,24 +49,61 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
49
49
|
// colour, reading like a tick. An indicator always shows the axis coordinate
|
|
50
50
|
// (the formatted `at`), never the marker's custom label (that stays the in-plot
|
|
51
51
|
// chip). Skipped when off-plot.
|
|
52
|
+
// Coincident indicator markers (same `at`) show the same time, so one pill
|
|
53
|
+
// stands for the group — dedup by `at` (the first wins), then place.
|
|
54
|
+
const seenAt = new Set();
|
|
52
55
|
const markerTags = container.annotations
|
|
53
56
|
.filter((a) => a.indicator && a.kind === 'marker' && a.xs[0] !== undefined)
|
|
57
|
+
.filter((a) => {
|
|
58
|
+
const at = a.xs[0];
|
|
59
|
+
if (seenAt.has(at))
|
|
60
|
+
return false;
|
|
61
|
+
seenAt.add(at);
|
|
62
|
+
return true;
|
|
63
|
+
})
|
|
54
64
|
.map((a) => {
|
|
55
65
|
const at = a.xs[0];
|
|
56
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
key: a.key,
|
|
68
|
+
id: a.id ?? `marker-at-${at}`,
|
|
69
|
+
x: xScale(at),
|
|
70
|
+
text: fmt(at),
|
|
71
|
+
};
|
|
57
72
|
})
|
|
58
73
|
.filter((t) => t.x >= 0 && t.x <= plotWidth);
|
|
74
|
+
// Stack overlapping marker pills into lanes — they all share this one strip.
|
|
75
|
+
// Greedy left→right; the dragged mark (`draggingKey`) is pinned to lane 0 so the
|
|
76
|
+
// static pills hold their lanes as it crosses them (no reshuffle mid-drag).
|
|
77
|
+
const pillWidth = (text) => text.length * theme.font.size * 0.62 + 10;
|
|
78
|
+
const pillLaneEnds = [];
|
|
79
|
+
const markerLanes = new Map();
|
|
80
|
+
for (const t of [...markerTags].sort((p, q) => p.x - q.x)) {
|
|
81
|
+
if (t.key === container.draggingKey) {
|
|
82
|
+
markerLanes.set(t.id, 0);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const left = t.x - pillWidth(t.text) / 2;
|
|
86
|
+
let lane = 0;
|
|
87
|
+
while (lane < pillLaneEnds.length && pillLaneEnds[lane] + 4 > left)
|
|
88
|
+
lane += 1;
|
|
89
|
+
pillLaneEnds[lane] = left + pillWidth(t.text);
|
|
90
|
+
markerLanes.set(t.id, lane);
|
|
91
|
+
}
|
|
92
|
+
const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
|
|
59
93
|
const placed = customTicks
|
|
60
94
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
61
95
|
: xScale.ticks(TICK_COUNT).map((d) => ({
|
|
62
96
|
x: xScale(d),
|
|
63
97
|
label: fmt(+d),
|
|
64
98
|
}));
|
|
65
|
-
const stripHeight = height ?? TICK_STRIP + (label ? LABEL_STRIP : 0);
|
|
66
99
|
const onTop = side === 'top';
|
|
67
100
|
// Axis pills (marker / crosshair) sit at the same offset as the tick labels so
|
|
68
101
|
// they line up with their tick-label neighbours (matches `labelOffset` below).
|
|
69
102
|
const pillOffset = align === 'right' ? 2 : 6;
|
|
103
|
+
// Per-lane vertical step for stacked pills; grow the strip to fit the stack.
|
|
104
|
+
const PILL_LANE_H = theme.font.size + 6;
|
|
105
|
+
const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
|
|
106
|
+
maxPillLane * PILL_LANE_H;
|
|
70
107
|
return (_jsxs("div", { style: {
|
|
71
108
|
position: 'relative',
|
|
72
109
|
marginLeft: `${leftGutter}px`,
|
|
@@ -119,26 +156,39 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
119
156
|
color: theme.axis.title?.color ?? theme.axis.label,
|
|
120
157
|
opacity: theme.axis.title?.opacity ?? 0.85,
|
|
121
158
|
whiteSpace: 'nowrap',
|
|
122
|
-
}, children: label })), markerTags.map((t) =>
|
|
159
|
+
}, children: label })), markerTags.map((t) => {
|
|
160
|
+
// The pill's lane: stacked below the base row when it would overlap a
|
|
161
|
+
// neighbour; the connector lengthens to reach it.
|
|
162
|
+
const laneY = pillOffset + (markerLanes.get(t.id) ?? 0) * PILL_LANE_H;
|
|
163
|
+
return (_jsxs(Fragment, { children: [_jsx("div", { style: {
|
|
164
|
+
position: 'absolute',
|
|
165
|
+
left: `${t.x}px`,
|
|
166
|
+
[onTop ? 'bottom' : 'top']: 0,
|
|
167
|
+
width: '1px',
|
|
168
|
+
height: `${laneY}px`,
|
|
169
|
+
background: annotationColor,
|
|
170
|
+
zIndex: 2,
|
|
171
|
+
} }), _jsx("div", { style: {
|
|
172
|
+
...axisPillStyle(theme, annotationColor),
|
|
173
|
+
left: `${t.x}px`,
|
|
174
|
+
transform: 'translateX(-50%)',
|
|
175
|
+
[onTop ? 'bottom' : 'top']: `${laneY}px`,
|
|
176
|
+
zIndex: 2,
|
|
177
|
+
}, children: t.text })] }, t.id));
|
|
178
|
+
}), showCursorTag && (_jsxs(Fragment, { children: [_jsx("div", { style: {
|
|
123
179
|
position: 'absolute',
|
|
124
|
-
left: `${
|
|
180
|
+
left: `${cursorX}px`,
|
|
125
181
|
[onTop ? 'bottom' : 'top']: 0,
|
|
126
182
|
width: '1px',
|
|
127
183
|
height: `${pillOffset}px`,
|
|
128
|
-
background:
|
|
129
|
-
zIndex:
|
|
184
|
+
background: cursorColor,
|
|
185
|
+
zIndex: 3,
|
|
130
186
|
} }), _jsx("div", { style: {
|
|
131
|
-
...axisPillStyle(theme,
|
|
132
|
-
left: `${
|
|
187
|
+
...axisPillStyle(theme, cursorColor),
|
|
188
|
+
left: `${cursorX}px`,
|
|
133
189
|
transform: 'translateX(-50%)',
|
|
134
190
|
[onTop ? 'bottom' : 'top']: `${pillOffset}px`,
|
|
135
|
-
zIndex:
|
|
136
|
-
}, children:
|
|
137
|
-
...axisPillStyle(theme, cursorColor),
|
|
138
|
-
left: `${cursorX}px`,
|
|
139
|
-
transform: 'translateX(-50%)',
|
|
140
|
-
[onTop ? 'bottom' : 'top']: `${pillOffset}px`,
|
|
141
|
-
zIndex: 3,
|
|
142
|
-
}, children: fmt(+xScale.invert(cursorX)) }))] }));
|
|
191
|
+
zIndex: 3,
|
|
192
|
+
}, children: fmt(+xScale.invert(cursorX)) })] }))] }));
|
|
143
193
|
}
|
|
144
194
|
//# sourceMappingURL=XAxis.js.map
|
package/dist/annotations.d.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import { type AnnotationSpec } from './context.js';
|
|
1
|
+
import { type AnnotationSpec, type LabelPlacement } from './context.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Lane placement for the **top-flag** labels (markers + regions). Returns, per
|
|
4
|
+
* slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
|
|
5
|
+
* don't participate.
|
|
6
|
+
*
|
|
7
|
+
* Three behaviours:
|
|
8
|
+
* - **Per row** — labels only contend within their own row's top space (a
|
|
9
|
+
* bottom-row label at the same x as a top-row one isn't "in the way").
|
|
10
|
+
* - **Coincident markers merge** — labelled markers at the *same x* (e.g. dragged
|
|
11
|
+
* together, snapped onto one line) fold into a **single** chip (`"z1, z2, max"`)
|
|
12
|
+
* on one lane, rather than stacking three deep. The first is the representative
|
|
13
|
+
* (its `label` is the joined text); the rest map to `label: null`.
|
|
14
|
+
* - **Greedy stacking** — non-coincident labels that would still overlap drop to
|
|
15
|
+
* the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
|
|
16
|
+
* label) so the static labels hold their lanes as it crosses them.
|
|
8
17
|
*/
|
|
9
|
-
export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number): Map<symbol,
|
|
18
|
+
export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
|
|
10
19
|
/**
|
|
11
20
|
* Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
|
|
12
21
|
* the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
|
package/dist/annotations.js
CHANGED
|
@@ -151,38 +151,93 @@ const LANE_H = 22;
|
|
|
151
151
|
const LABEL_CHAR_W = 7;
|
|
152
152
|
const LABEL_PAD = 16;
|
|
153
153
|
const LANE_GAP = 6;
|
|
154
|
+
/** Rough chip width (px) for the overlap model. */
|
|
155
|
+
const labelWidth = (text) => text.length * LABEL_CHAR_W + LABEL_PAD;
|
|
154
156
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
157
|
+
* Lane placement for the **top-flag** labels (markers + regions). Returns, per
|
|
158
|
+
* slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
|
|
159
|
+
* don't participate.
|
|
160
|
+
*
|
|
161
|
+
* Three behaviours:
|
|
162
|
+
* - **Per row** — labels only contend within their own row's top space (a
|
|
163
|
+
* bottom-row label at the same x as a top-row one isn't "in the way").
|
|
164
|
+
* - **Coincident markers merge** — labelled markers at the *same x* (e.g. dragged
|
|
165
|
+
* together, snapped onto one line) fold into a **single** chip (`"z1, z2, max"`)
|
|
166
|
+
* on one lane, rather than stacking three deep. The first is the representative
|
|
167
|
+
* (its `label` is the joined text); the rest map to `label: null`.
|
|
168
|
+
* - **Greedy stacking** — non-coincident labels that would still overlap drop to
|
|
169
|
+
* the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
|
|
170
|
+
* label) so the static labels hold their lanes as it crosses them.
|
|
160
171
|
*/
|
|
161
|
-
export function computeLabelLanes(annotations, toPixel) {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
a.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
172
|
+
export function computeLabelLanes(annotations, toPixel, draggingKey) {
|
|
173
|
+
const out = new Map();
|
|
174
|
+
const byRow = new Map();
|
|
175
|
+
for (const a of annotations) {
|
|
176
|
+
if ((a.kind !== 'marker' && a.kind !== 'region') ||
|
|
177
|
+
a.label.length === 0 ||
|
|
178
|
+
a.xs.length === 0)
|
|
179
|
+
continue;
|
|
180
|
+
const arr = byRow.get(a.rowKey);
|
|
181
|
+
if (arr)
|
|
182
|
+
arr.push(a);
|
|
183
|
+
else
|
|
184
|
+
byRow.set(a.rowKey, [a]);
|
|
185
|
+
}
|
|
186
|
+
for (const specs of byRow.values()) {
|
|
187
|
+
const flags = [];
|
|
188
|
+
const markerGroups = new Map();
|
|
189
|
+
for (const a of specs) {
|
|
190
|
+
if (a.kind === 'marker' && a.key !== draggingKey) {
|
|
191
|
+
const g = markerGroups.get(a.xs[0]);
|
|
192
|
+
if (g)
|
|
193
|
+
g.push(a);
|
|
194
|
+
else
|
|
195
|
+
markerGroups.set(a.xs[0], [a]);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
|
|
199
|
+
flags.push({
|
|
200
|
+
rep: a.key,
|
|
201
|
+
members: [a.key],
|
|
202
|
+
left: toPixel(ax),
|
|
203
|
+
width: labelWidth(a.label),
|
|
204
|
+
label: a.label,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const [x, group] of markerGroups) {
|
|
209
|
+
const label = group.map((g) => g.label).join(', ');
|
|
210
|
+
flags.push({
|
|
211
|
+
rep: group[0].key,
|
|
212
|
+
members: group.map((g) => g.key),
|
|
213
|
+
left: toPixel(x),
|
|
214
|
+
width: labelWidth(label),
|
|
215
|
+
label,
|
|
216
|
+
});
|
|
181
217
|
}
|
|
182
|
-
|
|
183
|
-
|
|
218
|
+
const assign = (f, lane) => {
|
|
219
|
+
out.set(f.rep, { lane, label: f.label });
|
|
220
|
+
for (const m of f.members)
|
|
221
|
+
if (m !== f.rep)
|
|
222
|
+
out.set(m, { lane, label: null });
|
|
223
|
+
};
|
|
224
|
+
// Greedy-pack the static flags; the dragged one is pinned to lane 0.
|
|
225
|
+
const dragged = flags.filter((f) => f.members.length === 1 && f.members[0] === draggingKey);
|
|
226
|
+
flags
|
|
227
|
+
.filter((f) => !dragged.includes(f))
|
|
228
|
+
.sort((p, q) => p.left - q.left)
|
|
229
|
+
.reduce((laneEnds, f) => {
|
|
230
|
+
let lane = 0;
|
|
231
|
+
while (lane < laneEnds.length && laneEnds[lane] + LANE_GAP > f.left)
|
|
232
|
+
lane += 1;
|
|
233
|
+
laneEnds[lane] = f.left + f.width;
|
|
234
|
+
assign(f, lane);
|
|
235
|
+
return laneEnds;
|
|
236
|
+
}, []);
|
|
237
|
+
for (const f of dragged)
|
|
238
|
+
assign(f, 0);
|
|
184
239
|
}
|
|
185
|
-
return
|
|
240
|
+
return out;
|
|
186
241
|
}
|
|
187
242
|
/** A mark's hover state, synced both ways with the consumer. The effective hover
|
|
188
243
|
* is the local pointer hover **OR** the controlled `hovered` prop (so a legend row
|
|
@@ -259,7 +314,7 @@ function Pill({ cx, cy, w, h, color, }) {
|
|
|
259
314
|
* **plot-pixel** position on press (`onDragStart`) / move (`onDrag`). With editing
|
|
260
315
|
* off, press / move bubble so a pan reads straight through.
|
|
261
316
|
*/
|
|
262
|
-
function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onDragStart, onDrag, }) {
|
|
317
|
+
function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onDragStart, onDrag, onDragActive, }) {
|
|
263
318
|
const dragging = useRef(false);
|
|
264
319
|
// Tracks whether this press became a drag (moved past a few px) — a click that
|
|
265
320
|
// didn't drag selects instead of edits. Tracked in *both* modes so a pan-drag
|
|
@@ -284,6 +339,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
284
339
|
return; // edit off: let it bubble (pan reads through)
|
|
285
340
|
e.stopPropagation(); // claim the gesture — don't let the plot start a pan
|
|
286
341
|
dragging.current = true;
|
|
342
|
+
onDragActive?.(true);
|
|
287
343
|
onDragStart?.(p[0], p[1]);
|
|
288
344
|
try {
|
|
289
345
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
@@ -312,6 +368,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
312
368
|
/* ignore */
|
|
313
369
|
}
|
|
314
370
|
dragging.current = false;
|
|
371
|
+
onDragActive?.(false);
|
|
315
372
|
onHover(false);
|
|
316
373
|
},
|
|
317
374
|
// A system gesture takeover fires pointercancel, not pointerup — clear the
|
|
@@ -319,6 +376,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
319
376
|
onPointerCancel: (e) => {
|
|
320
377
|
if (!dragging.current)
|
|
321
378
|
return;
|
|
379
|
+
onDragActive?.(false);
|
|
322
380
|
try {
|
|
323
381
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
324
382
|
}
|
|
@@ -364,12 +422,20 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
364
422
|
const h = row.height;
|
|
365
423
|
const opacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
|
|
366
424
|
const showHandle = editable && (editing || hovering);
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
425
|
+
// Placement decides the lane + the chip text: `chipLabel` is this marker's own
|
|
426
|
+
// label, the merged `"a, b"` when coincident markers fold together, or `null`
|
|
427
|
+
// for a folded-in member (its chip is subsumed by the representative's).
|
|
428
|
+
const placement = container.labelLanes.get(selfKey);
|
|
429
|
+
const lane = placement?.lane ?? 0;
|
|
430
|
+
const chipLabel = placement?.label ?? null;
|
|
431
|
+
// The staff (vertical line) hangs from the top of its flag — so a flag stacked
|
|
432
|
+
// into a lower lane doesn't leave line poking above it. No label ⇒ full height.
|
|
433
|
+
const staffTop = text ? FLAG_TOP + lane * LANE_H : 0;
|
|
434
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: staffTop, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
|
|
435
|
+
+container.xScale.invert(px)) }))] }), chipLabel && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
370
436
|
top: `${FLAG_TOP + lane * LANE_H}px`,
|
|
371
437
|
...flagChipX(x, container.plotWidth),
|
|
372
|
-
}, children:
|
|
438
|
+
}, children: chipLabel }))] }));
|
|
373
439
|
}
|
|
374
440
|
/** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
|
|
375
441
|
* Its label anchors at the left, at the line's height. */
|
|
@@ -413,7 +479,7 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
|
|
|
413
479
|
const text = label === false ? '' : (label ?? (fmt ? fmt(value) : String(value)));
|
|
414
480
|
// Handle pill near the right end (clears the left-anchored label).
|
|
415
481
|
const handleX = w - 14;
|
|
416
|
-
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
482
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
417
483
|
top: `${y}px`,
|
|
418
484
|
[labelSide === 'right' ? 'right' : 'left']: '2px',
|
|
419
485
|
// `center` rides on the line; `above` sits its bottom edge on the line.
|
|
@@ -466,7 +532,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
466
532
|
const fillOpacity = ann.fillOpacity *
|
|
467
533
|
rampAt(FILL_MULT, bodyLevel(selectable, editable, hovering, selected));
|
|
468
534
|
const showHandles = editable && (editing || hovering);
|
|
469
|
-
const lane = container.labelLanes.get(selfKey) ?? 0;
|
|
535
|
+
const lane = container.labelLanes.get(selfKey)?.lane ?? 0;
|
|
470
536
|
// Body move-drag: capture the start position + pointer on press, then move by
|
|
471
537
|
// the TOTAL delta from there, so the *raw* position accumulates from a fixed
|
|
472
538
|
// origin. Snap is applied only to the output — never fed back into this
|
|
@@ -478,7 +544,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
478
544
|
// region the other way instead of dead-ending at zero width.
|
|
479
545
|
const edgeRef = useRef(null);
|
|
480
546
|
const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
|
|
481
|
-
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edges && edge(xa), edges && edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: (px) => {
|
|
547
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edges && edge(xa), edges && edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: (px) => {
|
|
482
548
|
dragRef.current = { from, to, startPx: px };
|
|
483
549
|
}, onDrag: (px) => {
|
|
484
550
|
const s = dragRef.current;
|
|
@@ -503,10 +569,10 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
503
569
|
nt = st;
|
|
504
570
|
}
|
|
505
571
|
onChange?.({ from: nf, to: nt });
|
|
506
|
-
} }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: () => {
|
|
572
|
+
} }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
|
|
507
573
|
edgeRef.current = to; // the fixed pivot = the far edge
|
|
508
574
|
}, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
|
|
509
|
-
+container.xScale.invert(px), edgeRef.current ?? to)) }), _jsx(DragArea, { x: xb - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: () => {
|
|
575
|
+
+container.xScale.invert(px), edgeRef.current ?? to)) }), _jsx(DragArea, { x: xb - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
|
|
510
576
|
edgeRef.current = from; // the fixed pivot = the near edge
|
|
511
577
|
}, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
|
|
512
578
|
+container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
package/dist/context.d.ts
CHANGED
|
@@ -11,6 +11,14 @@ import type { AxisFormat } from './format.js';
|
|
|
11
11
|
* time→pixel `xScale` follow. Y scales stay per-row (row-local data), on the
|
|
12
12
|
* {@link RowFrame}.
|
|
13
13
|
*/
|
|
14
|
+
/** Where a top-flag label sits: its lane (0 = top; overlapping labels stack
|
|
15
|
+
* down) and the chip text to render — the merged label for the representative of
|
|
16
|
+
* a coincident-marker group, `null` for the members folded into it, else the
|
|
17
|
+
* mark's own label. Computed by `computeLabelLanes`. */
|
|
18
|
+
export interface LabelPlacement {
|
|
19
|
+
readonly lane: number;
|
|
20
|
+
readonly label: string | null;
|
|
21
|
+
}
|
|
14
22
|
export interface ContainerFrame {
|
|
15
23
|
readonly timeRange: readonly [number, number];
|
|
16
24
|
readonly width: number;
|
|
@@ -39,6 +47,22 @@ export interface ContainerFrame {
|
|
|
39
47
|
readonly cursorX: number | null;
|
|
40
48
|
/** Set the hovered plot-pixel x; a row's event surface calls this on pointer move. */
|
|
41
49
|
setHoverX(x: number | null): void;
|
|
50
|
+
/**
|
|
51
|
+
* The hovered plot-pixel **y** and the row it's in — for the free-form
|
|
52
|
+
* crosshair's horizontal line + value readout (which are row-specific, unlike
|
|
53
|
+
* the shared vertical `cursorX`). `null` when not hovering a plot. Hover-driven
|
|
54
|
+
* only (no controlled equivalent).
|
|
55
|
+
*/
|
|
56
|
+
readonly cursorY: number | null;
|
|
57
|
+
readonly cursorRowKey: symbol | null;
|
|
58
|
+
/** Set the hovered plot-pixel y + its row; the event surface calls this on move. */
|
|
59
|
+
setHoverY(y: number | null, rowKey: symbol | null): void;
|
|
60
|
+
/**
|
|
61
|
+
* `cursor="crosshair"` **y** snapping. **Default `true`** — the reticle centres
|
|
62
|
+
* on the nearest data point's value. `false` — the y follows the pointer freely
|
|
63
|
+
* (`yScale.invert`). The x always snaps to the data grid either way.
|
|
64
|
+
*/
|
|
65
|
+
readonly crosshairSnap: boolean;
|
|
42
66
|
/**
|
|
43
67
|
* The selected mark, or `null`. Shared across rows (single selection). A layer
|
|
44
68
|
* highlights the mark matching **both** the key (epoch ms) and the series
|
|
@@ -146,10 +170,20 @@ export interface ContainerFrame {
|
|
|
146
170
|
/** Every registered annotation — read by each row to draw the *other* rows'
|
|
147
171
|
* guides, and by a drag to find snap targets. */
|
|
148
172
|
readonly annotations: readonly AnnotationSpec[];
|
|
149
|
-
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
|
|
173
|
+
/** Per-key top-flag {@link LabelPlacement} — the lane (0 = top; overlapping
|
|
174
|
+
* labels stack down) + the chip text (merged for the representative of a
|
|
175
|
+
* coincident-marker group, `null` for the folded-in members). A key absent
|
|
176
|
+
* from the map sits at lane 0 with its own label. */
|
|
177
|
+
readonly labelLanes: ReadonlyMap<symbol, LabelPlacement>;
|
|
178
|
+
/**
|
|
179
|
+
* The annotation currently being **dragged** (its slot key), or `null`. Set on
|
|
180
|
+
* drag-start, cleared on release. The lane packers (label lanes + x-axis pill
|
|
181
|
+
* lanes) exclude it so the *static* marks don't reshuffle as the dragged one
|
|
182
|
+
* crosses them — only the mark under the pointer moves; it settles on release.
|
|
183
|
+
*/
|
|
184
|
+
readonly draggingKey: symbol | null;
|
|
185
|
+
/** Mark/clear the actively-dragged annotation; a mark's drag calls this. */
|
|
186
|
+
setDragging(key: symbol | null): void;
|
|
153
187
|
/**
|
|
154
188
|
* The armed creation tool, or `null` (idle). Set by the consumer's toolbar;
|
|
155
189
|
* when non-null the plot captures a **create gesture** (draw a new mark) instead
|
package/dist/tracker.js
CHANGED
|
@@ -25,9 +25,10 @@ export function cursorParts(mode) {
|
|
|
25
25
|
case 'flag':
|
|
26
26
|
return { line: false, dots: true, chip: 'flag' };
|
|
27
27
|
case 'crosshair':
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
28
|
+
// A single reticle (not per-series): `Layers` draws the dashed vertical +
|
|
29
|
+
// full-width horizontal lines, the centre dot, and one value pill itself
|
|
30
|
+
// (so no generic line/dots here); the x-time pill is on `<XAxis>`.
|
|
31
|
+
return { line: false, dots: false, chip: 'axis' };
|
|
31
32
|
case 'none':
|
|
32
33
|
return { line: false, dots: false, chip: 'none' };
|
|
33
34
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/charts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@pond-ts/react": "^0.
|
|
42
|
-
"pond-ts": "^0.
|
|
41
|
+
"@pond-ts/react": "^0.39.0",
|
|
42
|
+
"pond-ts": "^0.39.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|