@pond-ts/charts 0.31.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 +3254 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/AreaChart.d.ts +85 -0
- package/dist/AreaChart.js +119 -0
- package/dist/BandChart.d.ts +55 -0
- package/dist/BandChart.js +93 -0
- package/dist/BarChart.d.ts +72 -0
- package/dist/BarChart.js +137 -0
- package/dist/BoxPlot.d.ts +77 -0
- package/dist/BoxPlot.js +137 -0
- package/dist/Canvas.d.ts +37 -0
- package/dist/Canvas.js +39 -0
- package/dist/ChartContainer.d.ts +106 -0
- package/dist/ChartContainer.js +306 -0
- package/dist/ChartRow.d.ts +29 -0
- package/dist/ChartRow.js +215 -0
- package/dist/Layers.d.ts +22 -0
- package/dist/Layers.js +399 -0
- package/dist/LineChart.d.ts +60 -0
- package/dist/LineChart.js +105 -0
- package/dist/ScatterChart.d.ts +84 -0
- package/dist/ScatterChart.js +139 -0
- package/dist/TimeAxis.d.ts +9 -0
- package/dist/TimeAxis.js +12 -0
- package/dist/XAxis.d.ts +39 -0
- package/dist/XAxis.js +84 -0
- package/dist/YAxis.d.ts +42 -0
- package/dist/YAxis.js +86 -0
- package/dist/annotations.d.ts +110 -0
- package/dist/annotations.js +459 -0
- package/dist/area.d.ts +54 -0
- package/dist/area.js +186 -0
- package/dist/band.d.ts +31 -0
- package/dist/band.js +57 -0
- package/dist/bars.d.ts +96 -0
- package/dist/bars.js +171 -0
- package/dist/box.d.ts +59 -0
- package/dist/box.js +140 -0
- package/dist/chip.d.ts +23 -0
- package/dist/chip.js +43 -0
- package/dist/cjs-fallback.cjs +16 -0
- package/dist/context.d.ts +362 -0
- package/dist/context.js +5 -0
- package/dist/curve.d.ts +22 -0
- package/dist/curve.js +13 -0
- package/dist/data.d.ts +154 -0
- package/dist/data.js +197 -0
- package/dist/domain.d.ts +19 -0
- package/dist/domain.js +61 -0
- package/dist/encoding.d.ts +89 -0
- package/dist/encoding.js +144 -0
- package/dist/format.d.ts +53 -0
- package/dist/format.js +47 -0
- package/dist/gaps.d.ts +146 -0
- package/dist/gaps.js +209 -0
- package/dist/grid.d.ts +11 -0
- package/dist/grid.js +29 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +34 -0
- package/dist/line.d.ts +46 -0
- package/dist/line.js +88 -0
- package/dist/range.d.ts +15 -0
- package/dist/range.js +27 -0
- package/dist/scatter.d.ts +70 -0
- package/dist/scatter.js +213 -0
- package/dist/select.d.ts +13 -0
- package/dist/select.js +23 -0
- package/dist/slots.d.ts +48 -0
- package/dist/slots.js +64 -0
- package/dist/theme.d.ts +224 -0
- package/dist/theme.js +232 -0
- package/dist/tracker.d.ts +30 -0
- package/dist/tracker.js +47 -0
- package/dist/use-slot-key.d.ts +21 -0
- package/dist/use-slot-key.js +25 -0
- package/dist/viewport.d.ts +20 -0
- package/dist/viewport.js +30 -0
- package/package.json +67 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { scaleLinear, scaleTime } from 'd3-scale';
|
|
4
|
+
import { ContainerContext, } from './context.js';
|
|
5
|
+
import { maxSlotWidths, sum } from './slots.js';
|
|
6
|
+
import { resolveCursorX, DEFAULT_CURSOR_MODE } from './tracker.js';
|
|
7
|
+
import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
|
|
8
|
+
import { TimeAxis } from './TimeAxis.js';
|
|
9
|
+
import { defaultTheme } from './theme.js';
|
|
10
|
+
/** Time-axis tick count — matches `<TimeAxis>` so the cursor-time formatter is
|
|
11
|
+
* calibrated as the time-axis labels are. */
|
|
12
|
+
const TIME_TICK_COUNT = 5;
|
|
13
|
+
/**
|
|
14
|
+
* Normalize the `range` prop — a `[begin, end]` tuple or a `TimeRange` — to a
|
|
15
|
+
* plain `[number, number]`, or `undefined` when omitted (→ auto-fit). The
|
|
16
|
+
* `'begin' in range` check distinguishes the `TimeRange` from the tuple (a
|
|
17
|
+
* tuple has no `begin` key).
|
|
18
|
+
*/
|
|
19
|
+
function normalizeRange(range) {
|
|
20
|
+
if (range === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
return 'begin' in range ? [range.begin(), range.end()] : [range[0], range[1]];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The top of the chart layout (react-timeseries-charts-style). Owns the shared
|
|
26
|
+
* **x geometry**: it collects each row's per-slot gutter widths, reserves each
|
|
27
|
+
* slot's max across rows (so the innermost axis aligns column-by-column and
|
|
28
|
+
* every row's plot left-aligns), and from the slot sums derives `plotWidth` and
|
|
29
|
+
* the shared time `xScale`. It renders its rows (separated by `rowGap`) then one
|
|
30
|
+
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
31
|
+
* (`<YAxis>`).
|
|
32
|
+
*/
|
|
33
|
+
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorTime = false, timeFormat, theme, children, }) {
|
|
34
|
+
// The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
|
|
35
|
+
// ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
|
|
36
|
+
// is the placeholder while auto-fitting.
|
|
37
|
+
const explicitDomain = normalizeRange(range);
|
|
38
|
+
const seed = explicitDomain ?? [0, 1];
|
|
39
|
+
// View range: pan/zoom moves it. Controlled (onTimeRangeChange) reads the prop
|
|
40
|
+
// and routes gestures back through the callback; uncontrolled holds it
|
|
41
|
+
// internally. With panZoom off, the seed is used directly — so a static or live
|
|
42
|
+
// (sliding-prop) chart tracks the prop as before.
|
|
43
|
+
const [internalRange, setInternalRange] = useState([
|
|
44
|
+
seed[0],
|
|
45
|
+
seed[1],
|
|
46
|
+
]);
|
|
47
|
+
const uncontrolled = panZoom && onTimeRangeChange === undefined;
|
|
48
|
+
// While the internal view isn't in use (not uncontrolled), keep it synced to
|
|
49
|
+
// the prop — so *entering* uncontrolled pan/zoom (toggling panZoom on, or a
|
|
50
|
+
// controlled→uncontrolled switch) starts from the current range, not the
|
|
51
|
+
// mount-time one. While uncontrolled, leave it alone so a range change
|
|
52
|
+
// can't fight the user's pan. (Adjusting state during render — React re-renders
|
|
53
|
+
// before commit, no extra paint; the guard makes it converge in one step.)
|
|
54
|
+
if (!uncontrolled &&
|
|
55
|
+
(internalRange[0] !== seed[0] || internalRange[1] !== seed[1])) {
|
|
56
|
+
setInternalRange([seed[0], seed[1]]);
|
|
57
|
+
}
|
|
58
|
+
const view = uncontrolled ? internalRange : seed;
|
|
59
|
+
const t0 = view[0];
|
|
60
|
+
const t1 = view[1];
|
|
61
|
+
// Latest onTimeRangeChange in a ref so applyRange stays stable. Written after
|
|
62
|
+
// commit (not in render) so a gesture never reads a callback from a frame that
|
|
63
|
+
// was abandoned under concurrent rendering.
|
|
64
|
+
const onRangeRef = useRef(onTimeRangeChange);
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
onRangeRef.current = onTimeRangeChange;
|
|
67
|
+
});
|
|
68
|
+
const applyRange = useCallback((range) => {
|
|
69
|
+
const cb = onRangeRef.current;
|
|
70
|
+
if (cb)
|
|
71
|
+
cb(range);
|
|
72
|
+
else
|
|
73
|
+
setInternalRange(range);
|
|
74
|
+
}, []);
|
|
75
|
+
// Cross-row tracker. We store the cursor's plot-pixel x (not a timestamp), so a
|
|
76
|
+
// still cursor stays put while a live window slides under it; a controlled
|
|
77
|
+
// `trackerPosition` resolves to a pixel below.
|
|
78
|
+
const [hoverX, setHoverX] = useState(null);
|
|
79
|
+
// Draw layers register as tracker sources; on hover we fan in their values at
|
|
80
|
+
// the cursor and hand them out via onTrackerChanged (held in a ref so an
|
|
81
|
+
// inline callback doesn't churn the frame). This powers a readout rendered
|
|
82
|
+
// *outside* the chart — the preferred surface for hover values.
|
|
83
|
+
const [sources, setSources] = useState(() => new Map());
|
|
84
|
+
const registerTrackerSource = useCallback((key, source) => setSources((m) => new Map(m).set(key, source)), []);
|
|
85
|
+
const unregisterTrackerSource = useCallback((key) => {
|
|
86
|
+
setSources((m) => {
|
|
87
|
+
if (!m.has(key))
|
|
88
|
+
return m;
|
|
89
|
+
const next = new Map(m);
|
|
90
|
+
next.delete(key);
|
|
91
|
+
return next;
|
|
92
|
+
});
|
|
93
|
+
}, []);
|
|
94
|
+
// The shared x scale's kind, **inferred from the registered layers**: a
|
|
95
|
+
// ValueSeries row plots on a value axis, a TimeSeries on time. A container
|
|
96
|
+
// has one shared x (the synced cursor's whole point), so the rows must agree
|
|
97
|
+
// — a mix is a hard error. Defaults to `'time'` until a layer registers (the
|
|
98
|
+
// two-pass: register → re-resolve → rescale).
|
|
99
|
+
const resolvedKind = useMemo(() => {
|
|
100
|
+
let kind;
|
|
101
|
+
for (const s of sources.values()) {
|
|
102
|
+
if (kind === undefined)
|
|
103
|
+
kind = s.xKind;
|
|
104
|
+
else if (kind !== s.xKind) {
|
|
105
|
+
throw new Error(`ChartContainer: rows mix x-axis kinds ('${kind}' and '${s.xKind}'). ` +
|
|
106
|
+
`A container has one shared x axis — every row must plot the same ` +
|
|
107
|
+
`kind (all time-keyed, or all value-keyed).`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return kind ?? 'time';
|
|
111
|
+
}, [sources]);
|
|
112
|
+
// Auto-fit extent — the union of the layers' x extents — used as the domain
|
|
113
|
+
// when no explicit `range` is given. (Same source registry as the kind; the
|
|
114
|
+
// two-pass register→resolve applies.)
|
|
115
|
+
const autoExtent = useMemo(() => {
|
|
116
|
+
let lo = Infinity;
|
|
117
|
+
let hi = -Infinity;
|
|
118
|
+
for (const s of sources.values()) {
|
|
119
|
+
const e = s.xExtent();
|
|
120
|
+
if (e) {
|
|
121
|
+
if (e[0] < lo)
|
|
122
|
+
lo = e[0];
|
|
123
|
+
if (e[1] > hi)
|
|
124
|
+
hi = e[1];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return lo <= hi ? [lo, hi] : null;
|
|
128
|
+
}, [sources]);
|
|
129
|
+
const onTrackerRef = useRef(onTrackerChanged);
|
|
130
|
+
onTrackerRef.current = onTrackerChanged;
|
|
131
|
+
// Selection: controlled (`selected` prop) or uncontrolled (internal). A click
|
|
132
|
+
// on a selectable layer calls `select()` after hit-testing; `onSelect` notifies
|
|
133
|
+
// in both modes, the internal state is managed only when uncontrolled. The full
|
|
134
|
+
// SelectInfo is the identity (key + series), so multi-series marks at one
|
|
135
|
+
// timestamp stay distinct. Refs written after commit (not in render) so the
|
|
136
|
+
// click handler never reads a callback / mode from a frame abandoned under
|
|
137
|
+
// concurrent rendering.
|
|
138
|
+
const [internalSelected, setInternalSelected] = useState(null);
|
|
139
|
+
const controlledSelection = selected !== undefined;
|
|
140
|
+
const selectedValue = controlledSelection
|
|
141
|
+
? (selected ?? null)
|
|
142
|
+
: internalSelected;
|
|
143
|
+
const onSelectRef = useRef(onSelect);
|
|
144
|
+
const controlledSelectionRef = useRef(controlledSelection);
|
|
145
|
+
useLayoutEffect(() => {
|
|
146
|
+
onSelectRef.current = onSelect;
|
|
147
|
+
controlledSelectionRef.current = controlledSelection;
|
|
148
|
+
});
|
|
149
|
+
const select = useCallback((hit) => {
|
|
150
|
+
onSelectRef.current?.(hit);
|
|
151
|
+
if (!controlledSelectionRef.current)
|
|
152
|
+
setInternalSelected(hit);
|
|
153
|
+
}, []);
|
|
154
|
+
// Hover-highlight: the transient mark under the pointer (distinct from the
|
|
155
|
+
// committed selection). Deduped by key+label so the data canvas repaints only
|
|
156
|
+
// when the hovered mark changes — not on every pointer move (the move itself
|
|
157
|
+
// just slides the SVG cursor, which never touches the data canvas).
|
|
158
|
+
const [hovered, setHoveredState] = useState(null);
|
|
159
|
+
const setHovered = useCallback((hit) => {
|
|
160
|
+
setHoveredState((prev) => prev === hit ||
|
|
161
|
+
(prev !== null &&
|
|
162
|
+
hit !== null &&
|
|
163
|
+
prev.key === hit.key &&
|
|
164
|
+
prev.label === hit.label)
|
|
165
|
+
? prev
|
|
166
|
+
: hit);
|
|
167
|
+
}, []);
|
|
168
|
+
// Rows report their per-slot gutter widths; we reserve each slot's max.
|
|
169
|
+
const [gutters, setGutters] = useState([]);
|
|
170
|
+
const registerGutter = useCallback((req) => {
|
|
171
|
+
setGutters((g) => [...g, req]);
|
|
172
|
+
return () => setGutters((g) => g.filter((x) => x !== req));
|
|
173
|
+
}, []);
|
|
174
|
+
// Rows register on mount so we can mark the first (topmost) one — the shared
|
|
175
|
+
// cursor-time chip shows there only. Effect order = mount order = top-to-bottom
|
|
176
|
+
// for siblings, so the first row registers first (`rowKeys[0]`); this is robust
|
|
177
|
+
// even when rows are wrapped in a fragment/helper component, where an index
|
|
178
|
+
// injected into our direct children wouldn't reach through the wrapper.
|
|
179
|
+
const [rowKeys, setRowKeys] = useState([]);
|
|
180
|
+
const registerRow = useCallback((key) => {
|
|
181
|
+
setRowKeys((k) => (k.includes(key) ? k : [...k, key]));
|
|
182
|
+
return () => setRowKeys((k) => k.filter((x) => x !== key));
|
|
183
|
+
}, []);
|
|
184
|
+
const firstRowKey = rowKeys[0] ?? null;
|
|
185
|
+
const leftSlots = useMemo(() => maxSlotWidths(gutters.map((g) => g.left)), [gutters]);
|
|
186
|
+
const rightSlots = useMemo(() => maxSlotWidths(gutters.map((g) => g.right)), [gutters]);
|
|
187
|
+
const leftGutter = sum(leftSlots);
|
|
188
|
+
const rightGutter = sum(rightSlots);
|
|
189
|
+
const plotWidth = Math.max(0, width - leftGutter - rightGutter);
|
|
190
|
+
// The resolved x domain: while panning an explicit domain it's the live view
|
|
191
|
+
// (t0/t1); otherwise the auto-fit extent (→ [0, 1] before any layer registers,
|
|
192
|
+
// the two-pass settle). This is what the scale + cursor + axis read.
|
|
193
|
+
const [d0, d1] = explicitDomain !== undefined ? [t0, t1] : (autoExtent ?? [0, 1]);
|
|
194
|
+
// The shared x scale + the formatter for its ticks / cursor readout, built
|
|
195
|
+
// together so each branch keeps its concrete scale type (no casts): a value
|
|
196
|
+
// axis is a `scaleLinear` formatted by `resolveAxisFormat`, time is a
|
|
197
|
+
// `scaleTime` formatted by d3's multi-scale `resolveTimeFormat`. `formatTime`
|
|
198
|
+
// is the one formatter <TimeAxis> + the cursor readout share, so a tick and
|
|
199
|
+
// the cursor read identically. (The `formatTime` name predates the value axis
|
|
200
|
+
// — on a value axis it formats the value, not a time.)
|
|
201
|
+
const { xScale, formatTime } = useMemo(() => {
|
|
202
|
+
if (resolvedKind === 'value') {
|
|
203
|
+
const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
|
|
204
|
+
return {
|
|
205
|
+
xScale: s,
|
|
206
|
+
formatTime: resolveAxisFormat(s, TIME_TICK_COUNT, timeFormat),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const s = scaleTime().domain([d0, d1]).range([0, plotWidth]);
|
|
210
|
+
return {
|
|
211
|
+
xScale: s,
|
|
212
|
+
formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
|
|
213
|
+
};
|
|
214
|
+
}, [resolvedKind, d0, d1, plotWidth, timeFormat]);
|
|
215
|
+
// The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
|
|
216
|
+
// if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
|
|
217
|
+
// briefly stale until the next pointer move — rare, and the bounds check below
|
|
218
|
+
// hides an out-of-plot crosshair meanwhile.
|
|
219
|
+
const cursorX = resolveCursorX(trackerPosition, hoverX, xScale);
|
|
220
|
+
// Emit { time, values } for an outside readout — recomputed as the cursor moves
|
|
221
|
+
// *or* the window slides under it (xScale change → new time at the same pixel).
|
|
222
|
+
// Out of the plot (null, or a controlled trackerPosition d3 extrapolated past
|
|
223
|
+
// the edges) → no readout, matching the hidden overlay; the ref guard keeps a
|
|
224
|
+
// not-hovering live chart from spamming `null`.
|
|
225
|
+
const lastNullRef = useRef(false);
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const cb = onTrackerRef.current;
|
|
228
|
+
if (cb === undefined)
|
|
229
|
+
return;
|
|
230
|
+
if (cursorX === null || cursorX < 0 || cursorX > plotWidth) {
|
|
231
|
+
if (!lastNullRef.current)
|
|
232
|
+
cb(null);
|
|
233
|
+
lastNullRef.current = true;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
lastNullRef.current = false;
|
|
237
|
+
const time = +xScale.invert(cursorX);
|
|
238
|
+
const values = Array.from(sources.values()).flatMap((s) => s.sampleAt(time));
|
|
239
|
+
cb({ time, values });
|
|
240
|
+
}, [cursorX, xScale, sources, plotWidth]);
|
|
241
|
+
const frame = useMemo(() => ({
|
|
242
|
+
timeRange: [d0, d1],
|
|
243
|
+
width,
|
|
244
|
+
theme: theme ?? defaultTheme,
|
|
245
|
+
plotWidth,
|
|
246
|
+
leftSlots,
|
|
247
|
+
rightSlots,
|
|
248
|
+
leftGutter,
|
|
249
|
+
rightGutter,
|
|
250
|
+
rowGap,
|
|
251
|
+
cursorX,
|
|
252
|
+
setHoverX,
|
|
253
|
+
selected: selectedValue,
|
|
254
|
+
select,
|
|
255
|
+
hovered,
|
|
256
|
+
setHovered,
|
|
257
|
+
cursor,
|
|
258
|
+
cursorTime,
|
|
259
|
+
formatTime,
|
|
260
|
+
registerTrackerSource,
|
|
261
|
+
unregisterTrackerSource,
|
|
262
|
+
xScale,
|
|
263
|
+
xKind: resolvedKind,
|
|
264
|
+
panZoom,
|
|
265
|
+
minDuration,
|
|
266
|
+
applyRange,
|
|
267
|
+
registerGutter,
|
|
268
|
+
registerRow,
|
|
269
|
+
firstRowKey,
|
|
270
|
+
}), [
|
|
271
|
+
d0,
|
|
272
|
+
d1,
|
|
273
|
+
width,
|
|
274
|
+
theme,
|
|
275
|
+
plotWidth,
|
|
276
|
+
leftSlots,
|
|
277
|
+
rightSlots,
|
|
278
|
+
leftGutter,
|
|
279
|
+
rightGutter,
|
|
280
|
+
rowGap,
|
|
281
|
+
cursorX,
|
|
282
|
+
selectedValue,
|
|
283
|
+
select,
|
|
284
|
+
hovered,
|
|
285
|
+
setHovered,
|
|
286
|
+
cursor,
|
|
287
|
+
cursorTime,
|
|
288
|
+
formatTime,
|
|
289
|
+
registerTrackerSource,
|
|
290
|
+
unregisterTrackerSource,
|
|
291
|
+
xScale,
|
|
292
|
+
resolvedKind,
|
|
293
|
+
panZoom,
|
|
294
|
+
minDuration,
|
|
295
|
+
applyRange,
|
|
296
|
+
registerGutter,
|
|
297
|
+
registerRow,
|
|
298
|
+
firstRowKey,
|
|
299
|
+
]);
|
|
300
|
+
return (_jsx(ContainerContext.Provider, { value: frame, children: _jsxs("div", { style: { width: `${width}px` }, children: [_jsx("div", { style: {
|
|
301
|
+
display: 'flex',
|
|
302
|
+
flexDirection: 'column',
|
|
303
|
+
gap: `${rowGap}px`,
|
|
304
|
+
}, children: children }), showAxis && _jsx(TimeAxis, {})] }) }));
|
|
305
|
+
}
|
|
306
|
+
//# sourceMappingURL=ChartContainer.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type CursorMode } from './context.js';
|
|
3
|
+
export interface ChartRowProps {
|
|
4
|
+
/** Row height in CSS pixels. */
|
|
5
|
+
height: number;
|
|
6
|
+
/** Cursor presentation for this row, overriding the container's default
|
|
7
|
+
* ({@link ChartContainerProps.cursor}). Omit to inherit. See {@link CursorMode}. */
|
|
8
|
+
cursor?: CursorMode;
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A horizontal band sharing the container's time axis. `ChartRow` owns the
|
|
13
|
+
* **horizontal layout** (axes left/right around a `<Layers>` plot area) and
|
|
14
|
+
* coordinates the row's two registries — axes (`<YAxis>`) and draw layers
|
|
15
|
+
* (`<LineChart>`, registered through `<Layers>`). From the layers it derives a
|
|
16
|
+
* y-scale **per axis** (each axis auto-fits the layers linked to it, or uses its
|
|
17
|
+
* explicit `[min, max]`), and provides them via context.
|
|
18
|
+
*
|
|
19
|
+
* The x geometry (plot width, time scale) is shared and lives on the
|
|
20
|
+
* {@link ChartContainer}: the row reports its per-slot gutter widths so the
|
|
21
|
+
* container can reserve each slot's max, then sizes each axis to its slot and
|
|
22
|
+
* pads the outer slots it lacks, so its plot left-aligns with every other row
|
|
23
|
+
* under the one time axis.
|
|
24
|
+
*
|
|
25
|
+
* Children lay out left-to-right in author order, so `<YAxis side="left"/>` goes
|
|
26
|
+
* before `<Layers/>` and `<YAxis side="right"/>` after.
|
|
27
|
+
*/
|
|
28
|
+
export declare function ChartRow({ height, cursor, children }: ChartRowProps): import("react/jsx-runtime").JSX.Element;
|
|
29
|
+
//# sourceMappingURL=ChartRow.d.ts.map
|
package/dist/ChartRow.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
3
|
+
import { scaleLinear } from 'd3-scale';
|
|
4
|
+
import { resolveYDomain } from './domain.js';
|
|
5
|
+
import { resolveAxisFormat } from './format.js';
|
|
6
|
+
import { placeAxisSlots } from './slots.js';
|
|
7
|
+
import { useSlotKey } from './use-slot-key.js';
|
|
8
|
+
import { YAxis } from './YAxis.js';
|
|
9
|
+
import { ContainerContext, RowContext, } from './context.js';
|
|
10
|
+
/** Sentinel id for the implicit axis a row gets when no `<YAxis>` is declared. */
|
|
11
|
+
const IMPLICIT_AXIS_ID = '__default__';
|
|
12
|
+
/** Axis tick count for the per-axis formatter — matches `<YAxis>`'s tick count
|
|
13
|
+
* so the readout formatter is calibrated exactly as the labels are. */
|
|
14
|
+
const AXIS_TICK_COUNT = 5;
|
|
15
|
+
/**
|
|
16
|
+
* A horizontal band sharing the container's time axis. `ChartRow` owns the
|
|
17
|
+
* **horizontal layout** (axes left/right around a `<Layers>` plot area) and
|
|
18
|
+
* coordinates the row's two registries — axes (`<YAxis>`) and draw layers
|
|
19
|
+
* (`<LineChart>`, registered through `<Layers>`). From the layers it derives a
|
|
20
|
+
* y-scale **per axis** (each axis auto-fits the layers linked to it, or uses its
|
|
21
|
+
* explicit `[min, max]`), and provides them via context.
|
|
22
|
+
*
|
|
23
|
+
* The x geometry (plot width, time scale) is shared and lives on the
|
|
24
|
+
* {@link ChartContainer}: the row reports its per-slot gutter widths so the
|
|
25
|
+
* container can reserve each slot's max, then sizes each axis to its slot and
|
|
26
|
+
* pads the outer slots it lacks, so its plot left-aligns with every other row
|
|
27
|
+
* under the one time axis.
|
|
28
|
+
*
|
|
29
|
+
* Children lay out left-to-right in author order, so `<YAxis side="left"/>` goes
|
|
30
|
+
* before `<Layers/>` and `<YAxis side="right"/>` after.
|
|
31
|
+
*/
|
|
32
|
+
export function ChartRow({ height, cursor, children }) {
|
|
33
|
+
const container = useContext(ContainerContext);
|
|
34
|
+
if (container === null) {
|
|
35
|
+
throw new Error('<ChartRow> must be rendered inside a <ChartContainer>');
|
|
36
|
+
}
|
|
37
|
+
// Register on mount so the container can mark the first (topmost) row by
|
|
38
|
+
// mount order — the shared cursor-time chip renders there only.
|
|
39
|
+
const rowKey = useSlotKey();
|
|
40
|
+
const { registerRow } = container;
|
|
41
|
+
useEffect(() => registerRow(rowKey), [registerRow, rowKey]);
|
|
42
|
+
const isFirstRow = container.firstRowKey === rowKey;
|
|
43
|
+
// Keyed by a stable per-instance id (Map preserves insertion order; setting an
|
|
44
|
+
// existing key updates in place). So a re-register on a prop change keeps the
|
|
45
|
+
// entry's slot — the axis-default (first axis) and layer z-order stay stable
|
|
46
|
+
// across updates; only mount/unmount reorders. (registerAxis/Layer return
|
|
47
|
+
// void and update in place — *not* unregister-and-append, which would let a
|
|
48
|
+
// min/max or series change silently rebind axes / reorder the z-stack.)
|
|
49
|
+
const [axes, setAxes] = useState(() => new Map());
|
|
50
|
+
const [layers, setLayers] = useState(() => new Map());
|
|
51
|
+
const registerAxis = useCallback((key, spec) => {
|
|
52
|
+
setAxes((m) => new Map(m).set(key, spec));
|
|
53
|
+
}, []);
|
|
54
|
+
const unregisterAxis = useCallback((key) => {
|
|
55
|
+
setAxes((m) => {
|
|
56
|
+
if (!m.has(key))
|
|
57
|
+
return m;
|
|
58
|
+
const next = new Map(m);
|
|
59
|
+
next.delete(key);
|
|
60
|
+
return next;
|
|
61
|
+
});
|
|
62
|
+
}, []);
|
|
63
|
+
const registerLayer = useCallback((key, entry) => {
|
|
64
|
+
setLayers((m) => new Map(m).set(key, entry));
|
|
65
|
+
}, []);
|
|
66
|
+
const unregisterLayer = useCallback((key) => {
|
|
67
|
+
setLayers((m) => {
|
|
68
|
+
if (!m.has(key))
|
|
69
|
+
return m;
|
|
70
|
+
const next = new Map(m);
|
|
71
|
+
next.delete(key);
|
|
72
|
+
return next;
|
|
73
|
+
});
|
|
74
|
+
}, []);
|
|
75
|
+
// Layers in declaration order (the z-stack) — sorted by their injected JSX
|
|
76
|
+
// index, so order follows the markup regardless of mount timing.
|
|
77
|
+
const layerList = useMemo(() => Array.from(layers.values()).sort((a, b) => a.index - b.index), [layers]);
|
|
78
|
+
// Real declared axes in declaration order (by injected index) — the rendered
|
|
79
|
+
// <YAxis> children, as [slot key, spec] so layout can key off the per-instance
|
|
80
|
+
// symbol (not the data id, which may repeat across a mirror). A row with none
|
|
81
|
+
// gets a single implicit auto-domain axis, for *scaling* only (zero width, not
|
|
82
|
+
// rendered), so it still has a default.
|
|
83
|
+
const realEntries = useMemo(() => Array.from(axes.entries()).sort((a, b) => a[1].index - b[1].index), [axes]);
|
|
84
|
+
const realAxes = useMemo(() => realEntries.map(([, spec]) => spec), [realEntries]);
|
|
85
|
+
const effectiveAxes = useMemo(() => realAxes.length > 0
|
|
86
|
+
? realAxes
|
|
87
|
+
: [
|
|
88
|
+
{
|
|
89
|
+
id: IMPLICIT_AXIS_ID,
|
|
90
|
+
side: 'left',
|
|
91
|
+
width: 0,
|
|
92
|
+
min: undefined,
|
|
93
|
+
max: undefined,
|
|
94
|
+
format: undefined,
|
|
95
|
+
index: 0,
|
|
96
|
+
},
|
|
97
|
+
], [realAxes]);
|
|
98
|
+
const defaultAxisId = effectiveAxes[0].id;
|
|
99
|
+
// This row's axes per side (as {key, width}), in slot order (slot 0 = innermost,
|
|
100
|
+
// nearest the plot). Left axes are authored outer→inner so reverse them; right
|
|
101
|
+
// axes are authored inner→outer already. Reported to the container as the
|
|
102
|
+
// per-slot widths it maxes across rows.
|
|
103
|
+
const { leftAxes, rightAxes, ownLeftSlots, ownRightSlots } = useMemo(() => {
|
|
104
|
+
const l = [];
|
|
105
|
+
const r = [];
|
|
106
|
+
for (const [key, spec] of realEntries) {
|
|
107
|
+
(spec.side === 'left' ? l : r).push({ key, width: spec.width });
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
leftAxes: l,
|
|
111
|
+
rightAxes: r,
|
|
112
|
+
ownLeftSlots: l.map((a) => a.width).reverse(),
|
|
113
|
+
ownRightSlots: r.map((a) => a.width),
|
|
114
|
+
};
|
|
115
|
+
}, [realEntries]);
|
|
116
|
+
const { registerGutter } = container;
|
|
117
|
+
const gutterReq = useMemo(() => ({ left: ownLeftSlots, right: ownRightSlots }), [ownLeftSlots, ownRightSlots]);
|
|
118
|
+
// Depend on the *stable* registerGutter (a useCallback) + the memoized req —
|
|
119
|
+
// not the container frame, which is recreated whenever the reservation
|
|
120
|
+
// changes (depending on it would loop register → re-render → re-register).
|
|
121
|
+
useEffect(() => registerGutter(gutterReq), [registerGutter, gutterReq]);
|
|
122
|
+
// Map each axis id to its reserved slot width + the outer-slot padding this
|
|
123
|
+
// row lacks (see placeAxisSlots — slot 0 nearest the plot, pad keeps the plot
|
|
124
|
+
// aligned). Falls back to own width until the container has reserved.
|
|
125
|
+
const containerLeftSlots = container.leftSlots;
|
|
126
|
+
const containerRightSlots = container.rightSlots;
|
|
127
|
+
const { axisSlots, leftPad, rightPad } = useMemo(() => placeAxisSlots(leftAxes, rightAxes, containerLeftSlots, containerRightSlots), [leftAxes, rightAxes, containerLeftSlots, containerRightSlots]);
|
|
128
|
+
// One y-scale per axis. A layer counts toward an axis when its (late-resolved)
|
|
129
|
+
// axis id matches; `resolveYDomain` handles the auto-fit + empty/flat/inverted
|
|
130
|
+
// edges. yExtent() is O(points), so only walk the layers when a bound auto-fits.
|
|
131
|
+
const yScales = useMemo(() => {
|
|
132
|
+
const map = new Map();
|
|
133
|
+
for (const ax of effectiveAxes) {
|
|
134
|
+
const extents = ax.min === undefined || ax.max === undefined
|
|
135
|
+
? layerList
|
|
136
|
+
.filter((entry) => (entry.axisId ?? defaultAxisId) === ax.id)
|
|
137
|
+
.map((entry) => entry.layer.yExtent())
|
|
138
|
+
: [];
|
|
139
|
+
const [lo, hi] = resolveYDomain(ax.min, ax.max, extents);
|
|
140
|
+
map.set(ax.id, scaleLinear().domain([lo, hi]).range([height, 0]));
|
|
141
|
+
}
|
|
142
|
+
return map;
|
|
143
|
+
}, [effectiveAxes, layerList, height, defaultAxisId]);
|
|
144
|
+
// A value formatter per axis (its `format` resolved against its scale) — shared
|
|
145
|
+
// by the axis tick labels and the cursor readout so a value reads the same.
|
|
146
|
+
const formats = useMemo(() => {
|
|
147
|
+
const map = new Map();
|
|
148
|
+
for (const ax of effectiveAxes) {
|
|
149
|
+
const sc = yScales.get(ax.id);
|
|
150
|
+
if (sc)
|
|
151
|
+
map.set(ax.id, resolveAxisFormat(sc, AXIS_TICK_COUNT, ax.format));
|
|
152
|
+
}
|
|
153
|
+
return map;
|
|
154
|
+
}, [effectiveAxes, yScales]);
|
|
155
|
+
const frame = useMemo(() => ({
|
|
156
|
+
height,
|
|
157
|
+
cursor,
|
|
158
|
+
isFirstRow,
|
|
159
|
+
yScales,
|
|
160
|
+
formats,
|
|
161
|
+
defaultAxisId,
|
|
162
|
+
axisSlots,
|
|
163
|
+
registerAxis,
|
|
164
|
+
unregisterAxis,
|
|
165
|
+
registerLayer,
|
|
166
|
+
unregisterLayer,
|
|
167
|
+
layers: layerList,
|
|
168
|
+
}), [
|
|
169
|
+
height,
|
|
170
|
+
cursor,
|
|
171
|
+
isFirstRow,
|
|
172
|
+
yScales,
|
|
173
|
+
formats,
|
|
174
|
+
defaultAxisId,
|
|
175
|
+
axisSlots,
|
|
176
|
+
registerAxis,
|
|
177
|
+
unregisterAxis,
|
|
178
|
+
registerLayer,
|
|
179
|
+
unregisterLayer,
|
|
180
|
+
layerList,
|
|
181
|
+
]);
|
|
182
|
+
// Inject each direct child's JSX position so axes register their declaration
|
|
183
|
+
// order (the default-axis source). `<Layers>` receives an index too (harmless
|
|
184
|
+
// — it's not an axis) and injects its own into the draw layers.
|
|
185
|
+
const indexedChildren = Children.map(children, (child, index) => isValidElement(child)
|
|
186
|
+
? cloneElement(child, { index })
|
|
187
|
+
: child);
|
|
188
|
+
// Place axes by their `side`, not by JSX author position — so a `side="right"`
|
|
189
|
+
// axis always renders right of the plot (and a left axis left), **consistent
|
|
190
|
+
// with the side-based gutter reservation above**. (Author position only
|
|
191
|
+
// injects the index, which still drives slot order within a side + the
|
|
192
|
+
// default-axis pick.) This makes `side` the single source of truth for both
|
|
193
|
+
// placement *and* reserved space; mis-authoring can no longer desync them
|
|
194
|
+
// (the bug: a right axis authored before `<Layers>` rendered left while its
|
|
195
|
+
// gutter was reserved right). Non-axis children (`<Layers>`) stay in the middle.
|
|
196
|
+
const leftAxisEls = [];
|
|
197
|
+
const plotEls = [];
|
|
198
|
+
const rightAxisEls = [];
|
|
199
|
+
for (const child of indexedChildren ?? []) {
|
|
200
|
+
if (isValidElement(child) && child.type === YAxis) {
|
|
201
|
+
const side = child.props.side ?? 'left';
|
|
202
|
+
(side === 'right' ? rightAxisEls : leftAxisEls).push(child);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
plotEls.push(child);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return (_jsx(RowContext.Provider, { value: frame, children: _jsxs("div", { style: {
|
|
209
|
+
display: 'flex',
|
|
210
|
+
flexDirection: 'row',
|
|
211
|
+
width: `${container.width}px`,
|
|
212
|
+
height: `${height}px`,
|
|
213
|
+
}, children: [leftPad > 0 && _jsx("div", { style: { flex: `0 0 ${leftPad}px` } }), leftAxisEls, plotEls, rightAxisEls, rightPad > 0 && _jsx("div", { style: { flex: `0 0 ${rightPad}px` } })] }) }));
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=ChartRow.js.map
|
package/dist/Layers.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export interface LayersProps {
|
|
3
|
+
children?: ReactNode;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The plot area of a {@link ChartRow}: a single `<canvas>` plus the draw-layer
|
|
7
|
+
* registry. It is the boundary where the row's horizontal layout flips to
|
|
8
|
+
* z-stacking — child layers ({@link LineChart}, …) register here and paint into
|
|
9
|
+
* the one canvas, each with its own axis's y-scale (looked up by the layer's
|
|
10
|
+
* `axis` id, defaulting to the row's default axis).
|
|
11
|
+
*
|
|
12
|
+
* **Z-order — declaration order, last child on top** (SVG / DOM / RTC). A row is
|
|
13
|
+
* authored back-to-front: `<BandChart/>` then `<LineChart/>` puts the line over
|
|
14
|
+
* its band. Order comes from each child's **injected JSX index** (so the stack
|
|
15
|
+
* follows the markup regardless of mount timing — a layer toggled in between two
|
|
16
|
+
* others slots into place, not onto the top), and each layer keeps a stable,
|
|
17
|
+
* id-keyed slot so a series/style update holds its position (no jump to the
|
|
18
|
+
* front — the trap that bites live charts). Draw layers must be **direct
|
|
19
|
+
* children** of `<Layers>` for the index to reach them.
|
|
20
|
+
*/
|
|
21
|
+
export declare function Layers({ children }: LayersProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
//# sourceMappingURL=Layers.d.ts.map
|