@pond-ts/charts 0.36.0 → 0.38.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.
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { ContainerContext, RowContext, } from './context.js';
4
- import { flagChipStyle, flagChipX } from './chip.js';
4
+ import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
5
5
  import { useSlotKey } from './use-slot-key.js';
6
6
  /**
7
7
  * User-authored **annotations** — marks you place *on* a chart, in a register
@@ -114,7 +114,7 @@ function useAnnotationFrame(name) {
114
114
  * other rows, order regions, and serve snap targets), keyed by the caller's stable
115
115
  * per-instance slot key; unregister on unmount. `xs` should be memoised by the
116
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) {
117
+ function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, selectable, editing, label, indicator) {
118
118
  const { registerAnnotation, unregisterAnnotation } = container;
119
119
  useEffect(() => () => unregisterAnnotation(key), [unregisterAnnotation, key]);
120
120
  useEffect(() => {
@@ -128,6 +128,7 @@ function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, s
128
128
  selectable,
129
129
  editing,
130
130
  label,
131
+ indicator,
131
132
  });
132
133
  }, [
133
134
  registerAnnotation,
@@ -140,6 +141,7 @@ function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, s
140
141
  selectable,
141
142
  editing,
142
143
  label,
144
+ indicator,
143
145
  ]);
144
146
  }
145
147
  /** Vertical px between stacked label lanes. */
@@ -338,7 +340,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
338
340
  } }));
339
341
  }
340
342
  /** A vertical line at an x position (a time, a distance, a lap boundary). */
341
- export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
343
+ export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
342
344
  const { container, row, ann } = useAnnotationFrame('Marker');
343
345
  const selfKey = useSlotKey();
344
346
  const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
@@ -350,7 +352,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
350
352
  const xs = useMemo(() => [at], [at]);
351
353
  // `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label off the x formatter.
352
354
  const text = label === false ? '' : (label ?? container.formatTime(at));
353
- useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text);
355
+ useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text, indicator);
354
356
  // No select/edit while a create tool is armed — the chart is in draw mode then.
355
357
  const select = id !== undefined && container.creating === null
356
358
  ? () => container.onSelectAnnotation?.(id)
@@ -371,7 +373,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
371
373
  }
372
374
  /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
373
375
  * Its label anchors at the left, at the line's height. */
374
- export function Baseline({ value, axis, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
376
+ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition = 'center', id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
375
377
  const { container, row, ann } = useAnnotationFrame('Baseline');
376
378
  const selfKey = useSlotKey();
377
379
  const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
@@ -388,7 +390,7 @@ export function Baseline({ value, axis, label, id, selected = false, selectable
388
390
  // Baselines don't lane-pack (the label anchors at their y, not the top), so
389
391
  // this registered string is unused by `computeLabelLanes` — `|| ''` just
390
392
  // keeps it a string for `false`/'' (which mean "no label").
391
- label || '');
393
+ label || '', indicator);
392
394
  // No select/edit while a create tool is armed — the chart is in draw mode then.
393
395
  const select = id !== undefined && container.creating === null
394
396
  ? () => container.onSelectAnnotation?.(id)
@@ -411,11 +413,27 @@ export function Baseline({ value, axis, label, id, selected = false, selectable
411
413
  const text = label === false ? '' : (label ?? (fmt ? fmt(value) : String(value)));
412
414
  // Handle pill near the right end (clears the left-anchored label).
413
415
  const handleX = w - 14;
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 }))] }));
416
+ 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: {
417
+ top: `${y}px`,
418
+ [labelSide === 'right' ? 'right' : 'left']: '2px',
419
+ // `center` rides on the line; `above` sits its bottom edge on the line.
420
+ transform: labelPosition === 'above'
421
+ ? 'translateY(-100%)'
422
+ : 'translateY(-50%)',
423
+ }, children: text })), indicator &&
424
+ (() => {
425
+ const half = container.theme.font.size / 2 + 1;
426
+ return (_jsx("div", { style: {
427
+ ...axisPillStyle(container.theme, ann.color),
428
+ top: `${Math.max(half, Math.min(row.height - half, y))}px`,
429
+ transform: 'translateY(-50%)',
430
+ ...axisPillX(row.axisSides.get(axisId) ?? 'left', w),
431
+ }, children: fmt ? fmt(value) : String(value) }));
432
+ })()] }));
415
433
  }
416
434
  /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
417
435
  * flies as a flag off the left edge. */
418
- export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
436
+ export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, }) {
419
437
  const { container, row, ann } = useAnnotationFrame('Region');
420
438
  const selfKey = useSlotKey();
421
439
  const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
@@ -429,7 +447,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
429
447
  const text = label === false
430
448
  ? ''
431
449
  : (label ?? `${container.formatTime(from)}–${container.formatTime(to)}`);
432
- useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text);
450
+ useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text, false);
433
451
  // No select/edit while a create tool is armed — the chart is in draw mode then.
434
452
  const select = id !== undefined && container.creating === null
435
453
  ? () => container.onSelectAnnotation?.(id)
@@ -460,7 +478,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
460
478
  // region the other way instead of dead-ending at zero width.
461
479
  const edgeRef = useRef(null);
462
480
  const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
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) => {
481
+ 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 }), edges && edge(xa), edges && 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) => {
464
482
  dragRef.current = { from, to, startPx: px };
465
483
  }, onDrag: (px) => {
466
484
  const s = dragRef.current;
package/dist/chip.d.ts CHANGED
@@ -13,6 +13,42 @@ import type { ChartTheme } from './theme.js';
13
13
  * contrasting chip background) — a token to settle before this ships.
14
14
  */
15
15
  export declare function flagChipStyle(theme: ChartTheme): CSSProperties;
16
+ /**
17
+ * Pick a readable text colour (near-black or white) for text drawn **on top of**
18
+ * `bg`, by its sRGB relative luminance. Handles `#rgb`/`#rrggbb` (the theme
19
+ * palette); any other CSS colour falls back to white. So a saturated blue/red/
20
+ * teal pill gets white text, a pale turquoise pill gets dark text.
21
+ */
22
+ export declare function contrastText(bg: string): string;
23
+ /**
24
+ * The **axis indicator pill** look — a *solid* filled tag in `color` with
25
+ * auto-contrast text (the ChartIQ / Yahoo price-tag). Distinct from
26
+ * {@link flagChipStyle} (a light in-plot value chip): an on-axis indicator reads
27
+ * as a saturated pill covering the tick, not a floating readout. Note: it does
28
+ * **not** set `lineHeight` — it inherits `normal`, matching a bare tick label, so
29
+ * a pill anchored at the same offset lines up with its tick-label neighbours (a
30
+ * forced lineHeight would shift the text off the tick baseline). Shared by
31
+ * {@link YAxisIndicator}, the crosshair axis pills, and the Baseline/Marker
32
+ * `indicator` pills.
33
+ */
34
+ export declare function axisPillStyle(theme: ChartTheme, color: string): CSSProperties;
35
+ /**
36
+ * A small triangle on an axis pill's **plot-facing edge**, pointing into the
37
+ * plot at the value (the callout tab). For a `right`-side pill (extending right
38
+ * across the gutter) it sits on the pill's left edge pointing left; for a `left`
39
+ * pill, the mirror. Render as an absolutely-positioned child of the pill (the
40
+ * pill is itself absolute, so it's the containing block); colour matches the pill.
41
+ */
42
+ export declare function pointerStyle(side: 'left' | 'right', color: string): CSSProperties;
43
+ /**
44
+ * CSS placing a value pill **on the axis gutter** at `side`: anchor its inner
45
+ * edge at the plot boundary (`plotWidth`) and let it overflow outward across the
46
+ * reserved gutter (the plot div doesn't clip), lifted with `zIndex` above the
47
+ * sibling axis column (rendered later in the row) so it covers the tick behind
48
+ * it. Shared by {@link YAxisIndicator}'s `placement='axis'` and the crosshair
49
+ * cursor's per-series value pills, so both sit identically on the axis.
50
+ */
51
+ export declare function axisPillX(side: 'left' | 'right', plotWidth: number): CSSProperties;
16
52
  /**
17
53
  * Horizontal placement for a flag chip beside a vertical pole at plot-x `x`:
18
54
  * `FLAG_GAP` to the right, flipping to the left near the right edge so it stays
package/dist/chip.js CHANGED
@@ -14,7 +14,9 @@ export function flagChipStyle(theme) {
14
14
  return {
15
15
  position: 'absolute',
16
16
  background: theme.chip?.background,
17
- borderRadius: '3px',
17
+ // Square corners — a flag is a filled panel behind the number, not a pill
18
+ // (the rounded pill is reserved for axis indicators, see `axisPillStyle`).
19
+ borderRadius: '0',
18
20
  padding: '0 4px',
19
21
  fontFamily: theme.font.family,
20
22
  fontSize: `${theme.font.size}px`,
@@ -24,6 +26,88 @@ export function flagChipStyle(theme) {
24
26
  lineHeight: 1.5,
25
27
  };
26
28
  }
29
+ /**
30
+ * Pick a readable text colour (near-black or white) for text drawn **on top of**
31
+ * `bg`, by its sRGB relative luminance. Handles `#rgb`/`#rrggbb` (the theme
32
+ * palette); any other CSS colour falls back to white. So a saturated blue/red/
33
+ * teal pill gets white text, a pale turquoise pill gets dark text.
34
+ */
35
+ export function contrastText(bg) {
36
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(bg.trim());
37
+ const raw = m?.[1];
38
+ if (raw === undefined)
39
+ return '#ffffff';
40
+ const h = raw.length === 3
41
+ ? raw
42
+ .split('')
43
+ .map((c) => c + c)
44
+ .join('')
45
+ : raw;
46
+ const r = parseInt(h.slice(0, 2), 16) / 255;
47
+ const g = parseInt(h.slice(2, 4), 16) / 255;
48
+ const b = parseInt(h.slice(4, 6), 16) / 255;
49
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
50
+ return lum > 0.6 ? '#0b1220' : '#ffffff';
51
+ }
52
+ /**
53
+ * The **axis indicator pill** look — a *solid* filled tag in `color` with
54
+ * auto-contrast text (the ChartIQ / Yahoo price-tag). Distinct from
55
+ * {@link flagChipStyle} (a light in-plot value chip): an on-axis indicator reads
56
+ * as a saturated pill covering the tick, not a floating readout. Note: it does
57
+ * **not** set `lineHeight` — it inherits `normal`, matching a bare tick label, so
58
+ * a pill anchored at the same offset lines up with its tick-label neighbours (a
59
+ * forced lineHeight would shift the text off the tick baseline). Shared by
60
+ * {@link YAxisIndicator}, the crosshair axis pills, and the Baseline/Marker
61
+ * `indicator` pills.
62
+ */
63
+ export function axisPillStyle(theme, color) {
64
+ return {
65
+ position: 'absolute',
66
+ background: color,
67
+ color: contrastText(color),
68
+ borderRadius: '3px',
69
+ padding: '0 4px',
70
+ fontFamily: theme.font.family,
71
+ fontSize: `${theme.font.size}px`,
72
+ fontVariantNumeric: 'tabular-nums',
73
+ whiteSpace: 'nowrap',
74
+ pointerEvents: 'none',
75
+ };
76
+ }
77
+ /**
78
+ * A small triangle on an axis pill's **plot-facing edge**, pointing into the
79
+ * plot at the value (the callout tab). For a `right`-side pill (extending right
80
+ * across the gutter) it sits on the pill's left edge pointing left; for a `left`
81
+ * pill, the mirror. Render as an absolutely-positioned child of the pill (the
82
+ * pill is itself absolute, so it's the containing block); colour matches the pill.
83
+ */
84
+ export function pointerStyle(side, color) {
85
+ return {
86
+ position: 'absolute',
87
+ top: '50%',
88
+ transform: 'translateY(-50%)',
89
+ width: 0,
90
+ height: 0,
91
+ borderTop: '4px solid transparent',
92
+ borderBottom: '4px solid transparent',
93
+ ...(side === 'right'
94
+ ? { left: '-5px', borderRight: `5px solid ${color}` }
95
+ : { right: '-5px', borderLeft: `5px solid ${color}` }),
96
+ };
97
+ }
98
+ /**
99
+ * CSS placing a value pill **on the axis gutter** at `side`: anchor its inner
100
+ * edge at the plot boundary (`plotWidth`) and let it overflow outward across the
101
+ * reserved gutter (the plot div doesn't clip), lifted with `zIndex` above the
102
+ * sibling axis column (rendered later in the row) so it covers the tick behind
103
+ * it. Shared by {@link YAxisIndicator}'s `placement='axis'` and the crosshair
104
+ * cursor's per-series value pills, so both sit identically on the axis.
105
+ */
106
+ export function axisPillX(side, plotWidth) {
107
+ return side === 'right'
108
+ ? { left: `${plotWidth}px`, zIndex: 3 }
109
+ : { right: `${plotWidth}px`, zIndex: 3 };
110
+ }
27
111
  /** Gap (px) between a flag chip and its pole — the cursor staff or an annotation's
28
112
  * line — so the chip floats just beside the pole rather than sitting on it. */
29
113
  const FLAG_GAP = 4;
package/dist/context.d.ts CHANGED
@@ -233,6 +233,10 @@ export interface AnnotationSpec {
233
233
  /** The mark's resolved label text — used to pack overlapping top-flag labels
234
234
  * (markers + regions) into stacked vertical lanes. */
235
235
  readonly label: string;
236
+ /** Whether this mark shows its value as an **axis-edge pill** — a marker on the
237
+ * shared x-axis (drawn by `<XAxis>` at its `at`), a baseline on its y-axis
238
+ * (drawn in place). Regions never set it. */
239
+ readonly indicator: boolean;
236
240
  }
237
241
  /**
238
242
  * A row's per-slot axis widths each side, **slot 0 nearest the plot** (so the
@@ -368,8 +372,12 @@ export interface TrackerInfo {
368
372
  * - `inline` — dots + a value chip beside each.
369
373
  * - `flag` — dots + value flags (a staffed flag from each point; the staff
370
374
  * geometry lands in a later phase — for now flags stack at the top).
375
+ * - `crosshair` — the synced vertical line + a dot on each series, with each
376
+ * series' value pinned to its y-axis edge (an on-axis pill) and the cursor
377
+ * time pinned to the x-axis. The ChartIQ / trading-terminal readout. Values
378
+ * snap to the series (the axis pills read like ticks), not the raw mouse Y.
371
379
  */
372
- export type CursorMode = 'none' | 'line' | 'point' | 'inline' | 'flag';
380
+ export type CursorMode = 'none' | 'line' | 'point' | 'inline' | 'flag' | 'crosshair';
373
381
  /** A registered layer plus the axis id it draws against. */
374
382
  export interface LayerEntry {
375
383
  readonly layer: RowLayer;
@@ -396,6 +404,10 @@ export interface AxisSpec {
396
404
  /** Explicit domain bounds, or `undefined` to auto-fit linked layers. */
397
405
  readonly min: number | undefined;
398
406
  readonly max: number | undefined;
407
+ /** Fractional headroom added to each side of the resolved domain (`0` = none). */
408
+ readonly pad: number;
409
+ /** Title placement; `'top'` makes the row reserve a header band above the plot. */
410
+ readonly labelPlacement: 'rotated' | 'top';
399
411
  /** Value formatting for the tick labels + the cursor readout ({@link AxisFormat}),
400
412
  * or `undefined` for the scale's d3 default. */
401
413
  readonly format: AxisFormat | undefined;
@@ -427,6 +439,9 @@ export interface RowFrame {
427
439
  * that set `<YAxis ticks>` — so `Layers` draws gridlines at the same positions
428
440
  * the axis labels. Absent id ⇒ that axis auto-picks. */
429
441
  readonly tickValues: ReadonlyMap<string, readonly number[]>;
442
+ /** The side each axis sits on, keyed by id — so an axis-edge overlay (the
443
+ * crosshair value pills) hugs the correct gutter. */
444
+ readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;
430
445
  /** This row's cursor-mode override, or `undefined` to inherit the container's
431
446
  * default ({@link ContainerFrame.cursor}). */
432
447
  readonly cursor: CursorMode | undefined;
package/dist/domain.d.ts CHANGED
@@ -14,6 +14,11 @@
14
14
  * can otherwise invert it (e.g. `min=5` with no data would naively give
15
15
  * `[5, 1]`). Two explicit bounds are returned as-is (an inverted explicit domain
16
16
  * is a deliberate axis flip; we don't second-guess it).
17
+ *
18
+ * `pad` (fractional, default `0`) expands the resolved domain outward by
19
+ * `pad × span` on each side — headroom without hand-computing bounds, useful to
20
+ * lift a tight **explicit** domain off the plot edges. Applied last, to whatever
21
+ * domain was resolved (explicit or auto); `0` is a no-op.
17
22
  */
18
- export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>): [number, number];
23
+ export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number): [number, number];
19
24
  //# sourceMappingURL=domain.d.ts.map
package/dist/domain.js CHANGED
@@ -15,8 +15,22 @@ import { scaleLinear } from 'd3-scale';
15
15
  * can otherwise invert it (e.g. `min=5` with no data would naively give
16
16
  * `[5, 1]`). Two explicit bounds are returned as-is (an inverted explicit domain
17
17
  * is a deliberate axis flip; we don't second-guess it).
18
+ *
19
+ * `pad` (fractional, default `0`) expands the resolved domain outward by
20
+ * `pad × span` on each side — headroom without hand-computing bounds, useful to
21
+ * lift a tight **explicit** domain off the plot edges. Applied last, to whatever
22
+ * domain was resolved (explicit or auto); `0` is a no-op.
18
23
  */
19
- export function resolveYDomain(min, max, extents) {
24
+ export function resolveYDomain(min, max, extents, pad = 0) {
25
+ const result = resolveBase(min, max, extents);
26
+ if (pad) {
27
+ const [lo, hi] = result;
28
+ const p = pad * (hi - lo);
29
+ return [lo - p, hi + p];
30
+ }
31
+ return result;
32
+ }
33
+ function resolveBase(min, max, extents) {
20
34
  // Both bounds explicit: trust them verbatim (allows an intentional flip).
21
35
  if (min !== undefined && max !== undefined)
22
36
  return [min, max];
package/dist/index.d.ts CHANGED
@@ -45,6 +45,8 @@ export type { BarChartProps } from './BarChart.js';
45
45
  export { Region, Baseline, Marker } from './annotations.js';
46
46
  export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
47
47
  export type { AnnotationKind, CreateSpec } from './context.js';
48
+ export { YAxisIndicator, createLiveValue } from './indicators.js';
49
+ export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
48
50
  export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
49
51
  export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, } from './data.js';
50
52
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';
package/dist/index.js CHANGED
@@ -32,6 +32,9 @@ export { BarChart } from './BarChart.js';
32
32
  // Annotations — user-authored marks in the turquoise register (distinct from the
33
33
  // data): a shaded span, a horizontal value line, a vertical x line.
34
34
  export { Region, Baseline, Marker } from './annotations.js';
35
+ // Axis indicators — a value pill pinned to an axis edge (the ChartIQ live tag).
36
+ // `createLiveValue` is the high-frequency, isolated-repaint update path.
37
+ export { YAxisIndicator, createLiveValue } from './indicators.js';
35
38
  export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
36
39
  export { defaultTheme, estelaTheme } from './theme.js';
37
40
  // CSS-custom-property → ChartTheme bridge: build a theme from a design system's
@@ -0,0 +1,105 @@
1
+ import { type AxisFormat } from './format.js';
2
+ /**
3
+ * A **live scalar** an axis indicator subscribes to, pushed imperatively from
4
+ * outside React — a WebSocket `onmessage`, a `requestAnimationFrame` loop, a
5
+ * tick handler. Backed by `useSyncExternalStore` on the consuming indicator:
6
+ * calling {@link LiveValue.set} re-renders **only the indicators subscribed to
7
+ * this value** — never the chart tree, never a canvas repaint. This is the path
8
+ * for a value that ticks many times a second (a live last-price tag), set
9
+ * independently of the series' own last point.
10
+ *
11
+ * Create one with {@link createLiveValue} and pass it to
12
+ * `<YAxisIndicator source={…}>`.
13
+ */
14
+ export interface LiveValue {
15
+ /** Push a new value. Re-renders subscribed indicators only; a no-op if the
16
+ * value is unchanged. Safe to call from outside React at any frequency. */
17
+ set(value: number): void;
18
+ /** @internal Store subscribe, for `useSyncExternalStore`. */
19
+ subscribe(onStoreChange: () => void): () => void;
20
+ /** @internal Current value snapshot, for `useSyncExternalStore`. */
21
+ getSnapshot(): number;
22
+ }
23
+ /**
24
+ * Create a {@link LiveValue} seeded at `initial`. Hold the returned object,
25
+ * call `.set(v)` from your data source, and hand it to
26
+ * `<YAxisIndicator source={…}>` — the pill repositions and relabels on each
27
+ * `set` without re-rendering the chart.
28
+ *
29
+ * ```ts
30
+ * const price = createLiveValue(0);
31
+ * ws.onmessage = (e) => price.set(JSON.parse(e.data).last); // outside React
32
+ * // <YAxisIndicator source={price} color="#4af" format=",.2f" />
33
+ * ```
34
+ */
35
+ export declare function createLiveValue(initial: number): LiveValue;
36
+ export interface YAxisIndicatorProps {
37
+ /**
38
+ * A static value to pin the pill at. Pass this **or** {@link source}. Updating
39
+ * `value` re-renders with its parent — fine for an occasional change; for a
40
+ * high-frequency tick use `source` so only the pill repaints.
41
+ */
42
+ value?: number;
43
+ /**
44
+ * A {@link LiveValue} to subscribe to — the high-frequency path. `.set(v)`
45
+ * moves and relabels the pill **without re-rendering the chart**. Takes
46
+ * precedence over {@link value} if both are given.
47
+ */
48
+ source?: LiveValue;
49
+ /** Which `<YAxis>` (by id) to position against; omit for the row's default axis. */
50
+ axis?: string;
51
+ /**
52
+ * Which edge the pill hugs. Default `right` — the conventional side for a live
53
+ * value tag. (Independent of the linked axis's side; set it to match.)
54
+ */
55
+ side?: 'left' | 'right';
56
+ /**
57
+ * Pill hue — the colour of the series / value it tracks. Defaults to the axis
58
+ * label colour (`theme.axis.label`).
59
+ */
60
+ color?: string;
61
+ /**
62
+ * Value formatting: a d3 format specifier (e.g. `',.2f'`, `'.1%'`) or a
63
+ * `(value) => string`. Omit to use the linked axis's own formatter, so the pill
64
+ * reads exactly like a tick. Pass a specifier for finer precision than the
65
+ * tick-calibrated default (a live price usually wants `',.2f'`, not the
66
+ * coarser tick rounding). See {@link AxisFormat}.
67
+ *
68
+ * An indicator **always shows the axis value** — there is no label override. A
69
+ * name/annotation belongs on a `<Baseline label>`'s near-line chip, not on the
70
+ * axis pill (an axis pill reads like a tick).
71
+ */
72
+ format?: AxisFormat;
73
+ /**
74
+ * Draw a thin dashed guide line from the pill across the plot (the ChartIQ
75
+ * "price line"). Default `false`.
76
+ */
77
+ line?: boolean;
78
+ /**
79
+ * Add a small triangle on the pill's plot-facing edge, pointing **into** the
80
+ * plot at the value (a callout tab). Default `false`.
81
+ */
82
+ pointer?: boolean;
83
+ }
84
+ /**
85
+ * A **value pill pinned to a y-axis edge** — the ChartIQ / Yahoo-Finance live
86
+ * price tag. Positions at `yScale(value)` on the linked axis and renders a chip
87
+ * (the solid {@link axisPillStyle} pill) at the plot's `side` edge, optionally
88
+ * with a dashed guide line across the plot.
89
+ *
90
+ * Render it as a child of `<Layers>` (alongside the chart layers), so it shares
91
+ * the plot's coordinate space:
92
+ *
93
+ * ```tsx
94
+ * <Layers>
95
+ * <LineChart series={price} axis="usd" />
96
+ * <YAxisIndicator source={liveLast} axis="usd" color="#4af" format=",.2f" line />
97
+ * </Layers>
98
+ * ```
99
+ *
100
+ * The value is **decoupled from the series' last point** — feed it whatever the
101
+ * live feed reports. For high-frequency updates pass a {@link LiveValue}
102
+ * ({@link source}); `.set()` repaints only the pill.
103
+ */
104
+ export declare function YAxisIndicator({ value, source, axis, side, color, format, line, pointer, }: YAxisIndicatorProps): import("react/jsx-runtime").JSX.Element | null;
105
+ //# sourceMappingURL=indicators.d.ts.map
@@ -0,0 +1,114 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useContext, useSyncExternalStore } from 'react';
3
+ import { ContainerContext, RowContext } from './context.js';
4
+ import { axisPillStyle, axisPillX, pointerStyle } from './chip.js';
5
+ import { resolveAxisFormat } from './format.js';
6
+ /**
7
+ * Create a {@link LiveValue} seeded at `initial`. Hold the returned object,
8
+ * call `.set(v)` from your data source, and hand it to
9
+ * `<YAxisIndicator source={…}>` — the pill repositions and relabels on each
10
+ * `set` without re-rendering the chart.
11
+ *
12
+ * ```ts
13
+ * const price = createLiveValue(0);
14
+ * ws.onmessage = (e) => price.set(JSON.parse(e.data).last); // outside React
15
+ * // <YAxisIndicator source={price} color="#4af" format=",.2f" />
16
+ * ```
17
+ */
18
+ export function createLiveValue(initial) {
19
+ let value = initial;
20
+ const listeners = new Set();
21
+ return {
22
+ set(v) {
23
+ // Skip a redundant notify — a repeated identical tick shouldn't wake the
24
+ // subscriber (getSnapshot must be stable between real changes anyway).
25
+ if (v === value)
26
+ return;
27
+ value = v;
28
+ for (const listener of listeners)
29
+ listener();
30
+ },
31
+ subscribe(onStoreChange) {
32
+ listeners.add(onStoreChange);
33
+ return () => {
34
+ listeners.delete(onStoreChange);
35
+ };
36
+ },
37
+ getSnapshot() {
38
+ return value;
39
+ },
40
+ };
41
+ }
42
+ const noopSubscribe = () => () => { };
43
+ /** The full-plot overlay the guide line paints into — above the data canvas,
44
+ * inert to the pointer (matches the annotations' `overlayStyle`). */
45
+ const overlayStyle = {
46
+ position: 'absolute',
47
+ top: 0,
48
+ left: 0,
49
+ pointerEvents: 'none',
50
+ };
51
+ const TICK_COUNT = 5;
52
+ /**
53
+ * A **value pill pinned to a y-axis edge** — the ChartIQ / Yahoo-Finance live
54
+ * price tag. Positions at `yScale(value)` on the linked axis and renders a chip
55
+ * (the solid {@link axisPillStyle} pill) at the plot's `side` edge, optionally
56
+ * with a dashed guide line across the plot.
57
+ *
58
+ * Render it as a child of `<Layers>` (alongside the chart layers), so it shares
59
+ * the plot's coordinate space:
60
+ *
61
+ * ```tsx
62
+ * <Layers>
63
+ * <LineChart series={price} axis="usd" />
64
+ * <YAxisIndicator source={liveLast} axis="usd" color="#4af" format=",.2f" line />
65
+ * </Layers>
66
+ * ```
67
+ *
68
+ * The value is **decoupled from the series' last point** — feed it whatever the
69
+ * live feed reports. For high-frequency updates pass a {@link LiveValue}
70
+ * ({@link source}); `.set()` repaints only the pill.
71
+ */
72
+ export function YAxisIndicator({ value, source, axis, side = 'right', color, format, line = false, pointer = false, }) {
73
+ const container = useContext(ContainerContext);
74
+ if (container === null) {
75
+ throw new Error('<YAxisIndicator> must be rendered inside a <ChartContainer>');
76
+ }
77
+ const row = useContext(RowContext);
78
+ if (row === null) {
79
+ throw new Error('<YAxisIndicator> must be rendered inside a <ChartRow>');
80
+ }
81
+ // One unconditional hook that covers both paths: with a `source`, subscribe to
82
+ // its store (a `set` re-renders only this component); without one, a stable
83
+ // no-op subscribe + a snapshot that reads the static `value` prop (which
84
+ // re-renders with the parent). Either way `v` is the current value.
85
+ const v = useSyncExternalStore(source ? source.subscribe : noopSubscribe, source ? source.getSnapshot : () => value ?? NaN);
86
+ const { theme } = container;
87
+ const axisId = axis ?? row.defaultAxisId;
88
+ const yScale = row.yScales.get(axisId);
89
+ // Axis not resolved yet (a layer mounts before its <YAxis>), or no value fed —
90
+ // draw nothing rather than guess.
91
+ if (yScale === undefined || !Number.isFinite(v))
92
+ return null;
93
+ const resolvedColor = color ?? theme.axis.label;
94
+ // A caller `format` resolves against the scale (string specifier or fn);
95
+ // otherwise reuse the axis's own formatter so the pill reads like a tick.
96
+ const fmt = format
97
+ ? resolveAxisFormat(yScale, TICK_COUNT, format)
98
+ : row.formats.get(axisId);
99
+ // An indicator always shows the axis value (no label override — a name belongs
100
+ // on a Baseline's near-line chip, not the axis pill).
101
+ const text = fmt ? fmt(v) : String(v);
102
+ const rawY = yScale(v);
103
+ // Clamp the pill's centre so an off-scale value keeps it inside the row rather
104
+ // than half-overflowing the edge (matches the y-tick clamp, F-charts-6).
105
+ const half = theme.font.size / 2 + 1;
106
+ const top = Math.max(half, Math.min(row.height - half, rawY));
107
+ return (_jsxs(_Fragment, { children: [line && (_jsx("svg", { width: container.plotWidth, height: row.height, style: overlayStyle, children: _jsx("line", { x1: 0, y1: rawY, x2: container.plotWidth, y2: rawY, stroke: resolvedColor, strokeWidth: 1, opacity: 0.5, strokeDasharray: "3 3", shapeRendering: "crispEdges" }) })), _jsxs("div", { style: {
108
+ ...axisPillStyle(theme, resolvedColor),
109
+ top: `${top}px`,
110
+ ...axisPillX(side, container.plotWidth),
111
+ transform: 'translateY(-50%)',
112
+ }, children: [pointer && _jsx("span", { style: pointerStyle(side, resolvedColor) }), text] })] }));
113
+ }
114
+ //# sourceMappingURL=indicators.js.map
package/dist/theme.d.ts CHANGED
@@ -88,6 +88,17 @@ export interface ChartTheme {
88
88
  readonly grid: string;
89
89
  /** Gridline dash pattern (px on/off pairs); `[]` for solid. */
90
90
  readonly gridDash: readonly number[];
91
+ /**
92
+ * Typography for the axis **title** — the rotated y-axis unit strip and the
93
+ * x-axis label (distinct from the per-tick `label` colour above). Omit a
94
+ * field to fall back: `color` → `label`, `size` → `font.size + 1` (a touch
95
+ * larger than the ticks so the rotated strip reads), `opacity` → `0.85`.
96
+ */
97
+ readonly title?: {
98
+ readonly color?: string;
99
+ readonly size?: number;
100
+ readonly opacity?: number;
101
+ };
91
102
  };
92
103
  /** Label / tick typography. One source for axes + chrome. */
93
104
  readonly font: {
package/dist/tracker.d.ts CHANGED
@@ -18,7 +18,7 @@ export declare const DEFAULT_CURSOR_MODE: CursorMode;
18
18
  export declare function cursorParts(mode: CursorMode): {
19
19
  readonly line: boolean;
20
20
  readonly dots: boolean;
21
- readonly chip: 'none' | 'inline' | 'flag';
21
+ readonly chip: 'none' | 'inline' | 'flag' | 'axis';
22
22
  };
23
23
  /**
24
24
  * The crosshair's plot-pixel x from the tracker inputs. A controlled
package/dist/tracker.js CHANGED
@@ -24,6 +24,10 @@ export function cursorParts(mode) {
24
24
  return { line: false, dots: true, chip: 'inline' };
25
25
  case 'flag':
26
26
  return { line: false, dots: true, chip: 'flag' };
27
+ case 'crosshair':
28
+ // Vertical line + per-series dots, values pinned to the axes (y pills in
29
+ // `Layers`, the x-time pill on `<XAxis>`).
30
+ return { line: true, dots: true, chip: 'axis' };
27
31
  case 'none':
28
32
  return { line: false, dots: false, chip: 'none' };
29
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.36.0",
3
+ "version": "0.38.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.36.0",
42
- "pond-ts": "^0.36.0",
41
+ "@pond-ts/react": "^0.38.0",
42
+ "pond-ts": "^0.38.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {