@pond-ts/charts 0.48.1 → 0.50.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +225 -1
  2. package/dist/AreaChart.d.ts +18 -1
  3. package/dist/AreaChart.js +23 -2
  4. package/dist/BandChart.d.ts +21 -2
  5. package/dist/BandChart.js +68 -9
  6. package/dist/BarChart.d.ts +12 -1
  7. package/dist/BarChart.js +34 -1
  8. package/dist/BoxPlot.d.ts +29 -1
  9. package/dist/BoxPlot.js +61 -3
  10. package/dist/Candlestick.d.ts +21 -1
  11. package/dist/Candlestick.js +42 -7
  12. package/dist/ChartContainer.d.ts +23 -13
  13. package/dist/ChartContainer.js +86 -32
  14. package/dist/ChartRow.js +22 -3
  15. package/dist/Layers.js +37 -14
  16. package/dist/Legend.d.ts +62 -0
  17. package/dist/Legend.js +169 -0
  18. package/dist/LineChart.d.ts +20 -1
  19. package/dist/LineChart.js +23 -2
  20. package/dist/ScatterChart.d.ts +8 -1
  21. package/dist/ScatterChart.js +24 -1
  22. package/dist/XAxis.js +9 -2
  23. package/dist/YAxis.d.ts +9 -1
  24. package/dist/YAxis.js +27 -6
  25. package/dist/annotations.d.ts +21 -3
  26. package/dist/annotations.js +36 -15
  27. package/dist/area.d.ts +2 -1
  28. package/dist/area.js +29 -4
  29. package/dist/band.d.ts +2 -1
  30. package/dist/band.js +18 -1
  31. package/dist/bars.js +8 -1
  32. package/dist/box.d.ts +15 -1
  33. package/dist/box.js +71 -2
  34. package/dist/context.d.ts +51 -4
  35. package/dist/culling.d.ts +165 -0
  36. package/dist/culling.js +286 -0
  37. package/dist/data.d.ts +3 -1
  38. package/dist/decimate.d.ts +231 -0
  39. package/dist/decimate.js +478 -0
  40. package/dist/format.d.ts +20 -11
  41. package/dist/index.d.ts +6 -0
  42. package/dist/index.js +6 -0
  43. package/dist/line.d.ts +2 -1
  44. package/dist/line.js +38 -3
  45. package/dist/ohlc.d.ts +2 -1
  46. package/dist/ohlc.js +23 -2
  47. package/dist/scatter.js +42 -7
  48. package/dist/swatch.d.ts +104 -0
  49. package/dist/swatch.js +96 -0
  50. package/dist/theme.d.ts +27 -0
  51. package/dist/theme.js +12 -0
  52. package/dist/useChartLegend.d.ts +106 -0
  53. package/dist/useChartLegend.js +122 -0
  54. package/dist/yticks.d.ts +20 -0
  55. package/dist/yticks.js +28 -0
  56. package/package.json +3 -3
@@ -84,6 +84,12 @@ export interface MarkerProps {
84
84
  * `false` it's inert background context — drawn at the back (level 3) always,
85
85
  * no hover, no select, no edit. */
86
86
  selectable?: boolean;
87
+ /** Theme **role** — recolours this mark from `theme.annotation.roles[role]`
88
+ * (its `color`, optionally `fillOpacity`), keeping the shared depth ramp, so
89
+ * several marks can differ (a green ATM baseline, a distinct marker) without
90
+ * splitting the register. Omitted / unknown ⇒ the base annotation colour.
91
+ * Colour is a theme concern — there is no per-mark colour prop. */
92
+ role?: string;
87
93
  /** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
88
94
  * remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
89
95
  hovered?: boolean;
@@ -105,7 +111,7 @@ export interface MarkerProps {
105
111
  indicator?: boolean;
106
112
  }
107
113
  /** A vertical line at an x position (a time, a distance, a lap boundary). */
108
- export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, indicator, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
114
+ export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, indicator, role, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
109
115
  export interface BaselineProps {
110
116
  /** y value in the linked axis's units. */
111
117
  value: number;
@@ -127,6 +133,12 @@ export interface BaselineProps {
127
133
  /** Whether the baseline responds to hover + selection (default `true`). When
128
134
  * `false` it's inert background context — drawn at the back (level 3) always. */
129
135
  selectable?: boolean;
136
+ /** Theme **role** — recolours this mark from `theme.annotation.roles[role]`
137
+ * (its `color`, optionally `fillOpacity`), keeping the shared depth ramp, so
138
+ * several marks can differ (a green ATM baseline, a distinct marker) without
139
+ * splitting the register. Omitted / unknown ⇒ the base annotation colour.
140
+ * Colour is a theme concern — there is no per-mark colour prop. */
141
+ role?: string;
130
142
  /** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
131
143
  * remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
132
144
  hovered?: boolean;
@@ -148,7 +160,7 @@ export interface BaselineProps {
148
160
  }
149
161
  /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
150
162
  * Its label anchors at the left, at the line's height. */
151
- export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
163
+ export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, role, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
152
164
  export interface RegionProps {
153
165
  /** Start x in axis units (time or value). */
154
166
  from: number;
@@ -170,6 +182,12 @@ export interface RegionProps {
170
182
  * `false` it's inert background context — drawn at the back (level 3) always,
171
183
  * and the double-click hit-test skips it. */
172
184
  selectable?: boolean;
185
+ /** Theme **role** — recolours this mark from `theme.annotation.roles[role]`
186
+ * (its `color`, optionally `fillOpacity`), keeping the shared depth ramp, so
187
+ * several marks can differ (a green ATM band, a distinct marker) without
188
+ * splitting the register. Omitted / unknown ⇒ the base annotation colour.
189
+ * Colour is a theme concern — there is no per-mark colour prop. */
190
+ role?: string;
173
191
  /** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
174
192
  * remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
175
193
  hovered?: boolean;
@@ -192,6 +210,6 @@ export interface RegionProps {
192
210
  }
193
211
  /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
194
212
  * flies as a flag off the left edge. */
195
- export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
213
+ export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, role, }: RegionProps): import("react/jsx-runtime").JSX.Element;
196
214
  export {};
197
215
  //# sourceMappingURL=annotations.d.ts.map
@@ -97,8 +97,16 @@ const overlayStyle = {
97
97
  left: 0,
98
98
  pointerEvents: 'none',
99
99
  };
100
- /** Read the container + row frames an annotation needs, or throw if misplaced. */
101
- function useAnnotationFrame(name) {
100
+ /**
101
+ * Read the container + row frames an annotation needs (throw if misplaced), and
102
+ * resolve the mark's annotation style for its optional `role`. A `role` recolors
103
+ * *this* mark from the theme's `annotation.roles[role]` map (`color`, and
104
+ * optionally `fillOpacity`) while keeping the shared depth ramp — so a smile can
105
+ * place a green ATM baseline, a neutral vertical, and a distinct marker at once
106
+ * without splitting the whole register. An unknown / unset role falls back to
107
+ * the base `annotation` register (`roles[role] ?? annotation`).
108
+ */
109
+ function useAnnotationFrame(name, role) {
102
110
  const container = useContext(ContainerContext);
103
111
  if (container === null) {
104
112
  throw new Error(`<${name}> must be rendered inside a <ChartContainer>`);
@@ -107,7 +115,17 @@ function useAnnotationFrame(name) {
107
115
  if (row === null) {
108
116
  throw new Error(`<${name}> must be rendered inside a <ChartRow>`);
109
117
  }
110
- const ann = container.theme.annotation ?? DEFAULT_ANNOTATION;
118
+ const base = container.theme.annotation ?? DEFAULT_ANNOTATION;
119
+ const roleStyle = role !== undefined ? base.roles?.[role] : undefined;
120
+ // The role overrides only colour (+ optional fill); depth stays the shared
121
+ // ramp, so selection / hover / edit levels read identically per role.
122
+ const ann = roleStyle
123
+ ? {
124
+ ...base,
125
+ color: roleStyle.color,
126
+ fillOpacity: roleStyle.fillOpacity ?? base.fillOpacity,
127
+ }
128
+ : base;
111
129
  return { container, row, ann };
112
130
  }
113
131
  /** Register this annotation with the container (so it can draw the mark's guide on
@@ -450,8 +468,8 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
450
468
  } }));
451
469
  }
452
470
  /** A vertical line at an x position (a time, a distance, a lap boundary). */
453
- export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
454
- const { container, row, ann } = useAnnotationFrame('Marker');
471
+ export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, role, }) {
472
+ const { container, row, ann } = useAnnotationFrame('Marker', role);
455
473
  const selfKey = useSlotKey();
456
474
  const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
457
475
  // Draggable right now: global edit mode OR this mark's single-edit flag, and no
@@ -460,8 +478,11 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
460
478
  container.creating === null &&
461
479
  onChange !== undefined;
462
480
  const xs = useMemo(() => [at], [at]);
463
- // `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label off the x formatter.
464
- const text = label === false ? '' : (label ?? container.formatTime(at));
481
+ // `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label off the readout
482
+ // channel (else the x label formatter).
483
+ const text = label === false
484
+ ? ''
485
+ : (label ?? (container.formatReadout ?? container.formatTime)(at));
465
486
  useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text, indicator);
466
487
  // No select/edit while a create tool is armed — the chart is in draw mode then.
467
488
  const select = id !== undefined && container.creating === null
@@ -491,8 +512,8 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
491
512
  }
492
513
  /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
493
514
  * Its label anchors at the left, at the line's height. */
494
- export function Baseline({ value, axis, label, labelSide = 'left', labelPosition = 'center', id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
495
- const { container, row, ann } = useAnnotationFrame('Baseline');
515
+ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition = 'center', id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, role, }) {
516
+ const { container, row, ann } = useAnnotationFrame('Baseline', role);
496
517
  const selfKey = useSlotKey();
497
518
  const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
498
519
  // Draggable right now: global edit mode OR this mark's single-edit flag, and no
@@ -551,8 +572,8 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
551
572
  }
552
573
  /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
553
574
  * flies as a flag off the left edge. */
554
- export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, }) {
555
- const { container, row, ann } = useAnnotationFrame('Region');
575
+ export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, role, }) {
576
+ const { container, row, ann } = useAnnotationFrame('Region', role);
556
577
  const selfKey = useSlotKey();
557
578
  const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
558
579
  // Draggable right now: global edit mode OR this mark's single-edit flag, and no
@@ -561,10 +582,10 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
561
582
  container.creating === null &&
562
583
  onChange !== undefined;
563
584
  const xs = useMemo(() => [from, to], [from, to]);
564
- // `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label the `from–to` span.
565
- const text = label === false
566
- ? ''
567
- : (label ?? `${container.formatTime(from)}–${container.formatTime(to)}`);
585
+ // `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label the `from–to`
586
+ // span off the readout channel (else the x label formatter).
587
+ const fmtX = container.formatReadout ?? container.formatTime;
588
+ const text = label === false ? '' : (label ?? `${fmtX(from)}–${fmtX(to)}`);
568
589
  useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text, false);
569
590
  // No select/edit while a create tool is armed — the chart is in draw mode then.
570
591
  const select = id !== undefined && container.creating === null
package/dist/area.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { ChartSeries } from './data.js';
3
3
  import type { Scale } from './line.js';
4
4
  import type { AreaStyle } from './theme.js';
5
5
  import { type GapMode } from './gaps.js';
6
+ import { type DecimateOption } from './decimate.js';
6
7
  /**
7
8
  * The `[min, max]` vertical extent an area occupies — the finite values of
8
9
  * `cs.y` widened to include `baseline`, since the fill spans from each value to
@@ -50,5 +51,5 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
50
51
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
51
52
  * are collected by one O(N) walk ({@link collectGapEdges}).
52
53
  */
53
- export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number): void;
54
+ export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption): void;
54
55
  //# sourceMappingURL=area.d.ts.map
package/dist/area.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
2
  import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
3
+ import { cullChartSeries } from './culling.js';
4
+ import { decimateM4 } from './decimate.js';
3
5
  /**
4
6
  * The `[min, max]` vertical extent an area occupies — the finite values of
5
7
  * `cs.y` widened to include `baseline`, since the fill spans from each value to
@@ -70,8 +72,31 @@ export function areaExtent(cs, baseline) {
70
72
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
71
73
  * are collected by one O(N) walk ({@link collectGapEdges}).
72
74
  */
73
- export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY) {
75
+ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true) {
74
76
  const baselinePx = yScale(baselineValue);
77
+ // The fill gradient's vertical extent is computed from the **full** series (a
78
+ // vertical, position-anchored gradient spanning the data's whole pixel extent)
79
+ // so viewport culling stays behavior-neutral: the culled path below paints the
80
+ // exact same visible pixels under the same gradient. This is a cheap O(N)
81
+ // min/max scan; the expensive per-point path work (fill + outline) culls. (Cull
82
+ // the region too and the shade would drift under pan as off-screen extrema
83
+ // enter/leave — a visible change culling must not make.)
84
+ const fullYs = gaps === 'none' ? bridgeGaps(cs.y, cs.length) : cs.y;
85
+ const fill = buildGradient(ctx, fullYs, cs.length, yScale, baselinePx, style);
86
+ // Viewport culling (Phase 2): the path, outline, and gap bridges walk the
87
+ // visible slice (+1 entry/exit point) only. A no-op — the same `cs` back — when
88
+ // fully in view or `xScale` has no domain (a test stub), keeping that hot path
89
+ // byte-identical.
90
+ cs = cullChartSeries(cs, xScale);
91
+ // M4 decimation (Phase 3): the outline is a line, so the same {@link decimateM4}
92
+ // pre-pass shrinks the fill + outline + gap-bridge work to O(plot width) once
93
+ // dense — with the §2.2 gap-edge union so every gap mode composes. The gradient
94
+ // above is over the FULL series, so the decimated fill paints identical pixels
95
+ // under it. Gated off a smoothing `curve`; a no-op on a sparse slice / test scale.
96
+ if (decimate !== false && curve === curveLinear) {
97
+ const k = typeof decimate === 'object' ? decimate.threshold : undefined;
98
+ cs = decimateM4(cs, xScale, ctx, k);
99
+ }
75
100
  // `none` interpolates interior gaps so the fill + outline bridge them; every
76
101
  // other mode keeps NaN so d3 breaks both (the inferred line bridge, if any, is
77
102
  // a separate overlay pass below).
@@ -86,9 +111,9 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
86
111
  ctx.save();
87
112
  // The fill: a vertical gradient anchored at the baseline pixel, opaque at the
88
113
  // line and transparent at the baseline (see buildGradient — handles both the
89
- // one-sided elevation form and the two-sided above/below form). The gradient
90
- // spans the drawn region; `ys` (gap-bridged for `none`) is what's drawn.
91
- ctx.fillStyle = buildGradient(ctx, ys, cs.length, yScale, baselinePx, style);
114
+ // one-sided elevation form and the two-sided above/below form). Spans the full
115
+ // data region (above), so the culled `ys` paints identical pixels under it.
116
+ ctx.fillStyle = fill;
92
117
  ctx.globalAlpha = style.fillOpacity;
93
118
  ctx.beginPath();
94
119
  gen(ys);
package/dist/band.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type CurveFactory } from 'd3-shape';
2
2
  import type { BandSeries } from './data.js';
3
3
  import type { Scale } from './line.js';
4
4
  import type { BandStyle } from './theme.js';
5
+ import { type DecimateOption } from './decimate.js';
5
6
  /**
6
7
  * The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
7
8
  * and highest `upper` over samples where both edges are finite — or `null` if
@@ -27,5 +28,5 @@ export declare function bandExtent(band: BandSeries): [number, number] | null;
27
28
  * index, so there's no per-point object allocation. `globalAlpha` carries the
28
29
  * opacity and is restored so it doesn't leak into later layers.
29
30
  */
30
- export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory): void;
31
+ export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory, decimate?: DecimateOption): void;
31
32
  //# sourceMappingURL=band.d.ts.map
package/dist/band.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
+ import { cullBandSeries } from './culling.js';
3
+ import { decimateBand } from './decimate.js';
2
4
  /**
3
5
  * The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
4
6
  * and highest `upper` over samples where both edges are finite — or `null` if
@@ -38,7 +40,22 @@ export function bandExtent(band) {
38
40
  * index, so there's no per-point object allocation. `globalAlpha` carries the
39
41
  * opacity and is restored so it doesn't leak into later layers.
40
42
  */
41
- export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear) {
43
+ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear, decimate = true) {
44
+ // Viewport culling (Phase 2): clip the envelope to the visible slice (+1 each
45
+ // side) before filling, so a pan strokes O(visible). The solid fill has no
46
+ // cross-point state, so a zero-copy subarray view is exact; a no-op (same
47
+ // object) when fully in view or the scale has no domain (a test stub).
48
+ band = cullBandSeries(band, xScale);
49
+ // M4 band decimation (Phase 3): once the culled envelope is denser than ~2
50
+ // samples per device pixel, replace it with the per-column min-lower / max-upper
51
+ // envelope ({@link decimateBand}) — O(plot width) points that cover the same
52
+ // pixels. Gated off a smoothing `curve` (which would distort the per-column
53
+ // envelope) and `decimate === false`; `decimateBand` itself no-ops on a sparse
54
+ // envelope or a domainless test scale, so this stays byte-identical there.
55
+ if (decimate !== false && curve === curveLinear) {
56
+ const k = typeof decimate === 'object' ? decimate.threshold : undefined;
57
+ band = decimateBand(band, xScale, ctx, k);
58
+ }
42
59
  const gen = d3area()
43
60
  .defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
44
61
  .x((_, i) => xScale(band.x[i]))
package/dist/bars.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { barSpanPx } from './range.js';
2
+ import { visibleSpanRange } from './culling.js';
2
3
  /**
3
4
  * The `[min, max]` vertical extent the bars occupy — the finite values of `cs.y`
4
5
  * **widened to include `0`**, since a bar spans from its value to the baseline
@@ -96,7 +97,13 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
96
97
  export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered) {
97
98
  ctx.save();
98
99
  ctx.globalAlpha = style.opacity;
99
- for (let i = 0; i < cs.length; i += 1) {
100
+ // Viewport culling (Phase 2): draw only the bars whose span overlaps the
101
+ // visible x-window (+1 each side). The loop keeps the original index `i`, so
102
+ // the `begin[i]` selection/hover match stays correct; full range when `xScale`
103
+ // has no domain (a test stub). A selected/hovered bar off-screen isn't drawn
104
+ // (its highlight would be off-screen anyway).
105
+ const [vStart, vEnd] = visibleSpanRange(cs.begin, cs.end, cs.length, xScale);
106
+ for (let i = vStart; i < vEnd; i += 1) {
100
107
  const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
101
108
  if (rect === null)
102
109
  continue;
package/dist/box.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { BoxSeries } from './data.js';
2
2
  import type { Scale } from './line.js';
3
3
  import type { BoxStyle } from './theme.js';
4
+ import { type DecimateOption } from './decimate.js';
4
5
  /**
5
6
  * The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
6
7
  * whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
@@ -24,6 +25,19 @@ export declare function boxExtent(box: BoxSeries): [number, number] | null;
24
25
  * the caller drops it on the finiteness check. O(N) over the boxes (view-scale).
25
26
  */
26
27
  export declare function boxIndexAtTime(box: BoxSeries, time: number): number;
28
+ /**
29
+ * Hit-test plot-pixel `(px, py)` against `box`'s marks — the **first** box whose
30
+ * bounding rect (its x-slot × its `[upper, lower]` whisker extent) contains the
31
+ * point, or `null`. The x-span is {@link barSpanPx} (same `gapPx`/`minWidthPx`/
32
+ * `offsetPx` as {@link drawBox}), so the hit rect matches the drawn mark; the y
33
+ * extent is the full whisker reach, so a click anywhere on the box (body or
34
+ * whisker) selects it — a range-only bid→ask segment included. The returned
35
+ * tuple is `[index, begin, value]` (`begin` = the box's `x`, `value` = its
36
+ * `upper` — provenance) for the chart to assemble a `SelectInfo`; keeping this
37
+ * theme-free mirrors {@link barAt} and stays unit-testable without a
38
+ * `ChartTheme`. A gap box (some drawn quantile non-finite) is skipped. O(N).
39
+ */
40
+ export declare function boxAt(box: BoxSeries, px: number, py: number, xScale: Scale, yScale: Scale, gapPx: number, minWidthPx: number, offsetPx?: number): [index: number, begin: number, value: number] | null;
27
41
  /**
28
42
  * How a box renders its spread (pjm17971): **`whisker`** (today's thin stems +
29
43
  * end-caps), **`solid`** (the candlestick look — a light outer bar over the full
@@ -64,7 +78,7 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
64
78
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
65
79
  * beyond the `barSpanPx` tuple.
66
80
  */
67
- export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number): void;
81
+ export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null, decimate?: DecimateOption): void;
68
82
  /**
69
83
  * This key is drawable — the quantiles it actually carries are all finite at `i`.
70
84
  * `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
package/dist/box.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { barSpanPx } from './range.js';
2
+ import { visibleSpanRange } from './culling.js';
3
+ import { decimateBox } from './decimate.js';
2
4
  /** Fraction of the box width the whisker end-caps span (centred on the stem). */
3
5
  const WHISKER_CAP_FRACTION = 0.5;
4
6
  /**
@@ -44,6 +46,36 @@ export function boxIndexAtTime(box, time) {
44
46
  }
45
47
  return -1;
46
48
  }
49
+ /**
50
+ * Hit-test plot-pixel `(px, py)` against `box`'s marks — the **first** box whose
51
+ * bounding rect (its x-slot × its `[upper, lower]` whisker extent) contains the
52
+ * point, or `null`. The x-span is {@link barSpanPx} (same `gapPx`/`minWidthPx`/
53
+ * `offsetPx` as {@link drawBox}), so the hit rect matches the drawn mark; the y
54
+ * extent is the full whisker reach, so a click anywhere on the box (body or
55
+ * whisker) selects it — a range-only bid→ask segment included. The returned
56
+ * tuple is `[index, begin, value]` (`begin` = the box's `x`, `value` = its
57
+ * `upper` — provenance) for the chart to assemble a `SelectInfo`; keeping this
58
+ * theme-free mirrors {@link barAt} and stays unit-testable without a
59
+ * `ChartTheme`. A gap box (some drawn quantile non-finite) is skipped. O(N).
60
+ */
61
+ export function boxAt(box, px, py, xScale, yScale, gapPx, minWidthPx, offsetPx = 0) {
62
+ for (let i = 0; i < box.length; i += 1) {
63
+ if (!isFiniteBox(box, i))
64
+ continue;
65
+ const [span0, span1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
66
+ const x0 = span0 + offsetPx;
67
+ const x1 = span1 + offsetPx;
68
+ const yUpper = yScale(box.upper[i]);
69
+ const yLower = yScale(box.lower[i]);
70
+ // upper is the higher value ⇒ the smaller pixel y; order defensively.
71
+ const yTop = Math.min(yUpper, yLower);
72
+ const yBottom = Math.max(yUpper, yLower);
73
+ if (px >= x0 && px <= x1 && py >= yTop && py <= yBottom) {
74
+ return [i, box.x[i], box.upper[i]];
75
+ }
76
+ }
77
+ return null;
78
+ }
47
79
  /**
48
80
  * Draw a discrete box per key of `box`, mapping data→pixels through
49
81
  * `xScale`/`yScale`. The bar-chart analog of {@link drawBand}: each key gets its
@@ -76,12 +108,36 @@ export function boxIndexAtTime(box, time) {
76
108
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
77
109
  * beyond the `barSpanPx` tuple.
78
110
  */
79
- export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true, offsetPx = 0, capWidthPx) {
111
+ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true, offsetPx = 0, capWidthPx,
112
+ // Selection / hover highlight, keyed by the box's `x` (its `begin`, matched to
113
+ // the container selection's `key` by the caller). `null` ⇒ none. A selected
114
+ // box gets a full-strength bounding outline; a hovered one a fainter one —
115
+ // the box analog of the bar highlight, drawn without a new theme token.
116
+ selectedKey = null, hoveredKey = null, decimate = true) {
117
+ // Viewport cull first (Phase 2): the [vStart, vEnd) boxes whose span overlaps
118
+ // the window (+1 each side). Full range when `xScale` has no domain (a stub);
119
+ // `offsetPx` is a small pixel nudge the ±1 margin absorbs.
120
+ let [vStart, vEnd] = visibleSpanRange(box.x, box.xEnd, box.length, xScale);
121
+ // M4 box decimation (Phase 5): once the *visible* boxes are denser than ~2 per
122
+ // device pixel, replace them with per-column **aggregate boxes** ({@link
123
+ // decimateBox}). Gate on the visible count, NOT `box.length`: a box's width is
124
+ // its slot, so decimating when only a handful are on screen (deep zoom) would
125
+ // re-slot each to a 1px sliver. `decimateBox` no-ops (returns the same object)
126
+ // below the visible-density threshold or on a domainless scale, leaving the
127
+ // loop-bound cull above. A selection/hover highlight keyed by the source box's
128
+ // `x` won't match an aggregate column edge — but per-box highlight is
129
+ // meaningless at decimation density, and hit-testing still reads the source.
130
+ const decimated = decimate !== false ? decimateBox(box, xScale, ctx, 2, vEnd - vStart) : box;
131
+ if (decimated !== box) {
132
+ box = decimated; // aggregate boxes are already the visible set
133
+ vStart = 0;
134
+ vEnd = box.length;
135
+ }
80
136
  // A range-only box (bid→ask segment) has no body / median; the whisker (or the
81
137
  // solid bar) runs the full lower→upper. Flags default true (a full box).
82
138
  const hasBox = box.hasBox !== false;
83
139
  const drawMedian = showMedian && box.hasMedian !== false;
84
- for (let i = 0; i < box.length; i += 1) {
140
+ for (let i = vStart; i < vEnd; i += 1) {
85
141
  if (!isFiniteBox(box, i))
86
142
  continue;
87
143
  const [span0, span1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
@@ -159,6 +215,19 @@ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx =
159
215
  ctx.lineTo(x1, yMedian);
160
216
  ctx.stroke();
161
217
  }
218
+ // Selection / hover: outline the whole mark (x-slot × whisker extent) so a
219
+ // click / pointer-over reads back on the canvas. Selected = full strength;
220
+ // hovered = fainter. Bracketed so alpha/width don't leak to the next box.
221
+ const key = box.x[i];
222
+ if (key === selectedKey || key === hoveredKey) {
223
+ ctx.save();
224
+ ctx.strokeStyle = style.stroke;
225
+ ctx.lineWidth =
226
+ key === selectedKey ? style.strokeWidth + 1 : style.strokeWidth;
227
+ ctx.globalAlpha = key === selectedKey ? 1 : 0.5;
228
+ ctx.strokeRect(x0, yUpper, x1 - x0, yLower - yUpper);
229
+ ctx.restore();
230
+ }
162
231
  }
163
232
  }
164
233
  /**
package/dist/context.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ScaleLinear, ScaleTime } from 'd3-scale';
2
2
  import type { ChartTheme } from './theme.js';
3
3
  import type { AxisFormat } from './format.js';
4
+ import type { LegendItemSpec } from './swatch.js';
4
5
  import type { Interval } from 'pond-ts';
5
6
  import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
6
7
  import type { ScaleBand } from './bandScale.js';
@@ -139,7 +140,8 @@ export interface ContainerFrame {
139
140
  * a row may override it via its own `cursor`. */
140
141
  readonly cursor: CursorMode;
141
142
  /** Show the cursor's time atop the in-chart readout (when a row's cursor draws
142
- * one), formatted by {@link formatTime} to match the time axis. */
143
+ * one), formatted by {@link formatReadout} (else {@link formatTime}, matching
144
+ * the time axis). */
143
145
  readonly cursorTime: boolean;
144
146
  /**
145
147
  * Whether the chart is in **annotation-edit mode** — suppresses the data cursor
@@ -149,8 +151,18 @@ export interface ContainerFrame {
149
151
  */
150
152
  readonly editAnnotations: boolean;
151
153
  /** Format an epoch-ms instant the same way the time axis labels its ticks —
152
- * shared by `<TimeAxis>` and the cursor-time readout. */
154
+ * shared by `<TimeAxis>` and (absent {@link formatReadout}) the cursor-time
155
+ * readout. Shaped by the container `timeFormat` only, never `cursorFormat`. */
153
156
  readonly formatTime: (epochMs: number) => string;
157
+ /**
158
+ * The **readout** channel — defined only when the container's `cursorFormat`
159
+ * is set (time or value axis; a category axis reads names). Readout
160
+ * consumers — the crosshair x pill and in-plot cursor time, marker axis
161
+ * indicators, annotation auto-labels — read `formatReadout ?? <their label
162
+ * formatter>`, so the readout can be shaped (or made more precise than the
163
+ * tick labels) without moving them.
164
+ */
165
+ readonly formatReadout?: ((value: number) => string) | undefined;
154
166
  /** Whether an explicit container `timeFormat` shaped {@link formatTime}. The
155
167
  * x axis suppresses its boundary (second) label row when it's set — a
156
168
  * custom format owns the whole label, so the ladder mustn't second-line it. */
@@ -181,6 +193,25 @@ export interface ContainerFrame {
181
193
  */
182
194
  registerSelectable(key: symbol): void;
183
195
  unregisterSelectable(key: symbol): void;
196
+ /**
197
+ * Register this layer's **legend row** — its display label + resolved
198
+ * {@link SwatchSpec} (and selection `id` when it has one) — keyed by the
199
+ * layer's per-instance slot; unregister on unmount (see
200
+ * {@link useLegendItems}). `<Legend>` renders this registry in
201
+ * {@link rowOrder}-then-declaration order, deduped by `id ?? label`; a layer
202
+ * that opted out (`legend={false}`) simply never registers.
203
+ */
204
+ registerLegendItem(key: symbol, item: LegendItemSpec): void;
205
+ unregisterLegendItem(key: symbol): void;
206
+ /** The registered legend rows, keyed by layer slot (see
207
+ * {@link registerLegendItem}). */
208
+ readonly legendItems: ReadonlyMap<symbol, LegendItemSpec>;
209
+ /**
210
+ * The chart rows' keys in **display (top-to-bottom) order** — mount order,
211
+ * exactly the ordering {@link firstRowKey} is head of. `<Legend>` sorts its
212
+ * rows by this so a two-row chart lists the top row's series first.
213
+ */
214
+ readonly rowOrder: readonly symbol[];
184
215
  /**
185
216
  * Shared x→pixel scale, range `[0, plotWidth]`. A d3 `scaleTime` (default) so
186
217
  * ticks land on wall-clock boundaries, or a `scaleLinear` when the data is
@@ -448,7 +479,10 @@ export interface TrackerSample {
448
479
  readonly value: number;
449
480
  /** Dot / label colour — the layer's resolved style colour. */
450
481
  readonly color: string;
451
- /** Series identity (`as` ?? column) labels the value in a readout. */
482
+ /** Labels the value in a readout: the series identity (`as` ?? column) for a
483
+ * single-value mark; a multi-value mark (band edges, box quantiles, an OHLC
484
+ * quote) emits `"<as> <role>"` composites (`iv lower`, `SPY high`) when its
485
+ * `as` is set, else the raw column / role word. */
452
486
  readonly label: string;
453
487
  }
454
488
  /** One line of a {@link CursorFlag} — a labelled, coloured value. */
@@ -503,9 +537,13 @@ export interface SelectInfo {
503
537
  /**
504
538
  * The clicked sample's key as epoch ms (its event's `begin`) — click
505
539
  * **provenance**, informational. NOT the selection identity (that is {@link id}).
540
+ * A **series-scoped** selection with no sample under it (a `<Legend>` row's
541
+ * default hover/select) carries `NaN` here and in {@link value} — check
542
+ * `Number.isFinite` before treating them as a sample.
506
543
  */
507
544
  readonly key: number;
508
- /** The clicked sample's value (the plotted column) — provenance. */
545
+ /** The clicked sample's value (the plotted column) — provenance. `NaN` for a
546
+ * series-scoped selection (see {@link key}). */
509
547
  readonly value: number;
510
548
  /** The mark's resolved style colour. */
511
549
  readonly color: string;
@@ -581,6 +619,10 @@ export interface AxisSpec {
581
619
  /** Explicit tick values (from `<YAxis ticks>`), driving BOTH the axis labels
582
620
  * and the row's gridlines so they align; `undefined` auto-picks from the scale. */
583
621
  readonly tickValues: readonly number[] | undefined;
622
+ /** Explicit auto-tick **count** (from `<YAxis tickCount>`) — a `ticks(count)`
623
+ * target; `undefined` derives the count from the row height (see
624
+ * {@link resolveYTickCount}). Ignored when {@link tickValues} is set. */
625
+ readonly tickCount: number | undefined;
584
626
  /**
585
627
  * Declaration position among the row's children, injected by `ChartRow`. The
586
628
  * row sorts axes by this, so the **first declared** axis is the default
@@ -606,6 +648,11 @@ export interface RowFrame {
606
648
  * that set `<YAxis ticks>` — so `Layers` draws gridlines at the same positions
607
649
  * the axis labels. Absent id ⇒ that axis auto-picks. */
608
650
  readonly tickValues: ReadonlyMap<string, readonly number[]>;
651
+ /** Resolved auto-tick **count** per axis id — the explicit `<YAxis tickCount>`
652
+ * or the row-height-derived default ({@link resolveYTickCount}). The single
653
+ * source both the `<YAxis>` labels and the `Layers` gridlines read, so a
654
+ * label and its gridline stay on the same `ticks(count)`. */
655
+ readonly tickCounts: ReadonlyMap<string, number>;
609
656
  /** The side each axis sits on, keyed by id — so an axis-edge overlay (the
610
657
  * crosshair value pills) hugs the correct gutter. */
611
658
  readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;