@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +3254 -0
  2. package/LICENSE +21 -0
  3. package/README.md +229 -0
  4. package/dist/AreaChart.d.ts +85 -0
  5. package/dist/AreaChart.js +119 -0
  6. package/dist/BandChart.d.ts +55 -0
  7. package/dist/BandChart.js +93 -0
  8. package/dist/BarChart.d.ts +72 -0
  9. package/dist/BarChart.js +137 -0
  10. package/dist/BoxPlot.d.ts +77 -0
  11. package/dist/BoxPlot.js +137 -0
  12. package/dist/Canvas.d.ts +37 -0
  13. package/dist/Canvas.js +39 -0
  14. package/dist/ChartContainer.d.ts +106 -0
  15. package/dist/ChartContainer.js +306 -0
  16. package/dist/ChartRow.d.ts +29 -0
  17. package/dist/ChartRow.js +215 -0
  18. package/dist/Layers.d.ts +22 -0
  19. package/dist/Layers.js +399 -0
  20. package/dist/LineChart.d.ts +60 -0
  21. package/dist/LineChart.js +105 -0
  22. package/dist/ScatterChart.d.ts +84 -0
  23. package/dist/ScatterChart.js +139 -0
  24. package/dist/TimeAxis.d.ts +9 -0
  25. package/dist/TimeAxis.js +12 -0
  26. package/dist/XAxis.d.ts +39 -0
  27. package/dist/XAxis.js +84 -0
  28. package/dist/YAxis.d.ts +42 -0
  29. package/dist/YAxis.js +86 -0
  30. package/dist/annotations.d.ts +110 -0
  31. package/dist/annotations.js +459 -0
  32. package/dist/area.d.ts +54 -0
  33. package/dist/area.js +186 -0
  34. package/dist/band.d.ts +31 -0
  35. package/dist/band.js +57 -0
  36. package/dist/bars.d.ts +96 -0
  37. package/dist/bars.js +171 -0
  38. package/dist/box.d.ts +59 -0
  39. package/dist/box.js +140 -0
  40. package/dist/chip.d.ts +23 -0
  41. package/dist/chip.js +43 -0
  42. package/dist/cjs-fallback.cjs +16 -0
  43. package/dist/context.d.ts +362 -0
  44. package/dist/context.js +5 -0
  45. package/dist/curve.d.ts +22 -0
  46. package/dist/curve.js +13 -0
  47. package/dist/data.d.ts +154 -0
  48. package/dist/data.js +197 -0
  49. package/dist/domain.d.ts +19 -0
  50. package/dist/domain.js +61 -0
  51. package/dist/encoding.d.ts +89 -0
  52. package/dist/encoding.js +144 -0
  53. package/dist/format.d.ts +53 -0
  54. package/dist/format.js +47 -0
  55. package/dist/gaps.d.ts +146 -0
  56. package/dist/gaps.js +209 -0
  57. package/dist/grid.d.ts +11 -0
  58. package/dist/grid.js +29 -0
  59. package/dist/index.d.ts +53 -0
  60. package/dist/index.js +34 -0
  61. package/dist/line.d.ts +46 -0
  62. package/dist/line.js +88 -0
  63. package/dist/range.d.ts +15 -0
  64. package/dist/range.js +27 -0
  65. package/dist/scatter.d.ts +70 -0
  66. package/dist/scatter.js +213 -0
  67. package/dist/select.d.ts +13 -0
  68. package/dist/select.js +23 -0
  69. package/dist/slots.d.ts +48 -0
  70. package/dist/slots.js +64 -0
  71. package/dist/theme.d.ts +224 -0
  72. package/dist/theme.js +232 -0
  73. package/dist/tracker.d.ts +30 -0
  74. package/dist/tracker.js +47 -0
  75. package/dist/use-slot-key.d.ts +21 -0
  76. package/dist/use-slot-key.js +25 -0
  77. package/dist/viewport.d.ts +20 -0
  78. package/dist/viewport.js +30 -0
  79. 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