@pond-ts/charts 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3254 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/AreaChart.d.ts +85 -0
- package/dist/AreaChart.js +119 -0
- package/dist/BandChart.d.ts +55 -0
- package/dist/BandChart.js +93 -0
- package/dist/BarChart.d.ts +72 -0
- package/dist/BarChart.js +137 -0
- package/dist/BoxPlot.d.ts +77 -0
- package/dist/BoxPlot.js +137 -0
- package/dist/Canvas.d.ts +37 -0
- package/dist/Canvas.js +39 -0
- package/dist/ChartContainer.d.ts +106 -0
- package/dist/ChartContainer.js +306 -0
- package/dist/ChartRow.d.ts +29 -0
- package/dist/ChartRow.js +215 -0
- package/dist/Layers.d.ts +22 -0
- package/dist/Layers.js +399 -0
- package/dist/LineChart.d.ts +60 -0
- package/dist/LineChart.js +105 -0
- package/dist/ScatterChart.d.ts +84 -0
- package/dist/ScatterChart.js +139 -0
- package/dist/TimeAxis.d.ts +9 -0
- package/dist/TimeAxis.js +12 -0
- package/dist/XAxis.d.ts +39 -0
- package/dist/XAxis.js +84 -0
- package/dist/YAxis.d.ts +42 -0
- package/dist/YAxis.js +86 -0
- package/dist/annotations.d.ts +110 -0
- package/dist/annotations.js +459 -0
- package/dist/area.d.ts +54 -0
- package/dist/area.js +186 -0
- package/dist/band.d.ts +31 -0
- package/dist/band.js +57 -0
- package/dist/bars.d.ts +96 -0
- package/dist/bars.js +171 -0
- package/dist/box.d.ts +59 -0
- package/dist/box.js +140 -0
- package/dist/chip.d.ts +23 -0
- package/dist/chip.js +43 -0
- package/dist/cjs-fallback.cjs +16 -0
- package/dist/context.d.ts +362 -0
- package/dist/context.js +5 -0
- package/dist/curve.d.ts +22 -0
- package/dist/curve.js +13 -0
- package/dist/data.d.ts +154 -0
- package/dist/data.js +197 -0
- package/dist/domain.d.ts +19 -0
- package/dist/domain.js +61 -0
- package/dist/encoding.d.ts +89 -0
- package/dist/encoding.js +144 -0
- package/dist/format.d.ts +53 -0
- package/dist/format.js +47 -0
- package/dist/gaps.d.ts +146 -0
- package/dist/gaps.js +209 -0
- package/dist/grid.d.ts +11 -0
- package/dist/grid.js +29 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +34 -0
- package/dist/line.d.ts +46 -0
- package/dist/line.js +88 -0
- package/dist/range.d.ts +15 -0
- package/dist/range.js +27 -0
- package/dist/scatter.d.ts +70 -0
- package/dist/scatter.js +213 -0
- package/dist/select.d.ts +13 -0
- package/dist/select.js +23 -0
- package/dist/slots.d.ts +48 -0
- package/dist/slots.js +64 -0
- package/dist/theme.d.ts +224 -0
- package/dist/theme.js +232 -0
- package/dist/tracker.d.ts +30 -0
- package/dist/tracker.js +47 -0
- package/dist/use-slot-key.d.ts +21 -0
- package/dist/use-slot-key.js +25 -0
- package/dist/viewport.d.ts +20 -0
- package/dist/viewport.js +30 -0
- package/package.json +67 -0
|
@@ -0,0 +1,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
|