@pond-ts/charts 0.53.1 → 0.55.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,23 +1,10 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
+ import type { NumericColumn, ValueNumericColumn } from './column-names.js';
3
4
  import type { DecimateOption } from './decimate.js';
4
5
  import { type Curve } from './curve.js';
5
6
  import { type GapMode } from './gaps.js';
6
- export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
7
- /**
8
- * The source series. A `TimeSeries` plots against the time axis; a
9
- * `ValueSeries` (`series.byValue('cumDist')`) against its value axis — the
10
- * container infers which from the data, no axis-type prop. Either way the key
11
- * / axis column supplies x and `column` supplies y.
12
- *
13
- * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
14
- * passing `series={s.byValue('dist')}` inline re-registers this layer every
15
- * render — on a frequently re-rendering (e.g. scrub-driven) chart, memoize the
16
- * projection (`useMemo`) so the layer isn't rebuilt each frame.
17
- */
18
- series: TimeSeries<S> | ValueSeries<VS>;
19
- /** Name of the numeric value column to plot. */
20
- column: string;
7
+ export interface LineChartCommon<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
21
8
  /**
22
9
  * The series' semantic identifier — what the data _is_ / how it should read
23
10
  * (e.g. `heartrate`, `power`, or a role name like `foam`). The theme maps it
@@ -88,11 +75,38 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
88
75
  */
89
76
  index?: number;
90
77
  }
78
+ /**
79
+ * LineChart's source + column props, a **union over the series kind** so the
80
+ * column names are checked against the schema that was actually passed
81
+ * ([PND-CHARTAPI]). A single member carrying `NumericColumn<S> |
82
+ * ValueNumericColumn<VS>` would silently widen to `string`: only one of the
83
+ * two generics is ever inferred, and the other falls back (measured in
84
+ * `spikes/charts-type-seam/`). Loosely-typed series still accept any name.
85
+ */
86
+ type LineChartSource<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> = {
87
+ /**
88
+ * The source series. **Live charts:** `series.byValue(…)` mints a
89
+ * *fresh* projection each call, so an inline `series={s.byValue('d')}`
90
+ * re-registers this layer every render — on a frequently re-rendering
91
+ * (e.g. scrub-driven) chart, memoize the projection (`useMemo`) so the
92
+ * layer isn't rebuilt each frame.
93
+ */
94
+ series: TimeSeries<S>;
95
+ column: NumericColumn<S>;
96
+ readout?: NumericColumn<S>;
97
+ } | {
98
+ series: ValueSeries<VS>;
99
+ column: ValueNumericColumn<VS>;
100
+ readout?: ValueNumericColumn<VS>;
101
+ };
102
+ /** `<LineChart>`'s props: the shared knobs plus one series-kind source shape. */
103
+ export type LineChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> = LineChartCommon<S, VS> & LineChartSource<S, VS>;
91
104
  /**
92
105
  * A line draw layer. Reads `column` from `series` into a {@link ChartSeries}
93
106
  * (columnar, gaps as NaN), registers itself into the enclosing {@link Layers}
94
107
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
95
108
  * it. The line breaks at gaps rather than spanning them.
96
109
  */
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;
110
+ export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, curve, gaps, sessionBreaks, decimate, legend, index, }: LineChartProps<S, VS>): null;
111
+ export {};
98
112
  //# sourceMappingURL=LineChart.d.ts.map
package/dist/LineChart.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ValueSeries } from 'pond-ts';
3
- import { fromTimeSeries, fromValueSeries } from './data.js';
3
+ import { assertNumericColumn, fromTimeSeries, fromValueSeries, } from './data.js';
4
4
  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';
@@ -16,7 +16,7 @@ const NO_BREAKS = [];
16
16
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
17
17
  * it. The line breaks at gaps rather than spanning them.
18
18
  */
19
- export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, decimate = true, legend, index = 0, }) {
19
+ export function LineChart({ series, column, readout, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, decimate = true, legend, index = 0, }) {
20
20
  const container = useContext(ContainerContext);
21
21
  if (container === null) {
22
22
  throw new Error('<LineChart> must be rendered inside a <ChartContainer>');
@@ -28,6 +28,22 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
28
28
  const cs = useMemo(() => series instanceof ValueSeries
29
29
  ? fromValueSeries(series, column)
30
30
  : fromTimeSeries(series, column), [series, column]);
31
+ // Readout column values for a value-axis series (the time path reads it off
32
+ // the event in `sampleAt`). Built once per (series, readout) so the tracker
33
+ // can report a source value the line doesn't plot — see LineChartProps.readout.
34
+ //
35
+ // The time path buffers nothing (it has an event, not an index), so it
36
+ // validates the name here instead: otherwise a mistyped `readout` throws on a
37
+ // value axis but silently yields no readout on a time axis, and the same typo
38
+ // fails two different ways. Both now throw the reader's errors.
39
+ const readoutY = useMemo(() => {
40
+ if (readout === undefined)
41
+ return undefined;
42
+ if (series instanceof ValueSeries)
43
+ return fromValueSeries(series, readout).y;
44
+ assertNumericColumn(series, readout);
45
+ return undefined;
46
+ }, [series, readout]);
31
47
  // Styling: semantic identifier → theme style. The single styling channel.
32
48
  const { line } = container.theme;
33
49
  const style = (semantic !== undefined ? line[semantic] : undefined) ?? line.default;
@@ -68,8 +84,19 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
68
84
  if (i < 0)
69
85
  return [];
70
86
  const v = cs.y[i];
87
+ const rv = readoutY?.[i];
71
88
  return Number.isFinite(v)
72
- ? [{ x: cs.x[i], value: v, color: style.color, label }]
89
+ ? [
90
+ {
91
+ x: cs.x[i],
92
+ value: v,
93
+ color: style.color,
94
+ label,
95
+ ...(rv !== undefined && Number.isFinite(rv)
96
+ ? { readout: rv }
97
+ : {}),
98
+ },
99
+ ]
73
100
  : [];
74
101
  }
75
102
  const e = series.nearest(x);
@@ -78,9 +105,21 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
78
105
  // get() wants a literal key; column is a runtime string. Cast the
79
106
  // *event* (not the method — that would detach `this`) to a
80
107
  // string-keyed get; runtime-safe read + guard.
81
- const v = e.get(column);
108
+ const ev = e;
109
+ const v = ev.get(column);
110
+ const rv = readout !== undefined ? ev.get(readout) : undefined;
82
111
  return typeof v === 'number' && Number.isFinite(v)
83
- ? [{ x: e.begin(), value: v, color: style.color, label }]
112
+ ? [
113
+ {
114
+ x: e.begin(),
115
+ value: v,
116
+ color: style.color,
117
+ label,
118
+ ...(typeof rv === 'number' && Number.isFinite(rv)
119
+ ? { readout: rv }
120
+ : {}),
121
+ },
122
+ ]
84
123
  : [];
85
124
  },
86
125
  draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity, sessionBreakInstants, decimate),
@@ -91,6 +130,8 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
91
130
  cs,
92
131
  series,
93
132
  column,
133
+ readout,
134
+ readoutY,
94
135
  style,
95
136
  label,
96
137
  curveFactory,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @internal The shared row-table shell behind {@link BarList} / {@link BoxList}.
3
+ *
4
+ * Renders a real `<table>` — the point of the list family is table semantics
5
+ * (label cells that can be links, aligned data cells, a `colSpan` detail row
6
+ * for the expander, screen-reader-legible rows), which a canvas plot can't
7
+ * carry and which hand-rolled flex rows re-implement badly (per-row cell
8
+ * alignment is exactly what table layout solves). The glyph cell takes
9
+ * `width: 100%` so it absorbs the free width; every text cell shrinks to fit.
10
+ *
11
+ * Not exported from the package: the public surface is the two sisters, so the
12
+ * shared shell can evolve without a compatibility contract.
13
+ */
14
+ import { type ReactNode } from 'react';
15
+ import type { ListCellSpec, ListRow } from './list.js';
16
+ import type { ChartTheme } from './theme.js';
17
+ export interface ListTableProps<R extends ListRow> {
18
+ /** Rows in display order (the caller sorts). */
19
+ readonly rows: readonly R[];
20
+ /** Which sister is rendering — stamped as `data-list` for styling/tests. */
21
+ readonly kind: 'bar' | 'box';
22
+ /** The glyph cell's content for one row (the bar / box lines). */
23
+ readonly renderGlyphs: (row: R) => ReactNode;
24
+ readonly before?: readonly ListCellSpec<R>[] | undefined;
25
+ readonly after?: readonly ListCellSpec<R>[] | undefined;
26
+ readonly renderExpanded?: ((row: R) => ReactNode) | undefined;
27
+ readonly defaultExpanded?: readonly string[] | undefined;
28
+ readonly onExpandToggle?: ((key: string, expanded: boolean) => void) | undefined;
29
+ readonly selected?: string | null | undefined;
30
+ readonly onRowClick?: ((row: R) => void) | undefined;
31
+ readonly divided?: boolean | undefined;
32
+ /** Draw the vertical **baseline rule** at the scale origin (the glyph
33
+ * cell's left edge) — the shared reference the eye aligns rows against. */
34
+ readonly baseline?: boolean | undefined;
35
+ /**
36
+ * Reference markers, **pre-resolved to track fractions** by the caller (the
37
+ * shell knows pixels, not the scale): each draws a dotted vertical rule
38
+ * through every row's glyph area, plus a label strip above the list when
39
+ * any carries a `label`. A `null` fraction (out-of-scale gap) is skipped.
40
+ */
41
+ readonly markers?: ReadonlyArray<{
42
+ readonly frac: number | null;
43
+ readonly label?: string;
44
+ }> | undefined;
45
+ readonly theme: ChartTheme;
46
+ }
47
+ /** The shared text ink: the band-label tone when the theme has one (stronger
48
+ * than tick labels — these cells are primary content), else the tick ink. */
49
+ export declare function listInk(theme: ChartTheme): string;
50
+ export declare function ListTable<R extends ListRow>({ rows, kind, renderGlyphs, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, divided, baseline, markers, theme, }: ListTableProps<R>): import("react/jsx-runtime").JSX.Element;
51
+ //# sourceMappingURL=ListTable.d.ts.map
@@ -0,0 +1,143 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @internal The shared row-table shell behind {@link BarList} / {@link BoxList}.
4
+ *
5
+ * Renders a real `<table>` — the point of the list family is table semantics
6
+ * (label cells that can be links, aligned data cells, a `colSpan` detail row
7
+ * for the expander, screen-reader-legible rows), which a canvas plot can't
8
+ * carry and which hand-rolled flex rows re-implement badly (per-row cell
9
+ * alignment is exactly what table layout solves). The glyph cell takes
10
+ * `width: 100%` so it absorbs the free width; every text cell shrinks to fit.
11
+ *
12
+ * Not exported from the package: the public surface is the two sisters, so the
13
+ * shared shell can evolve without a compatibility contract.
14
+ */
15
+ import { Fragment, useState } from 'react';
16
+ /** The turquoise the selected-row edge falls back to when the theme has no
17
+ * annotation register — the same built-in the annotation layer uses. */
18
+ const FALLBACK_ACCENT = '#0d9488';
19
+ /** The shared text ink: the band-label tone when the theme has one (stronger
20
+ * than tick labels — these cells are primary content), else the tick ink. */
21
+ export function listInk(theme) {
22
+ return theme.axis.band?.label ?? theme.axis.label;
23
+ }
24
+ export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, divided = true, baseline = false, markers = [], theme, }) {
25
+ // Uncontrolled expansion, keyed on row identity so it survives a re-sort.
26
+ const [expanded, setExpanded] = useState(() => new Set(defaultExpanded ?? []));
27
+ const [hovered, setHovered] = useState(null);
28
+ const interactive = onRowClick !== undefined;
29
+ const toggle = (key) => {
30
+ const open = !expanded.has(key);
31
+ setExpanded((prev) => {
32
+ const next = new Set(prev);
33
+ if (open)
34
+ next.add(key);
35
+ else
36
+ next.delete(key);
37
+ return next;
38
+ });
39
+ onExpandToggle?.(key, open);
40
+ };
41
+ const ink = listInk(theme);
42
+ const accent = theme.annotation?.color ?? FALLBACK_ACCENT;
43
+ const divider = divided ? `1px solid ${theme.axis.grid}` : undefined;
44
+ // Label + before + glyph + after (+ expander) — the detail row spans them all.
45
+ const span = 2 + before.length + after.length + (renderExpanded ? 1 : 0);
46
+ const textCell = (align) => ({
47
+ padding: '6px 12px',
48
+ whiteSpace: 'nowrap',
49
+ textAlign: align ?? 'left',
50
+ verticalAlign: 'middle',
51
+ });
52
+ // The glyph cell's shared horizontal geometry — the label strip must use
53
+ // the SAME left/right padding (and baseline border) as the data rows, or
54
+ // its percentages would resolve against a different content width and the
55
+ // labels would sit off their rules.
56
+ const glyphCellStyle = (vertical) => ({
57
+ width: '100%',
58
+ padding: baseline ? `${vertical} 8px ${vertical} 5px` : `${vertical} 8px`,
59
+ verticalAlign: 'middle',
60
+ borderLeft: baseline ? `1px solid ${theme.axis.grid}` : undefined,
61
+ });
62
+ const drawnMarkers = markers.filter((m) => m.frac !== null);
63
+ return (_jsx("table", { "data-list": kind, style: {
64
+ width: '100%',
65
+ borderCollapse: 'collapse',
66
+ font: `${theme.font.size}px/${1.5} ${theme.font.family}`,
67
+ color: ink,
68
+ background: theme.background,
69
+ }, children: _jsxs("tbody", { children: [drawnMarkers.some((m) => m.label !== undefined) && (
70
+ // The marker label strip: one synthetic row above the data, its
71
+ // glyph cell sharing the data rows' horizontal geometry so each
72
+ // label centres exactly on its rule below.
73
+ _jsxs("tr", { "data-list-marker-labels": "", children: [_jsx("td", { style: textCell() }), before.map((cell) => (_jsx("td", { style: textCell(cell.align) }, cell.key))), _jsx("td", { style: glyphCellStyle('0px'), children: _jsx("div", { style: {
74
+ position: 'relative',
75
+ height: theme.font.size + 6,
76
+ }, children: drawnMarkers.map((m, mi) => m.label !== undefined && (_jsx("span", { "data-list-marker-label": "", style: {
77
+ position: 'absolute',
78
+ left: `${m.frac * 100}%`,
79
+ bottom: 0,
80
+ transform: 'translateX(-50%)',
81
+ whiteSpace: 'nowrap',
82
+ color: accent,
83
+ }, children: m.label }, mi))) }) }), after.map((cell) => (_jsx("td", { style: textCell(cell.align) }, cell.key))), renderExpanded !== undefined && _jsx("td", {})] })), rows.map((row, i) => {
84
+ const isSelected = selected != null && selected === row.key;
85
+ const isOpen = renderExpanded !== undefined && expanded.has(row.key);
86
+ return (_jsxs(Fragment, { children: [_jsxs("tr", { "data-list-row": row.key, ...(isSelected ? { 'data-selected': '' } : {}), onClick: onRowClick === undefined ? undefined : () => onRowClick(row),
87
+ // A clickable row is keyboard-reachable too: focusable, and
88
+ // Enter / Space activate it (Space's default scroll is eaten).
89
+ tabIndex: interactive ? 0 : undefined, onKeyDown: onRowClick === undefined
90
+ ? undefined
91
+ : (e) => {
92
+ if (e.key === 'Enter' || e.key === ' ') {
93
+ e.preventDefault();
94
+ onRowClick(row);
95
+ }
96
+ }, onPointerEnter: interactive ? () => setHovered(row.key) : undefined, onPointerLeave: interactive ? () => setHovered(null) : undefined, style: {
97
+ borderTop: i > 0 ? divider : undefined,
98
+ cursor: interactive ? 'pointer' : undefined,
99
+ background: interactive && hovered === row.key
100
+ ? (theme.legend?.border ?? theme.axis.grid)
101
+ : undefined,
102
+ // The selection accent: an inset edge in the annotation
103
+ // register (a *user's* mark, so it takes the marks colour,
104
+ // not a data hue) — reads on any ground, moves no layout.
105
+ boxShadow: isSelected ? `inset 3px 0 0 ${accent}` : undefined,
106
+ }, children: [_jsx("td", { "data-list-cell": "label", style: textCell(), children: row.label ?? row.key }), before.map((cell) => (_jsx("td", { "data-list-cell": cell.key, style: textCell(cell.align), children: cell.render(row) }, cell.key))), _jsx("td", { "data-list-cell": "glyphs",
107
+ // 100% absorbs the table's free width; every text cell
108
+ // shrinks to its content, staying aligned down the list.
109
+ // The baseline rule marks the scale origin: left padding
110
+ // narrows to a 5px breath so the glyphs start just off the
111
+ // rule, and border-collapse joins the rows' rules into one
112
+ // continuous vertical — the same thin `axis.grid` ink as
113
+ // the row dividers, so the two read as one quiet grid.
114
+ style: glyphCellStyle('6px'), children: _jsxs("div", { style: { position: 'relative' }, children: [renderGlyphs(row), drawnMarkers.map((m, mi) => (
115
+ // One dotted segment per row, bleeding through the
116
+ // row's vertical padding (+ divider) so adjacent rows'
117
+ // segments join into one continuous rule. Annotation
118
+ // register — a reference is a user's mark, not data.
119
+ _jsx("div", { "data-list-marker": "", style: {
120
+ position: 'absolute',
121
+ top: -7,
122
+ bottom: -7,
123
+ left: `calc(${m.frac * 100}% - 0.5px)`,
124
+ width: 0,
125
+ borderLeft: `1px dotted ${accent}`,
126
+ pointerEvents: 'none',
127
+ } }, mi)))] }) }), after.map((cell) => (_jsx("td", { "data-list-cell": cell.key, style: textCell(cell.align), children: cell.render(row) }, cell.key))), renderExpanded !== undefined && (_jsx("td", { style: { padding: '0 4px', verticalAlign: 'middle' }, children: _jsx("button", { type: "button", "data-list-expander": "", "aria-expanded": isOpen, "aria-label": isOpen ? 'Collapse row' : 'Expand row', onClick: (e) => {
128
+ // The chevron toggles; it must not double as a row click.
129
+ e.stopPropagation();
130
+ toggle(row.key);
131
+ }, style: {
132
+ background: 'none',
133
+ border: 'none',
134
+ cursor: 'pointer',
135
+ color: theme.axis.label,
136
+ font: 'inherit',
137
+ padding: '2px 6px',
138
+ transform: isOpen ? 'rotate(90deg)' : undefined,
139
+ transition: 'transform 120ms',
140
+ }, children: "\u25B8" }) }))] }), isOpen && (_jsx("tr", { "data-list-detail": row.key, children: _jsx("td", { colSpan: span, style: { padding: '2px 12px 12px' }, children: renderExpanded(row) }) }))] }, row.key));
141
+ })] }) }));
142
+ }
143
+ //# sourceMappingURL=ListTable.js.map
@@ -1,24 +1,9 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
+ import type { NumericColumn, ValueNumericColumn } from './column-names.js';
3
4
  import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
4
5
  import type { DecimateOption } from './decimate.js';
5
- export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
6
- /**
7
- * The source series. A `TimeSeries` scatters against the time axis; a
8
- * `ValueSeries` (`series.byValue('cumDist')`, or `ValueSeries.fromColumns`
9
- * for natively value-keyed data — IV marks keyed by strike) against its
10
- * value axis — the container infers which from the data, no axis-type prop
11
- * (mirrors `<LineChart>`). Either way the key / axis column supplies each
12
- * point's x and `column` supplies y.
13
- *
14
- * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call,
15
- * so passing `series={s.byValue('dist')}` inline re-registers this layer
16
- * every render — memoize the projection (`useMemo`) on a frequently
17
- * re-rendering chart.
18
- */
19
- series: TimeSeries<S> | ValueSeries<VS>;
20
- /** Name of the numeric value column — each point's y. */
21
- column: string;
6
+ export interface ScatterChartCommon<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
22
7
  /**
23
8
  * The scatter's semantic identifier — what the marks _are_ / how they should
24
9
  * read. The theme maps it to a {@link ScatterStyle} (`theme.scatter[as] ??
@@ -109,6 +94,30 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
109
94
  */
110
95
  index?: number;
111
96
  }
97
+ /**
98
+ * ScatterChart's source + column props, a **union over the series kind** so the
99
+ * column names are checked against the schema that was actually passed
100
+ * ([PND-CHARTAPI]). A single member carrying `NumericColumn<S> |
101
+ * ValueNumericColumn<VS>` would silently widen to `string`: only one of the
102
+ * two generics is ever inferred, and the other falls back (measured in
103
+ * `spikes/charts-type-seam/`). Loosely-typed series still accept any name.
104
+ */
105
+ type ScatterChartSource<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> = {
106
+ /**
107
+ * The source series. **Live charts:** `series.byValue(…)` mints a
108
+ * *fresh* projection each call, so an inline `series={s.byValue('d')}`
109
+ * re-registers this layer every render — on a frequently re-rendering
110
+ * (e.g. scrub-driven) chart, memoize the projection (`useMemo`) so the
111
+ * layer isn't rebuilt each frame.
112
+ */
113
+ series: TimeSeries<S>;
114
+ column: NumericColumn<S>;
115
+ } | {
116
+ series: ValueSeries<VS>;
117
+ column: ValueNumericColumn<VS>;
118
+ };
119
+ /** `<ScatterChart>`'s props: the shared knobs plus one series-kind source shape. */
120
+ export type ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> = ScatterChartCommon<S, VS> & ScatterChartSource<S, VS>;
112
121
  /**
113
122
  * A scatter draw layer: one mark per finite point at `(x, column-value)`
114
123
  * — x from the series' key / axis column (time or value axis) —
@@ -138,4 +147,5 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
138
147
  * ```
139
148
  */
140
149
  export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, decimate, legend, index, }: ScatterChartProps<S, VS>): null;
150
+ export {};
141
151
  //# sourceMappingURL=ScatterChart.d.ts.map
package/dist/YAxis.js CHANGED
@@ -72,14 +72,25 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
72
72
  // a cursor value read identically. `count` calibrates the default formatter's
73
73
  // precision to the tick density, exactly as the axis is.
74
74
  const fmt = yScale ? resolveAxisFormat(yScale, count, format) : String;
75
+ // A **horizontal categorical** layer on this axis supplies its category
76
+ // names ([PND-HCAT]); with no explicit `ticks`, label one per unit slot at
77
+ // its centre (`i + 0.5`) instead of the scale's numeric ticks — a slot index
78
+ // is not a number anyone wants to read. Explicit `ticks` still win, and a
79
+ // non-categorical row is unaffected (no layer answers, so this is `null`).
80
+ const layerCategories = row.layers
81
+ .filter((e) => (e.axisId ?? row.defaultAxisId) === id)
82
+ .map((e) => e.layer.binCategories?.() ?? null)
83
+ .find((c) => c !== null) ?? null;
75
84
  // Explicit `{ at, label }` ticks render verbatim (each label at its `at`),
76
85
  // overriding the auto-picked d3 ticks; otherwise label the scale's ticks via `fmt`.
77
86
  const tickList = ticks
78
87
  ? ticks.map((t) => ({ value: t.at, label: t.label }))
79
- : (yScale ? yScale.ticks(count) : []).map((t) => ({
80
- value: t,
81
- label: fmt(t),
82
- }));
88
+ : layerCategories !== null
89
+ ? layerCategories.map((label, i) => ({ value: i + 0.5, label }))
90
+ : (yScale ? yScale.ticks(count) : []).map((t) => ({
91
+ value: t,
92
+ label: fmt(t),
93
+ }));
83
94
  // The row reserves a slot per axis column (the widest in that column across
84
95
  // rows). Size the box to the slot and align this axis's own (narrower)
85
96
  // content toward the plot — left axes flush right, right axes flush left — so
package/dist/affine.d.ts CHANGED
@@ -2,13 +2,25 @@
2
2
  * Affine-scale fast path (charts perf, [PND-AFFINE]). A chart's continuous
3
3
  * scales — `scaleLinear` (value axis, every y axis), `scaleTime`, and the
4
4
  * **gap-free** `scaleTradingTime(identityProvider())` (the default continuous
5
- * time axis) — map data→pixels by a single `px = k·v + b`. The per-point draw
6
- * loops in `drawLine` / `drawArea` can then multiply-add inline over the typed
5
+ * time axis) — map data→pixels affinely. The per-point draw loops in
6
+ * `drawLine` / `drawArea` can then evaluate the map inline over the typed
7
7
  * arrays instead of paying a d3-scale closure call (deinterpolate → interpolate)
8
8
  * per point — the ~37% of stroke-bound frame self-time the 2026-07 external
9
9
  * bench profile attributed to `scale()` (see
10
10
  * `docs/notes/charts-bench-vs-scichart-suite-2026-07.md`, finding 1).
11
11
  *
12
+ * The map is stored and evaluated in the **rebased** form
13
+ * `px = (v − v0)·k + p0` (v0 = the domain's low endpoint, p0 = scale(v0)) —
14
+ * the same association d3's own deinterpolate → interpolate uses — never
15
+ * expanded to `k·v + b`. The expanded form is catastrophically ill-conditioned
16
+ * on epoch-millisecond domains: with t ≈ 1.8e12 and a deeply zoomed window,
17
+ * `k·t` and `b` are huge near-cancelling terms whose rounding (½ ULP of `k·t`)
18
+ * survives the cancellation — ~0.16 px reconstruction error at a 1 ms window,
19
+ * ~24 px at 1 µs (measured). Under the expanded form the interior probe below
20
+ * caught that drift and *rejected* the scale, so deep-zoomed frames silently
21
+ * lost the fast path; the rebased form evaluates to ≲1e-9 px of the exact
22
+ * d3 path at every zoom depth, so the fast path stays engaged.
23
+ *
12
24
  * The affine coefficients are recovered from the scale's own domain/range
13
25
  * endpoints, then **verified affine** by probing interior points: a scale that
14
26
  * deviates (a `scaleTradingTime` with *collapsed* gaps, or a future
@@ -19,23 +31,32 @@
19
31
  * a straight line.
20
32
  */
21
33
  import type { Scale } from './line.js';
22
- /** Coefficients of an affine pixel map `px = k·value + b`. */
34
+ /**
35
+ * Coefficients of an affine pixel map in rebased form:
36
+ * `px = (value − v0)·k + p0`. `v0` is the domain's low endpoint and `p0` its
37
+ * pixel image, so the multiply sees the O(span) offset `value − v0` (exact for
38
+ * in-domain values, by Sterbenz cancellation) rather than an O(1e12) absolute
39
+ * epoch value — see the module comment for why the expanded `k·value + b`
40
+ * form must not be reintroduced.
41
+ */
23
42
  export interface Affine {
24
43
  readonly k: number;
25
- readonly b: number;
44
+ readonly v0: number;
45
+ readonly p0: number;
26
46
  }
27
47
  /**
28
- * The affine coefficients `{ k, b }` with `scale(v) === v + b` for all `v`, or
29
- * `null` when the scale is not affine over its domain (a real-gap
30
- * `scaleTradingTime`, a non-linear axis) or exposes no numeric domain/range (a
31
- * bare `(v) => v` test stub, a `scaleBand` category axis). `null` ⇒ the caller
32
- * keeps the d3-scale path.
48
+ * The affine coefficients `{ k, v0, p0 }` with `scale(v) === (v v0)·k + p0`
49
+ * for all `v`, or `null` when the scale is not affine over its domain (a
50
+ * real-gap `scaleTradingTime`, a non-linear axis) or exposes no numeric
51
+ * domain/range (a bare `(v) => v` test stub, a `scaleBand` category axis).
52
+ * `null` ⇒ the caller keeps the d3-scale path.
33
53
  *
34
- * Recovered from the domain/range endpoints (`k` from the two extremes, `b`
35
- * pinning the low end), then verified at {@link PROBE_FRACTIONS}. Every probe
36
- * must map finite and within {@link PROBE_EPSILON} of the reconstruction so a
37
- * scale that returns non-numbers for an interior value (a `scaleBand`) or bends
38
- * away from the endpoint line (trading gaps, log) is rejected.
54
+ * Recovered from the domain/range endpoints (`k` from the two extremes, the
55
+ * `(v0, p0)` base pinning the low end), then verified at
56
+ * {@link PROBE_FRACTIONS}. Every probe must map finite and within
57
+ * {@link PROBE_EPSILON} of the reconstruction so a scale that returns
58
+ * non-numbers for an interior value (a `scaleBand`) or bends away from the
59
+ * endpoint line (trading gaps, log) is rejected.
39
60
  */
40
61
  export declare function affineOf(scale: Scale): Affine | null;
41
62
  //# sourceMappingURL=affine.d.ts.map
package/dist/affine.js CHANGED
@@ -2,13 +2,25 @@
2
2
  * Affine-scale fast path (charts perf, [PND-AFFINE]). A chart's continuous
3
3
  * scales — `scaleLinear` (value axis, every y axis), `scaleTime`, and the
4
4
  * **gap-free** `scaleTradingTime(identityProvider())` (the default continuous
5
- * time axis) — map data→pixels by a single `px = k·v + b`. The per-point draw
6
- * loops in `drawLine` / `drawArea` can then multiply-add inline over the typed
5
+ * time axis) — map data→pixels affinely. The per-point draw loops in
6
+ * `drawLine` / `drawArea` can then evaluate the map inline over the typed
7
7
  * arrays instead of paying a d3-scale closure call (deinterpolate → interpolate)
8
8
  * per point — the ~37% of stroke-bound frame self-time the 2026-07 external
9
9
  * bench profile attributed to `scale()` (see
10
10
  * `docs/notes/charts-bench-vs-scichart-suite-2026-07.md`, finding 1).
11
11
  *
12
+ * The map is stored and evaluated in the **rebased** form
13
+ * `px = (v − v0)·k + p0` (v0 = the domain's low endpoint, p0 = scale(v0)) —
14
+ * the same association d3's own deinterpolate → interpolate uses — never
15
+ * expanded to `k·v + b`. The expanded form is catastrophically ill-conditioned
16
+ * on epoch-millisecond domains: with t ≈ 1.8e12 and a deeply zoomed window,
17
+ * `k·t` and `b` are huge near-cancelling terms whose rounding (½ ULP of `k·t`)
18
+ * survives the cancellation — ~0.16 px reconstruction error at a 1 ms window,
19
+ * ~24 px at 1 µs (measured). Under the expanded form the interior probe below
20
+ * caught that drift and *rejected* the scale, so deep-zoomed frames silently
21
+ * lost the fast path; the rebased form evaluates to ≲1e-9 px of the exact
22
+ * d3 path at every zoom depth, so the fast path stays engaged.
23
+ *
12
24
  * The affine coefficients are recovered from the scale's own domain/range
13
25
  * endpoints, then **verified affine** by probing interior points: a scale that
14
26
  * deviates (a `scaleTradingTime` with *collapsed* gaps, or a future
@@ -30,22 +42,25 @@ const PROBE_FRACTIONS = [0.1213, 0.2857, 0.4391, 0.6137, 0.7649, 0.8831];
30
42
  /**
31
43
  * Pixel tolerance for the affinity probe. Far below a sub-pixel (so a real
32
44
  * non-affine deviation — a collapsed trading gap or a log curve is many pixels)
33
- * yet far above the float-reconstruction noise of `k·v + b` on wide domains
34
- * (~1e-9 px), so an exactly-affine scale is never rejected.
45
+ * yet far above the float-reconstruction noise of the rebased
46
+ * `(v − v0)·k + p0` against d3's own evaluation (≲1e-9 px at any domain
47
+ * magnitude or zoom depth — both sides subtract the domain origin before
48
+ * multiplying), so an exactly-affine scale is never rejected.
35
49
  */
36
50
  const PROBE_EPSILON = 1e-3;
37
51
  /**
38
- * The affine coefficients `{ k, b }` with `scale(v) === v + b` for all `v`, or
39
- * `null` when the scale is not affine over its domain (a real-gap
40
- * `scaleTradingTime`, a non-linear axis) or exposes no numeric domain/range (a
41
- * bare `(v) => v` test stub, a `scaleBand` category axis). `null` ⇒ the caller
42
- * keeps the d3-scale path.
52
+ * The affine coefficients `{ k, v0, p0 }` with `scale(v) === (v v0)·k + p0`
53
+ * for all `v`, or `null` when the scale is not affine over its domain (a
54
+ * real-gap `scaleTradingTime`, a non-linear axis) or exposes no numeric
55
+ * domain/range (a bare `(v) => v` test stub, a `scaleBand` category axis).
56
+ * `null` ⇒ the caller keeps the d3-scale path.
43
57
  *
44
- * Recovered from the domain/range endpoints (`k` from the two extremes, `b`
45
- * pinning the low end), then verified at {@link PROBE_FRACTIONS}. Every probe
46
- * must map finite and within {@link PROBE_EPSILON} of the reconstruction so a
47
- * scale that returns non-numbers for an interior value (a `scaleBand`) or bends
48
- * away from the endpoint line (trading gaps, log) is rejected.
58
+ * Recovered from the domain/range endpoints (`k` from the two extremes, the
59
+ * `(v0, p0)` base pinning the low end), then verified at
60
+ * {@link PROBE_FRACTIONS}. Every probe must map finite and within
61
+ * {@link PROBE_EPSILON} of the reconstruction so a scale that returns
62
+ * non-numbers for an interior value (a `scaleBand`) or bends away from the
63
+ * endpoint line (trading gaps, log) is rejected.
49
64
  */
50
65
  export function affineOf(scale) {
51
66
  const s = scale;
@@ -63,15 +78,17 @@ export function affineOf(scale) {
63
78
  if (!Number.isFinite(pLo) || !Number.isFinite(pHi))
64
79
  return null;
65
80
  const k = (pHi - pLo) / (hi - lo);
66
- const b = pLo - k * lo;
67
81
  const span = hi - lo;
68
82
  for (const t of PROBE_FRACTIONS) {
69
83
  const v = lo + t * span;
70
84
  const p = scale(v);
71
- if (!Number.isFinite(p) || Math.abs(p - (k * v + b)) > PROBE_EPSILON) {
85
+ // Probe the exact rebased expression the draw loops evaluate, so what is
86
+ // verified is what runs.
87
+ if (!Number.isFinite(p) ||
88
+ Math.abs(p - ((v - lo) * k + pLo)) > PROBE_EPSILON) {
72
89
  return null;
73
90
  }
74
91
  }
75
- return { k, b };
92
+ return { k, v0: lo, p0: pLo };
76
93
  }
77
94
  //# sourceMappingURL=affine.js.map
package/dist/area.js CHANGED
@@ -65,8 +65,8 @@ export function fillAffineArea(ctx, xs, ys, baselinePx, ax, ay) {
65
65
  for (let j = 0; j <= n; j += 1) {
66
66
  const finite = j < n && Number.isFinite(ys[j]);
67
67
  if (finite) {
68
- const px = ax.k * xs[j] + ax.b;
69
- const py = ay.k * ys[j] + ay.b;
68
+ const px = (xs[j] - ax.v0) * ax.k + ax.p0;
69
+ const py = (ys[j] - ay.v0) * ay.k + ay.p0;
70
70
  if (runStart < 0) {
71
71
  runStart = j;
72
72
  ctx.moveTo(px, py);
@@ -78,8 +78,8 @@ export function fillAffineArea(ctx, xs, ys, baselinePx, ax, ay) {
78
78
  else if (runStart >= 0) {
79
79
  // Close the run: drop to the baseline under the last point, run flat back
80
80
  // to the first point's x, close. (j-1 is the run's last finite index.)
81
- ctx.lineTo(ax.k * xs[j - 1] + ax.b, baselinePx);
82
- ctx.lineTo(ax.k * xs[runStart] + ax.b, baselinePx);
81
+ ctx.lineTo((xs[j - 1] - ax.v0) * ax.k + ax.p0, baselinePx);
82
+ ctx.lineTo((xs[runStart] - ax.v0) * ax.k + ax.p0, baselinePx);
83
83
  ctx.closePath();
84
84
  runStart = -1;
85
85
  }