@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
@@ -0,0 +1,459 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
+ import { ContainerContext, RowContext, } from './context.js';
4
+ import { flagChipStyle, flagChipX } from './chip.js';
5
+ import { useSlotKey } from './use-slot-key.js';
6
+ /**
7
+ * User-authored **annotations** — marks you place *on* a chart, in a register
8
+ * deliberately distinct from the data: `<Region>` (a shaded x-span), `<Baseline>`
9
+ * (a horizontal value line), and `<Marker>` (a vertical x line). All three render
10
+ * in the theme's turquoise {@link ChartTheme.annotation} register so a placed mark
11
+ * never reads as data ("the data stays foam; the marks you place are turquoise").
12
+ *
13
+ * They are children of `<Layers>` (so they share the plot's coordinate space) and
14
+ * paint an SVG overlay above the data canvas + below the cursor. Each label is a
15
+ * **flag** (the cursor value flag's shape). **Brightness encodes depth** — a mark
16
+ * draws at one of three {@link ChartTheme.annotation | depth} levels (forward =
17
+ * brightest); `selectable={false}` pins it at the back as inert context.
18
+ *
19
+ * **Three interaction modes** (all controlled — the consumer holds the ids):
20
+ * - **Inspect-select** (any mode): a single click selects a mark
21
+ * ({@link ContainerFrame.onSelectAnnotation}); `hovered`/`onHoverAnnotation` sync
22
+ * hover both ways (e.g. with a legend); both come **forward** (selected = level 1,
23
+ * hover = level 2).
24
+ * - **Single-annotation edit** (any mode): a double-click requests edit of just that
25
+ * mark ({@link ContainerFrame.onEditAnnotation}); the consumer sets its `editing`
26
+ * prop → it gains always-on handles and becomes draggable while the rest stay
27
+ * static. An empty plot click exits (fires `onSelectAnnotation(null)`).
28
+ * - **Global edit** ({@link ContainerFrame.editAnnotations}): the data cursor steps
29
+ * aside and *every* mark with an `onChange` is editable, handles on hover; armed
30
+ * {@link ContainerFrame.creating | create tools} draw new marks.
31
+ *
32
+ * Editing is controlled: a `<Region>` body drags to **move**, its edges **resize**;
33
+ * a `<Marker>`/`<Baseline>` drags whole — each reports via `onChange`. An edit hit
34
+ * area **claims the gesture** (pointer capture + `stopPropagation`) so a drag never
35
+ * starts a pan. Marks register with the container
36
+ * ({@link ContainerFrame.annotations}), which draws each mark's **guide** across the
37
+ * other rows and lets a drag **snap** to other marks' x-positions.
38
+ */
39
+ /** Fallback when a theme defines no `annotation` token — a neutral turquoise. */
40
+ const DEFAULT_ANNOTATION = {
41
+ color: '#14b8a6',
42
+ fillOpacity: 0.1,
43
+ depth: [1, 0.7, 0.4],
44
+ };
45
+ /** Handle-pill geometry (px) — long axis vs short axis. */
46
+ const HANDLE_LONG = 18;
47
+ const HANDLE_SHORT = 6;
48
+ /** How wide a line's invisible grab area is, each side (px). */
49
+ const HIT_PAD = 5;
50
+ /** How wide a region edge's resize grab area is (px) — sits over the body. */
51
+ const EDGE_GRAB = 8;
52
+ /** Flag-chip top offset (px from the row top) — shared so labels align. */
53
+ const FLAG_TOP = 2;
54
+ /** Depth level (1 = forward/brightest … 3 = back/dimmest) → an index into the
55
+ * theme's `depth` ramp. Brighter reads as more forward, i.e. more attention.
56
+ *
57
+ * A mark's LINES (marker/baseline line, region edges) and a region's BODY fill
58
+ * can sit at different levels: in edit mode the lines come fully forward (level
59
+ * 1) while a region body stays one step back (level 2), so the edges read as the
60
+ * grabbable thing. A non-`selectable` mark is inert background context — always
61
+ * level 3, ignoring hover + selection. */
62
+ function lineLevel(selectable, editing, hovering, selected) {
63
+ if (!selectable)
64
+ return 3;
65
+ if (selected)
66
+ return 1;
67
+ if (editing)
68
+ return 1; // edit mode brings the structural lines forward
69
+ if (hovering)
70
+ return 2;
71
+ return 3;
72
+ }
73
+ /** Region body-fill level — like {@link lineLevel} but one step back in edit mode. */
74
+ function bodyLevel(selectable, editing, hovering, selected) {
75
+ if (!selectable)
76
+ return 3;
77
+ if (selected)
78
+ return 1;
79
+ if (editing || hovering)
80
+ return 2;
81
+ return 3;
82
+ }
83
+ /** Region body-fill opacity multiplier by depth level (the fill is subtle so the
84
+ * data reads through; it lifts as the region comes forward). */
85
+ const FILL_MULT = [1.6, 1.3, 1];
86
+ /** Pick the value for a depth level (1–3) from a three-stop ramp. Indexes by a
87
+ * literal so the lookup is total (no out-of-range `undefined`). */
88
+ function rampAt(ramp, level) {
89
+ return level === 1 ? ramp[0] : level === 2 ? ramp[1] : ramp[2];
90
+ }
91
+ /** The full-plot overlay each annotation paints into — above the data canvas,
92
+ * below the cursor. Inert to the pointer by default; an edit-mode hit area opts
93
+ * back in to `pointerEvents: auto` for itself only. */
94
+ const overlayStyle = {
95
+ position: 'absolute',
96
+ top: 0,
97
+ left: 0,
98
+ pointerEvents: 'none',
99
+ };
100
+ /** Read the container + row frames an annotation needs, or throw if misplaced. */
101
+ function useAnnotationFrame(name) {
102
+ const container = useContext(ContainerContext);
103
+ if (container === null) {
104
+ throw new Error(`<${name}> must be rendered inside a <ChartContainer>`);
105
+ }
106
+ const row = useContext(RowContext);
107
+ if (row === null) {
108
+ throw new Error(`<${name}> must be rendered inside a <ChartRow>`);
109
+ }
110
+ const ann = container.theme.annotation ?? DEFAULT_ANNOTATION;
111
+ return { container, row, ann };
112
+ }
113
+ /** Register this annotation with the container (so it can draw the mark's guide on
114
+ * other rows, order regions, and serve snap targets), keyed by the caller's stable
115
+ * per-instance slot key; unregister on unmount. `xs` should be memoised by the
116
+ * caller so the effect only re-runs when the position actually moves. */
117
+ function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, selectable, editing, label) {
118
+ const { registerAnnotation, unregisterAnnotation } = container;
119
+ useEffect(() => () => unregisterAnnotation(key), [unregisterAnnotation, key]);
120
+ useEffect(() => {
121
+ registerAnnotation(key, {
122
+ key,
123
+ id,
124
+ kind,
125
+ rowKey,
126
+ xs,
127
+ selected,
128
+ selectable,
129
+ editing,
130
+ label,
131
+ });
132
+ }, [
133
+ registerAnnotation,
134
+ key,
135
+ id,
136
+ kind,
137
+ rowKey,
138
+ xs,
139
+ selected,
140
+ selectable,
141
+ editing,
142
+ label,
143
+ ]);
144
+ }
145
+ /** Vertical px between stacked label lanes. */
146
+ const LANE_H = 22;
147
+ /** Rough chip-width model for overlap detection (monospace-ish: chars × width +
148
+ * padding) and the min px gap kept between two labels sharing a lane. */
149
+ const LABEL_CHAR_W = 7;
150
+ const LABEL_PAD = 16;
151
+ const LANE_GAP = 6;
152
+ /**
153
+ * Greedy left→right lane packing for the **top-flag** labels (markers + regions): a
154
+ * label that would overlap the one to its left drops to the next free lane below,
155
+ * so close-in-x labels stack instead of colliding (and a dragged label slides under
156
+ * its neighbour). Returns slot-key → lane (0 = top). Baselines, whose labels anchor
157
+ * at the left at their own y, don't participate.
158
+ */
159
+ export function computeLabelLanes(annotations, toPixel) {
160
+ const flags = annotations
161
+ .filter((a) => (a.kind === 'marker' || a.kind === 'region') &&
162
+ a.label.length > 0 &&
163
+ a.xs.length > 0)
164
+ .map((a) => {
165
+ const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
166
+ return {
167
+ key: a.key,
168
+ left: toPixel(ax),
169
+ width: a.label.length * LABEL_CHAR_W + LABEL_PAD,
170
+ };
171
+ })
172
+ .sort((p, q) => p.left - q.left);
173
+ const laneEnds = []; // right-px of the last label placed in each lane
174
+ const lanes = new Map();
175
+ for (const f of flags) {
176
+ let lane = 0;
177
+ while (lane < laneEnds.length && laneEnds[lane] + LANE_GAP > f.left) {
178
+ lane += 1;
179
+ }
180
+ laneEnds[lane] = f.left + f.width;
181
+ lanes.set(f.key, lane);
182
+ }
183
+ return lanes;
184
+ }
185
+ /** A mark's hover state, synced both ways with the consumer. The effective hover
186
+ * is the local pointer hover **OR** the controlled `hovered` prop (so a legend row
187
+ * can light the mark remotely); `reportHover` mirrors local pointer enter/leave out
188
+ * to {@link ContainerFrame.onHoverAnnotation} by `id` (so the mark can light the
189
+ * legend). A mark with no `id` keeps its hover purely local. */
190
+ function useAnnotationHover(container, id, hovered) {
191
+ const [selfHover, setSelfHover] = useState(false);
192
+ const hovering = selfHover || hovered === true;
193
+ const reportHover = (h) => {
194
+ setSelfHover(h);
195
+ if (id !== undefined)
196
+ container.onHoverAnnotation?.(h ? id : null);
197
+ };
198
+ return { hovering, reportHover };
199
+ }
200
+ /** Pixel radius within which a drag snaps to a guideline (another mark's x). */
201
+ const SNAP_PX = 6;
202
+ /**
203
+ * Snap a dragged plot-pixel `px` to the nearest **guideline** — another
204
+ * annotation's x — within {@link SNAP_PX}. Returns that guideline's **axis** value
205
+ * to snap to, or `null` if none is near (the caller keeps the raw position).
206
+ * Excludes the dragging mark's own `key`, and reads the same registry the guides
207
+ * draw from, so a drag visibly clicks onto the lines you can see.
208
+ */
209
+ function snapToGuides(container, selfKey, px) {
210
+ let best = null;
211
+ let bestDist = SNAP_PX;
212
+ for (const a of container.annotations) {
213
+ if (a.key === selfKey)
214
+ continue;
215
+ for (const tx of a.xs) {
216
+ const d = Math.abs(container.xScale(tx) - px);
217
+ if (d < bestDist) {
218
+ bestDist = d;
219
+ best = tx;
220
+ }
221
+ }
222
+ }
223
+ return best;
224
+ }
225
+ /** A label chip — the cursor value flag's shape (shared {@link flagChipStyle}:
226
+ * filled, no outline) with text in the annotation register. */
227
+ function Chip({ theme, color, style, children, }) {
228
+ return (_jsx("div", { style: { ...flagChipStyle(theme), color, ...style }, children: children }));
229
+ }
230
+ /** A non-interactive handle pill, shown on hover (edit mode) / when selected. */
231
+ function Pill({ cx, cy, w, h, color, }) {
232
+ return (_jsx("rect", { x: cx - w / 2, y: cy - h / 2, width: w, height: h, rx: 3, fill: color, style: { pointerEvents: 'none' } }));
233
+ }
234
+ /**
235
+ * A transparent hit rect over a selectable mark. It **always** reports hover
236
+ * (`onHover`, → level 2 even with edit off), and a **single click that didn't drag
237
+ * selects** the mark (`onSelect`) in *any* mode — the inspect-select — while a
238
+ * **double-click edits** it (`onEdit` — the consumer flips it into single-annotation
239
+ * edit). Both clicks `stopPropagation` so they never reach the plot's data-select /
240
+ * deselect. Only when `editable` does it claim the drag gesture (`stopPropagation` +
241
+ * guarded pointer capture, so a drag never starts a pan) and report the pointer's
242
+ * **plot-pixel** position on press (`onDragStart`) / move (`onDrag`). With editing
243
+ * off, press / move bubble so a pan reads straight through.
244
+ */
245
+ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onDragStart, onDrag, }) {
246
+ const dragging = useRef(false);
247
+ // Tracks whether this press became a drag (moved past a few px) — a click that
248
+ // didn't drag selects instead of edits. Tracked in *both* modes so a pan-drag
249
+ // started on the mark (edit off) doesn't fire a spurious select on release.
250
+ const moved = useRef(false);
251
+ const downAt = useRef(null);
252
+ const at = (e) => {
253
+ const svg = e.currentTarget.ownerSVGElement;
254
+ if (svg === null)
255
+ return [0, 0];
256
+ const r = svg.getBoundingClientRect();
257
+ return [e.clientX - r.left, e.clientY - r.top];
258
+ };
259
+ return (_jsx("rect", { x: x, y: y, width: Math.max(w, 1), height: Math.max(h, 1), fill: "transparent", style: { pointerEvents: 'auto', cursor }, onPointerEnter: () => onHover(true), onPointerLeave: () => {
260
+ if (!dragging.current)
261
+ onHover(false);
262
+ }, onPointerDown: (e) => {
263
+ const p = at(e);
264
+ moved.current = false;
265
+ downAt.current = p; // tracked in both modes for the click/drag guard
266
+ if (!editable)
267
+ return; // edit off: let it bubble (pan reads through)
268
+ e.stopPropagation(); // claim the gesture — don't let the plot start a pan
269
+ dragging.current = true;
270
+ onDragStart?.(p[0], p[1]);
271
+ try {
272
+ e.currentTarget.setPointerCapture(e.pointerId);
273
+ }
274
+ catch {
275
+ /* ignore (synthetic / already-released pointer) */
276
+ }
277
+ }, onPointerMove: (e) => {
278
+ const [px, py] = at(e);
279
+ const d = downAt.current;
280
+ if (d !== null && Math.hypot(px - d[0], py - d[1]) > 3) {
281
+ moved.current = true;
282
+ }
283
+ if (!editable || !dragging.current)
284
+ return;
285
+ e.stopPropagation();
286
+ onDrag(px, py);
287
+ }, onPointerUp: (e) => {
288
+ if (!dragging.current)
289
+ return;
290
+ e.stopPropagation();
291
+ try {
292
+ e.currentTarget.releasePointerCapture(e.pointerId);
293
+ }
294
+ catch {
295
+ /* ignore */
296
+ }
297
+ dragging.current = false;
298
+ onHover(false);
299
+ },
300
+ // Click (no drag) selects; double-click edits. Stop both so a mark click
301
+ // never reaches the plot's data-select / deselect.
302
+ onClick: (e) => {
303
+ e.stopPropagation();
304
+ if (!moved.current)
305
+ onSelect?.();
306
+ }, onDoubleClick: (e) => {
307
+ e.stopPropagation();
308
+ onEdit?.();
309
+ } }));
310
+ }
311
+ /** A vertical line at an x position (a time, a distance, a lap boundary). */
312
+ export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
313
+ const { container, row, ann } = useAnnotationFrame('Marker');
314
+ const selfKey = useSlotKey();
315
+ const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
316
+ // Draggable right now: global edit mode OR this mark's single-edit flag, and no
317
+ // tool armed, and it has an onChange to report to.
318
+ const editable = (container.editAnnotations || editing) &&
319
+ container.creating === null &&
320
+ onChange !== undefined;
321
+ const xs = useMemo(() => [at], [at]);
322
+ const text = label ?? container.formatTime(at);
323
+ useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text);
324
+ // No select/edit while a create tool is armed — the chart is in draw mode then.
325
+ const select = id !== undefined && container.creating === null
326
+ ? () => container.onSelectAnnotation?.(id)
327
+ : undefined;
328
+ const edit = id !== undefined && container.creating === null
329
+ ? () => container.onEditAnnotation?.(id)
330
+ : undefined;
331
+ const x = container.xScale(at);
332
+ const h = row.height;
333
+ const opacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
334
+ const showHandle = editable && (editing || hovering);
335
+ const lane = container.labelLanes.get(selfKey) ?? 0;
336
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: 0, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
337
+ +container.xScale.invert(px)) }))] }), _jsx(Chip, { theme: container.theme, color: ann.color, style: {
338
+ top: `${FLAG_TOP + lane * LANE_H}px`,
339
+ ...flagChipX(x, container.plotWidth),
340
+ }, children: text })] }));
341
+ }
342
+ /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
343
+ * Its label anchors at the left, at the line's height. */
344
+ export function Baseline({ value, axis, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
345
+ const { container, row, ann } = useAnnotationFrame('Baseline');
346
+ const selfKey = useSlotKey();
347
+ const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
348
+ // Draggable right now: global edit mode OR this mark's single-edit flag, and no
349
+ // tool armed, and it has an onChange to report to.
350
+ const editable = (container.editAnnotations || editing) &&
351
+ container.creating === null &&
352
+ onChange !== undefined;
353
+ // A horizontal line casts no vertical guide — register with no xs (still
354
+ // tracked for ordering / future use). No snap target either (the guidelines are
355
+ // vertical; a baseline drags vertically).
356
+ const xs = useMemo(() => [], []);
357
+ useRegisterAnnotation(container, selfKey, id, row.rowKey, 'baseline', xs, selected, selectable, editing, label ?? '');
358
+ // No select/edit while a create tool is armed — the chart is in draw mode then.
359
+ const select = id !== undefined && container.creating === null
360
+ ? () => container.onSelectAnnotation?.(id)
361
+ : undefined;
362
+ const edit = id !== undefined && container.creating === null
363
+ ? () => container.onEditAnnotation?.(id)
364
+ : undefined;
365
+ const axisId = axis ?? row.defaultAxisId;
366
+ const yScale = row.yScales.get(axisId);
367
+ // The axis may not have resolved yet (a layer mounts before its <YAxis>); skip
368
+ // until its scale exists rather than guessing a domain.
369
+ if (yScale === undefined)
370
+ return null;
371
+ const y = yScale(value);
372
+ const w = container.plotWidth;
373
+ const opacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
374
+ const showHandle = editable && (editing || hovering);
375
+ const fmt = row.formats.get(axisId);
376
+ const text = label ?? (fmt ? fmt(value) : String(value));
377
+ // Handle pill near the right end (clears the left-anchored label).
378
+ const handleX = w - 14;
379
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), _jsx(Chip, { theme: container.theme, color: ann.color, style: { top: `${y}px`, left: '2px', transform: 'translateY(-50%)' }, children: text })] }));
380
+ }
381
+ /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
382
+ * flies as a flag off the left edge. */
383
+ export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
384
+ const { container, row, ann } = useAnnotationFrame('Region');
385
+ const selfKey = useSlotKey();
386
+ const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
387
+ // Draggable right now: global edit mode OR this mark's single-edit flag, and no
388
+ // tool armed, and it has an onChange to report to.
389
+ const editable = (container.editAnnotations || editing) &&
390
+ container.creating === null &&
391
+ onChange !== undefined;
392
+ const xs = useMemo(() => [from, to], [from, to]);
393
+ const text = label ?? `${container.formatTime(from)}–${container.formatTime(to)}`;
394
+ useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text);
395
+ // No select/edit while a create tool is armed — the chart is in draw mode then.
396
+ const select = id !== undefined && container.creating === null
397
+ ? () => container.onSelectAnnotation?.(id)
398
+ : undefined;
399
+ const edit = id !== undefined && container.creating === null
400
+ ? () => container.onEditAnnotation?.(id)
401
+ : undefined;
402
+ const xa = container.xScale(from);
403
+ const xb = container.xScale(to);
404
+ const left = Math.min(xa, xb);
405
+ const spanW = Math.abs(xb - xa);
406
+ const h = row.height;
407
+ // The edges (lines) come fully forward in edit mode; the body fill sits one step
408
+ // back (so the edges read as the grabbable thing). Both jump to level 1 selected.
409
+ const edgeOpacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
410
+ const fillOpacity = ann.fillOpacity *
411
+ rampAt(FILL_MULT, bodyLevel(selectable, editable, hovering, selected));
412
+ const showHandles = editable && (editing || hovering);
413
+ const lane = container.labelLanes.get(selfKey) ?? 0;
414
+ // Body move-drag: capture the start position + pointer on press, then move by
415
+ // the TOTAL delta from there, so the *raw* position accumulates from a fixed
416
+ // origin. Snap is applied only to the output — never fed back into this
417
+ // accumulator — so once you drag past SNAP_PX the region releases cleanly
418
+ // instead of re-snapping on every small move.
419
+ const dragRef = useRef(null);
420
+ const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
421
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edge(xa), edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: (px) => {
422
+ dragRef.current = { from, to, startPx: px };
423
+ }, onDrag: (px) => {
424
+ const s = dragRef.current;
425
+ if (s === null)
426
+ return;
427
+ // Raw position = start + TOTAL pointer delta (snap-independent),
428
+ // so dragging past SNAP_PX escapes a snapped edge.
429
+ const delta = +container.xScale.invert(px) -
430
+ +container.xScale.invert(s.startPx);
431
+ let nf = s.from + delta;
432
+ let nt = s.to + delta;
433
+ // Snap whichever edge lands near a guideline, keeping the width —
434
+ // output only, so the raw drift above can pull free of it.
435
+ const sf = snapToGuides(container, selfKey, container.xScale(nf));
436
+ const st = snapToGuides(container, selfKey, container.xScale(nt));
437
+ if (sf !== null) {
438
+ nt += sf - nf;
439
+ nf = sf;
440
+ }
441
+ else if (st !== null) {
442
+ nf += st - nt;
443
+ nt = st;
444
+ }
445
+ onChange?.({ from: nf, to: nt });
446
+ } }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (px) => onChange?.({
447
+ from: snapToGuides(container, selfKey, px) ??
448
+ +container.xScale.invert(px),
449
+ to,
450
+ }) }), _jsx(DragArea, { x: xb - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (px) => onChange?.({
451
+ from,
452
+ to: snapToGuides(container, selfKey, px) ??
453
+ +container.xScale.invert(px),
454
+ }) })] }))] }))] }), _jsx(Chip, { theme: container.theme, color: ann.color, style: {
455
+ top: `${FLAG_TOP + lane * LANE_H}px`,
456
+ ...flagChipX(left, container.plotWidth),
457
+ }, children: text })] }));
458
+ }
459
+ //# sourceMappingURL=annotations.js.map
package/dist/area.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { type CurveFactory } from 'd3-shape';
2
+ import type { ChartSeries } from './data.js';
3
+ import type { Scale } from './line.js';
4
+ import type { AreaStyle } from './theme.js';
5
+ import { type GapMode } from './gaps.js';
6
+ /**
7
+ * The `[min, max]` vertical extent an area occupies — the finite values of
8
+ * `cs.y` widened to include `baseline`, since the fill spans from each value to
9
+ * the baseline (so the baseline must be in-domain or the fill clips). `null` if
10
+ * no value is finite. When `baseline` is `undefined` the area rests on the
11
+ * axis's own lower bound (resolved later), so only the values constrain the
12
+ * domain — matching {@link yExtent}.
13
+ *
14
+ * NaN values (the gap signal) are ignored, so a coast doesn't drag the domain.
15
+ */
16
+ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined): [number, number] | null;
17
+ /**
18
+ * Fill the area between `cs`'s value line and a horizontal `baseline`, with a
19
+ * vertical gradient (most opaque at the line, fading to transparent at the
20
+ * baseline) and an outline stroke on top.
21
+ *
22
+ * Two forms, selected by `baseline`:
23
+ *
24
+ * - **Elevation** (`baseline` = the axis lower bound, supplied as the resolved
25
+ * `baselineValue`): the line sits above the baseline, the shade grades down
26
+ * from it — the estela elevation look.
27
+ * - **Above/below axis** (`baseline` = `0`): positive values fill up, negative
28
+ * fill down (d3's `area` handles the zero crossing in one path). The gradient
29
+ * is anchored at the baseline pixel so each side grades *away* from the axis —
30
+ * opaque at the line, transparent at the axis — in both directions. Compose
31
+ * two layers (e.g. an "in" column and an "out" column) for the esnet
32
+ * two-colour traffic look; each layer's colour is its own `as` token (the
33
+ * single styling channel).
34
+ *
35
+ * **Gap handling is driven by `gaps`** (a {@link GapMode}, default `'empty'`).
36
+ * In every mode **the fill obeys the mode's break/bridge decision**: `'none'`
37
+ * fills straight across the gap (interior gaps interpolated via
38
+ * {@link bridgeGaps}, so the value edge bridges and the fill spans it); every
39
+ * other mode breaks the fill (`.defined(Number.isFinite)` — a coast is a hole in
40
+ * the shade, never a slab to the baseline, `docs/rfcs/charts.md` trap #2). For
41
+ * `'dashed'` / `'step'` / `'fade'` the **outline** (the value line on top)
42
+ * additionally gets an inferred bridge across each interior gap — a dashed line,
43
+ * a flat dashed line at the average of the edge values, or estela's
44
+ * fade-to-baseline — while the *fill* stays broken. `dashed` / `step` are drawn
45
+ * faint (`gapConnectorOpacity`). So the shade is always honest about absence;
46
+ * only the line offers the inferred connector.
47
+ *
48
+ * `cs.y` (a `Float64Array`) is the datum iterable; accessors read by index, so
49
+ * there's no per-point object allocation. The gradient + `globalAlpha` are
50
+ * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
51
+ * are collected by one O(N) walk ({@link collectGapEdges}).
52
+ */
53
+ export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number): void;
54
+ //# sourceMappingURL=area.d.ts.map