@pond-ts/charts 0.31.2 → 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/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,38 @@ 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: 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)) {
363
+ c.onSelectAnnotation?.(null);
364
+ return;
365
+ }
253
366
  const r = rowRef.current;
254
367
  const rect = e.currentTarget.getBoundingClientRect();
255
368
  const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
@@ -281,19 +394,9 @@ export function Layers({ children }) {
281
394
  // dot ('inline', clamped within the row) or stack at the top of the flag staff
282
395
  // ('flag'). line / point / none draw no chips — surface values off-chart.
283
396
  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
- };
397
+ // Cursor chips share the annotation label look (filled, no outline) — one
398
+ // source of truth so a flag and a placed label read as the same object.
399
+ const chipStyle = flagChipStyle(container.theme);
297
400
  // Show the cursor's time atop the readout (opt-in via `cursorTime`), whenever
298
401
  // the cursor is active (any mode that draws marks). A single chip at the cursor
299
402
  // x, top of the row; for `flag` it sits above the value chips (which shift down).
@@ -304,96 +407,135 @@ export function Layers({ children }) {
304
407
  cursorTime !== null &&
305
408
  (parts.line || parts.dots) &&
306
409
  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).
410
+ // Flag geometry: each value flies as a flag from the top of its own staff — the
411
+ // chip's top sits at `flagBase` (just below the time chip when shown) and the
412
+ // staff drops from there to the dot. (Chips share that top and spread by x, so
413
+ // near-coincident flags can overlap — a de-overlap heuristic is a follow-up.)
309
414
  const flagTop = 2;
310
415
  const flagBase = flagTop + (showTime ? flagLineHeight : 0);
311
- const stackBottom = flagBase + trackerSamples.length * flagLineHeight;
312
416
  // The cursor-time chip caps the readout. In `flag` mode it tops the flag stack,
313
417
  // so anchor it to the stack's x (the nearest sample's point) so time + flag +
314
418
  // staff + dot read as one column; otherwise it labels the cursor line at cursorX.
315
419
  const timeX = parts.chip === 'flag' && trackerSamples.length > 0
316
420
  ? trackerSamples[0].px
317
421
  : cursorX;
422
+ // Cross-row guide lines: the x-positions of annotations on the OTHER rows
423
+ // (markers + region edges), so a mark on one row reads against this row's data +
424
+ // the shared x axis. A mark's own row skips itself; baselines cast no vertical
425
+ // guide (empty `xs`). Faint + dashed so they read as reference, not data.
426
+ const guideXs = container.annotations
427
+ .filter((a) => a.rowKey !== row.rowKey)
428
+ .flatMap((a) => a.xs)
429
+ .map((xv) => xScale(xv));
430
+ const guideColor = container.theme.annotation?.color ?? gridColor;
431
+ // Create preview: while a tool is armed, the hovered row (the one with
432
+ // `createPt`) shows a cursor-style line tracking the pointer — vertical for
433
+ // marker/region, horizontal for baseline, a span once a region is being dragged.
434
+ // The OTHER rows show the faint guide at the shared preview x (markers/regions).
435
+ const creating = container.creating;
436
+ let createPreview = null;
437
+ if (creating !== null && createPt !== null) {
438
+ if (creating === 'baseline') {
439
+ createPreview = (_jsx("line", { x1: 0, y1: createPt.y, x2: plotWidth, y2: createPt.y, stroke: guideColor, strokeWidth: 1, opacity: 0.85, shapeRendering: "crispEdges" }));
440
+ }
441
+ else if (drawFrom !== null) {
442
+ const l = Math.min(drawFrom, createPt.x);
443
+ const w = Math.abs(createPt.x - drawFrom);
444
+ 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" })] }));
445
+ }
446
+ else {
447
+ createPreview = (_jsx("line", { x1: createPt.x, y1: 0, x2: createPt.x, y2: row.height, stroke: guideColor, strokeWidth: 1, opacity: 0.85, shapeRendering: "crispEdges" }));
448
+ }
449
+ }
450
+ else if (creating !== null && creating !== 'baseline' && cursorX !== null) {
451
+ // Another row — the faint preview guide at the shared pointer x.
452
+ createPreview = (_jsx("line", { x1: cursorX, y1: 0, x2: cursorX, y2: row.height, stroke: guideColor, strokeWidth: 1, opacity: 0.22, strokeDasharray: "2 3", shapeRendering: "crispEdges" }));
453
+ }
318
454
  // Inject each draw layer's JSX position so it registers its declaration order
319
455
  // (z-stack: lower index at the back), independent of mount timing.
320
456
  const indexedChildren = Children.map(children, (child, index) => isValidElement(child)
321
457
  ? cloneElement(child, { index })
322
458
  : 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: {
459
+ return (_jsx(LayersContext.Provider, { value: registry, children: _jsxs("div", { ref: plotRef, style: {
460
+ position: 'relative',
461
+ width: `${plotWidth}px`,
462
+ height: `${row.height}px`,
463
+ // Edit mode: a plain cursor on the plot (the annotations supply their
464
+ // own grab/resize cursors); crosshair only when the data cursor is live
465
+ // (suppressed in single-annotation edit too, not just global edit).
466
+ cursor: editingActive ? 'default' : 'crosshair',
467
+ // The turquoise edit border — the "you're in *global* Edit" signal (not
468
+ // single-annotation edit). Inset shadow so it doesn't shift layout.
469
+ boxShadow: container.editAnnotations
470
+ ? `inset 0 0 0 1px ${guideColor}`
471
+ : undefined,
472
+ // Let pan/zoom own touch gestures (no native scroll) when enabled.
473
+ touchAction: container.panZoom ? 'none' : 'auto',
474
+ }, 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: {
475
+ position: 'absolute',
476
+ top: 0,
477
+ left: 0,
478
+ pointerEvents: 'none',
479
+ }, 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: {
480
+ position: 'absolute',
481
+ top: 0,
482
+ left: 0,
483
+ pointerEvents: 'none',
484
+ }, children: createPreview })), indexedChildren, _jsxs("svg", { width: plotWidth, height: row.height, style: {
485
+ position: 'absolute',
486
+ top: 0,
487
+ left: 0,
488
+ pointerEvents: 'none',
489
+ }, children: [parts.line &&
490
+ cursorX !== null &&
491
+ cursorX >= 0 &&
492
+ 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' &&
493
+ 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' &&
494
+ 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 &&
495
+ 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: {
496
+ ...chipStyle,
497
+ top: `${flagTop}px`,
498
+ left: timeX > plotWidth * LABEL_FLIP_FRACTION
499
+ ? undefined
500
+ : `${timeX + 4}px`,
501
+ right: timeX > plotWidth * LABEL_FLIP_FRACTION
502
+ ? `${plotWidth - timeX + 4}px`
503
+ : undefined,
504
+ color: cursorColor,
505
+ }, children: formatTime(cursorTime) })), parts.chip === 'inline' &&
506
+ trackerSamples.map((s, i) => {
507
+ // Flip the chip left of its dot near the right edge so it stays in-plot.
508
+ const flip = s.px > plotWidth * LABEL_FLIP_FRACTION;
509
+ // Clamp within the row so a chip near the top/bottom isn't clipped by
510
+ // (or spilling into) the neighbouring row. Chip-vs-chip de-overlap is
511
+ // a later refinement; this keeps each chip inside its own row.
512
+ const top = Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, s.py));
513
+ return (_jsx("div", { style: {
514
+ ...chipStyle,
515
+ top: `${top}px`,
516
+ transform: 'translateY(-50%)',
517
+ left: flip ? undefined : `${s.px + 8}px`,
518
+ right: flip ? `${plotWidth - s.px + 8}px` : undefined,
519
+ color: s.color,
520
+ }, children: s.format(s.value) }, i));
521
+ }), parts.chip === 'flag' &&
522
+ cursorX !== null &&
523
+ trackerSamples.map((s, i) => (
524
+ // The flag flies from the top of its staff — chip top at the staff top
525
+ // (`flagBase`), beside the pole at the point's x (shared `flagChipX`).
526
+ _jsx("div", { style: {
527
+ ...chipStyle,
528
+ top: `${flagBase}px`,
529
+ ...flagChipX(s.px, plotWidth),
530
+ color: s.color,
531
+ }, children: s.format(s.value) }, i))), parts.chip === 'flag' &&
532
+ trackerFlags.map((f, i) => (_jsx("div", { style: {
346
533
  ...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] }));
534
+ top: `${flagBase}px`,
535
+ ...flagChipX(f.px, plotWidth),
536
+ display: 'flex',
537
+ flexDirection: 'row',
538
+ gap: '6px',
539
+ }, children: f.lines.map((l, j) => (_jsx("span", { style: { color: l.color }, children: l.text }, j))) }, `boxflag-${i}`)))] }) }));
398
540
  }
399
541
  //# 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,