@pond-ts/charts 0.32.0 → 0.33.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 CHANGED
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
8
8
  them all. Pre-1.0: minor bumps may include new features and type-level changes;
9
9
  patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.32.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.33.0...HEAD
12
+ [0.33.0]: https://github.com/pjm17971/pond-ts/compare/v0.32.0...v0.33.0
12
13
  [0.32.0]: https://github.com/pjm17971/pond-ts/compare/v0.31.2...v0.32.0
13
14
  [0.31.2]: https://github.com/pjm17971/pond-ts/compare/v0.31.1...v0.31.2
14
15
  [0.31.1]: https://github.com/pjm17971/pond-ts/compare/v0.30.0...v0.31.1
@@ -27,6 +28,41 @@ patch bumps are strictly additive.
27
28
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
28
29
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
29
30
 
31
+ ## [0.33.0] — 2026-06-30
32
+
33
+ A `@pond-ts/charts` release: a label opt-out for annotations plus interaction
34
+ fixes from an adversarial review of the #306 annotation system. `pond-ts`,
35
+ `@pond-ts/react`, and `@pond-ts/fit` carry no code changes — republished in
36
+ lock-step (their `pond-ts` / `@pond-ts/react` peer ranges widen to `^0.33.0`).
37
+
38
+ ### Added
39
+
40
+ - **Charts — annotation label opt-out.** `<Region>`, `<Marker>`, and
41
+ `<Baseline>` accept `label={false}` (or `label=""`) to render **no label
42
+ chip** — for an inert background mark (e.g. a `selectable={false}` highlight
43
+ band) where the auto-label would only show a raw axis value. Omitting `label`
44
+ still auto-labels; a string still renders it. `label` widens to
45
+ `string | false`. New `Highlight` story + Linux e2e baselines for the
46
+ `Annotations` stories. (#308)
47
+
48
+ ### Changed
49
+
50
+ - **Charts — `<Region>` / `<Marker>` / `<Baseline>` with `label=""` now render
51
+ no chip** (previously an empty, zero-width chip). The label-less path; pass a
52
+ non-empty string for a visible label. (#308)
53
+
54
+ ### Fixed
55
+
56
+ - **Charts — annotation edge-resize no longer inverts.** Dragging a `<Region>`
57
+ edge past the opposite one previously reported `{ from > to }`; it now pivots
58
+ around the fixed opposite edge, so the reported span stays ordered and a drag
59
+ either way re-opens the region instead of dead-ending at zero width. (#308)
60
+ - **Charts — annotation drag releases on `pointercancel`.** A system gesture
61
+ takeover (which fires `pointercancel`, not `pointerup`) no longer leaves a mark
62
+ stuck mid-drag. (#308)
63
+ - **Charts — single-annotation edit exits on an empty-plot click** even when the
64
+ mark is `editing` but not `selected`. (#308)
65
+
30
66
  ## [0.32.0] — 2026-06-29
31
67
 
32
68
  A `@pond-ts/charts` release: value-axis support across the fill/bar layers, plus
package/dist/Layers.js CHANGED
@@ -354,10 +354,12 @@ export function Layers({ children }) {
354
354
  const c = containerRef.current;
355
355
  // A click that reached the plot (no mark's DragArea claimed it) is an empty
356
356
  // click. Deselect / exit edit when the consumer is tracking annotations — in
357
- // global edit mode, or whenever a mark is currently active (a selected mark, or
358
- // the single-edit target, which also reads as selected). Marks stop their own
359
- // clicks in DragArea, so this only fires on true empty space.
360
- if (c.editAnnotations || c.annotations.some((a) => a.selected)) {
357
+ // global edit mode, or whenever a mark is currently active: selected, OR the
358
+ // single-edit target (`editing`). Checking `editing` too means a consumer that
359
+ // sets `editing` without also setting `selected` still gets the exit signal.
360
+ // Marks stop their own clicks in DragArea, so this only fires on true empty space.
361
+ if (c.editAnnotations ||
362
+ c.annotations.some((a) => a.selected || a.editing)) {
361
363
  c.onSelectAnnotation?.(null);
362
364
  return;
363
365
  }
@@ -7,13 +7,28 @@ import { type AnnotationSpec } from './context.js';
7
7
  * at the left at their own y, don't participate.
8
8
  */
9
9
  export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number): Map<symbol, number>;
10
+ /**
11
+ * Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
12
+ * the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
13
+ * here, so dragging an edge past the pivot never emits an inverted span — instead
14
+ * the edges meet (zero width) and a continued drag **re-opens the region the other
15
+ * way** (the grabbed handle becomes the far edge). The pivot is captured at
16
+ * drag-start (a ref) so it stays fixed even as the emitted bounds swap on cross.
17
+ */
18
+ export declare function orderRegion(v: number, pivot: number): {
19
+ from: number;
20
+ to: number;
21
+ };
10
22
  export interface MarkerProps {
11
23
  /** x position in axis units — epoch ms on a time axis, the value on a value
12
24
  * axis. (The generalisation of the mockup's "time line": a mark at an x, time
13
25
  * or value.) */
14
26
  at: number;
15
- /** Chip label; omit to auto-label with the shared x formatter (the axis's). */
16
- label?: string;
27
+ /** Chip label. Omit to auto-label with the shared x formatter (the axis's);
28
+ * pass `false` (or `''`) to render **no label chip** — for an inert background
29
+ * mark you don't want labelled (where the auto-label would just show a raw
30
+ * axis value). */
31
+ label?: string | false;
17
32
  /** Stable consumer id — a click reports it via the container's
18
33
  * `onSelectAnnotation`, so the consumer can track which mark is selected. */
19
34
  id?: string;
@@ -44,8 +59,9 @@ export interface BaselineProps {
44
59
  value: number;
45
60
  /** Which `<YAxis>` (by id) to measure against; omit for the row's default axis. */
46
61
  axis?: string;
47
- /** Chip label; omit to format `value` with that axis's formatter. */
48
- label?: string;
62
+ /** Chip label. Omit to format `value` with that axis's formatter; pass `false`
63
+ * (or `''`) to render **no label chip**. */
64
+ label?: string | false;
49
65
  /** Stable consumer id — a click reports it via `onSelectAnnotation`. */
50
66
  id?: string;
51
67
  /** Controlled selection — brightens to the front (level 1). Handles are an
@@ -75,8 +91,11 @@ export interface RegionProps {
75
91
  from: number;
76
92
  /** End x in axis units. */
77
93
  to: number;
78
- /** Chip label; omit to auto-label `from–to` with the shared x formatter. */
79
- label?: string;
94
+ /** Chip label. Omit to auto-label `from–to` with the shared x formatter; pass
95
+ * `false` (or `''`) to render **no label chip** — e.g. an inert
96
+ * `selectable={false}` highlight band, where the auto-label would just show
97
+ * raw axis values. */
98
+ label?: string | false;
80
99
  /** Stable consumer id — a click (or double-click outside edit) reports it via
81
100
  * `onSelectAnnotation`. */
82
101
  id?: string;
@@ -226,6 +226,17 @@ function snapToGuides(container, selfKey, px) {
226
226
  }
227
227
  return best;
228
228
  }
229
+ /**
230
+ * Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
231
+ * the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
232
+ * here, so dragging an edge past the pivot never emits an inverted span — instead
233
+ * the edges meet (zero width) and a continued drag **re-opens the region the other
234
+ * way** (the grabbed handle becomes the far edge). The pivot is captured at
235
+ * drag-start (a ref) so it stays fixed even as the emitted bounds swap on cross.
236
+ */
237
+ export function orderRegion(v, pivot) {
238
+ return v <= pivot ? { from: v, to: pivot } : { from: pivot, to: v };
239
+ }
229
240
  /** A label chip — the cursor value flag's shape (shared {@link flagChipStyle}:
230
241
  * filled, no outline) with text in the annotation register. */
231
242
  function Chip({ theme, color, style, children, }) {
@@ -301,6 +312,20 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
301
312
  dragging.current = false;
302
313
  onHover(false);
303
314
  },
315
+ // A system gesture takeover fires pointercancel, not pointerup — clear the
316
+ // same drag/hover state so the mark doesn't stay stuck "grabbed".
317
+ onPointerCancel: (e) => {
318
+ if (!dragging.current)
319
+ return;
320
+ try {
321
+ e.currentTarget.releasePointerCapture(e.pointerId);
322
+ }
323
+ catch {
324
+ /* ignore */
325
+ }
326
+ dragging.current = false;
327
+ onHover(false);
328
+ },
304
329
  // Click (no drag) selects; double-click edits. Stop both so a mark click
305
330
  // never reaches the plot's data-select / deselect.
306
331
  onClick: (e) => {
@@ -323,7 +348,8 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
323
348
  container.creating === null &&
324
349
  onChange !== undefined;
325
350
  const xs = useMemo(() => [at], [at]);
326
- const text = label ?? container.formatTime(at);
351
+ // `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label off the x formatter.
352
+ const text = label === false ? '' : (label ?? container.formatTime(at));
327
353
  useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text);
328
354
  // No select/edit while a create tool is armed — the chart is in draw mode then.
329
355
  const select = id !== undefined && container.creating === null
@@ -338,10 +364,10 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
338
364
  const showHandle = editable && (editing || hovering);
339
365
  const lane = container.labelLanes.get(selfKey) ?? 0;
340
366
  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: {
367
+ +container.xScale.invert(px)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
342
368
  top: `${FLAG_TOP + lane * LANE_H}px`,
343
369
  ...flagChipX(x, container.plotWidth),
344
- }, children: text })] }));
370
+ }, children: text }))] }));
345
371
  }
346
372
  /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
347
373
  * Its label anchors at the left, at the line's height. */
@@ -358,7 +384,11 @@ export function Baseline({ value, axis, label, id, selected = false, selectable
358
384
  // tracked for ordering / future use). No snap target either (the guidelines are
359
385
  // vertical; a baseline drags vertically).
360
386
  const xs = useMemo(() => [], []);
361
- useRegisterAnnotation(container, selfKey, id, row.rowKey, 'baseline', xs, selected, selectable, editing, label ?? '');
387
+ useRegisterAnnotation(container, selfKey, id, row.rowKey, 'baseline', xs, selected, selectable, editing,
388
+ // Baselines don't lane-pack (the label anchors at their y, not the top), so
389
+ // this registered string is unused by `computeLabelLanes` — `|| ''` just
390
+ // keeps it a string for `false`/'' (which mean "no label").
391
+ label || '');
362
392
  // No select/edit while a create tool is armed — the chart is in draw mode then.
363
393
  const select = id !== undefined && container.creating === null
364
394
  ? () => container.onSelectAnnotation?.(id)
@@ -377,10 +407,11 @@ export function Baseline({ value, axis, label, id, selected = false, selectable
377
407
  const opacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
378
408
  const showHandle = editable && (editing || hovering);
379
409
  const fmt = row.formats.get(axisId);
380
- const text = label ?? (fmt ? fmt(value) : String(value));
410
+ // `label === false` (or '') ⇒ no chip; omitted ⇒ format `value` off the axis.
411
+ const text = label === false ? '' : (label ?? (fmt ? fmt(value) : String(value)));
381
412
  // Handle pill near the right end (clears the left-anchored label).
382
413
  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 })] }));
414
+ 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)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: { top: `${y}px`, left: '2px', transform: 'translateY(-50%)' }, children: text }))] }));
384
415
  }
385
416
  /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
386
417
  * flies as a flag off the left edge. */
@@ -394,7 +425,10 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
394
425
  container.creating === null &&
395
426
  onChange !== undefined;
396
427
  const xs = useMemo(() => [from, to], [from, to]);
397
- const text = label ?? `${container.formatTime(from)}–${container.formatTime(to)}`;
428
+ // `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label the `from–to` span.
429
+ const text = label === false
430
+ ? ''
431
+ : (label ?? `${container.formatTime(from)}–${container.formatTime(to)}`);
398
432
  useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text);
399
433
  // No select/edit while a create tool is armed — the chart is in draw mode then.
400
434
  const select = id !== undefined && container.creating === null
@@ -421,6 +455,10 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
421
455
  // accumulator — so once you drag past SNAP_PX the region releases cleanly
422
456
  // instead of re-snapping on every small move.
423
457
  const dragRef = useRef(null);
458
+ // Edge resize pivots around the OPPOSITE edge, captured on press so it stays put
459
+ // even after the dragged edge crosses it — {@link orderRegion} then re-opens the
460
+ // region the other way instead of dead-ending at zero width.
461
+ const edgeRef = useRef(null);
424
462
  const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
425
463
  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
464
  dragRef.current = { from, to, startPx: px };
@@ -447,17 +485,15 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
447
485
  nt = st;
448
486
  }
449
487
  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: {
488
+ } }), 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, onDragStart: () => {
489
+ edgeRef.current = to; // the fixed pivot = the far edge
490
+ }, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
491
+ +container.xScale.invert(px), edgeRef.current ?? to)) }), _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, onDragStart: () => {
492
+ edgeRef.current = from; // the fixed pivot = the near edge
493
+ }, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
494
+ +container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
459
495
  top: `${FLAG_TOP + lane * LANE_H}px`,
460
496
  ...flagChipX(left, container.plotWidth),
461
- }, children: text })] }));
497
+ }, children: text }))] }));
462
498
  }
463
499
  //# sourceMappingURL=annotations.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.32.0",
42
- "pond-ts": "^0.32.0",
41
+ "@pond-ts/react": "^0.33.0",
42
+ "pond-ts": "^0.33.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {