@pond-ts/charts 0.37.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 +103 -3
- package/dist/ChartContainer.d.ts +11 -1
- package/dist/ChartContainer.js +20 -2
- package/dist/ChartRow.js +10 -0
- package/dist/Layers.js +148 -25
- package/dist/XAxis.js +99 -2
- package/dist/annotations.d.ts +41 -10
- package/dist/annotations.js +130 -46
- package/dist/chip.d.ts +36 -0
- package/dist/chip.js +85 -1
- package/dist/context.d.ts +50 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/indicators.d.ts +105 -0
- package/dist/indicators.js +114 -0
- package/dist/tracker.d.ts +1 -1
- package/dist/tracker.js +5 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,9 @@ 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
|
|
13
|
+
[0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
|
|
12
14
|
[0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
|
|
13
15
|
[0.36.0]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...v0.36.0
|
|
14
16
|
[0.35.0]: https://github.com/pjm17971/pond-ts/compare/v0.34.1...v0.35.0
|
|
@@ -33,6 +35,104 @@ patch bumps are strictly additive.
|
|
|
33
35
|
[0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
|
|
34
36
|
[0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
|
|
35
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
|
+
|
|
93
|
+
## [0.38.0] — 2026-07-03
|
|
94
|
+
|
|
95
|
+
A `@pond-ts/charts` release: **axis-edge value indicators + the crosshair
|
|
96
|
+
cursor** — the ChartIQ / Yahoo-Finance price-tag family, driven by the Tidal
|
|
97
|
+
terminal. `pond-ts`, `@pond-ts/react`, and `@pond-ts/fit` carry no code changes —
|
|
98
|
+
republished in lock-step (peer ranges widen to `^0.38.0`).
|
|
99
|
+
|
|
100
|
+
### Added
|
|
101
|
+
|
|
102
|
+
- `@pond-ts/charts`: **`<YAxisIndicator>` + `createLiveValue`** — a value pill
|
|
103
|
+
pinned to a y-axis edge, decoupled from the series' last point. A `LiveValue`
|
|
104
|
+
`source` updates it at high frequency **without re-rendering the chart** (only
|
|
105
|
+
the subscribed pill repaints). Props: `value` / `source`, `axis`, `side`,
|
|
106
|
+
`color`, `format`, `line` (dashed guide), `pointer` (callout triangle).
|
|
107
|
+
- `@pond-ts/charts`: **`cursor="crosshair"`** `CursorMode` — a synced vertical
|
|
108
|
+
line + per-series dots, each series' value pinned to its y-axis and the hovered
|
|
109
|
+
time pinned to the x-axis.
|
|
110
|
+
- `@pond-ts/charts`: **`indicator`** opt-in on `<Baseline>` (a y-axis value pill)
|
|
111
|
+
and `<Marker>` (an x-axis time pill, with a connector down to the mark).
|
|
112
|
+
- `@pond-ts/charts`: `<Baseline labelSide>` (`left` / `right`) + `labelPosition`
|
|
113
|
+
(`center` on the line / `above` it) for the near-line label chip.
|
|
114
|
+
- `@pond-ts/charts`: `<Region edges>` (default `true`; `false` = shaded fill with
|
|
115
|
+
no side outlines).
|
|
116
|
+
- `@pond-ts/charts`: `axisPillStyle`, `contrastText`, `pointerStyle` chip helpers
|
|
117
|
+
are exported.
|
|
118
|
+
|
|
119
|
+
### Changed
|
|
120
|
+
|
|
121
|
+
- `@pond-ts/charts`: axis indicator pills are **solid** (colour fill +
|
|
122
|
+
auto-contrast text), aligned to the tick-label row, and **always show the axis
|
|
123
|
+
coordinate** — never a custom label (a label stays the in-plot chip).
|
|
124
|
+
- `@pond-ts/charts`: cursor flag / inline chips now have **square corners**; the
|
|
125
|
+
cursor **time** atop a flag stack renders as plain text (no chip background).
|
|
126
|
+
- `@pond-ts/charts`: Storybook reorganized into a feature-axis reference tree with
|
|
127
|
+
systematic per-prop coverage (dev-only; stories are excluded from the package).
|
|
128
|
+
|
|
129
|
+
### Fixed
|
|
130
|
+
|
|
131
|
+
- `@pond-ts/charts`: the crosshair x-axis pill used the container's time formatter
|
|
132
|
+
(showing a raw number on a value axis) — it now uses the axis's own resolved
|
|
133
|
+
formatter, matching the ticks. The crosshair also no longer double-renders the
|
|
134
|
+
time (a stray per-row chip alongside the x-axis pill).
|
|
135
|
+
|
|
36
136
|
## [0.37.0] — 2026-07-02
|
|
37
137
|
|
|
38
138
|
A `@pond-ts/charts` release: the axis wave — label, tick, and domain controls
|
|
@@ -110,7 +210,7 @@ lock-step (their `pond-ts` / `@pond-ts/react` peer ranges widen to `^0.35.0`).
|
|
|
110
210
|
(`[6, 4]` dashed, `[2, 3]` ≈ dotted; omit or `[]` = solid) applied to the
|
|
111
211
|
series stroke. Lets a theme set a **modeled / forecast** line (e.g. GARCH
|
|
112
212
|
vol) apart from an observed one at a glance. Distinct from a `GapMode`'s
|
|
113
|
-
inferred gap-bridge dashing (which marks
|
|
213
|
+
inferred gap-bridge dashing (which marks _missing data_, not the whole
|
|
114
214
|
line). Additive: existing themes are unaffected; a solid line never touches
|
|
115
215
|
`setLineDash`. New `Charts/LineChart → LineStyles` story. (#313)
|
|
116
216
|
|
|
@@ -149,7 +249,7 @@ Tidal wire-format spike. `@pond-ts/react`, `@pond-ts/charts`, and
|
|
|
149
249
|
- **`TimeSeries.fromColumns`** — the columnar (struct-of-arrays) ingress,
|
|
150
250
|
the counterpart to `fromJSON`'s row-tuple shape. Accepts either a plain
|
|
151
251
|
`number[]` or a `Float64Array` per column — one polymorphic door, so a
|
|
152
|
-
wire format only changes the
|
|
252
|
+
wire format only changes the _decoder_, not the ingest. `Float64Array`
|
|
153
253
|
columns are adopted directly (zero-copy); `number[]` columns are copied.
|
|
154
254
|
A `null`/`undefined` cell or a non-finite value (`NaN`/`Infinity`) is a
|
|
155
255
|
gap, identically across both input shapes. Enforces the same
|
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/ChartRow.js
CHANGED
|
@@ -179,6 +179,14 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
179
179
|
}
|
|
180
180
|
return map;
|
|
181
181
|
}, [effectiveAxes]);
|
|
182
|
+
// Which side each axis sits on — so an axis-edge overlay (the crosshair's
|
|
183
|
+
// value pills) hugs the correct gutter without re-deriving from the specs.
|
|
184
|
+
const axisSides = useMemo(() => {
|
|
185
|
+
const map = new Map();
|
|
186
|
+
for (const ax of effectiveAxes)
|
|
187
|
+
map.set(ax.id, ax.side);
|
|
188
|
+
return map;
|
|
189
|
+
}, [effectiveAxes]);
|
|
182
190
|
const frame = useMemo(() => ({
|
|
183
191
|
height,
|
|
184
192
|
cursor,
|
|
@@ -187,6 +195,7 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
187
195
|
yScales,
|
|
188
196
|
formats,
|
|
189
197
|
tickValues,
|
|
198
|
+
axisSides,
|
|
190
199
|
defaultAxisId,
|
|
191
200
|
axisSlots,
|
|
192
201
|
registerAxis,
|
|
@@ -202,6 +211,7 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
202
211
|
yScales,
|
|
203
212
|
formats,
|
|
204
213
|
tickValues,
|
|
214
|
+
axisSides,
|
|
205
215
|
defaultAxisId,
|
|
206
216
|
axisSlots,
|
|
207
217
|
registerAxis,
|
package/dist/Layers.js
CHANGED
|
@@ -5,7 +5,7 @@ import { drawGrid } from './grid.js';
|
|
|
5
5
|
import { cursorParts } from './tracker.js';
|
|
6
6
|
import { resolveSelection } from './select.js';
|
|
7
7
|
import { panRange, zoomRange } from './viewport.js';
|
|
8
|
-
import { flagChipStyle, flagChipX } from './chip.js';
|
|
8
|
+
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
9
9
|
import { ContainerContext, LayersContext, RowContext, } from './context.js';
|
|
10
10
|
/** Gridline tick count — matches the axes (`YAxis`/`TimeAxis`) so they align. */
|
|
11
11
|
const GRID_TICKS = 5;
|
|
@@ -49,7 +49,7 @@ export function Layers({ children }) {
|
|
|
49
49
|
}), [row.registerLayer, row.unregisterLayer]);
|
|
50
50
|
const background = container.theme.background;
|
|
51
51
|
const { grid: gridColor, gridDash } = container.theme.axis;
|
|
52
|
-
const { layers, yScales, formats, defaultAxisId, tickValues } = row;
|
|
52
|
+
const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
|
|
53
53
|
// x geometry is shared and lives on the container (uniform across rows).
|
|
54
54
|
const { xScale, plotWidth } = container;
|
|
55
55
|
const draw = useCallback((ctx, w, h) => {
|
|
@@ -132,6 +132,8 @@ export function Layers({ children }) {
|
|
|
132
132
|
// The chip uses this layer's axis formatter, so a readout value reads
|
|
133
133
|
// exactly as the axis labels it.
|
|
134
134
|
const fmt = formats.get(axisId) ?? String;
|
|
135
|
+
// Which gutter the crosshair value pill hugs (the axis's own side).
|
|
136
|
+
const side = axisSides.get(axisId) ?? 'left';
|
|
135
137
|
for (const s of entry.layer.sampleAt(cursorTime)) {
|
|
136
138
|
out.push({
|
|
137
139
|
px: xScale(s.x),
|
|
@@ -139,6 +141,7 @@ export function Layers({ children }) {
|
|
|
139
141
|
value: s.value,
|
|
140
142
|
color: s.color,
|
|
141
143
|
format: fmt,
|
|
144
|
+
side,
|
|
142
145
|
});
|
|
143
146
|
}
|
|
144
147
|
}
|
|
@@ -148,6 +151,7 @@ export function Layers({ children }) {
|
|
|
148
151
|
layers,
|
|
149
152
|
yScales,
|
|
150
153
|
formats,
|
|
154
|
+
axisSides,
|
|
151
155
|
xScale,
|
|
152
156
|
defaultAxisId,
|
|
153
157
|
parts.dots,
|
|
@@ -230,17 +234,23 @@ export function Layers({ children }) {
|
|
|
230
234
|
if (!c.panZoom)
|
|
231
235
|
return;
|
|
232
236
|
const r = c.timeRange;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
};
|
|
244
254
|
}, []);
|
|
245
255
|
const handlePointerMove = useCallback((e) => {
|
|
246
256
|
const c = containerRef.current;
|
|
@@ -251,6 +261,16 @@ export function Layers({ children }) {
|
|
|
251
261
|
c.setHoverX(px); // share the preview x so other rows draw a guide there
|
|
252
262
|
return;
|
|
253
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;
|
|
254
274
|
const drag = dragRef.current;
|
|
255
275
|
if (drag) {
|
|
256
276
|
// Pan from the start range by the total drag — right → earlier (−dt).
|
|
@@ -259,21 +279,61 @@ export function Layers({ children }) {
|
|
|
259
279
|
// the view nor shifts the scale the click then hit-tests against.
|
|
260
280
|
if (Math.abs(dx) <= DRAG_SLOP)
|
|
261
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
|
+
}
|
|
262
299
|
const span = drag.startRange[1] - drag.startRange[0];
|
|
263
300
|
const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
|
|
264
301
|
c.applyRange(panRange(drag.startRange, dt));
|
|
265
302
|
return; // tracker suppressed during a pan
|
|
266
303
|
}
|
|
267
304
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
268
|
-
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
|
+
}
|
|
269
329
|
c.setHoverX(px);
|
|
330
|
+
c.setHoverY(py, r.rowKey);
|
|
270
331
|
// Hover-highlight: hit-test the row's selectable layers (Bar) under the
|
|
271
332
|
// pointer and set the hovered mark. Deduped in the container, so the data
|
|
272
333
|
// canvas repaints only on a mark transition — not every move (the move just
|
|
273
334
|
// slides the SVG cursor). A row with no selectable layer (line/area/band)
|
|
274
|
-
// resolves to null → a no-op.
|
|
275
|
-
const
|
|
276
|
-
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));
|
|
277
337
|
c.setHovered(hit);
|
|
278
338
|
}, []);
|
|
279
339
|
const handlePointerUp = useCallback((e) => {
|
|
@@ -319,13 +379,18 @@ export function Layers({ children }) {
|
|
|
319
379
|
}
|
|
320
380
|
return;
|
|
321
381
|
}
|
|
322
|
-
|
|
382
|
+
const drag = dragRef.current;
|
|
383
|
+
if (drag) {
|
|
323
384
|
dragRef.current = null;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}
|
|
329
394
|
}
|
|
330
395
|
}
|
|
331
396
|
}, []);
|
|
@@ -337,9 +402,11 @@ export function Layers({ children }) {
|
|
|
337
402
|
drawFromRef.current = null;
|
|
338
403
|
setDrawFrom(null);
|
|
339
404
|
c.setHoverX(null);
|
|
405
|
+
c.setHoverY(null, null);
|
|
340
406
|
return;
|
|
341
407
|
}
|
|
342
408
|
c.setHoverX(null);
|
|
409
|
+
c.setHoverY(null, null);
|
|
343
410
|
c.setHovered(null);
|
|
344
411
|
}, []);
|
|
345
412
|
// Click selection: ignore the click that ends a drag/pan (moved past a few px),
|
|
@@ -403,9 +470,13 @@ export function Layers({ children }) {
|
|
|
403
470
|
// The time is shared across rows (one cursor, one time), so it shows **once**,
|
|
404
471
|
// atop the first row — not repeated per row. (Gating it here also drops the
|
|
405
472
|
// top-of-stack space reservation on the other rows, see `flagBase`.)
|
|
473
|
+
// Crosshair (`chip: 'axis'`) is excluded: it pins the time to the shared x-axis
|
|
474
|
+
// pill (`<XAxis>`), so a per-row chip here would double it (and land wrong on a
|
|
475
|
+
// stacked row).
|
|
406
476
|
const showTime = showCursorTime &&
|
|
407
477
|
cursorTime !== null &&
|
|
408
478
|
(parts.line || parts.dots) &&
|
|
479
|
+
parts.chip !== 'axis' &&
|
|
409
480
|
row.isFirstRow;
|
|
410
481
|
// Flag geometry: each value flies as a flag from the top of its own staff — the
|
|
411
482
|
// chip's top sits at `flagBase` (just below the time chip when shown) and the
|
|
@@ -419,6 +490,48 @@ export function Layers({ children }) {
|
|
|
419
490
|
const timeX = parts.chip === 'flag' && trackerSamples.length > 0
|
|
420
491
|
? trackerSamples[0].px
|
|
421
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
|
+
})();
|
|
422
535
|
// Cross-row guide lines: the x-positions of annotations on the OTHER rows
|
|
423
536
|
// (markers + region edges), so a mark on one row reads against this row's data +
|
|
424
537
|
// the shared x axis. A mark's own row skips itself; baselines cast no vertical
|
|
@@ -491,9 +604,14 @@ export function Layers({ children }) {
|
|
|
491
604
|
cursorX >= 0 &&
|
|
492
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' &&
|
|
493
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' &&
|
|
494
|
-
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.
|
|
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' &&
|
|
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 &&
|
|
495
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: {
|
|
496
612
|
...chipStyle,
|
|
613
|
+
background: 'transparent',
|
|
614
|
+
padding: 0,
|
|
497
615
|
top: `${flagTop}px`,
|
|
498
616
|
left: timeX > plotWidth * LABEL_FLIP_FRACTION
|
|
499
617
|
? undefined
|
|
@@ -518,7 +636,12 @@ export function Layers({ children }) {
|
|
|
518
636
|
right: flip ? `${plotWidth - s.px + 8}px` : undefined,
|
|
519
637
|
color: s.color,
|
|
520
638
|
}, children: s.format(s.value) }, i));
|
|
521
|
-
}),
|
|
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' &&
|
|
522
645
|
cursorX !== null &&
|
|
523
646
|
trackerSamples.map((s, i) => (
|
|
524
647
|
// The flag flies from the top of its staff — chip top at the staff top
|