@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/dist/Layers.js CHANGED
@@ -1,10 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, } from 'react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { Canvas } from './Canvas.js';
4
4
  import { drawGrid } from './grid.js';
5
5
  import { cursorParts } from './tracker.js';
6
6
  import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange } from './viewport.js';
8
+ import { flagChipStyle, flagChipX } from './chip.js';
8
9
  import { ContainerContext, LayersContext, RowContext, } from './context.js';
9
10
  /** Gridline tick count — matches the axes (`YAxis`/`TimeAxis`) so they align. */
10
11
  const GRID_TICKS = 5;
@@ -48,7 +49,7 @@ export function Layers({ children }) {
48
49
  }), [row.registerLayer, row.unregisterLayer]);
49
50
  const background = container.theme.background;
50
51
  const { grid: gridColor, gridDash } = container.theme.axis;
51
- const { layers, yScales, formats, defaultAxisId } = row;
52
+ const { layers, yScales, formats, defaultAxisId, tickValues } = row;
52
53
  // x geometry is shared and lives on the container (uniform across rows).
53
54
  const { xScale, plotWidth } = container;
54
55
  const draw = useCallback((ctx, w, h) => {
@@ -59,8 +60,13 @@ export function Layers({ children }) {
59
60
  // Gridlines behind the data, from the same ticks the axes label: vertical
60
61
  // from the shared time scale, horizontal from the row's default y-axis.
61
62
  const gridY = yScales.get(defaultAxisId);
63
+ // Explicit `<YAxis ticks>` drive the gridlines too, so they align with the
64
+ // axis labels; otherwise d3 auto-picks (the default).
65
+ const explicitY = tickValues.get(defaultAxisId);
62
66
  const xTicks = xScale.ticks(GRID_TICKS).map((d) => xScale(d));
63
- const yTicks = gridY ? gridY.ticks(GRID_TICKS).map((t) => gridY(t)) : [];
67
+ const yTicks = gridY
68
+ ? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
69
+ : [];
64
70
  drawGrid(ctx, xTicks, yTicks, w, h, gridColor, gridDash);
65
71
  for (const entry of layers) {
66
72
  const yScale = yScales.get(entry.axisId ?? defaultAxisId);
@@ -68,7 +74,16 @@ export function Layers({ children }) {
68
74
  continue;
69
75
  entry.layer.draw(ctx, xScale, yScale);
70
76
  }
71
- }, [layers, yScales, xScale, defaultAxisId, background, gridColor, gridDash]);
77
+ }, [
78
+ layers,
79
+ yScales,
80
+ xScale,
81
+ defaultAxisId,
82
+ tickValues,
83
+ background,
84
+ gridColor,
85
+ gridDash,
86
+ ]);
72
87
  // Interaction overlay: the cursor marks live on a DOM/SVG overlay above the
73
88
  // data, so hovering never repaints the data canvas (whose `draw` doesn't depend
74
89
  // on the cursor). Reading the container's cursorX — set by whichever row the
@@ -79,7 +94,13 @@ export function Layers({ children }) {
79
94
  // Cursor mode: the row's override, else the container default. One mode per
80
95
  // row (the synced vertical line is shared across rows); each layer renders the
81
96
  // mode in its own way. `parts` decomposes it into {line, dots, chip}.
82
- const parts = cursorParts(row.cursor ?? container.cursor);
97
+ // Editing suppresses the data cursor the marks get the surface (hover/drag),
98
+ // and a crosshair would just be noise. True in global edit mode *and* while a
99
+ // single annotation is being edited (the double-click target).
100
+ const editingActive = container.editAnnotations || container.annotations.some((a) => a.editing);
101
+ const parts = editingActive
102
+ ? cursorParts('none')
103
+ : cursorParts(row.cursor ?? container.cursor);
83
104
  const cursorColor = container.theme.cursor ?? container.theme.axis.label;
84
105
  // Only read a time when the cursor is within the plot. An out-of-bounds
85
106
  // controlled trackerPosition hides the cursor, so the dots + chips hide too —
@@ -181,9 +202,31 @@ export function Layers({ children }) {
181
202
  });
182
203
  // Pointer-down position, to tell a click (select) from the tail of a drag/pan.
183
204
  const clickStartRef = useRef(null);
205
+ // Create gesture (when a tool is armed): `createPt` is the live pointer driving
206
+ // the preview on the hovered row; `drawFrom` is a region's fixed start edge (px)
207
+ // once pressed. `drawFromRef` mirrors it for the stable up-handler to read.
208
+ const [createPt, setCreatePt] = useState(null);
209
+ const [drawFrom, setDrawFrom] = useState(null);
210
+ const drawFromRef = useRef(null);
184
211
  const handlePointerDown = useCallback((e) => {
185
212
  clickStartRef.current = { x: e.clientX, y: e.clientY };
186
213
  const c = containerRef.current;
214
+ if (c.creating !== null) {
215
+ // Armed: a region presses to fix its start edge; a line just tracks until
216
+ // release. Capture so the draw can continue outside the plot.
217
+ if (c.creating === 'region') {
218
+ const px = e.clientX - e.currentTarget.getBoundingClientRect().left;
219
+ drawFromRef.current = px;
220
+ setDrawFrom(px);
221
+ }
222
+ try {
223
+ e.currentTarget.setPointerCapture(e.pointerId);
224
+ }
225
+ catch {
226
+ /* ignore */
227
+ }
228
+ return;
229
+ }
187
230
  if (!c.panZoom)
188
231
  return;
189
232
  const r = c.timeRange;
@@ -201,6 +244,13 @@ export function Layers({ children }) {
201
244
  }, []);
202
245
  const handlePointerMove = useCallback((e) => {
203
246
  const c = containerRef.current;
247
+ if (c.creating !== null) {
248
+ const rect = e.currentTarget.getBoundingClientRect();
249
+ const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
250
+ setCreatePt({ x: px, y: e.clientY - rect.top });
251
+ c.setHoverX(px); // share the preview x so other rows draw a guide there
252
+ return;
253
+ }
204
254
  const drag = dragRef.current;
205
255
  if (drag) {
206
256
  // Pan from the start range by the total drag — right → earlier (−dt).
@@ -227,6 +277,48 @@ export function Layers({ children }) {
227
277
  c.setHovered(hit);
228
278
  }, []);
229
279
  const handlePointerUp = useCallback((e) => {
280
+ const c = containerRef.current;
281
+ if (c.creating !== null) {
282
+ const rect = e.currentTarget.getBoundingClientRect();
283
+ const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
284
+ const py = e.clientY - rect.top;
285
+ if (c.creating === 'marker') {
286
+ c.onCreate?.({ kind: 'marker', at: +c.xScale.invert(px) });
287
+ }
288
+ else if (c.creating === 'baseline') {
289
+ const r = rowRef.current;
290
+ const ys = r.yScales.get(r.defaultAxisId);
291
+ if (ys) {
292
+ c.onCreate?.({
293
+ kind: 'baseline',
294
+ value: ys.invert(py),
295
+ axis: r.defaultAxisId,
296
+ });
297
+ }
298
+ }
299
+ else if (c.creating === 'region') {
300
+ const fromPx = drawFromRef.current;
301
+ // Need a real drag — a click (no span) creates nothing.
302
+ if (fromPx !== null && Math.abs(px - fromPx) > DRAG_SLOP) {
303
+ const a = +c.xScale.invert(fromPx);
304
+ const b = +c.xScale.invert(px);
305
+ c.onCreate?.({
306
+ kind: 'region',
307
+ from: Math.min(a, b),
308
+ to: Math.max(a, b),
309
+ });
310
+ }
311
+ }
312
+ drawFromRef.current = null;
313
+ setDrawFrom(null);
314
+ try {
315
+ e.currentTarget.releasePointerCapture(e.pointerId);
316
+ }
317
+ catch {
318
+ /* ignore */
319
+ }
320
+ return;
321
+ }
230
322
  if (dragRef.current) {
231
323
  dragRef.current = null;
232
324
  try {
@@ -239,17 +331,36 @@ export function Layers({ children }) {
239
331
  }, []);
240
332
  const handlePointerLeave = useCallback(() => {
241
333
  const c = containerRef.current;
334
+ if (c.creating !== null) {
335
+ // Leaving mid-arm cancels the preview (and an in-progress region draw).
336
+ setCreatePt(null);
337
+ drawFromRef.current = null;
338
+ setDrawFrom(null);
339
+ c.setHoverX(null);
340
+ return;
341
+ }
242
342
  c.setHoverX(null);
243
343
  c.setHovered(null);
244
344
  }, []);
245
345
  // Click selection: ignore the click that ends a drag/pan (moved past a few px),
246
346
  // else hit-test the row's layers top-down and select — or clear on a miss.
247
347
  const handleClick = useCallback((e) => {
348
+ if (containerRef.current.creating !== null)
349
+ return; // the draw owns the click
248
350
  const start = clickStartRef.current;
249
351
  if (start &&
250
352
  Math.hypot(e.clientX - start.x, e.clientY - start.y) > DRAG_SLOP)
251
353
  return;
252
354
  const c = containerRef.current;
355
+ // A click that reached the plot (no mark's DragArea claimed it) is an empty
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)) {
361
+ c.onSelectAnnotation?.(null);
362
+ return;
363
+ }
253
364
  const r = rowRef.current;
254
365
  const rect = e.currentTarget.getBoundingClientRect();
255
366
  const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
@@ -281,19 +392,9 @@ export function Layers({ children }) {
281
392
  // dot ('inline', clamped within the row) or stack at the top of the flag staff
282
393
  // ('flag'). line / point / none draw no chips — surface values off-chart.
283
394
  const flagLineHeight = container.theme.font.size + 5;
284
- const chipStyle = {
285
- position: 'absolute',
286
- background: container.theme.chip?.background,
287
- border: `1px solid ${gridColor}`,
288
- borderRadius: '3px',
289
- padding: '0 4px',
290
- fontFamily: container.theme.font.family,
291
- fontSize: `${container.theme.font.size}px`,
292
- fontVariantNumeric: 'tabular-nums',
293
- whiteSpace: 'nowrap',
294
- pointerEvents: 'none',
295
- lineHeight: 1.5,
296
- };
395
+ // Cursor chips share the annotation label look (filled, no outline) — one
396
+ // source of truth so a flag and a placed label read as the same object.
397
+ const chipStyle = flagChipStyle(container.theme);
297
398
  // Show the cursor's time atop the readout (opt-in via `cursorTime`), whenever
298
399
  // the cursor is active (any mode that draws marks). A single chip at the cursor
299
400
  // x, top of the row; for `flag` it sits above the value chips (which shift down).
@@ -304,96 +405,135 @@ export function Layers({ children }) {
304
405
  cursorTime !== null &&
305
406
  (parts.line || parts.dots) &&
306
407
  row.isFirstRow;
307
- // Flag stacking geometry: chips stack from `flagBase` (below the time chip when
308
- // shown); each staff rises from its dot up to `stackBottom` (the stack's foot).
408
+ // Flag geometry: each value flies as a flag from the top of its own staff — the
409
+ // chip's top sits at `flagBase` (just below the time chip when shown) and the
410
+ // staff drops from there to the dot. (Chips share that top and spread by x, so
411
+ // near-coincident flags can overlap — a de-overlap heuristic is a follow-up.)
309
412
  const flagTop = 2;
310
413
  const flagBase = flagTop + (showTime ? flagLineHeight : 0);
311
- const stackBottom = flagBase + trackerSamples.length * flagLineHeight;
312
414
  // The cursor-time chip caps the readout. In `flag` mode it tops the flag stack,
313
415
  // so anchor it to the stack's x (the nearest sample's point) so time + flag +
314
416
  // staff + dot read as one column; otherwise it labels the cursor line at cursorX.
315
417
  const timeX = parts.chip === 'flag' && trackerSamples.length > 0
316
418
  ? trackerSamples[0].px
317
419
  : cursorX;
420
+ // Cross-row guide lines: the x-positions of annotations on the OTHER rows
421
+ // (markers + region edges), so a mark on one row reads against this row's data +
422
+ // the shared x axis. A mark's own row skips itself; baselines cast no vertical
423
+ // guide (empty `xs`). Faint + dashed so they read as reference, not data.
424
+ const guideXs = container.annotations
425
+ .filter((a) => a.rowKey !== row.rowKey)
426
+ .flatMap((a) => a.xs)
427
+ .map((xv) => xScale(xv));
428
+ const guideColor = container.theme.annotation?.color ?? gridColor;
429
+ // Create preview: while a tool is armed, the hovered row (the one with
430
+ // `createPt`) shows a cursor-style line tracking the pointer — vertical for
431
+ // marker/region, horizontal for baseline, a span once a region is being dragged.
432
+ // The OTHER rows show the faint guide at the shared preview x (markers/regions).
433
+ const creating = container.creating;
434
+ let createPreview = null;
435
+ if (creating !== null && createPt !== null) {
436
+ if (creating === 'baseline') {
437
+ createPreview = (_jsx("line", { x1: 0, y1: createPt.y, x2: plotWidth, y2: createPt.y, stroke: guideColor, strokeWidth: 1, opacity: 0.85, shapeRendering: "crispEdges" }));
438
+ }
439
+ else if (drawFrom !== null) {
440
+ const l = Math.min(drawFrom, createPt.x);
441
+ const w = Math.abs(createPt.x - drawFrom);
442
+ createPreview = (_jsxs(_Fragment, { children: [_jsx("rect", { x: l, y: 0, width: w, height: row.height, fill: guideColor, opacity: 0.12 }), _jsx("line", { x1: drawFrom, y1: 0, x2: drawFrom, y2: row.height, stroke: guideColor, strokeWidth: 1, opacity: 0.85, strokeDasharray: "3 2", shapeRendering: "crispEdges" }), _jsx("line", { x1: createPt.x, y1: 0, x2: createPt.x, y2: row.height, stroke: guideColor, strokeWidth: 1, opacity: 0.85, shapeRendering: "crispEdges" })] }));
443
+ }
444
+ else {
445
+ createPreview = (_jsx("line", { x1: createPt.x, y1: 0, x2: createPt.x, y2: row.height, stroke: guideColor, strokeWidth: 1, opacity: 0.85, shapeRendering: "crispEdges" }));
446
+ }
447
+ }
448
+ else if (creating !== null && creating !== 'baseline' && cursorX !== null) {
449
+ // Another row — the faint preview guide at the shared pointer x.
450
+ createPreview = (_jsx("line", { x1: cursorX, y1: 0, x2: cursorX, y2: row.height, stroke: guideColor, strokeWidth: 1, opacity: 0.22, strokeDasharray: "2 3", shapeRendering: "crispEdges" }));
451
+ }
318
452
  // Inject each draw layer's JSX position so it registers its declaration order
319
453
  // (z-stack: lower index at the back), independent of mount timing.
320
454
  const indexedChildren = Children.map(children, (child, index) => isValidElement(child)
321
455
  ? cloneElement(child, { index })
322
456
  : child);
323
- return (_jsxs(LayersContext.Provider, { value: registry, children: [_jsxs("div", { ref: plotRef, style: {
324
- position: 'relative',
325
- width: `${plotWidth}px`,
326
- height: `${row.height}px`,
327
- cursor: 'crosshair',
328
- // Let pan/zoom own touch gestures (no native scroll) when enabled.
329
- touchAction: container.panZoom ? 'none' : 'auto',
330
- }, onPointerMove: handlePointerMove, onPointerDown: handlePointerDown, onPointerUp: handlePointerUp, onPointerCancel: handlePointerUp, onPointerLeave: handlePointerLeave, onClick: handleClick, children: [_jsx(Canvas, { width: plotWidth, height: row.height, draw: draw }), _jsxs("svg", { width: plotWidth, height: row.height, style: {
331
- position: 'absolute',
332
- top: 0,
333
- left: 0,
334
- pointerEvents: 'none',
335
- }, children: [parts.line &&
336
- cursorX !== null &&
337
- cursorX >= 0 &&
338
- cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
339
- trackerSamples.map((s, i) => s.py > stackBottom ? (_jsx("line", { x1: s.px, y1: stackBottom, x2: s.px, y2: s.py, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `staff-${i}`)) : null), parts.chip === 'flag' &&
340
- trackerFlags.map((f, i) => {
341
- // One horizontal row of values → a single-line chip.
342
- const flagBottom = flagBase + flagLineHeight;
343
- return f.topPy > flagBottom ? (_jsx("line", { x1: f.px, y1: flagBottom, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null;
344
- }), parts.dots &&
345
- trackerSamples.map((s, i) => (_jsx("circle", { cx: s.px, cy: s.py, r: 3, fill: s.color, stroke: background, strokeWidth: background ? 1 : 0 }, `dot-${i}`)))] }), showTime && timeX !== null && cursorTime !== null && (_jsx("div", { style: {
457
+ return (_jsx(LayersContext.Provider, { value: registry, children: _jsxs("div", { ref: plotRef, style: {
458
+ position: 'relative',
459
+ width: `${plotWidth}px`,
460
+ height: `${row.height}px`,
461
+ // Edit mode: a plain cursor on the plot (the annotations supply their
462
+ // own grab/resize cursors); crosshair only when the data cursor is live
463
+ // (suppressed in single-annotation edit too, not just global edit).
464
+ cursor: editingActive ? 'default' : 'crosshair',
465
+ // The turquoise edit border — the "you're in *global* Edit" signal (not
466
+ // single-annotation edit). Inset shadow so it doesn't shift layout.
467
+ boxShadow: container.editAnnotations
468
+ ? `inset 0 0 0 1px ${guideColor}`
469
+ : undefined,
470
+ // Let pan/zoom own touch gestures (no native scroll) when enabled.
471
+ touchAction: container.panZoom ? 'none' : 'auto',
472
+ }, onPointerMove: handlePointerMove, onPointerDown: handlePointerDown, onPointerUp: handlePointerUp, onPointerCancel: handlePointerUp, onPointerLeave: handlePointerLeave, onClick: handleClick, children: [_jsx(Canvas, { width: plotWidth, height: row.height, draw: draw }), guideXs.length > 0 && (_jsx("svg", { width: plotWidth, height: row.height, style: {
473
+ position: 'absolute',
474
+ top: 0,
475
+ left: 0,
476
+ pointerEvents: 'none',
477
+ }, children: guideXs.map((gx, i) => (_jsx("line", { x1: gx, y1: 0, x2: gx, y2: row.height, stroke: guideColor, strokeWidth: 1, opacity: 0.22, strokeDasharray: "2 3", shapeRendering: "crispEdges" }, i))) })), createPreview !== null && (_jsx("svg", { width: plotWidth, height: row.height, style: {
478
+ position: 'absolute',
479
+ top: 0,
480
+ left: 0,
481
+ pointerEvents: 'none',
482
+ }, children: createPreview })), indexedChildren, _jsxs("svg", { width: plotWidth, height: row.height, style: {
483
+ position: 'absolute',
484
+ top: 0,
485
+ left: 0,
486
+ pointerEvents: 'none',
487
+ }, children: [parts.line &&
488
+ cursorX !== null &&
489
+ cursorX >= 0 &&
490
+ cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
491
+ trackerSamples.map((s, i) => s.py > flagBase ? (_jsx("line", { x1: s.px, y1: flagBase, x2: s.px, y2: s.py, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `staff-${i}`)) : null), parts.chip === 'flag' &&
492
+ trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.dots &&
493
+ trackerSamples.map((s, i) => (_jsx("circle", { cx: s.px, cy: s.py, r: 3, fill: s.color, stroke: background, strokeWidth: background ? 1 : 0 }, `dot-${i}`)))] }), showTime && timeX !== null && cursorTime !== null && (_jsx("div", { style: {
494
+ ...chipStyle,
495
+ top: `${flagTop}px`,
496
+ left: timeX > plotWidth * LABEL_FLIP_FRACTION
497
+ ? undefined
498
+ : `${timeX + 4}px`,
499
+ right: timeX > plotWidth * LABEL_FLIP_FRACTION
500
+ ? `${plotWidth - timeX + 4}px`
501
+ : undefined,
502
+ color: cursorColor,
503
+ }, children: formatTime(cursorTime) })), parts.chip === 'inline' &&
504
+ trackerSamples.map((s, i) => {
505
+ // Flip the chip left of its dot near the right edge so it stays in-plot.
506
+ const flip = s.px > plotWidth * LABEL_FLIP_FRACTION;
507
+ // Clamp within the row so a chip near the top/bottom isn't clipped by
508
+ // (or spilling into) the neighbouring row. Chip-vs-chip de-overlap is
509
+ // a later refinement; this keeps each chip inside its own row.
510
+ const top = Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, s.py));
511
+ return (_jsx("div", { style: {
512
+ ...chipStyle,
513
+ top: `${top}px`,
514
+ transform: 'translateY(-50%)',
515
+ left: flip ? undefined : `${s.px + 8}px`,
516
+ right: flip ? `${plotWidth - s.px + 8}px` : undefined,
517
+ color: s.color,
518
+ }, children: s.format(s.value) }, i));
519
+ }), parts.chip === 'flag' &&
520
+ cursorX !== null &&
521
+ trackerSamples.map((s, i) => (
522
+ // The flag flies from the top of its staff — chip top at the staff top
523
+ // (`flagBase`), beside the pole at the point's x (shared `flagChipX`).
524
+ _jsx("div", { style: {
525
+ ...chipStyle,
526
+ top: `${flagBase}px`,
527
+ ...flagChipX(s.px, plotWidth),
528
+ color: s.color,
529
+ }, children: s.format(s.value) }, i))), parts.chip === 'flag' &&
530
+ trackerFlags.map((f, i) => (_jsx("div", { style: {
346
531
  ...chipStyle,
347
- top: `${flagTop}px`,
348
- left: timeX > plotWidth * LABEL_FLIP_FRACTION
349
- ? undefined
350
- : `${timeX + 4}px`,
351
- right: timeX > plotWidth * LABEL_FLIP_FRACTION
352
- ? `${plotWidth - timeX + 4}px`
353
- : undefined,
354
- color: cursorColor,
355
- }, children: formatTime(cursorTime) })), parts.chip === 'inline' &&
356
- trackerSamples.map((s, i) => {
357
- // Flip the chip left of its dot near the right edge so it stays in-plot.
358
- const flip = s.px > plotWidth * LABEL_FLIP_FRACTION;
359
- // Clamp within the row so a chip near the top/bottom isn't clipped by
360
- // (or spilling into) the neighbouring row. Chip-vs-chip de-overlap is
361
- // a later refinement; this keeps each chip inside its own row.
362
- const top = Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, s.py));
363
- return (_jsx("div", { style: {
364
- ...chipStyle,
365
- top: `${top}px`,
366
- transform: 'translateY(-50%)',
367
- left: flip ? undefined : `${s.px + 8}px`,
368
- right: flip ? `${plotWidth - s.px + 8}px` : undefined,
369
- color: s.color,
370
- }, children: s.format(s.value) }, i));
371
- }), parts.chip === 'flag' &&
372
- cursorX !== null &&
373
- trackerSamples.map((s, i) => {
374
- // Each flag caps its own staff — anchored to the data point's x
375
- // (`s.px`), riding the point with the dot + staff, not the cursor.
376
- // Flip left near the right edge so it stays in-plot.
377
- const flip = s.px > plotWidth * LABEL_FLIP_FRACTION;
378
- return (_jsx("div", { style: {
379
- ...chipStyle,
380
- top: `${flagBase + i * flagLineHeight}px`,
381
- left: flip ? undefined : `${s.px + 4}px`,
382
- right: flip ? `${plotWidth - s.px + 4}px` : undefined,
383
- color: s.color,
384
- }, children: s.format(s.value) }, i));
385
- }), parts.chip === 'flag' &&
386
- trackerFlags.map((f, i) => {
387
- const flip = f.px > plotWidth * LABEL_FLIP_FRACTION;
388
- return (_jsx("div", { style: {
389
- ...chipStyle,
390
- top: `${flagBase}px`,
391
- left: flip ? undefined : `${f.px + 4}px`,
392
- right: flip ? `${plotWidth - f.px + 4}px` : undefined,
393
- display: 'flex',
394
- flexDirection: 'row',
395
- gap: '6px',
396
- }, children: f.lines.map((l, j) => (_jsx("span", { style: { color: l.color }, children: l.text }, j))) }, `boxflag-${i}`));
397
- })] }), indexedChildren] }));
532
+ top: `${flagBase}px`,
533
+ ...flagChipX(f.px, plotWidth),
534
+ display: 'flex',
535
+ flexDirection: 'row',
536
+ gap: '6px',
537
+ }, children: f.lines.map((l, j) => (_jsx("span", { style: { color: l.color }, children: l.text }, j))) }, `boxflag-${i}`)))] }) }));
398
538
  }
399
539
  //# sourceMappingURL=Layers.js.map
package/dist/YAxis.d.ts CHANGED
@@ -22,6 +22,21 @@ export interface YAxisProps {
22
22
  * (e.g. `',.2f'`) when you want finer readout precision. See {@link AxisFormat}.
23
23
  */
24
24
  format?: AxisFormat;
25
+ /**
26
+ * Explicit ticks — `{ at, label }` in axis-value units — instead of the
27
+ * scale's automatic ticks, driving BOTH the labels and the row's gridlines so
28
+ * the two align. The y-axis counterpart of `<XAxis ticks>` (same shape): the
29
+ * lever for a non-uniform axis like pace, where the caller chooses round-pace
30
+ * positions and their own `m:ss` labels (`{ at: -300, label: '5:00' }`). `at`
31
+ * values outside `[min, max]` extrapolate off-plot (the scale does not clamp).
32
+ * Pass `[]` to draw none. For a live / animating chart, **memoize the array** —
33
+ * an inline `ticks={[…]}` is a fresh reference each render and re-registers the
34
+ * axis (like `format`; harmless for a static chart).
35
+ */
36
+ ticks?: ReadonlyArray<{
37
+ readonly at: number;
38
+ readonly label: string;
39
+ }>;
25
40
  /** Gutter width in CSS pixels (default 50). */
26
41
  width?: number;
27
42
  /**
@@ -38,5 +53,5 @@ export interface YAxisProps {
38
53
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
39
54
  * (default: the first axis).
40
55
  */
41
- export declare function YAxis({ id, side, label, min, max, format, width, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
56
+ export declare function YAxis({ id, side, label, min, max, format, ticks, width, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
42
57
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -13,7 +13,7 @@ const TICK_COUNT = 5;
13
13
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
14
14
  * (default: the first axis).
15
15
  */
16
- export function YAxis({ id, side = 'left', label, min, max, format, width = DEFAULT_WIDTH, index = 0, }) {
16
+ export function YAxis({ id, side = 'left', label, min, max, format, ticks, width = DEFAULT_WIDTH, index = 0, }) {
17
17
  const container = useContext(ContainerContext);
18
18
  if (container === null) {
19
19
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -22,7 +22,16 @@ export function YAxis({ id, side = 'left', label, min, max, format, width = DEFA
22
22
  if (row === null) {
23
23
  throw new Error('<YAxis> must be rendered inside a <ChartRow>');
24
24
  }
25
- const spec = useMemo(() => ({ id, side, width, min, max, format, index }), [id, side, width, min, max, format, index]);
25
+ const spec = useMemo(() => ({
26
+ id,
27
+ side,
28
+ width,
29
+ min,
30
+ max,
31
+ format,
32
+ tickValues: ticks?.map((t) => t.at),
33
+ index,
34
+ }), [id, side, width, min, max, format, ticks, index]);
26
35
  // A stable per-instance slot (see useSlotKey) keeps this axis in a fixed
27
36
  // registry position, so a min/max/side change updates in place rather than
28
37
  // re-appending (which would move the first axis behind a later one and
@@ -37,10 +46,17 @@ export function YAxis({ id, side = 'left', label, min, max, format, width = DEFA
37
46
  }, [registerAxis, slot, spec]);
38
47
  const { theme } = container;
39
48
  const yScale = row.yScales.get(id);
40
- const ticks = yScale ? yScale.ticks(TICK_COUNT) : [];
41
49
  // Same formatter the readout uses (resolved per axis on the row), so a tick and
42
50
  // a cursor value read identically.
43
51
  const fmt = yScale ? resolveAxisFormat(yScale, TICK_COUNT, format) : String;
52
+ // Explicit `{ at, label }` ticks render verbatim (each label at its `at`),
53
+ // overriding the auto-picked d3 ticks; otherwise label the scale's ticks via `fmt`.
54
+ const tickList = ticks
55
+ ? ticks.map((t) => ({ value: t.at, label: t.label }))
56
+ : (yScale ? yScale.ticks(TICK_COUNT) : []).map((t) => ({
57
+ value: t,
58
+ label: fmt(t),
59
+ }));
44
60
  // The row reserves a slot per axis column (the widest in that column across
45
61
  // rows). Size the box to the slot and align this axis's own (narrower)
46
62
  // content toward the plot — left axes flush right, right axes flush left — so
@@ -60,13 +76,13 @@ export function YAxis({ id, side = 'left', label, min, max, format, width = DEFA
60
76
  fontSize: `${theme.font.size}px`,
61
77
  color: theme.axis.label,
62
78
  }, children: [yScale &&
63
- ticks.map((t) => (_jsx("div", { style: {
79
+ tickList.map(({ value, label }) => (_jsx("div", { style: {
64
80
  position: 'absolute',
65
- top: `${yScale(t)}px`,
81
+ top: `${yScale(value)}px`,
66
82
  [side === 'left' ? 'right' : 'left']: '4px',
67
83
  transform: 'translateY(-50%)',
68
84
  whiteSpace: 'nowrap',
69
- }, children: fmt(t) }, t))), _jsx("div", { style: {
85
+ }, children: label }, value))), _jsx("div", { style: {
70
86
  position: 'absolute',
71
87
  [side === 'left' ? 'left' : 'right']: '1px',
72
88
  top: 0,
@@ -0,0 +1,110 @@
1
+ import { type AnnotationSpec } from './context.js';
2
+ /**
3
+ * Greedy left→right lane packing for the **top-flag** labels (markers + regions): a
4
+ * label that would overlap the one to its left drops to the next free lane below,
5
+ * so close-in-x labels stack instead of colliding (and a dragged label slides under
6
+ * its neighbour). Returns slot-key → lane (0 = top). Baselines, whose labels anchor
7
+ * at the left at their own y, don't participate.
8
+ */
9
+ export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number): Map<symbol, number>;
10
+ export interface MarkerProps {
11
+ /** x position in axis units — epoch ms on a time axis, the value on a value
12
+ * axis. (The generalisation of the mockup's "time line": a mark at an x, time
13
+ * or value.) */
14
+ at: number;
15
+ /** Chip label; omit to auto-label with the shared x formatter (the axis's). */
16
+ label?: string;
17
+ /** Stable consumer id — a click reports it via the container's
18
+ * `onSelectAnnotation`, so the consumer can track which mark is selected. */
19
+ id?: string;
20
+ /** Controlled selection — brightens to the front (level 1). Handles are an
21
+ * edit-mode hover affordance, not a selection cue. Ignored if not `selectable`. */
22
+ selected?: boolean;
23
+ /** Whether the mark responds to hover + selection (default `true`). When
24
+ * `false` it's inert background context — drawn at the back (level 3) always,
25
+ * no hover, no select, no edit. */
26
+ selectable?: boolean;
27
+ /** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
28
+ * remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
29
+ hovered?: boolean;
30
+ /** When `true`, this mark is in **single-annotation edit** (the double-click
31
+ * target): handles stay out, it's draggable, and it reads as level 1 — while
32
+ * other marks stay static. Independent of the container's global
33
+ * `editAnnotations`. Pair with `onEditAnnotation` (the consumer holds an
34
+ * `editingId` and sets `editing={editingId === id}`). */
35
+ editing?: boolean;
36
+ /** Make the marker **editable** (in edit mode): dragging its line reports the
37
+ * new `at` (controlled — wire it back to `at`). The whole line moves. */
38
+ onChange?: (at: number) => void;
39
+ }
40
+ /** A vertical line at an x position (a time, a distance, a lap boundary). */
41
+ export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
42
+ export interface BaselineProps {
43
+ /** y value in the linked axis's units. */
44
+ value: number;
45
+ /** Which `<YAxis>` (by id) to measure against; omit for the row's default axis. */
46
+ axis?: string;
47
+ /** Chip label; omit to format `value` with that axis's formatter. */
48
+ label?: string;
49
+ /** Stable consumer id — a click reports it via `onSelectAnnotation`. */
50
+ id?: string;
51
+ /** Controlled selection — brightens to the front (level 1). Handles are an
52
+ * edit-mode hover affordance, not a selection cue. Ignored if not `selectable`. */
53
+ selected?: boolean;
54
+ /** Whether the baseline responds to hover + selection (default `true`). When
55
+ * `false` it's inert background context — drawn at the back (level 3) always. */
56
+ selectable?: boolean;
57
+ /** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
58
+ * remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
59
+ hovered?: boolean;
60
+ /** When `true`, this mark is in **single-annotation edit** (the double-click
61
+ * target): handles stay out, it's draggable, and it reads as level 1 — while
62
+ * other marks stay static. Independent of the container's global
63
+ * `editAnnotations`. Pair with `onEditAnnotation` (the consumer holds an
64
+ * `editingId` and sets `editing={editingId === id}`). */
65
+ editing?: boolean;
66
+ /** Make the baseline **editable** (in edit mode): dragging it vertically reports
67
+ * the new `value` (controlled — wire it back to `value`). */
68
+ onChange?: (value: number) => void;
69
+ }
70
+ /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
71
+ * Its label anchors at the left, at the line's height. */
72
+ export declare function Baseline({ value, axis, label, id, selected, selectable, hovered, editing, onChange, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
73
+ export interface RegionProps {
74
+ /** Start x in axis units (time or value). */
75
+ from: number;
76
+ /** End x in axis units. */
77
+ to: number;
78
+ /** Chip label; omit to auto-label `from–to` with the shared x formatter. */
79
+ label?: string;
80
+ /** Stable consumer id — a click (or double-click outside edit) reports it via
81
+ * `onSelectAnnotation`. */
82
+ id?: string;
83
+ /** Controlled selection — brightens to the front (level 1; the body too). Edge
84
+ * handles are an edit-mode hover affordance, not a selection cue. Ignored if not
85
+ * `selectable`. */
86
+ selected?: boolean;
87
+ /** Whether the region responds to hover + selection (default `true`). When
88
+ * `false` it's inert background context — drawn at the back (level 3) always,
89
+ * and the double-click hit-test skips it. */
90
+ selectable?: boolean;
91
+ /** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
92
+ * remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
93
+ hovered?: boolean;
94
+ /** When `true`, this mark is in **single-annotation edit** (the double-click
95
+ * target): handles stay out, it's draggable, and it reads as level 1 — while
96
+ * other marks stay static. Independent of the container's global
97
+ * `editAnnotations`. Pair with `onEditAnnotation` (the consumer holds an
98
+ * `editingId` and sets `editing={editingId === id}`). */
99
+ editing?: boolean;
100
+ /** Make the region **editable** (in edit mode): drag the body to move it (both
101
+ * edges shift), drag an edge to resize. Reports the new `{ from, to }`. */
102
+ onChange?: (next: {
103
+ from: number;
104
+ to: number;
105
+ }) => void;
106
+ }
107
+ /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
108
+ * flies as a flag off the left edge. */
109
+ export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, }: RegionProps): import("react/jsx-runtime").JSX.Element;
110
+ //# sourceMappingURL=annotations.d.ts.map