@pond-ts/charts 0.39.0 → 0.40.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 +41 -14
- package/dist/AreaChart.d.ts +4 -0
- package/dist/BandChart.d.ts +4 -0
- package/dist/BarChart.d.ts +10 -0
- package/dist/BarChart.js +6 -0
- package/dist/ChartContainer.d.ts +26 -1
- package/dist/ChartContainer.js +29 -11
- package/dist/ChartRow.js +78 -2
- package/dist/LineChart.d.ts +5 -0
- package/dist/YAxis.d.ts +10 -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.40.0...HEAD
|
|
12
|
+
[0.40.0]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...v0.40.0
|
|
12
13
|
[0.39.0]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...v0.39.0
|
|
13
14
|
[0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
|
|
14
15
|
[0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
|
|
@@ -37,21 +38,34 @@ patch bumps are strictly additive.
|
|
|
37
38
|
|
|
38
39
|
## [Unreleased]
|
|
39
40
|
|
|
41
|
+
## [0.40.0] — 2026-07-05
|
|
42
|
+
|
|
43
|
+
A **core + charts** release from the estela `DataChart`-port friction wave.
|
|
44
|
+
`@pond-ts/react` and `@pond-ts/fit` carry no code changes — republished in
|
|
45
|
+
lock-step (peer ranges widen to `^0.40.0`).
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- `pond-ts`: **`TimeSeries.fromColumns({ sort })`** — an opt-in `sort?: boolean`
|
|
50
|
+
(default `false`) that stable-sorts a columnar payload by key before
|
|
51
|
+
construction, the columnar counterpart of `fromJSON`'s `sort`. The default path
|
|
52
|
+
is unchanged: a decreasing key still throws (a backwards key on the trusted fast
|
|
53
|
+
door is a corruption signal, not silently accepted), and the `Float64Array`
|
|
54
|
+
zero-copy adoption is preserved when `sort` is unset. (#344)
|
|
55
|
+
- `@pond-ts/charts`: **controlled bar hover** — `<ChartContainer hovered
|
|
56
|
+
onHover>`, the transient-hover analog of the existing `selected` / `onSelect`
|
|
57
|
+
pair, keyed by the same `SelectInfo`. Pin a lit `<BarChart>` bar from a legend
|
|
58
|
+
or list row (`hovered`), or mirror a bar-originated hover out-of-band
|
|
59
|
+
(`onHover`); omit both for today's uncontrolled behavior. (#343)
|
|
60
|
+
|
|
40
61
|
### Fixed
|
|
41
62
|
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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)
|
|
63
|
+
- `@pond-ts/charts`: **axis and layer registration are value-equality-guarded** —
|
|
64
|
+
a fresh-but-value-equal `ticks` / `format` / `byValue()`-projected `series`
|
|
65
|
+
reference no longer re-registers the axis/layer, fixing a "Maximum update depth
|
|
66
|
+
exceeded" loop on frequently re-rendering (scrub-driven) charts. The layer and
|
|
67
|
+
axis docs gain a memoize note for `format` / `series` (an inline `format`
|
|
68
|
+
closure still must be hoisted — a closure can't be value-compared). (#342)
|
|
55
69
|
|
|
56
70
|
## [0.39.0] — 2026-07-03
|
|
57
71
|
|
|
@@ -89,6 +103,19 @@ carry no code changes — republished in lock-step (peer ranges widen to `^0.39.
|
|
|
89
103
|
|
|
90
104
|
- `@pond-ts/charts`: the crosshair x-axis pill and marker pills read the axis's
|
|
91
105
|
own formatter (a value-axis / off-boundary time no longer shows a raw number).
|
|
106
|
+
- **Charts — click-to-select an annotation now works while `panZoom` is on.** A
|
|
107
|
+
_selectable but non-editable_ `<Region>` / `<Marker>` (one with no `onChange`)
|
|
108
|
+
lets its press bubble to the plot so a drag can pan _through_ it. The plot
|
|
109
|
+
captured the pointer on press to start the pan, and the browser then retargeted
|
|
110
|
+
the resulting `click` onto the plot (Pointer Events spec: a captured pointer's
|
|
111
|
+
compatibility mouse events fire on the capture target) — silently dropping the
|
|
112
|
+
mark's `onSelectAnnotation`. The plot now **defers** its pan pointer-capture
|
|
113
|
+
until the pointer actually moves past the drag slop, so a click (no drag) leaves
|
|
114
|
+
the pointer on the mark and its select fires, while a press-drag still pans
|
|
115
|
+
through and the tracker still hides once the pan commits. Resolves the
|
|
116
|
+
browser-dependent finding deferred from #308; adds
|
|
117
|
+
`e2e/annotations-panzoom.spec.ts`, the first real-pointer-event behavior e2e for
|
|
118
|
+
the annotation layer. (#309)
|
|
92
119
|
|
|
93
120
|
## [0.38.0] — 2026-07-03
|
|
94
121
|
|
package/dist/AreaChart.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extend
|
|
|
8
8
|
* `ValueSeries` (`series.byValue('dist')`) against its value axis — the
|
|
9
9
|
* container infers which from the data, no axis-type prop (mirrors
|
|
10
10
|
* `<LineChart>`). Either way `column` names the numeric value to fill from.
|
|
11
|
+
*
|
|
12
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
|
|
13
|
+
* an inline `series={s.byValue('dist')}` re-registers this layer every render —
|
|
14
|
+
* on a frequently re-rendering chart, memoize the projection (`useMemo`).
|
|
11
15
|
*/
|
|
12
16
|
series: TimeSeries<S> | ValueSeries<VS>;
|
|
13
17
|
/** Name of the numeric value column to fill from. */
|
package/dist/BandChart.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface BandChartProps<S extends SeriesSchema = SeriesSchema, VS extend
|
|
|
8
8
|
* container infers which from the data, no axis-type prop (mirrors
|
|
9
9
|
* `<LineChart>` / `<AreaChart>`). Either way `lower`/`upper` name the numeric
|
|
10
10
|
* edge columns.
|
|
11
|
+
*
|
|
12
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
|
|
13
|
+
* an inline `series={s.byValue('dist')}` re-registers this layer every render —
|
|
14
|
+
* on a frequently re-rendering chart, memoize the projection (`useMemo`).
|
|
11
15
|
*/
|
|
12
16
|
series: TimeSeries<S> | ValueSeries<VS>;
|
|
13
17
|
/** Name of the numeric column for the band's lower edge (e.g. `p25`). */
|
package/dist/BarChart.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
|
|
|
10
10
|
* the same neighbour-spacing span applies (see {@link barsFromValueSeries}); the
|
|
11
11
|
* container infers the x-kind from the data, no axis-type prop (mirrors the
|
|
12
12
|
* other layers).
|
|
13
|
+
*
|
|
14
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
|
|
15
|
+
* an inline `series={s.byValue('dist')}` re-registers this layer every render —
|
|
16
|
+
* on a frequently re-rendering chart, memoize the projection (`useMemo`).
|
|
13
17
|
*/
|
|
14
18
|
series: TimeSeries<S> | ValueSeries<VS>;
|
|
15
19
|
/** Name of the numeric value column for the bar height. */
|
|
@@ -63,6 +67,12 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
|
|
|
63
67
|
* readout reads the same bar you click, even across a wide bucket (they differ
|
|
64
68
|
* only by the `gap` inset, where the pixel rect is narrower than the span).
|
|
65
69
|
*
|
|
70
|
+
* Both channels are also **controllable from outside** the chart via the
|
|
71
|
+
* container: `selected`/`onSelect` (committed) and `hovered`/`onHover` (transient)
|
|
72
|
+
* — pass either to pin the lit/selected bar from a legend or list row, and read
|
|
73
|
+
* the callback to mirror a bar-originated hover/click out-of-band. Symmetric pair,
|
|
74
|
+
* keyed by the same {@link SelectInfo} identity.
|
|
75
|
+
*
|
|
66
76
|
* **Value axis** — bars also scale on a value axis when fed a `ValueSeries`
|
|
67
77
|
* (`series.byValue('dist')`): estela's distance-domain splits/laps, one bar per
|
|
68
78
|
* segment over a monotonic axis. A `ValueSeries` is point-keyed, so the span is
|
package/dist/BarChart.js
CHANGED
|
@@ -26,6 +26,12 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
26
26
|
* readout reads the same bar you click, even across a wide bucket (they differ
|
|
27
27
|
* only by the `gap` inset, where the pixel rect is narrower than the span).
|
|
28
28
|
*
|
|
29
|
+
* Both channels are also **controllable from outside** the chart via the
|
|
30
|
+
* container: `selected`/`onSelect` (committed) and `hovered`/`onHover` (transient)
|
|
31
|
+
* — pass either to pin the lit/selected bar from a legend or list row, and read
|
|
32
|
+
* the callback to mirror a bar-originated hover/click out-of-band. Symmetric pair,
|
|
33
|
+
* keyed by the same {@link SelectInfo} identity.
|
|
34
|
+
*
|
|
29
35
|
* **Value axis** — bars also scale on a value axis when fed a `ValueSeries`
|
|
30
36
|
* (`series.byValue('dist')`): estela's distance-domain splits/laps, one bar per
|
|
31
37
|
* segment over a monotonic axis. A `ValueSeries` is point-keyed, so the span is
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -57,6 +57,31 @@ export interface ChartContainerProps {
|
|
|
57
57
|
* works in both controlled and uncontrolled mode.
|
|
58
58
|
*/
|
|
59
59
|
onSelect?: (hit: SelectInfo | null) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Controlled hover-highlight — the transiently lit mark (echo the `onHover` arg
|
|
62
|
+
* back), or `null`. **Omitted ⇒ uncontrolled** (the pointer over a selectable
|
|
63
|
+
* layer manages it internally). The hover analog of {@link selected}: pass it to
|
|
64
|
+
* **pin** a lit mark from outside the chart (e.g. hovering a legend / list row
|
|
65
|
+
* lights the matching {@link BarChart} bar). Only layers with a hover-highlight
|
|
66
|
+
* (currently `BarChart`) render it; keyed by the same {@link SelectInfo} identity
|
|
67
|
+
* as selection.
|
|
68
|
+
*/
|
|
69
|
+
hovered?: SelectInfo | null;
|
|
70
|
+
/**
|
|
71
|
+
* Fires when the pointer enters a selectable layer's mark (the hit mark) or
|
|
72
|
+
* leaves every mark (`null`) — the hover analog of {@link onSelect}. Notification
|
|
73
|
+
* only (works controlled or uncontrolled), and **deduped**: it fires on a mark
|
|
74
|
+
* transition, not on every pointer move. Wire it to mirror hover out-of-band
|
|
75
|
+
* (e.g. a list row ↔ the bar), pairing with {@link hovered} to sync both ways.
|
|
76
|
+
* (The annotation counterpart is {@link onHoverAnnotation}.)
|
|
77
|
+
*
|
|
78
|
+
* **Dedup key:** by the mark's `key` + `label` only (not `value`/`color`). So on
|
|
79
|
+
* a live chart where a bar's value changes while the cursor stays on it, this
|
|
80
|
+
* won't re-fire — read the current value from your series, not the last
|
|
81
|
+
* `onHover` payload. (Matches the internal hover-highlight, which repaints on
|
|
82
|
+
* key transitions.)
|
|
83
|
+
*/
|
|
84
|
+
onHover?: (hit: SelectInfo | null) => void;
|
|
60
85
|
/**
|
|
61
86
|
* Enable pan/zoom: drag the plot to pan the time range, wheel to zoom around
|
|
62
87
|
* the cursor. **Default off** — so it doesn't capture drag/scroll unless asked.
|
|
@@ -155,5 +180,5 @@ export interface ChartContainerProps {
|
|
|
155
180
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
156
181
|
* (`<YAxis>`).
|
|
157
182
|
*/
|
|
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;
|
|
183
|
+
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
159
184
|
//# 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, crosshairSnap = true, 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, 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, }) {
|
|
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.
|
|
@@ -175,18 +175,36 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
175
175
|
setInternalSelected(hit);
|
|
176
176
|
}, []);
|
|
177
177
|
// Hover-highlight: the transient mark under the pointer (distinct from the
|
|
178
|
-
// committed selection).
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
178
|
+
// committed selection). Controlled (`hovered` prop) or uncontrolled (internal),
|
|
179
|
+
// mirroring selection; `onHover` notifies in both modes. Deduped by key+label
|
|
180
|
+
// so it fires — and the data canvas repaints — only when the hovered mark
|
|
181
|
+
// changes, not on every pointer move (the move itself just slides the SVG
|
|
182
|
+
// cursor, which never touches the data canvas).
|
|
183
|
+
const [internalHovered, setInternalHovered] = useState(null);
|
|
184
|
+
const controlledHover = hovered !== undefined;
|
|
185
|
+
const hoveredValue = controlledHover ? (hovered ?? null) : internalHovered;
|
|
186
|
+
const onHoverRef = useRef(onHover);
|
|
187
|
+
const controlledHoverRef = useRef(controlledHover);
|
|
188
|
+
// The last mark we reported — so the callback dedups across pointer moves even
|
|
189
|
+
// in controlled mode, where there's no internal state to compare against.
|
|
190
|
+
const lastHoverRef = useRef(null);
|
|
191
|
+
useLayoutEffect(() => {
|
|
192
|
+
onHoverRef.current = onHover;
|
|
193
|
+
controlledHoverRef.current = controlledHover;
|
|
194
|
+
});
|
|
182
195
|
const setHovered = useCallback((hit) => {
|
|
183
|
-
|
|
196
|
+
const prev = lastHoverRef.current;
|
|
197
|
+
const same = prev === hit ||
|
|
184
198
|
(prev !== null &&
|
|
185
199
|
hit !== null &&
|
|
186
200
|
prev.key === hit.key &&
|
|
187
|
-
prev.label === hit.label)
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
prev.label === hit.label);
|
|
202
|
+
if (same)
|
|
203
|
+
return;
|
|
204
|
+
lastHoverRef.current = hit;
|
|
205
|
+
onHoverRef.current?.(hit);
|
|
206
|
+
if (!controlledHoverRef.current)
|
|
207
|
+
setInternalHovered(hit);
|
|
190
208
|
}, []);
|
|
191
209
|
// Rows report their per-slot gutter widths; we reserve each slot's max.
|
|
192
210
|
const [gutters, setGutters] = useState([]);
|
|
@@ -284,7 +302,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
284
302
|
setDragging,
|
|
285
303
|
selected: selectedValue,
|
|
286
304
|
select,
|
|
287
|
-
hovered,
|
|
305
|
+
hovered: hoveredValue,
|
|
288
306
|
setHovered,
|
|
289
307
|
cursor,
|
|
290
308
|
cursorTime,
|
|
@@ -329,7 +347,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
329
347
|
setDragging,
|
|
330
348
|
selectedValue,
|
|
331
349
|
select,
|
|
332
|
-
|
|
350
|
+
hoveredValue,
|
|
333
351
|
setHovered,
|
|
334
352
|
cursor,
|
|
335
353
|
cursorTime,
|
package/dist/ChartRow.js
CHANGED
|
@@ -9,6 +9,62 @@ import { YAxis } from './YAxis.js';
|
|
|
9
9
|
import { ContainerContext, RowContext, } from './context.js';
|
|
10
10
|
/** Sentinel id for the implicit axis a row gets when no `<YAxis>` is declared. */
|
|
11
11
|
const IMPLICIT_AXIS_ID = '__default__';
|
|
12
|
+
/** Element-wise compare of two optional number arrays (an axis's tick values) —
|
|
13
|
+
* so a *fresh* `ticks={[…]}` array whose contents are unchanged doesn't count as
|
|
14
|
+
* a new spec (see {@link axisSpecEqual}). */
|
|
15
|
+
function numberArraysEqual(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return true;
|
|
18
|
+
if (a === undefined || b === undefined)
|
|
19
|
+
return false;
|
|
20
|
+
if (a.length !== b.length)
|
|
21
|
+
return false;
|
|
22
|
+
for (let i = 0; i < a.length; i += 1)
|
|
23
|
+
if (!Object.is(a[i], b[i]))
|
|
24
|
+
return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Value-equality for two {@link AxisSpec}s — the registration guard's compare
|
|
29
|
+
* (see `registerAxis`). Every field is a plain value except `format`, which may
|
|
30
|
+
* be a `(value) => string` closure: those are compared by **reference**
|
|
31
|
+
* (`Object.is`), so a stable/hoisted formatter (or a string specifier) is equal
|
|
32
|
+
* across renders but a *fresh inline function* is not — the one case a structural
|
|
33
|
+
* guard provably can't collapse, hence the `<YAxis format>` memoize note. A
|
|
34
|
+
* fresh-but-value-equal `ticks` array (the common live-chart footgun) compares
|
|
35
|
+
* equal element-wise and no-ops.
|
|
36
|
+
*/
|
|
37
|
+
function axisSpecEqual(a, b) {
|
|
38
|
+
return (a.id === b.id &&
|
|
39
|
+
a.side === b.side &&
|
|
40
|
+
a.width === b.width &&
|
|
41
|
+
// Object.is (not ===) so a degenerate NaN bound compares equal to itself and
|
|
42
|
+
// doesn't re-register every render.
|
|
43
|
+
Object.is(a.min, b.min) &&
|
|
44
|
+
Object.is(a.max, b.max) &&
|
|
45
|
+
a.pad === b.pad &&
|
|
46
|
+
a.labelPlacement === b.labelPlacement &&
|
|
47
|
+
a.index === b.index &&
|
|
48
|
+
Object.is(a.format, b.format) &&
|
|
49
|
+
numberArraysEqual(a.tickValues, b.tickValues));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Value-equality for two {@link LayerEntry}s — **defensive, not load-bearing.**
|
|
53
|
+
* The axis guard (`axisSpecEqual`) is what breaks the update-depth loop; this is
|
|
54
|
+
* belt-and-suspenders for the layer setter. Note that under the current draw-layer
|
|
55
|
+
* structure it *won't* actually fire: every layer builds `layer` inside the same
|
|
56
|
+
* `useMemo` as `entry`, so the register effect only runs when `entry` is fresh —
|
|
57
|
+
* and a fresh `entry` always carries a fresh `layer`, so `a.layer === b.layer` is
|
|
58
|
+
* never true when the guard runs (it falls through to a normal register). It would
|
|
59
|
+
* only bite if a future layer memoized `layer` separately from `entry` (or the
|
|
60
|
+
* `Layers` registry ref changed under a stable entry). A fresh `series` projection
|
|
61
|
+
* (`byValue()` mints one each call) rebuilds the memo → a new `layer` and
|
|
62
|
+
* legitimately re-registers; that's a consumer-side memoize (see the `series` note
|
|
63
|
+
* on the draw-layer components), not something the setter can value-compare.
|
|
64
|
+
*/
|
|
65
|
+
function layerEntryEqual(a, b) {
|
|
66
|
+
return a.layer === b.layer && a.axisId === b.axisId && a.index === b.index;
|
|
67
|
+
}
|
|
12
68
|
/** Axis tick count for the per-axis formatter — matches `<YAxis>`'s tick count
|
|
13
69
|
* so the readout formatter is calibrated exactly as the labels are. */
|
|
14
70
|
const AXIS_TICK_COUNT = 5;
|
|
@@ -52,8 +108,19 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
52
108
|
// min/max or series change silently rebind axes / reorder the z-stack.)
|
|
53
109
|
const [axes, setAxes] = useState(() => new Map());
|
|
54
110
|
const [layers, setLayers] = useState(() => new Map());
|
|
111
|
+
// Registration is idempotent under value-equality: a `<YAxis>` re-fires its
|
|
112
|
+
// register effect whenever its `spec` memo yields a fresh object — which an
|
|
113
|
+
// inline `ticks={[]}` / `format` or a re-rendered parent does every render. If
|
|
114
|
+
// the spec is *value*-equal to the stored one, skip the `setState` entirely so
|
|
115
|
+
// it can't spin `register → setState → re-render → register` into React's
|
|
116
|
+
// "Maximum update depth exceeded" on a scrub-heavy chart (F-charts-axis-reregister).
|
|
55
117
|
const registerAxis = useCallback((key, spec) => {
|
|
56
|
-
setAxes((m) =>
|
|
118
|
+
setAxes((m) => {
|
|
119
|
+
const prev = m.get(key);
|
|
120
|
+
if (prev !== undefined && axisSpecEqual(prev, spec))
|
|
121
|
+
return m;
|
|
122
|
+
return new Map(m).set(key, spec);
|
|
123
|
+
});
|
|
57
124
|
}, []);
|
|
58
125
|
const unregisterAxis = useCallback((key) => {
|
|
59
126
|
setAxes((m) => {
|
|
@@ -64,8 +131,17 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
64
131
|
return next;
|
|
65
132
|
});
|
|
66
133
|
}, []);
|
|
134
|
+
// Same value-equality guard as `registerAxis`: a re-register carrying the same
|
|
135
|
+
// `layer` object (stable while the draw layer's inputs are) + `axisId`/`index`
|
|
136
|
+
// no-ops rather than churning state. (A fresh `series` projection rebuilds the
|
|
137
|
+
// layer and legitimately re-registers — see `layerEntryEqual`.)
|
|
67
138
|
const registerLayer = useCallback((key, entry) => {
|
|
68
|
-
setLayers((m) =>
|
|
139
|
+
setLayers((m) => {
|
|
140
|
+
const prev = m.get(key);
|
|
141
|
+
if (prev !== undefined && layerEntryEqual(prev, entry))
|
|
142
|
+
return m;
|
|
143
|
+
return new Map(m).set(key, entry);
|
|
144
|
+
});
|
|
69
145
|
}, []);
|
|
70
146
|
const unregisterLayer = useCallback((key) => {
|
|
71
147
|
setLayers((m) => {
|
package/dist/LineChart.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
|
|
|
8
8
|
* `ValueSeries` (`series.byValue('cumDist')`) against its value axis — the
|
|
9
9
|
* container infers which from the data, no axis-type prop. Either way the key
|
|
10
10
|
* / axis column supplies x and `column` supplies y.
|
|
11
|
+
*
|
|
12
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
|
|
13
|
+
* passing `series={s.byValue('dist')}` inline re-registers this layer every
|
|
14
|
+
* render — on a frequently re-rendering (e.g. scrub-driven) chart, memoize the
|
|
15
|
+
* projection (`useMemo`) so the layer isn't rebuilt each frame.
|
|
11
16
|
*/
|
|
12
17
|
series: TimeSeries<S> | ValueSeries<VS>;
|
|
13
18
|
/** Name of the numeric value column to plot. */
|
package/dist/YAxis.d.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface YAxisProps {
|
|
|
36
36
|
* function. Omit for the scale's d3 default — which is calibrated to the tick
|
|
37
37
|
* step, so a between-ticks readout rounds to tick precision; pass a specifier
|
|
38
38
|
* (e.g. `',.2f'`) when you want finer readout precision. See {@link AxisFormat}.
|
|
39
|
+
*
|
|
40
|
+
* **Live charts:** a string specifier is value-compared, so an inline
|
|
41
|
+
* `format='.0%'` is safe every render. An inline `format={(v) => …}` **function**
|
|
42
|
+
* is a fresh reference each render — the one axis prop a structural guard can't
|
|
43
|
+
* value-compare — so on a frequently re-rendering (e.g. scrub-driven) chart,
|
|
44
|
+
* hoist it or wrap it in `useCallback`, or it re-registers the axis each frame.
|
|
39
45
|
*/
|
|
40
46
|
format?: AxisFormat;
|
|
41
47
|
/**
|
|
@@ -45,9 +51,10 @@ export interface YAxisProps {
|
|
|
45
51
|
* lever for a non-uniform axis like pace, where the caller chooses round-pace
|
|
46
52
|
* positions and their own `m:ss` labels (`{ at: -300, label: '5:00' }`). `at`
|
|
47
53
|
* values outside `[min, max]` extrapolate off-plot (the scale does not clamp).
|
|
48
|
-
* Pass `[]` to draw none.
|
|
49
|
-
*
|
|
50
|
-
* axis
|
|
54
|
+
* Pass `[]` to draw none. The array is **value-compared on registration**, so an
|
|
55
|
+
* inline `ticks={[…]}` (or `ticks={[]}`) with unchanged contents no longer
|
|
56
|
+
* re-registers the axis — only genuinely changed tick positions do. (An inline
|
|
57
|
+
* `format` *function* still needs hoisting; see `format`.)
|
|
51
58
|
*/
|
|
52
59
|
ticks?: ReadonlyArray<{
|
|
53
60
|
readonly at: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/charts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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.40.0",
|
|
42
|
+
"pond-ts": "^0.40.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|