@pond-ts/charts 0.31.1 → 0.32.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 +55 -1
- package/dist/AreaChart.d.ts +11 -5
- package/dist/AreaChart.js +23 -10
- package/dist/BandChart.d.ts +12 -5
- package/dist/BandChart.js +28 -10
- package/dist/BarChart.d.ts +17 -11
- package/dist/BarChart.js +12 -6
- package/dist/ChartContainer.d.ts +45 -2
- package/dist/ChartContainer.js +42 -1
- package/dist/ChartRow.js +16 -0
- package/dist/Layers.js +236 -96
- package/dist/YAxis.d.ts +16 -1
- package/dist/YAxis.js +22 -6
- package/dist/annotations.d.ts +110 -0
- package/dist/annotations.js +463 -0
- package/dist/chip.d.ts +23 -0
- package/dist/chip.js +43 -0
- package/dist/context.d.ts +116 -0
- package/dist/data.d.ts +34 -0
- package/dist/data.js +89 -14
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/theme.d.ts +18 -0
- package/dist/theme.js +13 -0
- package/package.json +3 -3
|
@@ -0,0 +1,463 @@
|
|
|
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
|
+
// The container's snap toggle gates guideline snapping — off ⇒ the drag keeps
|
|
211
|
+
// its raw position (no clicking onto neighbours).
|
|
212
|
+
if (!container.snap)
|
|
213
|
+
return null;
|
|
214
|
+
let best = null;
|
|
215
|
+
let bestDist = SNAP_PX;
|
|
216
|
+
for (const a of container.annotations) {
|
|
217
|
+
if (a.key === selfKey)
|
|
218
|
+
continue;
|
|
219
|
+
for (const tx of a.xs) {
|
|
220
|
+
const d = Math.abs(container.xScale(tx) - px);
|
|
221
|
+
if (d < bestDist) {
|
|
222
|
+
bestDist = d;
|
|
223
|
+
best = tx;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return best;
|
|
228
|
+
}
|
|
229
|
+
/** A label chip — the cursor value flag's shape (shared {@link flagChipStyle}:
|
|
230
|
+
* filled, no outline) with text in the annotation register. */
|
|
231
|
+
function Chip({ theme, color, style, children, }) {
|
|
232
|
+
return (_jsx("div", { style: { ...flagChipStyle(theme), color, ...style }, children: children }));
|
|
233
|
+
}
|
|
234
|
+
/** A non-interactive handle pill, shown on hover (edit mode) / when selected. */
|
|
235
|
+
function Pill({ cx, cy, w, h, color, }) {
|
|
236
|
+
return (_jsx("rect", { x: cx - w / 2, y: cy - h / 2, width: w, height: h, rx: 3, fill: color, style: { pointerEvents: 'none' } }));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* A transparent hit rect over a selectable mark. It **always** reports hover
|
|
240
|
+
* (`onHover`, → level 2 even with edit off), and a **single click that didn't drag
|
|
241
|
+
* selects** the mark (`onSelect`) in *any* mode — the inspect-select — while a
|
|
242
|
+
* **double-click edits** it (`onEdit` — the consumer flips it into single-annotation
|
|
243
|
+
* edit). Both clicks `stopPropagation` so they never reach the plot's data-select /
|
|
244
|
+
* deselect. Only when `editable` does it claim the drag gesture (`stopPropagation` +
|
|
245
|
+
* guarded pointer capture, so a drag never starts a pan) and report the pointer's
|
|
246
|
+
* **plot-pixel** position on press (`onDragStart`) / move (`onDrag`). With editing
|
|
247
|
+
* off, press / move bubble so a pan reads straight through.
|
|
248
|
+
*/
|
|
249
|
+
function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onDragStart, onDrag, }) {
|
|
250
|
+
const dragging = useRef(false);
|
|
251
|
+
// Tracks whether this press became a drag (moved past a few px) — a click that
|
|
252
|
+
// didn't drag selects instead of edits. Tracked in *both* modes so a pan-drag
|
|
253
|
+
// started on the mark (edit off) doesn't fire a spurious select on release.
|
|
254
|
+
const moved = useRef(false);
|
|
255
|
+
const downAt = useRef(null);
|
|
256
|
+
const at = (e) => {
|
|
257
|
+
const svg = e.currentTarget.ownerSVGElement;
|
|
258
|
+
if (svg === null)
|
|
259
|
+
return [0, 0];
|
|
260
|
+
const r = svg.getBoundingClientRect();
|
|
261
|
+
return [e.clientX - r.left, e.clientY - r.top];
|
|
262
|
+
};
|
|
263
|
+
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: () => {
|
|
264
|
+
if (!dragging.current)
|
|
265
|
+
onHover(false);
|
|
266
|
+
}, onPointerDown: (e) => {
|
|
267
|
+
const p = at(e);
|
|
268
|
+
moved.current = false;
|
|
269
|
+
downAt.current = p; // tracked in both modes for the click/drag guard
|
|
270
|
+
if (!editable)
|
|
271
|
+
return; // edit off: let it bubble (pan reads through)
|
|
272
|
+
e.stopPropagation(); // claim the gesture — don't let the plot start a pan
|
|
273
|
+
dragging.current = true;
|
|
274
|
+
onDragStart?.(p[0], p[1]);
|
|
275
|
+
try {
|
|
276
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* ignore (synthetic / already-released pointer) */
|
|
280
|
+
}
|
|
281
|
+
}, onPointerMove: (e) => {
|
|
282
|
+
const [px, py] = at(e);
|
|
283
|
+
const d = downAt.current;
|
|
284
|
+
if (d !== null && Math.hypot(px - d[0], py - d[1]) > 3) {
|
|
285
|
+
moved.current = true;
|
|
286
|
+
}
|
|
287
|
+
if (!editable || !dragging.current)
|
|
288
|
+
return;
|
|
289
|
+
e.stopPropagation();
|
|
290
|
+
onDrag(px, py);
|
|
291
|
+
}, onPointerUp: (e) => {
|
|
292
|
+
if (!dragging.current)
|
|
293
|
+
return;
|
|
294
|
+
e.stopPropagation();
|
|
295
|
+
try {
|
|
296
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
/* ignore */
|
|
300
|
+
}
|
|
301
|
+
dragging.current = false;
|
|
302
|
+
onHover(false);
|
|
303
|
+
},
|
|
304
|
+
// Click (no drag) selects; double-click edits. Stop both so a mark click
|
|
305
|
+
// never reaches the plot's data-select / deselect.
|
|
306
|
+
onClick: (e) => {
|
|
307
|
+
e.stopPropagation();
|
|
308
|
+
if (!moved.current)
|
|
309
|
+
onSelect?.();
|
|
310
|
+
}, onDoubleClick: (e) => {
|
|
311
|
+
e.stopPropagation();
|
|
312
|
+
onEdit?.();
|
|
313
|
+
} }));
|
|
314
|
+
}
|
|
315
|
+
/** A vertical line at an x position (a time, a distance, a lap boundary). */
|
|
316
|
+
export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
317
|
+
const { container, row, ann } = useAnnotationFrame('Marker');
|
|
318
|
+
const selfKey = useSlotKey();
|
|
319
|
+
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
320
|
+
// Draggable right now: global edit mode OR this mark's single-edit flag, and no
|
|
321
|
+
// tool armed, and it has an onChange to report to.
|
|
322
|
+
const editable = (container.editAnnotations || editing) &&
|
|
323
|
+
container.creating === null &&
|
|
324
|
+
onChange !== undefined;
|
|
325
|
+
const xs = useMemo(() => [at], [at]);
|
|
326
|
+
const text = label ?? container.formatTime(at);
|
|
327
|
+
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text);
|
|
328
|
+
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
329
|
+
const select = id !== undefined && container.creating === null
|
|
330
|
+
? () => container.onSelectAnnotation?.(id)
|
|
331
|
+
: undefined;
|
|
332
|
+
const edit = id !== undefined && container.creating === null
|
|
333
|
+
? () => container.onEditAnnotation?.(id)
|
|
334
|
+
: undefined;
|
|
335
|
+
const x = container.xScale(at);
|
|
336
|
+
const h = row.height;
|
|
337
|
+
const opacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
|
|
338
|
+
const showHandle = editable && (editing || hovering);
|
|
339
|
+
const lane = container.labelLanes.get(selfKey) ?? 0;
|
|
340
|
+
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) ??
|
|
341
|
+
+container.xScale.invert(px)) }))] }), _jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
342
|
+
top: `${FLAG_TOP + lane * LANE_H}px`,
|
|
343
|
+
...flagChipX(x, container.plotWidth),
|
|
344
|
+
}, children: text })] }));
|
|
345
|
+
}
|
|
346
|
+
/** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
|
|
347
|
+
* Its label anchors at the left, at the line's height. */
|
|
348
|
+
export function Baseline({ value, axis, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
349
|
+
const { container, row, ann } = useAnnotationFrame('Baseline');
|
|
350
|
+
const selfKey = useSlotKey();
|
|
351
|
+
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
352
|
+
// Draggable right now: global edit mode OR this mark's single-edit flag, and no
|
|
353
|
+
// tool armed, and it has an onChange to report to.
|
|
354
|
+
const editable = (container.editAnnotations || editing) &&
|
|
355
|
+
container.creating === null &&
|
|
356
|
+
onChange !== undefined;
|
|
357
|
+
// A horizontal line casts no vertical guide — register with no xs (still
|
|
358
|
+
// tracked for ordering / future use). No snap target either (the guidelines are
|
|
359
|
+
// vertical; a baseline drags vertically).
|
|
360
|
+
const xs = useMemo(() => [], []);
|
|
361
|
+
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'baseline', xs, selected, selectable, editing, label ?? '');
|
|
362
|
+
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
363
|
+
const select = id !== undefined && container.creating === null
|
|
364
|
+
? () => container.onSelectAnnotation?.(id)
|
|
365
|
+
: undefined;
|
|
366
|
+
const edit = id !== undefined && container.creating === null
|
|
367
|
+
? () => container.onEditAnnotation?.(id)
|
|
368
|
+
: undefined;
|
|
369
|
+
const axisId = axis ?? row.defaultAxisId;
|
|
370
|
+
const yScale = row.yScales.get(axisId);
|
|
371
|
+
// The axis may not have resolved yet (a layer mounts before its <YAxis>); skip
|
|
372
|
+
// until its scale exists rather than guessing a domain.
|
|
373
|
+
if (yScale === undefined)
|
|
374
|
+
return null;
|
|
375
|
+
const y = yScale(value);
|
|
376
|
+
const w = container.plotWidth;
|
|
377
|
+
const opacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
|
|
378
|
+
const showHandle = editable && (editing || hovering);
|
|
379
|
+
const fmt = row.formats.get(axisId);
|
|
380
|
+
const text = label ?? (fmt ? fmt(value) : String(value));
|
|
381
|
+
// Handle pill near the right end (clears the left-anchored label).
|
|
382
|
+
const handleX = w - 14;
|
|
383
|
+
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 })] }));
|
|
384
|
+
}
|
|
385
|
+
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
386
|
+
* flies as a flag off the left edge. */
|
|
387
|
+
export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
388
|
+
const { container, row, ann } = useAnnotationFrame('Region');
|
|
389
|
+
const selfKey = useSlotKey();
|
|
390
|
+
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
391
|
+
// Draggable right now: global edit mode OR this mark's single-edit flag, and no
|
|
392
|
+
// tool armed, and it has an onChange to report to.
|
|
393
|
+
const editable = (container.editAnnotations || editing) &&
|
|
394
|
+
container.creating === null &&
|
|
395
|
+
onChange !== undefined;
|
|
396
|
+
const xs = useMemo(() => [from, to], [from, to]);
|
|
397
|
+
const text = label ?? `${container.formatTime(from)}–${container.formatTime(to)}`;
|
|
398
|
+
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text);
|
|
399
|
+
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
400
|
+
const select = id !== undefined && container.creating === null
|
|
401
|
+
? () => container.onSelectAnnotation?.(id)
|
|
402
|
+
: undefined;
|
|
403
|
+
const edit = id !== undefined && container.creating === null
|
|
404
|
+
? () => container.onEditAnnotation?.(id)
|
|
405
|
+
: undefined;
|
|
406
|
+
const xa = container.xScale(from);
|
|
407
|
+
const xb = container.xScale(to);
|
|
408
|
+
const left = Math.min(xa, xb);
|
|
409
|
+
const spanW = Math.abs(xb - xa);
|
|
410
|
+
const h = row.height;
|
|
411
|
+
// The edges (lines) come fully forward in edit mode; the body fill sits one step
|
|
412
|
+
// back (so the edges read as the grabbable thing). Both jump to level 1 selected.
|
|
413
|
+
const edgeOpacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
|
|
414
|
+
const fillOpacity = ann.fillOpacity *
|
|
415
|
+
rampAt(FILL_MULT, bodyLevel(selectable, editable, hovering, selected));
|
|
416
|
+
const showHandles = editable && (editing || hovering);
|
|
417
|
+
const lane = container.labelLanes.get(selfKey) ?? 0;
|
|
418
|
+
// Body move-drag: capture the start position + pointer on press, then move by
|
|
419
|
+
// the TOTAL delta from there, so the *raw* position accumulates from a fixed
|
|
420
|
+
// origin. Snap is applied only to the output — never fed back into this
|
|
421
|
+
// accumulator — so once you drag past SNAP_PX the region releases cleanly
|
|
422
|
+
// instead of re-snapping on every small move.
|
|
423
|
+
const dragRef = useRef(null);
|
|
424
|
+
const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
|
|
425
|
+
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) => {
|
|
426
|
+
dragRef.current = { from, to, startPx: px };
|
|
427
|
+
}, onDrag: (px) => {
|
|
428
|
+
const s = dragRef.current;
|
|
429
|
+
if (s === null)
|
|
430
|
+
return;
|
|
431
|
+
// Raw position = start + TOTAL pointer delta (snap-independent),
|
|
432
|
+
// so dragging past SNAP_PX escapes a snapped edge.
|
|
433
|
+
const delta = +container.xScale.invert(px) -
|
|
434
|
+
+container.xScale.invert(s.startPx);
|
|
435
|
+
let nf = s.from + delta;
|
|
436
|
+
let nt = s.to + delta;
|
|
437
|
+
// Snap whichever edge lands near a guideline, keeping the width —
|
|
438
|
+
// output only, so the raw drift above can pull free of it.
|
|
439
|
+
const sf = snapToGuides(container, selfKey, container.xScale(nf));
|
|
440
|
+
const st = snapToGuides(container, selfKey, container.xScale(nt));
|
|
441
|
+
if (sf !== null) {
|
|
442
|
+
nt += sf - nf;
|
|
443
|
+
nf = sf;
|
|
444
|
+
}
|
|
445
|
+
else if (st !== null) {
|
|
446
|
+
nf += st - nt;
|
|
447
|
+
nt = st;
|
|
448
|
+
}
|
|
449
|
+
onChange?.({ from: nf, to: nt });
|
|
450
|
+
} }), 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?.({
|
|
451
|
+
from: snapToGuides(container, selfKey, px) ??
|
|
452
|
+
+container.xScale.invert(px),
|
|
453
|
+
to,
|
|
454
|
+
}) }), _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?.({
|
|
455
|
+
from,
|
|
456
|
+
to: snapToGuides(container, selfKey, px) ??
|
|
457
|
+
+container.xScale.invert(px),
|
|
458
|
+
}) })] }))] }))] }), _jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
459
|
+
top: `${FLAG_TOP + lane * LANE_H}px`,
|
|
460
|
+
...flagChipX(left, container.plotWidth),
|
|
461
|
+
}, children: text })] }));
|
|
462
|
+
}
|
|
463
|
+
//# sourceMappingURL=annotations.js.map
|
package/dist/chip.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import type { ChartTheme } from './theme.js';
|
|
3
|
+
/**
|
|
4
|
+
* The shared **chip** look — one source of truth for the cursor's value flag and
|
|
5
|
+
* the annotation labels, so a placed label and a cursor flag read as the same
|
|
6
|
+
* object. A filled panel (the theme's `chip.background`) with crisp tabular text
|
|
7
|
+
* and **no outline** — the fill delineates it (a border read as a hard edge on a
|
|
8
|
+
* dark ground). The caller layers on `color` (the series / annotation hue) and the
|
|
9
|
+
* position (`top` + `left`/`right`).
|
|
10
|
+
*
|
|
11
|
+
* Note: on a light theme whose `chip.background` doesn't contrast with the plot
|
|
12
|
+
* ground, the borderless chip needs another delineator (a subtle shadow, or a
|
|
13
|
+
* contrasting chip background) — a token to settle before this ships.
|
|
14
|
+
*/
|
|
15
|
+
export declare function flagChipStyle(theme: ChartTheme): CSSProperties;
|
|
16
|
+
/**
|
|
17
|
+
* Horizontal placement for a flag chip beside a vertical pole at plot-x `x`:
|
|
18
|
+
* `FLAG_GAP` to the right, flipping to the left near the right edge so it stays
|
|
19
|
+
* in-plot. Shared by the cursor value flag and the annotation labels so a chip
|
|
20
|
+
* sits **identically** relative to its pole in both.
|
|
21
|
+
*/
|
|
22
|
+
export declare function flagChipX(x: number, plotWidth: number): CSSProperties;
|
|
23
|
+
//# sourceMappingURL=chip.d.ts.map
|
package/dist/chip.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared **chip** look — one source of truth for the cursor's value flag and
|
|
3
|
+
* the annotation labels, so a placed label and a cursor flag read as the same
|
|
4
|
+
* object. A filled panel (the theme's `chip.background`) with crisp tabular text
|
|
5
|
+
* and **no outline** — the fill delineates it (a border read as a hard edge on a
|
|
6
|
+
* dark ground). The caller layers on `color` (the series / annotation hue) and the
|
|
7
|
+
* position (`top` + `left`/`right`).
|
|
8
|
+
*
|
|
9
|
+
* Note: on a light theme whose `chip.background` doesn't contrast with the plot
|
|
10
|
+
* ground, the borderless chip needs another delineator (a subtle shadow, or a
|
|
11
|
+
* contrasting chip background) — a token to settle before this ships.
|
|
12
|
+
*/
|
|
13
|
+
export function flagChipStyle(theme) {
|
|
14
|
+
return {
|
|
15
|
+
position: 'absolute',
|
|
16
|
+
background: theme.chip?.background,
|
|
17
|
+
borderRadius: '3px',
|
|
18
|
+
padding: '0 4px',
|
|
19
|
+
fontFamily: theme.font.family,
|
|
20
|
+
fontSize: `${theme.font.size}px`,
|
|
21
|
+
fontVariantNumeric: 'tabular-nums',
|
|
22
|
+
whiteSpace: 'nowrap',
|
|
23
|
+
pointerEvents: 'none',
|
|
24
|
+
lineHeight: 1.5,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Gap (px) between a flag chip and its pole — the cursor staff or an annotation's
|
|
28
|
+
* line — so the chip floats just beside the pole rather than sitting on it. */
|
|
29
|
+
const FLAG_GAP = 4;
|
|
30
|
+
/** Past this fraction of the plot, a flag flips to the left of its pole to stay in. */
|
|
31
|
+
const FLAG_FLIP = 0.85;
|
|
32
|
+
/**
|
|
33
|
+
* Horizontal placement for a flag chip beside a vertical pole at plot-x `x`:
|
|
34
|
+
* `FLAG_GAP` to the right, flipping to the left near the right edge so it stays
|
|
35
|
+
* in-plot. Shared by the cursor value flag and the annotation labels so a chip
|
|
36
|
+
* sits **identically** relative to its pole in both.
|
|
37
|
+
*/
|
|
38
|
+
export function flagChipX(x, plotWidth) {
|
|
39
|
+
return x > plotWidth * FLAG_FLIP
|
|
40
|
+
? { right: `${plotWidth - x + FLAG_GAP}px` }
|
|
41
|
+
: { left: `${x + FLAG_GAP}px` };
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=chip.js.map
|