@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
package/dist/Layers.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, } from 'react';
|
|
3
|
+
import { Canvas } from './Canvas.js';
|
|
4
|
+
import { drawGrid } from './grid.js';
|
|
5
|
+
import { cursorParts } from './tracker.js';
|
|
6
|
+
import { resolveSelection } from './select.js';
|
|
7
|
+
import { panRange, zoomRange } from './viewport.js';
|
|
8
|
+
import { ContainerContext, LayersContext, RowContext, } from './context.js';
|
|
9
|
+
/** Gridline tick count — matches the axes (`YAxis`/`TimeAxis`) so they align. */
|
|
10
|
+
const GRID_TICKS = 5;
|
|
11
|
+
/** Wheel-zoom sensitivity: `factor = exp(deltaY * k)` (one ~100px notch ≈ ±15%). */
|
|
12
|
+
const ZOOM_SENSITIVITY = 0.0015;
|
|
13
|
+
/** Pointer slop (px): a drag must exceed this before it pans, and a click within
|
|
14
|
+
* it still selects. One threshold for both so a click never also nudges the pan
|
|
15
|
+
* (and never hit-tests against a shifted scale). */
|
|
16
|
+
const DRAG_SLOP = 4;
|
|
17
|
+
/** Past this fraction of the plot, a readout label flips left of its dot so it
|
|
18
|
+
* doesn't overflow the right edge. */
|
|
19
|
+
const LABEL_FLIP_FRACTION = 0.85;
|
|
20
|
+
/**
|
|
21
|
+
* The plot area of a {@link ChartRow}: a single `<canvas>` plus the draw-layer
|
|
22
|
+
* registry. It is the boundary where the row's horizontal layout flips to
|
|
23
|
+
* z-stacking — child layers ({@link LineChart}, …) register here and paint into
|
|
24
|
+
* the one canvas, each with its own axis's y-scale (looked up by the layer's
|
|
25
|
+
* `axis` id, defaulting to the row's default axis).
|
|
26
|
+
*
|
|
27
|
+
* **Z-order — declaration order, last child on top** (SVG / DOM / RTC). A row is
|
|
28
|
+
* authored back-to-front: `<BandChart/>` then `<LineChart/>` puts the line over
|
|
29
|
+
* its band. Order comes from each child's **injected JSX index** (so the stack
|
|
30
|
+
* follows the markup regardless of mount timing — a layer toggled in between two
|
|
31
|
+
* others slots into place, not onto the top), and each layer keeps a stable,
|
|
32
|
+
* id-keyed slot so a series/style update holds its position (no jump to the
|
|
33
|
+
* front — the trap that bites live charts). Draw layers must be **direct
|
|
34
|
+
* children** of `<Layers>` for the index to reach them.
|
|
35
|
+
*/
|
|
36
|
+
export function Layers({ children }) {
|
|
37
|
+
const container = useContext(ContainerContext);
|
|
38
|
+
if (container === null) {
|
|
39
|
+
throw new Error('<Layers> must be rendered inside a <ChartContainer>');
|
|
40
|
+
}
|
|
41
|
+
const row = useContext(RowContext);
|
|
42
|
+
if (row === null) {
|
|
43
|
+
throw new Error('<Layers> must be rendered inside a <ChartRow>');
|
|
44
|
+
}
|
|
45
|
+
const registry = useMemo(() => ({
|
|
46
|
+
registerLayer: row.registerLayer,
|
|
47
|
+
unregisterLayer: row.unregisterLayer,
|
|
48
|
+
}), [row.registerLayer, row.unregisterLayer]);
|
|
49
|
+
const background = container.theme.background;
|
|
50
|
+
const { grid: gridColor, gridDash } = container.theme.axis;
|
|
51
|
+
const { layers, yScales, formats, defaultAxisId } = row;
|
|
52
|
+
// x geometry is shared and lives on the container (uniform across rows).
|
|
53
|
+
const { xScale, plotWidth } = container;
|
|
54
|
+
const draw = useCallback((ctx, w, h) => {
|
|
55
|
+
if (background !== undefined) {
|
|
56
|
+
ctx.fillStyle = background;
|
|
57
|
+
ctx.fillRect(0, 0, w, h);
|
|
58
|
+
}
|
|
59
|
+
// Gridlines behind the data, from the same ticks the axes label: vertical
|
|
60
|
+
// from the shared time scale, horizontal from the row's default y-axis.
|
|
61
|
+
const gridY = yScales.get(defaultAxisId);
|
|
62
|
+
const xTicks = xScale.ticks(GRID_TICKS).map((d) => xScale(d));
|
|
63
|
+
const yTicks = gridY ? gridY.ticks(GRID_TICKS).map((t) => gridY(t)) : [];
|
|
64
|
+
drawGrid(ctx, xTicks, yTicks, w, h, gridColor, gridDash);
|
|
65
|
+
for (const entry of layers) {
|
|
66
|
+
const yScale = yScales.get(entry.axisId ?? defaultAxisId);
|
|
67
|
+
if (yScale === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
entry.layer.draw(ctx, xScale, yScale);
|
|
70
|
+
}
|
|
71
|
+
}, [layers, yScales, xScale, defaultAxisId, background, gridColor, gridDash]);
|
|
72
|
+
// Interaction overlay: the cursor marks live on a DOM/SVG overlay above the
|
|
73
|
+
// data, so hovering never repaints the data canvas (whose `draw` doesn't depend
|
|
74
|
+
// on the cursor). Reading the container's cursorX — set by whichever row the
|
|
75
|
+
// pointer is over — syncs the cursor across every row for free. cursorX is a
|
|
76
|
+
// *pixel*, so it stays put while a live window slides; the time + values under
|
|
77
|
+
// it derive from the current xScale.
|
|
78
|
+
const { cursorX, cursorTime: showCursorTime, formatTime } = container;
|
|
79
|
+
// Cursor mode: the row's override, else the container default. One mode per
|
|
80
|
+
// row (the synced vertical line is shared across rows); each layer renders the
|
|
81
|
+
// mode in its own way. `parts` decomposes it into {line, dots, chip}.
|
|
82
|
+
const parts = cursorParts(row.cursor ?? container.cursor);
|
|
83
|
+
const cursorColor = container.theme.cursor ?? container.theme.axis.label;
|
|
84
|
+
// Only read a time when the cursor is within the plot. An out-of-bounds
|
|
85
|
+
// controlled trackerPosition hides the cursor, so the dots + chips hide too —
|
|
86
|
+
// gating cursorTime makes trackerSamples empty, which drives both the SVG marks
|
|
87
|
+
// and the DOM chip branches.
|
|
88
|
+
const cursorTime = cursorX !== null && cursorX >= 0 && cursorX <= plotWidth
|
|
89
|
+
? +xScale.invert(cursorX)
|
|
90
|
+
: null;
|
|
91
|
+
// Per-layer readout samples at the cursor time (nearest data point) — pixel
|
|
92
|
+
// position + value + colour. Drives the overlay dots and the DOM value labels;
|
|
93
|
+
// recomputes as the cursor moves or the window slides under it. Empty when not
|
|
94
|
+
// hovering, so the data canvas is never touched.
|
|
95
|
+
const trackerSamples = useMemo(() => {
|
|
96
|
+
// Only needed for the in-chart dots / chips; skip the per-layer walk when the
|
|
97
|
+
// mode shows neither (the off-chart readout fans in separately on the container).
|
|
98
|
+
if (cursorTime === null || (!parts.dots && parts.chip === 'none'))
|
|
99
|
+
return [];
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const entry of layers) {
|
|
102
|
+
// A layer with a consolidated flag (BoxPlot) renders that, not per-sample
|
|
103
|
+
// dots/chips — skip it here (its values still fan to the off-chart readout
|
|
104
|
+
// via sampleAt on the container).
|
|
105
|
+
if (entry.layer.cursorFlag)
|
|
106
|
+
continue;
|
|
107
|
+
const axisId = entry.axisId ?? defaultAxisId;
|
|
108
|
+
const yScale = yScales.get(axisId);
|
|
109
|
+
if (yScale === undefined)
|
|
110
|
+
continue;
|
|
111
|
+
// The chip uses this layer's axis formatter, so a readout value reads
|
|
112
|
+
// exactly as the axis labels it.
|
|
113
|
+
const fmt = formats.get(axisId) ?? String;
|
|
114
|
+
for (const s of entry.layer.sampleAt(cursorTime)) {
|
|
115
|
+
out.push({
|
|
116
|
+
px: xScale(s.x),
|
|
117
|
+
py: yScale(s.value),
|
|
118
|
+
value: s.value,
|
|
119
|
+
color: s.color,
|
|
120
|
+
format: fmt,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}, [
|
|
126
|
+
cursorTime,
|
|
127
|
+
layers,
|
|
128
|
+
yScales,
|
|
129
|
+
formats,
|
|
130
|
+
xScale,
|
|
131
|
+
defaultAxisId,
|
|
132
|
+
parts.dots,
|
|
133
|
+
parts.chip,
|
|
134
|
+
]);
|
|
135
|
+
// Consolidated multi-value flags (BoxPlot) — one flag per such layer, only in
|
|
136
|
+
// `flag` mode: all the box's values on one chip, anchored at its top-centre
|
|
137
|
+
// (`px`, `topPy`). Rendered as one staff + one multi-line chip (vs the
|
|
138
|
+
// per-sample dots/chips above), the values each coloured to their box piece.
|
|
139
|
+
const trackerFlags = useMemo(() => {
|
|
140
|
+
if (cursorTime === null || parts.chip !== 'flag')
|
|
141
|
+
return [];
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const entry of layers) {
|
|
144
|
+
const flagOf = entry.layer.cursorFlag;
|
|
145
|
+
if (flagOf === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
const axisId = entry.axisId ?? defaultAxisId;
|
|
148
|
+
const yScale = yScales.get(axisId);
|
|
149
|
+
if (yScale === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
const fmt = formats.get(axisId) ?? String;
|
|
152
|
+
// `cursorFlag` is an arrow (captures bx/style, no `this`), so a detached
|
|
153
|
+
// call is safe — and avoids re-reading the optional method.
|
|
154
|
+
const f = flagOf(cursorTime);
|
|
155
|
+
if (f === null)
|
|
156
|
+
continue;
|
|
157
|
+
out.push({
|
|
158
|
+
px: xScale(f.x),
|
|
159
|
+
topPy: yScale(f.topValue),
|
|
160
|
+
lines: f.lines.map((l) => ({ text: fmt(l.value), color: l.color })),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}, [cursorTime, layers, yScales, formats, xScale, defaultAxisId, parts.chip]);
|
|
165
|
+
// Pan/zoom + tracker share the plot's event surface. Container fields are read
|
|
166
|
+
// through a ref so the handlers + the (once-attached) wheel listener always see
|
|
167
|
+
// the latest frame without re-subscribing. Written after commit (not in render)
|
|
168
|
+
// so a wheel/pointer event can't read a frame that was abandoned mid-render
|
|
169
|
+
// under concurrent rendering.
|
|
170
|
+
const containerRef = useRef(container);
|
|
171
|
+
useLayoutEffect(() => {
|
|
172
|
+
containerRef.current = container;
|
|
173
|
+
});
|
|
174
|
+
const plotRef = useRef(null);
|
|
175
|
+
const dragRef = useRef(null);
|
|
176
|
+
// Row read through a ref so the click handler hit-tests the latest layers +
|
|
177
|
+
// y-scales without re-subscribing (same after-commit discipline as containerRef).
|
|
178
|
+
const rowRef = useRef(row);
|
|
179
|
+
useLayoutEffect(() => {
|
|
180
|
+
rowRef.current = row;
|
|
181
|
+
});
|
|
182
|
+
// Pointer-down position, to tell a click (select) from the tail of a drag/pan.
|
|
183
|
+
const clickStartRef = useRef(null);
|
|
184
|
+
const handlePointerDown = useCallback((e) => {
|
|
185
|
+
clickStartRef.current = { x: e.clientX, y: e.clientY };
|
|
186
|
+
const c = containerRef.current;
|
|
187
|
+
if (!c.panZoom)
|
|
188
|
+
return;
|
|
189
|
+
const r = c.timeRange;
|
|
190
|
+
dragRef.current = { startX: e.clientX, startRange: [r[0], r[1]] };
|
|
191
|
+
c.setHoverX(null); // hide the tracker while panning
|
|
192
|
+
c.setHovered(null); // and drop any hover-highlight
|
|
193
|
+
// Capture so the pan continues outside the plot; an enhancement, not
|
|
194
|
+
// critical — guard the throw for synthetic / already-released pointers.
|
|
195
|
+
try {
|
|
196
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* ignore */
|
|
200
|
+
}
|
|
201
|
+
}, []);
|
|
202
|
+
const handlePointerMove = useCallback((e) => {
|
|
203
|
+
const c = containerRef.current;
|
|
204
|
+
const drag = dragRef.current;
|
|
205
|
+
if (drag) {
|
|
206
|
+
// Pan from the start range by the total drag — right → earlier (−dt).
|
|
207
|
+
const dx = e.clientX - drag.startX;
|
|
208
|
+
// Don't pan until past the slop, so a click's 1–4px jitter neither moves
|
|
209
|
+
// the view nor shifts the scale the click then hit-tests against.
|
|
210
|
+
if (Math.abs(dx) <= DRAG_SLOP)
|
|
211
|
+
return;
|
|
212
|
+
const span = drag.startRange[1] - drag.startRange[0];
|
|
213
|
+
const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
|
|
214
|
+
c.applyRange(panRange(drag.startRange, dt));
|
|
215
|
+
return; // tracker suppressed during a pan
|
|
216
|
+
}
|
|
217
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
218
|
+
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
|
|
219
|
+
c.setHoverX(px);
|
|
220
|
+
// Hover-highlight: hit-test the row's selectable layers (Bar) under the
|
|
221
|
+
// pointer and set the hovered mark. Deduped in the container, so the data
|
|
222
|
+
// canvas repaints only on a mark transition — not every move (the move just
|
|
223
|
+
// slides the SVG cursor). A row with no selectable layer (line/area/band)
|
|
224
|
+
// resolves to null → a no-op.
|
|
225
|
+
const r = rowRef.current;
|
|
226
|
+
const hit = resolveSelection(r.layers, px, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
|
|
227
|
+
c.setHovered(hit);
|
|
228
|
+
}, []);
|
|
229
|
+
const handlePointerUp = useCallback((e) => {
|
|
230
|
+
if (dragRef.current) {
|
|
231
|
+
dragRef.current = null;
|
|
232
|
+
try {
|
|
233
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* ignore */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}, []);
|
|
240
|
+
const handlePointerLeave = useCallback(() => {
|
|
241
|
+
const c = containerRef.current;
|
|
242
|
+
c.setHoverX(null);
|
|
243
|
+
c.setHovered(null);
|
|
244
|
+
}, []);
|
|
245
|
+
// Click selection: ignore the click that ends a drag/pan (moved past a few px),
|
|
246
|
+
// else hit-test the row's layers top-down and select — or clear on a miss.
|
|
247
|
+
const handleClick = useCallback((e) => {
|
|
248
|
+
const start = clickStartRef.current;
|
|
249
|
+
if (start &&
|
|
250
|
+
Math.hypot(e.clientX - start.x, e.clientY - start.y) > DRAG_SLOP)
|
|
251
|
+
return;
|
|
252
|
+
const c = containerRef.current;
|
|
253
|
+
const r = rowRef.current;
|
|
254
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
255
|
+
const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
|
|
256
|
+
c.select(hit);
|
|
257
|
+
}, []);
|
|
258
|
+
// Wheel-zoom — a native non-passive listener so `preventDefault` works (React's
|
|
259
|
+
// onWheel is passive). Attached once; no-ops (and lets the page scroll) when
|
|
260
|
+
// panZoom is off.
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
const el = plotRef.current;
|
|
263
|
+
if (el === null)
|
|
264
|
+
return;
|
|
265
|
+
const onWheel = (e) => {
|
|
266
|
+
const c = containerRef.current;
|
|
267
|
+
if (!c.panZoom)
|
|
268
|
+
return;
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
const rect = el.getBoundingClientRect();
|
|
271
|
+
const localX = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
|
|
272
|
+
const pivot = +c.xScale.invert(localX);
|
|
273
|
+
const factor = Math.exp(e.deltaY * ZOOM_SENSITIVITY);
|
|
274
|
+
c.applyRange(zoomRange(c.timeRange, pivot, factor, c.minDuration));
|
|
275
|
+
};
|
|
276
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
277
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
278
|
+
}, []);
|
|
279
|
+
// Cursor presentation is a DOM/SVG overlay (no cursor canvas): an SVG holds the
|
|
280
|
+
// line / dots / flag staffs; these value chips (DOM, crisp text) sit beside each
|
|
281
|
+
// dot ('inline', clamped within the row) or stack at the top of the flag staff
|
|
282
|
+
// ('flag'). line / point / none draw no chips — surface values off-chart.
|
|
283
|
+
const flagLineHeight = container.theme.font.size + 5;
|
|
284
|
+
const chipStyle = {
|
|
285
|
+
position: 'absolute',
|
|
286
|
+
background: container.theme.chip?.background,
|
|
287
|
+
border: `1px solid ${gridColor}`,
|
|
288
|
+
borderRadius: '3px',
|
|
289
|
+
padding: '0 4px',
|
|
290
|
+
fontFamily: container.theme.font.family,
|
|
291
|
+
fontSize: `${container.theme.font.size}px`,
|
|
292
|
+
fontVariantNumeric: 'tabular-nums',
|
|
293
|
+
whiteSpace: 'nowrap',
|
|
294
|
+
pointerEvents: 'none',
|
|
295
|
+
lineHeight: 1.5,
|
|
296
|
+
};
|
|
297
|
+
// Show the cursor's time atop the readout (opt-in via `cursorTime`), whenever
|
|
298
|
+
// the cursor is active (any mode that draws marks). A single chip at the cursor
|
|
299
|
+
// x, top of the row; for `flag` it sits above the value chips (which shift down).
|
|
300
|
+
// The time is shared across rows (one cursor, one time), so it shows **once**,
|
|
301
|
+
// atop the first row — not repeated per row. (Gating it here also drops the
|
|
302
|
+
// top-of-stack space reservation on the other rows, see `flagBase`.)
|
|
303
|
+
const showTime = showCursorTime &&
|
|
304
|
+
cursorTime !== null &&
|
|
305
|
+
(parts.line || parts.dots) &&
|
|
306
|
+
row.isFirstRow;
|
|
307
|
+
// Flag stacking geometry: chips stack from `flagBase` (below the time chip when
|
|
308
|
+
// shown); each staff rises from its dot up to `stackBottom` (the stack's foot).
|
|
309
|
+
const flagTop = 2;
|
|
310
|
+
const flagBase = flagTop + (showTime ? flagLineHeight : 0);
|
|
311
|
+
const stackBottom = flagBase + trackerSamples.length * flagLineHeight;
|
|
312
|
+
// The cursor-time chip caps the readout. In `flag` mode it tops the flag stack,
|
|
313
|
+
// so anchor it to the stack's x (the nearest sample's point) so time + flag +
|
|
314
|
+
// staff + dot read as one column; otherwise it labels the cursor line at cursorX.
|
|
315
|
+
const timeX = parts.chip === 'flag' && trackerSamples.length > 0
|
|
316
|
+
? trackerSamples[0].px
|
|
317
|
+
: cursorX;
|
|
318
|
+
// Inject each draw layer's JSX position so it registers its declaration order
|
|
319
|
+
// (z-stack: lower index at the back), independent of mount timing.
|
|
320
|
+
const indexedChildren = Children.map(children, (child, index) => isValidElement(child)
|
|
321
|
+
? cloneElement(child, { index })
|
|
322
|
+
: child);
|
|
323
|
+
return (_jsxs(LayersContext.Provider, { value: registry, children: [_jsxs("div", { ref: plotRef, style: {
|
|
324
|
+
position: 'relative',
|
|
325
|
+
width: `${plotWidth}px`,
|
|
326
|
+
height: `${row.height}px`,
|
|
327
|
+
cursor: 'crosshair',
|
|
328
|
+
// Let pan/zoom own touch gestures (no native scroll) when enabled.
|
|
329
|
+
touchAction: container.panZoom ? 'none' : 'auto',
|
|
330
|
+
}, onPointerMove: handlePointerMove, onPointerDown: handlePointerDown, onPointerUp: handlePointerUp, onPointerCancel: handlePointerUp, onPointerLeave: handlePointerLeave, onClick: handleClick, children: [_jsx(Canvas, { width: plotWidth, height: row.height, draw: draw }), _jsxs("svg", { width: plotWidth, height: row.height, style: {
|
|
331
|
+
position: 'absolute',
|
|
332
|
+
top: 0,
|
|
333
|
+
left: 0,
|
|
334
|
+
pointerEvents: 'none',
|
|
335
|
+
}, children: [parts.line &&
|
|
336
|
+
cursorX !== null &&
|
|
337
|
+
cursorX >= 0 &&
|
|
338
|
+
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' &&
|
|
339
|
+
trackerSamples.map((s, i) => s.py > stackBottom ? (_jsx("line", { x1: s.px, y1: stackBottom, x2: s.px, y2: s.py, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `staff-${i}`)) : null), parts.chip === 'flag' &&
|
|
340
|
+
trackerFlags.map((f, i) => {
|
|
341
|
+
// One horizontal row of values → a single-line chip.
|
|
342
|
+
const flagBottom = flagBase + flagLineHeight;
|
|
343
|
+
return f.topPy > flagBottom ? (_jsx("line", { x1: f.px, y1: flagBottom, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null;
|
|
344
|
+
}), parts.dots &&
|
|
345
|
+
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: {
|
|
346
|
+
...chipStyle,
|
|
347
|
+
top: `${flagTop}px`,
|
|
348
|
+
left: timeX > plotWidth * LABEL_FLIP_FRACTION
|
|
349
|
+
? undefined
|
|
350
|
+
: `${timeX + 4}px`,
|
|
351
|
+
right: timeX > plotWidth * LABEL_FLIP_FRACTION
|
|
352
|
+
? `${plotWidth - timeX + 4}px`
|
|
353
|
+
: undefined,
|
|
354
|
+
color: cursorColor,
|
|
355
|
+
}, children: formatTime(cursorTime) })), parts.chip === 'inline' &&
|
|
356
|
+
trackerSamples.map((s, i) => {
|
|
357
|
+
// Flip the chip left of its dot near the right edge so it stays in-plot.
|
|
358
|
+
const flip = s.px > plotWidth * LABEL_FLIP_FRACTION;
|
|
359
|
+
// Clamp within the row so a chip near the top/bottom isn't clipped by
|
|
360
|
+
// (or spilling into) the neighbouring row. Chip-vs-chip de-overlap is
|
|
361
|
+
// a later refinement; this keeps each chip inside its own row.
|
|
362
|
+
const top = Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, s.py));
|
|
363
|
+
return (_jsx("div", { style: {
|
|
364
|
+
...chipStyle,
|
|
365
|
+
top: `${top}px`,
|
|
366
|
+
transform: 'translateY(-50%)',
|
|
367
|
+
left: flip ? undefined : `${s.px + 8}px`,
|
|
368
|
+
right: flip ? `${plotWidth - s.px + 8}px` : undefined,
|
|
369
|
+
color: s.color,
|
|
370
|
+
}, children: s.format(s.value) }, i));
|
|
371
|
+
}), parts.chip === 'flag' &&
|
|
372
|
+
cursorX !== null &&
|
|
373
|
+
trackerSamples.map((s, i) => {
|
|
374
|
+
// Each flag caps its own staff — anchored to the data point's x
|
|
375
|
+
// (`s.px`), riding the point with the dot + staff, not the cursor.
|
|
376
|
+
// Flip left near the right edge so it stays in-plot.
|
|
377
|
+
const flip = s.px > plotWidth * LABEL_FLIP_FRACTION;
|
|
378
|
+
return (_jsx("div", { style: {
|
|
379
|
+
...chipStyle,
|
|
380
|
+
top: `${flagBase + i * flagLineHeight}px`,
|
|
381
|
+
left: flip ? undefined : `${s.px + 4}px`,
|
|
382
|
+
right: flip ? `${plotWidth - s.px + 4}px` : undefined,
|
|
383
|
+
color: s.color,
|
|
384
|
+
}, children: s.format(s.value) }, i));
|
|
385
|
+
}), parts.chip === 'flag' &&
|
|
386
|
+
trackerFlags.map((f, i) => {
|
|
387
|
+
const flip = f.px > plotWidth * LABEL_FLIP_FRACTION;
|
|
388
|
+
return (_jsx("div", { style: {
|
|
389
|
+
...chipStyle,
|
|
390
|
+
top: `${flagBase}px`,
|
|
391
|
+
left: flip ? undefined : `${f.px + 4}px`,
|
|
392
|
+
right: flip ? `${plotWidth - f.px + 4}px` : undefined,
|
|
393
|
+
display: 'flex',
|
|
394
|
+
flexDirection: 'row',
|
|
395
|
+
gap: '6px',
|
|
396
|
+
}, children: f.lines.map((l, j) => (_jsx("span", { style: { color: l.color }, children: l.text }, j))) }, `boxflag-${i}`));
|
|
397
|
+
})] }), indexedChildren] }));
|
|
398
|
+
}
|
|
399
|
+
//# sourceMappingURL=Layers.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ValueSeries } from 'pond-ts';
|
|
2
|
+
import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
|
|
3
|
+
import { type Curve } from './curve.js';
|
|
4
|
+
import { type GapMode } from './gaps.js';
|
|
5
|
+
export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
|
|
6
|
+
/**
|
|
7
|
+
* The source series. A `TimeSeries` plots against the time axis; a
|
|
8
|
+
* `ValueSeries` (`series.byValue('cumDist')`) against its value axis — the
|
|
9
|
+
* container infers which from the data, no axis-type prop. Either way the key
|
|
10
|
+
* / axis column supplies x and `column` supplies y.
|
|
11
|
+
*/
|
|
12
|
+
series: TimeSeries<S> | ValueSeries<VS>;
|
|
13
|
+
/** Name of the numeric value column to plot. */
|
|
14
|
+
column: string;
|
|
15
|
+
/**
|
|
16
|
+
* The series' semantic identifier — what the data _is_ / how it should read
|
|
17
|
+
* (e.g. `heartrate`, `power`, or a role name like `foam`). The theme maps it
|
|
18
|
+
* to a {@link LineStyle} (`theme.line[as] ?? theme.line.default`). **Omitted ⇒
|
|
19
|
+
* the `default` style** — `column` is the data, `as` is the identity, and
|
|
20
|
+
* there's no per-component colour/width override (that second styling channel
|
|
21
|
+
* is what bred react-timeseries-charts' styling bugs; restyle via the theme).
|
|
22
|
+
*/
|
|
23
|
+
as?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Which `<YAxis>` (by its `id`) this line scales against — picks the *scale*,
|
|
26
|
+
* where `as` picks the *style* (separate concerns). **Omitted ⇒ the row's
|
|
27
|
+
* default axis** (the first declared, or the implicit auto-domain axis).
|
|
28
|
+
*/
|
|
29
|
+
axis?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Render-time path interpolation between points — a view concern (denoise the
|
|
32
|
+
* data with pond's `smooth()` upstream). **Omitted ⇒ `'linear'`** (straight
|
|
33
|
+
* segments). `'monotone'` is a smooth line that still passes through points.
|
|
34
|
+
*/
|
|
35
|
+
curve?: Curve;
|
|
36
|
+
/**
|
|
37
|
+
* How a **gap** (a coast / dropout — a run of NaN in `column`) is rendered (a
|
|
38
|
+
* {@link GapMode}). **Omitted ⇒ `'empty'`**: the line breaks at the gap and
|
|
39
|
+
* leaves a hole (the honest default). `'none'` bridges straight across;
|
|
40
|
+
* `'dashed'` adds a faint dashed bridge over the break; `'step'` adds a faint
|
|
41
|
+
* flat dashed line at the average of the two edge values; `'fade'` is estela's
|
|
42
|
+
* fade-to-baseline at each gap edge. Shared with `<AreaChart>` — one concept.
|
|
43
|
+
* (The `'dashed'` / `'step'` connector faintness is the theme's
|
|
44
|
+
* `gap.connectorOpacity`.)
|
|
45
|
+
*/
|
|
46
|
+
gaps?: GapMode;
|
|
47
|
+
/**
|
|
48
|
+
* @internal Declaration position among the `<Layers>` children, injected by
|
|
49
|
+
* `Layers` so z-order follows JSX order. Do not set.
|
|
50
|
+
*/
|
|
51
|
+
index?: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A line draw layer. Reads `column` from `series` into a {@link ChartSeries}
|
|
55
|
+
* (columnar, gaps as NaN), registers itself into the enclosing {@link Layers}
|
|
56
|
+
* (scaling against its `axis`), and renders nothing to the DOM — the row draws
|
|
57
|
+
* it. The line breaks at gaps rather than spanning them.
|
|
58
|
+
*/
|
|
59
|
+
export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, curve, gaps, index, }: LineChartProps<S, VS>): null;
|
|
60
|
+
//# sourceMappingURL=LineChart.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useContext, useEffect, useMemo } from 'react';
|
|
2
|
+
import { ValueSeries } from 'pond-ts';
|
|
3
|
+
import { fromTimeSeries, fromValueSeries } from './data.js';
|
|
4
|
+
import { drawLine, yExtent } from './line.js';
|
|
5
|
+
import { resolveCurve } from './curve.js';
|
|
6
|
+
import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
7
|
+
import { ContainerContext, LayersContext } from './context.js';
|
|
8
|
+
import { useSlotKey } from './use-slot-key.js';
|
|
9
|
+
/**
|
|
10
|
+
* A line draw layer. Reads `column` from `series` into a {@link ChartSeries}
|
|
11
|
+
* (columnar, gaps as NaN), registers itself into the enclosing {@link Layers}
|
|
12
|
+
* (scaling against its `axis`), and renders nothing to the DOM — the row draws
|
|
13
|
+
* it. The line breaks at gaps rather than spanning them.
|
|
14
|
+
*/
|
|
15
|
+
export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, index = 0, }) {
|
|
16
|
+
const container = useContext(ContainerContext);
|
|
17
|
+
if (container === null) {
|
|
18
|
+
throw new Error('<LineChart> must be rendered inside a <ChartContainer>');
|
|
19
|
+
}
|
|
20
|
+
const layers = useContext(LayersContext);
|
|
21
|
+
if (layers === null) {
|
|
22
|
+
throw new Error('<LineChart> must be rendered inside a <Layers>');
|
|
23
|
+
}
|
|
24
|
+
const cs = useMemo(() => series instanceof ValueSeries
|
|
25
|
+
? fromValueSeries(series, column)
|
|
26
|
+
: fromTimeSeries(series, column), [series, column]);
|
|
27
|
+
// Styling: semantic identifier → theme style. The single styling channel.
|
|
28
|
+
const { line } = container.theme;
|
|
29
|
+
const style = (semantic !== undefined ? line[semantic] : undefined) ?? line.default;
|
|
30
|
+
// Series identity for the readout (the `as` role, else the column name).
|
|
31
|
+
const label = semantic ?? column;
|
|
32
|
+
const curveFactory = resolveCurve(curve);
|
|
33
|
+
// Faintness of the inferred dashed connectors (dashed / step) — theme-level,
|
|
34
|
+
// falling back to the shared default so a theme without it still renders faint.
|
|
35
|
+
const gapConnectorOpacity = container.theme.gap?.connectorOpacity ?? DEFAULT_GAP_CONNECTOR_OPACITY;
|
|
36
|
+
const entry = useMemo(() => ({
|
|
37
|
+
layer: {
|
|
38
|
+
yExtent: () => yExtent(cs),
|
|
39
|
+
// The container infers the shared x scale's kind + auto-fit domain from
|
|
40
|
+
// its layers: a ValueSeries plots on a value axis, a TimeSeries on time.
|
|
41
|
+
xKind: series instanceof ValueSeries ? 'value' : 'time',
|
|
42
|
+
xExtent: () => cs.length === 0 ? null : [cs.x[0], cs.x[cs.length - 1]],
|
|
43
|
+
sampleAt: (x) => {
|
|
44
|
+
// No readout past the data (tracker policy — nearest clamps to an
|
|
45
|
+
// endpoint outside the span); bounds from the columnar x axis.
|
|
46
|
+
if (cs.length === 0 || x < cs.x[0] || x > cs.x[cs.length - 1]) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (series instanceof ValueSeries) {
|
|
50
|
+
// Value axis: bisect the axis for the nearest row, read y from `cs`.
|
|
51
|
+
const i = series.nearestIndex(x);
|
|
52
|
+
if (i < 0)
|
|
53
|
+
return [];
|
|
54
|
+
const v = cs.y[i];
|
|
55
|
+
return Number.isFinite(v)
|
|
56
|
+
? [{ x: cs.x[i], value: v, color: style.color, label }]
|
|
57
|
+
: [];
|
|
58
|
+
}
|
|
59
|
+
const e = series.nearest(x);
|
|
60
|
+
if (e === undefined)
|
|
61
|
+
return [];
|
|
62
|
+
// get() wants a literal key; column is a runtime string. Cast the
|
|
63
|
+
// *event* (not the method — that would detach `this`) to a
|
|
64
|
+
// string-keyed get; runtime-safe read + guard.
|
|
65
|
+
const v = e.get(column);
|
|
66
|
+
return typeof v === 'number' && Number.isFinite(v)
|
|
67
|
+
? [{ x: e.begin(), value: v, color: style.color, label }]
|
|
68
|
+
: [];
|
|
69
|
+
},
|
|
70
|
+
draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity),
|
|
71
|
+
},
|
|
72
|
+
axisId: axis,
|
|
73
|
+
index,
|
|
74
|
+
}), [
|
|
75
|
+
cs,
|
|
76
|
+
series,
|
|
77
|
+
column,
|
|
78
|
+
style,
|
|
79
|
+
label,
|
|
80
|
+
curveFactory,
|
|
81
|
+
gaps,
|
|
82
|
+
gapConnectorOpacity,
|
|
83
|
+
axis,
|
|
84
|
+
index,
|
|
85
|
+
]);
|
|
86
|
+
// A stable per-instance slot (see useSlotKey) keeps this layer's z-position
|
|
87
|
+
// fixed: a series or style change updates the slot in place rather than
|
|
88
|
+
// re-appending (which would jump the layer to the front of the z-stack on
|
|
89
|
+
// every live update).
|
|
90
|
+
const slot = useSlotKey();
|
|
91
|
+
// Unregister on unmount only (stable deps); register + update in place.
|
|
92
|
+
useEffect(() => () => layers.unregisterLayer(slot), [layers, slot]);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
layers.registerLayer(slot, entry);
|
|
95
|
+
}, [layers, slot, entry]);
|
|
96
|
+
// Also register as a tracker source so the container can fan in this series'
|
|
97
|
+
// value at the cursor for the (outside-the-chart) readout.
|
|
98
|
+
const { registerTrackerSource, unregisterTrackerSource } = container;
|
|
99
|
+
useEffect(() => () => unregisterTrackerSource(slot), [unregisterTrackerSource, slot]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
registerTrackerSource(slot, entry.layer);
|
|
102
|
+
}, [registerTrackerSource, slot, entry.layer]);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=LineChart.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { SeriesSchema, TimeSeries } from 'pond-ts';
|
|
2
|
+
import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
|
|
3
|
+
export interface ScatterChartProps<S extends SeriesSchema> {
|
|
4
|
+
/** The source series. Its key column supplies the time axis (each point's x). */
|
|
5
|
+
series: TimeSeries<S>;
|
|
6
|
+
/** Name of the numeric value column — each point's y. */
|
|
7
|
+
column: string;
|
|
8
|
+
/**
|
|
9
|
+
* The scatter's semantic identifier — what the marks _are_ / how they should
|
|
10
|
+
* read. The theme maps it to a {@link ScatterStyle} (`theme.scatter[as] ??
|
|
11
|
+
* theme.scatter.default`) — the **base** fill, radius, outline, and label
|
|
12
|
+
* colour. **Omitted ⇒ the `default` style.** This is the single styling
|
|
13
|
+
* channel for the base mark; per-point size / colour come from the data-driven
|
|
14
|
+
* `radius` / `color` encodings below (the deliberate, signed-off scatter
|
|
15
|
+
* exception), not a per-component style override.
|
|
16
|
+
*/
|
|
17
|
+
as?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Which `<YAxis>` (by its `id`) this scatter scales against — picks the
|
|
20
|
+
* *scale*, where `as` picks the *style*. **Omitted ⇒ the row's default axis.**
|
|
21
|
+
*/
|
|
22
|
+
axis?: string;
|
|
23
|
+
/**
|
|
24
|
+
* **Data-driven point radius** — the signed-off exception to one-channel
|
|
25
|
+
* styling. Either a fixed px radius, or `{ column, range }` to size each point
|
|
26
|
+
* from a numeric column (its finite extent → `[minR, maxR]` px via a linear
|
|
27
|
+
* scale). A point whose radius column is non-finite falls back to the base
|
|
28
|
+
* radius. **Omitted ⇒ the style's base radius.** The encoding is a column +
|
|
29
|
+
* range, *not* a per-datum callback — there's no place for a styling bug to
|
|
30
|
+
* hide (the trap the package avoids).
|
|
31
|
+
*/
|
|
32
|
+
radius?: RadiusEncoding;
|
|
33
|
+
/**
|
|
34
|
+
* **Data-driven point colour** — `{ column, range }`: colour each point from a
|
|
35
|
+
* numeric column (its finite extent → a two-stop hex ramp via a linear scale).
|
|
36
|
+
* A point whose colour column is non-finite falls back to the base colour.
|
|
37
|
+
* **Omitted ⇒ the style's base colour** for every point (the single styling
|
|
38
|
+
* channel). Same discipline as `radius`: a column + range, not a callback.
|
|
39
|
+
*/
|
|
40
|
+
color?: ColorEncoding;
|
|
41
|
+
/**
|
|
42
|
+
* An optional per-point text label, drawn just right of each mark, in the
|
|
43
|
+
* style's `label` colour + the theme font. Two forms:
|
|
44
|
+
* - **a column name** ⇒ that column's value at each point, stringified;
|
|
45
|
+
* - **`true`** ⇒ the plotted `column`'s value, stringified.
|
|
46
|
+
*
|
|
47
|
+
* **Omitted / `false` ⇒ no labels.** Keep it sparse — a label per point on a
|
|
48
|
+
* dense scatter is noise; this is for a handful of called-out marks.
|
|
49
|
+
*/
|
|
50
|
+
label?: string | boolean;
|
|
51
|
+
/**
|
|
52
|
+
* @internal Declaration position among the `<Layers>` children, injected by
|
|
53
|
+
* `Layers` so z-order follows JSX order. Do not set.
|
|
54
|
+
*/
|
|
55
|
+
index?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A scatter draw layer: one mark per finite point at `(time, column-value)`,
|
|
59
|
+
* with **data-driven radius + colour** (the signed-off exception — encode from
|
|
60
|
+
* columns via scales, not a per-event style callback). Reads `column` into a
|
|
61
|
+
* {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
|
|
62
|
+
* {@link Layers} (scaling against its `axis`), and renders nothing to the DOM —
|
|
63
|
+
* the row draws it.
|
|
64
|
+
*
|
|
65
|
+
* **Interactions.** Hover snaps the tracker dot to the nearest point
|
|
66
|
+
* (`sampleAt`), and that sample flows to the container's `onTrackerChanged` —
|
|
67
|
+
* the nearest-point readout. Scatter reuses the shared tracker rather than
|
|
68
|
+
* adding a separate `onNearest` channel, so a scatter reads out exactly like a
|
|
69
|
+
* line. Click selection hit-tests each point's disc (`hitTest`); the selected
|
|
70
|
+
* point (matching both its key and this series' label) gets a highlight ring.
|
|
71
|
+
*
|
|
72
|
+
* ```tsx
|
|
73
|
+
* <Layers>
|
|
74
|
+
* <ScatterChart
|
|
75
|
+
* series={s}
|
|
76
|
+
* column="price"
|
|
77
|
+
* radius={{ column: 'volume', range: [3, 14] }}
|
|
78
|
+
* color={{ column: 'change', range: ['#e8836b', '#15B3A6'] }}
|
|
79
|
+
* />
|
|
80
|
+
* </Layers>
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
|
|
84
|
+
//# sourceMappingURL=ScatterChart.d.ts.map
|