@pond-ts/charts 0.48.1 → 0.49.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 (55) hide show
  1. package/CHANGELOG.md +200 -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 +18 -1
  9. package/dist/BoxPlot.js +60 -3
  10. package/dist/Candlestick.d.ts +8 -1
  11. package/dist/Candlestick.js +40 -6
  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 +14 -1
  33. package/dist/box.js +56 -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 +193 -0
  39. package/dist/decimate.js +359 -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.js +6 -1
  46. package/dist/scatter.js +42 -7
  47. package/dist/swatch.d.ts +104 -0
  48. package/dist/swatch.js +96 -0
  49. package/dist/theme.d.ts +27 -0
  50. package/dist/theme.js +12 -0
  51. package/dist/useChartLegend.d.ts +106 -0
  52. package/dist/useChartLegend.js +122 -0
  53. package/dist/yticks.d.ts +20 -0
  54. package/dist/yticks.js +28 -0
  55. package/package.json +3 -3
package/dist/Layers.js CHANGED
@@ -7,12 +7,12 @@ import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
8
8
  import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
9
9
  import { ContainerContext, LayersContext, RowContext, } from './context.js';
10
- /** **Y**-gridline tick count. **Must match the y-axis label counts** (`YAxis`
11
- * `TICK_COUNT`, `ChartRow` `AXIS_TICK_COUNT`) horizontal gridlines and the y
12
- * labels are both derived from `ticks(count)`, so they only line up while the
13
- * counts agree; kept at 5 across all three. The **x** side instead reads the
14
- * container's shared `xTickCount` (as `<XAxis>` and `formatTime` do), which is
15
- * width-derived on a trading-time axis. */
10
+ /** Fallback **y**-gridline tick count, used only before the row publishes its
11
+ * resolved `tickCounts` (pre-registration). Normally the gridlines read the
12
+ * default axis's resolved count from `row.tickCounts` the same value the
13
+ * `<YAxis>` labels use so they line up by construction (was three hardcoded
14
+ * `5`s agreeing by convention). The **x** side reads the container's shared
15
+ * `xTickCount`, width-derived on a trading-time axis. */
16
16
  const GRID_TICKS = 5;
17
17
  /** Minimum px between `'labeled'` session dividers — thins dense collapse
18
18
  * points (e.g. a daily chart where every candle is a new session) so the axis
@@ -79,7 +79,7 @@ export function Layers({ children }) {
79
79
  }), [row.registerLayer, row.unregisterLayer]);
80
80
  const background = container.theme.background;
81
81
  const { grid: gridColor, gridDash } = container.theme.axis;
82
- const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
82
+ const { layers, yScales, formats, defaultAxisId, tickValues, tickCounts, axisSides, } = row;
83
83
  // x geometry is shared and lives on the container (uniform across rows), and
84
84
  // so is the x tick count — vertical gridlines must sit under the `<XAxis>`
85
85
  // labels, which pass the same `xTickCount` to the same scale.
@@ -101,8 +101,12 @@ export function Layers({ children }) {
101
101
  // The reference grid — behind the data, opt-out via `grid={false}` for
102
102
  // a clean backdrop (session dividers below stay independent of it).
103
103
  if (container.grid) {
104
+ // Auto gridlines use the default axis's RESOLVED tick count (the row's
105
+ // single source, height-derived or an explicit `<YAxis tickCount>`), so
106
+ // a gridline sits under every `<YAxis>` label and no more.
107
+ const yCount = tickCounts.get(defaultAxisId) ?? GRID_TICKS;
104
108
  const yTicks = gridY
105
- ? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
109
+ ? (explicitY ?? gridY.ticks(yCount)).map((t) => gridY(t))
106
110
  : [];
107
111
  // On a calendar axis the verticals are the FULL grain populations —
108
112
  // every day in the month, every month in the year, every aligned
@@ -213,6 +217,7 @@ export function Layers({ children }) {
213
217
  xTickCount,
214
218
  defaultAxisId,
215
219
  tickValues,
220
+ tickCounts,
216
221
  background,
217
222
  gridColor,
218
223
  gridDash,
@@ -347,6 +352,16 @@ export function Layers({ children }) {
347
352
  const [createPt, setCreatePt] = useState(null);
348
353
  const [drawFrom, setDrawFrom] = useState(null);
349
354
  const drawFromRef = useRef(null);
355
+ // The region-select drag anchor, mirrored for the gesture handlers (the same
356
+ // ref+state discipline as `drawFromRef`): the container's `regionAnchor`
357
+ // STATE is only how the rows paint the band; the gesture logic must never
358
+ // read it back, because a batched pointer stream (automation, jsdom, a very
359
+ // fast flick under load) delivers down→up before the down's setState commits
360
+ // — the up would see `regionAnchor === null`, silently drop the select, and
361
+ // the late-committing anchor would then stick (#508 item 7). Trusted
362
+ // human-paced input hides this (React flushes trusted discrete events
363
+ // synchronously); the ref is correct under both.
364
+ const regionAnchorRef = useRef(null);
350
365
  const handlePointerDown = useCallback((e) => {
351
366
  clickStartRef.current = { x: e.clientX, y: e.clientY };
352
367
  const c = containerRef.current;
@@ -380,7 +395,8 @@ export function Layers({ children }) {
380
395
  const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
381
396
  if (!needsShift || e.shiftKey) {
382
397
  const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
383
- c.setRegionAnchor(+c.xScale.invert(px));
398
+ regionAnchorRef.current = +c.xScale.invert(px);
399
+ c.setRegionAnchor(regionAnchorRef.current); // paint-only mirror
384
400
  c.setHoverX(px);
385
401
  try {
386
402
  e.currentTarget.setPointerCapture(e.pointerId);
@@ -423,8 +439,9 @@ export function Layers({ children }) {
423
439
  return;
424
440
  }
425
441
  // Region drag in progress: just track the pointer x (the band spans from the
426
- // anchor bucket to here); no pan, no hover hit-test.
427
- if (c.regionAnchor !== null) {
442
+ // anchor bucket to here); no pan, no hover hit-test. Gesture truth is the
443
+ // ref — the state mirror may not have committed yet (see regionAnchorRef).
444
+ if (regionAnchorRef.current !== null) {
428
445
  const rect = e.currentTarget.getBoundingClientRect();
429
446
  c.setHoverX(Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left)));
430
447
  return;
@@ -516,9 +533,14 @@ export function Layers({ children }) {
516
533
  const c = containerRef.current;
517
534
  // End a region drag: commit the anchor→pointer span as a one-shot range,
518
535
  // then clear the anchor (the cursor reverts to the single-bucket highlight).
519
- if (c.regionAnchor !== null) {
536
+ // The anchor is read from the ref, never the state mirror — under a batched
537
+ // pointer stream the state hasn't committed yet and the select would be
538
+ // silently dropped (and the anchor stuck). See regionAnchorRef.
539
+ if (regionAnchorRef.current !== null) {
540
+ const anchor = regionAnchorRef.current;
541
+ regionAnchorRef.current = null;
520
542
  const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
521
- const span = regionSpan(c.cursorBuckets ?? [], c.regionAnchor, +c.xScale.invert(px));
543
+ const span = regionSpan(c.cursorBuckets ?? [], anchor, +c.xScale.invert(px));
522
544
  c.setRegionAnchor(null);
523
545
  try {
524
546
  e.currentTarget.releasePointerCapture(e.pointerId);
@@ -599,6 +621,7 @@ export function Layers({ children }) {
599
621
  }
600
622
  // Cancel a region-drag on leave (no commit) — a safety net for the rare case
601
623
  // where the pointer capture didn't take, so the anchor can't get stuck.
624
+ regionAnchorRef.current = null;
602
625
  if (c.regionAnchor !== null)
603
626
  c.setRegionAnchor(null);
604
627
  c.setHoverX(null);
@@ -838,7 +861,7 @@ export function Layers({ children }) {
838
861
  ? `${plotWidth - timeX + 4}px`
839
862
  : undefined,
840
863
  color: cursorColor,
841
- }, children: formatTime(cursorTime) })), parts.chip === 'inline' &&
864
+ }, children: (container.formatReadout ?? formatTime)(cursorTime) })), parts.chip === 'inline' &&
842
865
  trackerSamples.map((s, i) => {
843
866
  // Flip the chip left of its dot near the right edge so it stays in-plot.
844
867
  const flip = s.px > plotWidth * LABEL_FLIP_FRACTION;
@@ -0,0 +1,62 @@
1
+ import type { LegendItemInput } from './swatch.js';
2
+ import { type ChartTheme } from './theme.js';
3
+ /** Where the in-container legend card anchors, relative to the rows block. */
4
+ export type LegendPlacement = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
5
+ export interface LegendProps {
6
+ /**
7
+ * Which corner of the **rows block** the card anchors to (8px inset).
8
+ * **Omitted ⇒ `'top-right'`.** Ignored in standalone `items` mode outside a
9
+ * container, where the card renders in normal flow (the consumer places it).
10
+ */
11
+ placement?: LegendPlacement;
12
+ /**
13
+ * **Escape hatch — explicit rows.** Renders exactly these (in order, no
14
+ * dedup) instead of the container's registry, and works **outside** a
15
+ * `<ChartContainer>` (a dashboard-side key). Each row is a `label` +
16
+ * resolved {@link SwatchSpec} (+ optional `id` for the interactions).
17
+ */
18
+ items?: readonly LegendItemInput[];
19
+ /**
20
+ * Row click. **Omitted ⇒ the id-gated default:** a row whose layer has an
21
+ * `id` toggles the container selection (the same `select()` path a mark
22
+ * click uses); rows without an `id` are inert. Provide to take over (e.g.
23
+ * a consumer-side show/hide toggle — visibility stays consumer-side by
24
+ * design, a legend mutating composition would be a second styling channel).
25
+ */
26
+ onRowClick?: (row: LegendItemInput) => void;
27
+ /**
28
+ * Row hover (`null` on leave). **Omitted ⇒ the id-gated default:** hovering
29
+ * a row with an `id` echoes into the container's `hovered` channel — the
30
+ * same one an in-plot hover drives — and clears on leave.
31
+ */
32
+ onRowHover?: (row: LegendItemInput | null) => void;
33
+ /** Theme for the **standalone** (`items`, outside-a-container) mode, where
34
+ * there is no frame to read one from. **Omitted ⇒ the container's theme,
35
+ * else {@link defaultTheme}.** */
36
+ theme?: ChartTheme;
37
+ }
38
+ /**
39
+ * The **series key** — one row per registered draw layer: a swatch of the
40
+ * layer's *resolved* style (so the key can never drift from the plot) and its
41
+ * readout identity (`as ?? column`, or the layer's `legend="name"` override).
42
+ * A child of {@link ChartContainer} (anywhere — it reads the registry, not its
43
+ * position): renders a small card anchored to a corner of the rows block.
44
+ *
45
+ * - **Rows** follow chart-row order, then declaration order (the z-order
46
+ * convention); two layers sharing an identity (`id ?? label`) collapse to
47
+ * one row, exactly as the tracker readout merges keys.
48
+ * - **A layer opts out** with `legend={false}`, or renames its row with
49
+ * `legend="Display name"`.
50
+ * - **Interactivity is id-gated** (the selection contract): rows whose layer
51
+ * has an `id` echo hover into the container and toggle selection on click;
52
+ * `onRowHover` / `onRowClick` take over when provided. The selected row
53
+ * reads emphasized; the hovered row tints.
54
+ * - **Scope follows placement:** at the container level it lists **all** rows;
55
+ * placed inside a `<Layers>` it **scopes to that `<ChartRow>`** and anchors
56
+ * to that row's plot (like an annotation) — a per-row legend needs no prop.
57
+ * - **Standalone mode:** `<Legend items={…}>` renders explicit rows — inside
58
+ * a container (replacing the registry) or entirely outside one (a
59
+ * dashboard-side key; pass `theme` there).
60
+ */
61
+ export declare function Legend({ placement, items, onRowClick, onRowHover, theme: themeProp, }: LegendProps): import("react/jsx-runtime").JSX.Element | null;
62
+ //# sourceMappingURL=Legend.d.ts.map
package/dist/Legend.js ADDED
@@ -0,0 +1,169 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext } from 'react';
3
+ import { ContainerContext, RowContext } from './context.js';
4
+ import { buildChartLegend } from './useChartLegend.js';
5
+ import { defaultTheme } from './theme.js';
6
+ /** One 20×12 swatch glyph, drawn from the layer's resolved style. */
7
+ function SwatchGlyph({ spec }) {
8
+ const w = 20;
9
+ const h = 12;
10
+ const mid = h / 2;
11
+ switch (spec.kind) {
12
+ case 'line': {
13
+ const sw = Math.min(spec.width, 4);
14
+ // A dashed series hand-renders as exactly THREE dashes — a canonical
15
+ // "dashed" glyph at swatch scale — rather than scaling the layer's own
16
+ // dash cadence into 20px (which can alias to 1 or 7 dashes and stop
17
+ // reading as dashed at all). Colour/width stay the resolved style.
18
+ if (spec.dash && spec.dash.length > 0) {
19
+ return (_jsxs("g", { stroke: spec.color, strokeWidth: sw, strokeLinecap: "butt", children: [_jsx("line", { x1: 0, y1: mid, x2: 4.6, y2: mid }), _jsx("line", { x1: 7.7, y1: mid, x2: 12.3, y2: mid }), _jsx("line", { x1: 15.4, y1: mid, x2: w, y2: mid })] }));
20
+ }
21
+ return (_jsx("line", { x1: 0, y1: mid, x2: w, y2: mid, stroke: spec.color, strokeWidth: sw }));
22
+ }
23
+ case 'area':
24
+ return (_jsxs("g", { children: [_jsx("rect", { x: 0, y: 3, width: w, height: h - 3, fill: spec.fill, fillOpacity: spec.fillOpacity }), _jsx("line", { x1: 0, y1: 3, x2: w, y2: 3, stroke: spec.line, strokeWidth: 1.5 })] }));
25
+ case 'band':
26
+ return (_jsx("rect", { x: 0, y: 2, width: w, height: h - 4, fill: spec.fill, fillOpacity: spec.opacity }));
27
+ case 'scatter': {
28
+ const r = Math.min(spec.radius, 5);
29
+ return (_jsx("circle", { cx: w / 2, cy: mid, r: r, fill: spec.color, stroke: spec.outline, strokeWidth: spec.outline !== undefined ? 1 : 0 }));
30
+ }
31
+ case 'box': {
32
+ const sw = Math.min(spec.whiskerWidth, 3);
33
+ return (_jsxs("g", { stroke: spec.whisker, strokeWidth: sw, children: [_jsx("line", { x1: w / 2, y1: 1, x2: w / 2, y2: h - 1 }), _jsx("line", { x1: w / 2 - 4, y1: 1, x2: w / 2 + 4, y2: 1 }), _jsx("line", { x1: w / 2 - 4, y1: h - 1, x2: w / 2 + 4, y2: h - 1 })] }));
34
+ }
35
+ case 'bar':
36
+ // A centred rounded square — reads as "a fill" without pretending to be
37
+ // a bar of any particular width (#512 follow-up feedback, second pass:
38
+ // centred in the swatch box like the other glyphs).
39
+ return (_jsx("rect", { x: (w - 10) / 2, y: 1, width: 10, height: 10, rx: 2, fill: spec.fill }));
40
+ case 'candle':
41
+ return (_jsxs("g", { children: [_jsx("line", { x1: 5.5, y1: 0, x2: 5.5, y2: h, stroke: spec.up, strokeWidth: 1 }), _jsx("rect", { x: 3, y: 2, width: 5, height: h - 5, fill: spec.up }), _jsx("line", { x1: 14.5, y1: 0, x2: 14.5, y2: h, stroke: spec.down, strokeWidth: 1 }), _jsx("rect", { x: 12, y: 3, width: 5, height: h - 5, fill: spec.down })] }));
42
+ }
43
+ }
44
+ /** The card's corner offsets — anchored to the **plot area**, not the rows
45
+ * block: the horizontal inset adds the container's axis gutter on that side,
46
+ * so a left placement never sits over the y-axis labels (#512 follow-up
47
+ * feedback). Vertically the rows block already IS the plot (the x-axis strip
48
+ * renders outside it). */
49
+ function placementStyle(placement, leftGutter, rightGutter) {
50
+ switch (placement) {
51
+ case 'top-left':
52
+ return { top: 8, left: leftGutter + 8 };
53
+ case 'top-right':
54
+ return { top: 8, right: rightGutter + 8 };
55
+ case 'bottom-left':
56
+ return { bottom: 8, left: leftGutter + 8 };
57
+ case 'bottom-right':
58
+ return { bottom: 8, right: rightGutter + 8 };
59
+ }
60
+ }
61
+ /**
62
+ * The **series key** — one row per registered draw layer: a swatch of the
63
+ * layer's *resolved* style (so the key can never drift from the plot) and its
64
+ * readout identity (`as ?? column`, or the layer's `legend="name"` override).
65
+ * A child of {@link ChartContainer} (anywhere — it reads the registry, not its
66
+ * position): renders a small card anchored to a corner of the rows block.
67
+ *
68
+ * - **Rows** follow chart-row order, then declaration order (the z-order
69
+ * convention); two layers sharing an identity (`id ?? label`) collapse to
70
+ * one row, exactly as the tracker readout merges keys.
71
+ * - **A layer opts out** with `legend={false}`, or renames its row with
72
+ * `legend="Display name"`.
73
+ * - **Interactivity is id-gated** (the selection contract): rows whose layer
74
+ * has an `id` echo hover into the container and toggle selection on click;
75
+ * `onRowHover` / `onRowClick` take over when provided. The selected row
76
+ * reads emphasized; the hovered row tints.
77
+ * - **Scope follows placement:** at the container level it lists **all** rows;
78
+ * placed inside a `<Layers>` it **scopes to that `<ChartRow>`** and anchors
79
+ * to that row's plot (like an annotation) — a per-row legend needs no prop.
80
+ * - **Standalone mode:** `<Legend items={…}>` renders explicit rows — inside
81
+ * a container (replacing the registry) or entirely outside one (a
82
+ * dashboard-side key; pass `theme` there).
83
+ */
84
+ export function Legend({ placement = 'top-right', items, onRowClick, onRowHover, theme: themeProp, }) {
85
+ const container = useContext(ContainerContext);
86
+ if (container === null && items === undefined) {
87
+ throw new Error('<Legend> must be inside a <ChartContainer> (or be given explicit `items`)');
88
+ }
89
+ // A RowContext in scope (the card is inside a <Layers>) narrows the registry
90
+ // to that row's layers AND means we're already inside the row's plot cell —
91
+ // so the corner inset drops the axis gutter (see placementStyle below).
92
+ const row = useContext(RowContext);
93
+ const theme = themeProp ?? container?.theme ?? defaultTheme;
94
+ const slot = theme.legend ?? {
95
+ background: theme.chip?.background ?? '#ffffff',
96
+ border: theme.axis.grid,
97
+ text: theme.axis.label,
98
+ };
99
+ // The shared headless core (also `useChartLegend`'s) — rows + the id-gated
100
+ // hover/select verbs — so the built-in card and a custom-rendered legend
101
+ // can never disagree. `null` in standalone `items` mode (no chart to sync).
102
+ const legend = container !== null ? buildChartLegend(container, row?.rowKey) : null;
103
+ // The card renders a flat item list — `rows` is grouped by chart row, so
104
+ // flatten it (a scoped legend has one group anyway).
105
+ const entries = items ?? legend.rows.flatMap((r) => r.items);
106
+ if (entries.length === 0)
107
+ return null;
108
+ // Selection reads by CONTRAST: the selected item goes bold and every other
109
+ // dulls (the ticker-compare treatment) — quieter and clearer than
110
+ // decorating the selected item itself. Only when the selection points at an
111
+ // item of THIS legend, so selecting an off-legend mark dulls nothing.
112
+ const selectedId = container?.selected?.id;
113
+ const anySelected = selectedId !== undefined && entries.some((it) => it.id === selectedId);
114
+ const enter = (item) => {
115
+ if (onRowHover)
116
+ return onRowHover(item);
117
+ legend?.hover(item);
118
+ };
119
+ const leave = (item) => {
120
+ if (onRowHover)
121
+ return onRowHover(null);
122
+ if (item.id !== undefined)
123
+ legend?.hover(null);
124
+ };
125
+ const click = (item) => {
126
+ if (onRowClick)
127
+ return onRowClick(item);
128
+ legend?.select(item);
129
+ };
130
+ // Standalone (no container): a normal-flow card the consumer places.
131
+ const positioned = container !== null;
132
+ return (_jsx("div", { "data-legend": "", style: {
133
+ ...(positioned
134
+ ? {
135
+ position: 'absolute',
136
+ // Row-scoped (inside the plot cell): no gutter inset — the cell
137
+ // already starts at the plot edge. Container-level: inset by the
138
+ // axis gutter so the card clears the y-axis labels.
139
+ ...placementStyle(placement, row !== null ? 0 : container.leftGutter, row !== null ? 0 : container.rightGutter),
140
+ zIndex: 5,
141
+ }
142
+ : { display: 'inline-block' }),
143
+ background: slot.background,
144
+ border: `1px solid ${slot.border}`,
145
+ borderRadius: 4,
146
+ padding: '4px 8px',
147
+ font: `${theme.font.size}px ${theme.font.family}`,
148
+ color: slot.text,
149
+ lineHeight: 1.6,
150
+ }, children: entries.map((item) => {
151
+ const interactive = onRowClick !== undefined ||
152
+ onRowHover !== undefined ||
153
+ (item.id !== undefined && container !== null);
154
+ const selected = item.id !== undefined && container?.selected?.id === item.id;
155
+ const hovered = item.id !== undefined && container?.hovered?.id === item.id;
156
+ return (_jsxs("div", { onPointerEnter: () => enter(item), onPointerLeave: () => leave(item), onClick: () => click(item), style: {
157
+ display: 'flex',
158
+ alignItems: 'center',
159
+ gap: 6,
160
+ cursor: interactive ? 'pointer' : 'default',
161
+ fontWeight: selected ? 600 : 400,
162
+ opacity: anySelected && !selected ? 0.45 : 1,
163
+ background: hovered ? slot.border : 'transparent',
164
+ borderRadius: 2,
165
+ padding: '0 2px',
166
+ }, children: [_jsx("svg", { width: 20, height: 12, style: { flex: 'none' }, children: _jsx(SwatchGlyph, { spec: item.swatch }) }), _jsx("span", { children: item.label })] }, item.id !== undefined ? `${item.id}${item.label}` : item.label));
167
+ }) }));
168
+ }
169
+ //# sourceMappingURL=Legend.js.map
@@ -1,5 +1,6 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
+ import type { DecimateOption } from './decimate.js';
3
4
  import { type Curve } from './curve.js';
4
5
  import { type GapMode } from './gaps.js';
5
6
  export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
@@ -63,6 +64,24 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
63
64
  * no-op on a continuous axis (no provider) or a provider without `boundaries`.
64
65
  */
65
66
  sessionBreaks?: boolean;
67
+ /**
68
+ * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
69
+ * once the visible data is denser than ~2 samples per device pixel, the line
70
+ * is drawn from the per-pixel-column min/max/first/last (a pixel-identical
71
+ * polyline of O(plot width) points) instead of every sample — so a 1M-point
72
+ * series pans at interactive rates. It is **visually lossless** (a perf knob,
73
+ * not a style), and applies only to the honest default draw: a solid line with
74
+ * `gaps="empty"`, a linear `curve`, and no `sessionBreaks` (other modes draw
75
+ * full-resolution until later phases wire them). Pass `false` to always draw
76
+ * every point, or `{ threshold }` to tune the samples-per-pixel factor.
77
+ */
78
+ decimate?: DecimateOption;
79
+ /**
80
+ * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
81
+ * row's display name. **Omitted ⇒ a row named by the layer's readout
82
+ * identity** (`as` ?? `column`). The swatch is the resolved line style.
83
+ */
84
+ legend?: boolean | string;
66
85
  /**
67
86
  * @internal Declaration position among the `<Layers>` children, injected by
68
87
  * `Layers` so z-order follows JSX order. Do not set.
@@ -75,5 +94,5 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
75
94
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
76
95
  * it. The line breaks at gaps rather than spanning them.
77
96
  */
78
- export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, curve, gaps, sessionBreaks, index, }: LineChartProps<S, VS>): null;
97
+ export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, curve, gaps, sessionBreaks, decimate, legend, index, }: LineChartProps<S, VS>): null;
79
98
  //# sourceMappingURL=LineChart.d.ts.map
package/dist/LineChart.js CHANGED
@@ -5,6 +5,7 @@ import { drawLine, yExtent } from './line.js';
5
5
  import { resolveCurve } from './curve.js';
6
6
  import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
7
7
  import { ContainerContext, LayersContext } from './context.js';
8
+ import { legendLabelFor, useLegendItems, } from './swatch.js';
8
9
  import { useSlotKey } from './use-slot-key.js';
9
10
  /** Stable empty boundary list — so `sessionBreaks={false}` keeps a referentially
10
11
  * constant array and the layer entry isn't rebuilt every render. */
@@ -15,7 +16,7 @@ const NO_BREAKS = [];
15
16
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
16
17
  * it. The line breaks at gaps rather than spanning them.
17
18
  */
18
- export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, index = 0, }) {
19
+ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, decimate = true, legend, index = 0, }) {
19
20
  const container = useContext(ContainerContext);
20
21
  if (container === null) {
21
22
  throw new Error('<LineChart> must be rendered inside a <ChartContainer>');
@@ -81,7 +82,7 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
81
82
  ? [{ x: e.begin(), value: v, color: style.color, label }]
82
83
  : [];
83
84
  },
84
- draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity, sessionBreakInstants),
85
+ draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity, sessionBreakInstants, decimate),
85
86
  },
86
87
  axisId: axis,
87
88
  index,
@@ -95,6 +96,7 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
95
96
  gaps,
96
97
  gapConnectorOpacity,
97
98
  sessionBreakInstants,
99
+ decimate,
98
100
  axis,
99
101
  index,
100
102
  ]);
@@ -115,6 +117,25 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
115
117
  useEffect(() => {
116
118
  registerTrackerSource(slot, entry.layer);
117
119
  }, [registerTrackerSource, slot, entry.layer]);
120
+ // And a legend row: the readout identity + the resolved line style, so a
121
+ // `<Legend>` swatch can never drift from what the canvas draws.
122
+ const legendRows = useMemo(() => {
123
+ const name = legendLabelFor(legend, label);
124
+ return name === null
125
+ ? null
126
+ : [
127
+ {
128
+ label: name,
129
+ swatch: {
130
+ kind: 'line',
131
+ color: style.color,
132
+ width: style.width,
133
+ dash: style.dash,
134
+ },
135
+ },
136
+ ];
137
+ }, [legend, label, style]);
138
+ useLegendItems(container, slot, index, legendRows);
118
139
  return null;
119
140
  }
120
141
  //# sourceMappingURL=LineChart.js.map
@@ -85,6 +85,13 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
85
85
  * together, so a nudged point still selects.
86
86
  */
87
87
  offset?: number;
88
+ /**
89
+ * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
90
+ * row's display name. **Omitted ⇒ a row named by the layer's readout
91
+ * identity** (`as` ?? `column`). The swatch is the resolved base dot style
92
+ * (a data-driven `radius`/`color` encoding shows its base, not the range).
93
+ */
94
+ legend?: boolean | string;
88
95
  /**
89
96
  * @internal Declaration position among the `<Layers>` children, injected by
90
97
  * `Layers` so z-order follows JSX order. Do not set.
@@ -119,5 +126,5 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
119
126
  * </Layers>
120
127
  * ```
121
128
  */
122
- export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, index, }: ScatterChartProps<S, VS>): null;
129
+ export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, legend, index, }: ScatterChartProps<S, VS>): null;
123
130
  //# sourceMappingURL=ScatterChart.d.ts.map
@@ -4,6 +4,7 @@ import { fromTimeSeries, fromValueSeries } from './data.js';
4
4
  import { drawScatter, hitTestScatter, nearestIndex, scatterExtent, } from './scatter.js';
5
5
  import { resolveEncoding, } from './encoding.js';
6
6
  import { ContainerContext, LayersContext } from './context.js';
7
+ import { legendLabelFor, useLegendItems, } from './swatch.js';
7
8
  import { useSlotKey } from './use-slot-key.js';
8
9
  /**
9
10
  * A scatter draw layer: one mark per finite point at `(x, column-value)`
@@ -33,7 +34,7 @@ import { useSlotKey } from './use-slot-key.js';
33
34
  * </Layers>
34
35
  * ```
35
36
  */
36
- export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, index = 0, }) {
37
+ export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, legend, index = 0, }) {
37
38
  const container = useContext(ContainerContext);
38
39
  if (container === null) {
39
40
  throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
@@ -163,6 +164,28 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
163
164
  useEffect(() => {
164
165
  registerTrackerSource(slot, entry.layer);
165
166
  }, [registerTrackerSource, slot, entry.layer]);
167
+ // And a legend row: the readout identity + the resolved base dot (a fixed
168
+ // `radius` number shows at size; an encoding shows the style's base radius).
169
+ // Carries the layer's `id` so the legend's default interactions are id-gated
170
+ // exactly like the mark's own.
171
+ const legendRows = useMemo(() => {
172
+ const name = legendLabelFor(legend, seriesLabel);
173
+ return name === null
174
+ ? null
175
+ : [
176
+ {
177
+ label: name,
178
+ id,
179
+ swatch: {
180
+ kind: 'scatter',
181
+ color: style.color,
182
+ radius: typeof radius === 'number' ? radius : style.radius,
183
+ outline: style.outline,
184
+ },
185
+ },
186
+ ];
187
+ }, [legend, seriesLabel, id, style, radius]);
188
+ useLegendItems(container, slot, index, legendRows);
166
189
  // Advertise selectability (only when an `id` was given) so the container can
167
190
  // warn if selection is wired but nothing is selectable.
168
191
  const { registerSelectable, unregisterSelectable } = container;
package/dist/XAxis.js CHANGED
@@ -158,6 +158,13 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
158
158
  // Split from `fmt` so an axis's terse tick labels never leak into the cursor /
159
159
  // marker readouts, which stay full timestamps.
160
160
  const tickFmt = flatFmt ?? (stacked ? baseFmt : undefined) ?? fmt;
161
+ // The **readout** formatter — the cursor pill + marker indicator pills. A
162
+ // container `cursorFormat` wins even over an explicit axis `format` (pill
163
+ // precedence `cursorFormat → axis format → container`): the readout is its
164
+ // own channel, so precise-pill-over-terse-ticks works on any axis kind. A
165
+ // `transform`ed axis is exempt — its pill speaks the derived unit, which a
166
+ // data-unit `cursorFormat` can't address.
167
+ const readoutFmt = transform === undefined ? (container.formatReadout ?? fmt) : fmt;
161
168
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
162
169
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
163
170
  // colour, reading like a tick. An indicator always shows the axis coordinate
@@ -181,7 +188,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
181
188
  key: a.key,
182
189
  id: a.id ?? `marker-at-${at}`,
183
190
  x: xScale(at),
184
- text: fmt(at),
191
+ text: readoutFmt(at),
185
192
  };
186
193
  })
187
194
  .filter((t) => t.x >= 0 && t.x <= plotWidth);
@@ -411,6 +418,6 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
411
418
  transform: 'translateX(-50%)',
412
419
  [onTop ? 'bottom' : 'top']: `${pillOffset}px`,
413
420
  zIndex: 3,
414
- }, children: fmt(+xScale.invert(cursorX)) })] }))] }));
421
+ }, children: readoutFmt(+xScale.invert(cursorX)) })] }))] }));
415
422
  }
416
423
  //# sourceMappingURL=XAxis.js.map
package/dist/YAxis.d.ts CHANGED
@@ -60,6 +60,14 @@ export interface YAxisProps {
60
60
  readonly at: number;
61
61
  readonly label: string;
62
62
  }>;
63
+ /**
64
+ * Target number of **auto** ticks — the `count` passed to `scale.ticks()`
65
+ * (d3 returns nice 1-2-5 values near it, not exactly this many). **Omitted ⇒
66
+ * derived from the row height** so a short strip isn't crushed with a tall
67
+ * row's density (mirrors the width-derived x axis). Ignored when explicit
68
+ * {@link ticks} are given (those set both labels and gridlines directly).
69
+ */
70
+ tickCount?: number;
63
71
  /**
64
72
  * Render the tick labels at the domain extremes (the top & bottom ticks)?
65
73
  * **Default `true`.** `false` drops just those two numbers — the gridlines
@@ -92,5 +100,5 @@ export interface YAxisProps {
92
100
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
93
101
  * (default: the first axis).
94
102
  */
95
- export declare function YAxis({ id, side, label, min, max, format, ticks, pad, boundaryLabels, width, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
103
+ export declare function YAxis({ id, side, label, min, max, format, ticks, tickCount, pad, boundaryLabels, width, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
96
104
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -4,7 +4,10 @@ import { ContainerContext, RowContext } from './context.js';
4
4
  import { resolveAxisFormat } from './format.js';
5
5
  import { useSlotKey } from './use-slot-key.js';
6
6
  const DEFAULT_WIDTH = 50;
7
- const TICK_COUNT = 5;
7
+ /** Fallback tick count before the row has published its resolved count (the
8
+ * first render, pre-registration). The row's height-derived value takes over
9
+ * immediately after. */
10
+ const DEFAULT_TICK_COUNT = 5;
8
11
  /**
9
12
  * A y-axis for a {@link ChartRow}, rendered as DOM chrome (not canvas) so the
10
13
  * text is crisp, themeable, and accessible. Registers its id / side / width /
@@ -13,7 +16,7 @@ const TICK_COUNT = 5;
13
16
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
14
17
  * (default: the first axis).
15
18
  */
16
- export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
19
+ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
17
20
  const container = useContext(ContainerContext);
18
21
  if (container === null) {
19
22
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -32,8 +35,21 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
32
35
  labelPlacement,
33
36
  format,
34
37
  tickValues: ticks?.map((t) => t.at),
38
+ tickCount,
35
39
  index,
36
- }), [id, side, width, min, max, pad, labelPlacement, format, ticks, index]);
40
+ }), [
41
+ id,
42
+ side,
43
+ width,
44
+ min,
45
+ max,
46
+ pad,
47
+ labelPlacement,
48
+ format,
49
+ ticks,
50
+ tickCount,
51
+ index,
52
+ ]);
37
53
  // A stable per-instance slot (see useSlotKey) keeps this axis in a fixed
38
54
  // registry position, so a min/max/side change updates in place rather than
39
55
  // re-appending (which would move the first axis behind a later one and
@@ -48,14 +64,19 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
48
64
  }, [registerAxis, slot, spec]);
49
65
  const { theme } = container;
50
66
  const yScale = row.yScales.get(id);
67
+ // The auto-tick count — the row resolves it (explicit `tickCount` else
68
+ // height-derived) and both this axis's labels AND the row's gridlines read
69
+ // the same value, so a label and its gridline can't drift apart.
70
+ const count = row.tickCounts.get(id) ?? DEFAULT_TICK_COUNT;
51
71
  // Same formatter the readout uses (resolved per axis on the row), so a tick and
52
- // a cursor value read identically.
53
- const fmt = yScale ? resolveAxisFormat(yScale, TICK_COUNT, format) : String;
72
+ // a cursor value read identically. `count` calibrates the default formatter's
73
+ // precision to the tick density, exactly as the axis is.
74
+ const fmt = yScale ? resolveAxisFormat(yScale, count, format) : String;
54
75
  // Explicit `{ at, label }` ticks render verbatim (each label at its `at`),
55
76
  // overriding the auto-picked d3 ticks; otherwise label the scale's ticks via `fmt`.
56
77
  const tickList = ticks
57
78
  ? ticks.map((t) => ({ value: t.at, label: t.label }))
58
- : (yScale ? yScale.ticks(TICK_COUNT) : []).map((t) => ({
79
+ : (yScale ? yScale.ticks(count) : []).map((t) => ({
59
80
  value: t,
60
81
  label: fmt(t),
61
82
  }));