@pond-ts/charts 0.31.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 (79) hide show
  1. package/CHANGELOG.md +3254 -0
  2. package/LICENSE +21 -0
  3. package/README.md +229 -0
  4. package/dist/AreaChart.d.ts +85 -0
  5. package/dist/AreaChart.js +119 -0
  6. package/dist/BandChart.d.ts +55 -0
  7. package/dist/BandChart.js +93 -0
  8. package/dist/BarChart.d.ts +72 -0
  9. package/dist/BarChart.js +137 -0
  10. package/dist/BoxPlot.d.ts +77 -0
  11. package/dist/BoxPlot.js +137 -0
  12. package/dist/Canvas.d.ts +37 -0
  13. package/dist/Canvas.js +39 -0
  14. package/dist/ChartContainer.d.ts +106 -0
  15. package/dist/ChartContainer.js +306 -0
  16. package/dist/ChartRow.d.ts +29 -0
  17. package/dist/ChartRow.js +215 -0
  18. package/dist/Layers.d.ts +22 -0
  19. package/dist/Layers.js +399 -0
  20. package/dist/LineChart.d.ts +60 -0
  21. package/dist/LineChart.js +105 -0
  22. package/dist/ScatterChart.d.ts +84 -0
  23. package/dist/ScatterChart.js +139 -0
  24. package/dist/TimeAxis.d.ts +9 -0
  25. package/dist/TimeAxis.js +12 -0
  26. package/dist/XAxis.d.ts +39 -0
  27. package/dist/XAxis.js +84 -0
  28. package/dist/YAxis.d.ts +42 -0
  29. package/dist/YAxis.js +86 -0
  30. package/dist/annotations.d.ts +110 -0
  31. package/dist/annotations.js +459 -0
  32. package/dist/area.d.ts +54 -0
  33. package/dist/area.js +186 -0
  34. package/dist/band.d.ts +31 -0
  35. package/dist/band.js +57 -0
  36. package/dist/bars.d.ts +96 -0
  37. package/dist/bars.js +171 -0
  38. package/dist/box.d.ts +59 -0
  39. package/dist/box.js +140 -0
  40. package/dist/chip.d.ts +23 -0
  41. package/dist/chip.js +43 -0
  42. package/dist/cjs-fallback.cjs +16 -0
  43. package/dist/context.d.ts +362 -0
  44. package/dist/context.js +5 -0
  45. package/dist/curve.d.ts +22 -0
  46. package/dist/curve.js +13 -0
  47. package/dist/data.d.ts +154 -0
  48. package/dist/data.js +197 -0
  49. package/dist/domain.d.ts +19 -0
  50. package/dist/domain.js +61 -0
  51. package/dist/encoding.d.ts +89 -0
  52. package/dist/encoding.js +144 -0
  53. package/dist/format.d.ts +53 -0
  54. package/dist/format.js +47 -0
  55. package/dist/gaps.d.ts +146 -0
  56. package/dist/gaps.js +209 -0
  57. package/dist/grid.d.ts +11 -0
  58. package/dist/grid.js +29 -0
  59. package/dist/index.d.ts +53 -0
  60. package/dist/index.js +34 -0
  61. package/dist/line.d.ts +46 -0
  62. package/dist/line.js +88 -0
  63. package/dist/range.d.ts +15 -0
  64. package/dist/range.js +27 -0
  65. package/dist/scatter.d.ts +70 -0
  66. package/dist/scatter.js +213 -0
  67. package/dist/select.d.ts +13 -0
  68. package/dist/select.js +23 -0
  69. package/dist/slots.d.ts +48 -0
  70. package/dist/slots.js +64 -0
  71. package/dist/theme.d.ts +224 -0
  72. package/dist/theme.js +232 -0
  73. package/dist/tracker.d.ts +30 -0
  74. package/dist/tracker.js +47 -0
  75. package/dist/use-slot-key.d.ts +21 -0
  76. package/dist/use-slot-key.js +25 -0
  77. package/dist/viewport.d.ts +20 -0
  78. package/dist/viewport.js +30 -0
  79. package/package.json +67 -0
package/dist/data.d.ts ADDED
@@ -0,0 +1,154 @@
1
+ import type { SeriesSchema, TimeSeries, ValueSeries, ValueSeriesSchema } from 'pond-ts';
2
+ /**
3
+ * A chart-ready columnar view of a series: parallel typed arrays for the time
4
+ * (x) and value (y) axes, plus the logical row count.
5
+ *
6
+ * Missing / non-finite values are `NaN` in `y` — the gap signal the draw layers
7
+ * break the line on (`Number.isFinite`, never `!= null`; see
8
+ * `docs/rfcs/charts.md` trap #2).
9
+ *
10
+ * Both arrays are length `length`. `x` is a zero-copy view of the key column's
11
+ * `begin` buffer (immutable by contract — do not mutate); `y` is the value
12
+ * column materialized to a `Float64Array`.
13
+ */
14
+ export interface ChartSeries {
15
+ readonly x: Float64Array;
16
+ readonly y: Float64Array;
17
+ readonly length: number;
18
+ }
19
+ /**
20
+ * A chart-ready view of a band (variance envelope): the time axis plus a paired
21
+ * `lower`/`upper` edge per sample. A sample is part of the filled band only
22
+ * where **both** edges are finite; either edge `NaN` is a gap (the fill breaks,
23
+ * it does not bridge — same contract as {@link ChartSeries}).
24
+ */
25
+ export interface BandSeries {
26
+ readonly x: Float64Array;
27
+ readonly lower: Float64Array;
28
+ readonly upper: Float64Array;
29
+ readonly length: number;
30
+ }
31
+ /**
32
+ * A chart-ready view of a box-and-whisker series ({@link BoxPlot}): the
33
+ * interval-keyed time axis (`x` = key `begin`, `xEnd` = key `end`, the box's
34
+ * horizontal span) plus the five quantile edges per key —
35
+ * `lower`/`q1`/`median`/`q3`/`upper`. The quantiles are pre-computed columns
36
+ * (a `rolling`/`aggregate` percentile pass upstream); the chart only reads them.
37
+ *
38
+ * A key is drawn only where **all five** quantiles are finite; any one `NaN` is
39
+ * a gap (the box draws nothing — same gap contract as {@link BandSeries}).
40
+ *
41
+ * `x` and `xEnd` are zero-copy views of the key column's `begin`/`end` buffers
42
+ * (immutable by contract — do not mutate). For a point-in-time key the column's
43
+ * `end` coincides with `begin`, so `xEnd === x` and the box collapses to a
44
+ * minimum-width mark via `barSpanPx`; an interval key gives the box real width.
45
+ */
46
+ export interface BoxSeries {
47
+ readonly x: Float64Array;
48
+ readonly xEnd: Float64Array;
49
+ readonly lower: Float64Array;
50
+ readonly q1: Float64Array;
51
+ readonly median: Float64Array;
52
+ readonly q3: Float64Array;
53
+ readonly upper: Float64Array;
54
+ readonly length: number;
55
+ }
56
+ /**
57
+ * A chart-ready view of an interval-keyed series for bars: each mark spans
58
+ * `[begin[i], end[i]]` (the key's range) with height `y[i]`. Unlike
59
+ * {@link ChartSeries} (a single `x` point per row), a bar needs **both** key
60
+ * endpoints to know its x-span, so the time axis is split into `begin`/`end`.
61
+ *
62
+ * Missing / non-finite values are `NaN` in `y` — the gap signal {@link drawBars}
63
+ * skips (no bar), same `Number.isFinite` contract as {@link ChartSeries}. For a
64
+ * **point-keyed** series (`begin === end`), `barsFromTimeSeries` derives a span
65
+ * from neighbour spacing so the bars still have width (see there).
66
+ */
67
+ export interface BarSeries {
68
+ readonly begin: Float64Array;
69
+ readonly end: Float64Array;
70
+ readonly y: Float64Array;
71
+ readonly length: number;
72
+ }
73
+ /** The five quantile column names a {@link boxFromTimeSeries} reads, in order. */
74
+ export interface BoxColumns {
75
+ /** Lower whisker end (e.g. `p5` / `min`). */
76
+ readonly lower: string;
77
+ /** Box bottom — first quartile (e.g. `p25`). */
78
+ readonly q1: string;
79
+ /** Median line inside the box (e.g. `p50`). */
80
+ readonly median: string;
81
+ /** Box top — third quartile (e.g. `p75`). */
82
+ readonly q3: string;
83
+ /** Upper whisker end (e.g. `p95` / `max`). */
84
+ readonly upper: string;
85
+ }
86
+ /**
87
+ * Build a {@link ChartSeries} from a pond `TimeSeries` by reading its columnar
88
+ * buffers directly — no per-event materialization. `column` names a numeric
89
+ * value column; the key column supplies the time axis (`begin`, in ms).
90
+ *
91
+ * @throws RangeError if `column` does not exist.
92
+ * @throws TypeError if `column` is not a numeric column.
93
+ */
94
+ export declare function fromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, column: string): ChartSeries;
95
+ /**
96
+ * Build a {@link ChartSeries} from a pond `ValueSeries` — the value-axis sibling
97
+ * of {@link fromTimeSeries}. The x axis is the series' monotonic value axis
98
+ * (`axisValues()`, e.g. cumulative distance) instead of time; `column` names a
99
+ * numeric value channel (HR, pace, …). The resulting `ChartSeries` is identical
100
+ * in shape — the chart draws it exactly as it draws a time series, only the x
101
+ * scale differs (a value scale rather than `scaleTime`).
102
+ *
103
+ * `x` is the axis key buffer zero-copy (immutable by contract — do not mutate);
104
+ * `y` is the channel materialized to a `Float64Array`, missing cells as `NaN`
105
+ * (the gap signal, same `Number.isFinite` contract as {@link fromTimeSeries}).
106
+ *
107
+ * @throws RangeError if `column` does not exist.
108
+ * @throws TypeError if `column` is not a numeric column.
109
+ */
110
+ export declare function fromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, column: string): ChartSeries;
111
+ /**
112
+ * Build a {@link BandSeries} from a pond `TimeSeries` — two numeric columns for
113
+ * the `lower`/`upper` edges sharing the series' time axis. The edge columns are
114
+ * typically `rollingByColumn` percentiles (e.g. p25/p75); a sample with either
115
+ * edge missing reads as a gap in the fill.
116
+ *
117
+ * @throws RangeError if `lower` or `upper` does not exist.
118
+ * @throws TypeError if `lower` or `upper` is not a numeric column.
119
+ */
120
+ export declare function bandFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, lower: string, upper: string): BandSeries;
121
+ /**
122
+ * Build a {@link BoxSeries} from a pond `TimeSeries` — five numeric quantile
123
+ * columns (`lower`/`q1`/`median`/`q3`/`upper`) sharing the series' interval time
124
+ * axis (`begin`/`end`, the box's horizontal span). The quantile columns are
125
+ * typically `rolling`/`aggregate` percentiles (e.g. p5/p25/p50/p75/p95); a key
126
+ * with any quantile missing reads as a gap (the box draws nothing).
127
+ *
128
+ * @throws RangeError if any quantile column does not exist.
129
+ * @throws TypeError if any quantile column is not a numeric column.
130
+ */
131
+ export declare function boxFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, columns: BoxColumns): BoxSeries;
132
+ /**
133
+ * Build a {@link BarSeries} from a pond `TimeSeries` — one bar per event, the
134
+ * key's `[begin, end]` as the x-span and `column` as the height.
135
+ *
136
+ * **Key-shape fallback (point-keyed series).** The primary form is
137
+ * interval / timeRange-keyed, where each key already carries a `[begin, end]`
138
+ * span. A **point-keyed** (`time`) series has `begin === end` (zero width), so
139
+ * this derives a span from neighbour spacing: each bar is centred on its
140
+ * timestamp and reaches **halfway to each neighbour** (a Voronoi cell on the
141
+ * time axis). The first/last bars mirror their single adjacent gap so the row's
142
+ * end bars match their interior width. A lone point (length 1) has no
143
+ * neighbour, so it keeps zero width and falls back to the renderer's `minWidth`.
144
+ *
145
+ * This makes a uniformly-sampled point series render as contiguous bars (the
146
+ * histogram look) without the caller pre-keying to intervals, while an
147
+ * interval-keyed series (e.g. an `aggregate`/`window` rollup) draws its true
148
+ * bucket spans. Detected by `keyColumn().kind === 'time'`.
149
+ *
150
+ * @throws RangeError if `column` does not exist.
151
+ * @throws TypeError if `column` is not a numeric column.
152
+ */
153
+ export declare function barsFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, column: string): BarSeries;
154
+ //# sourceMappingURL=data.d.ts.map
package/dist/data.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Read a numeric column into a `Float64Array`, missing cells as `NaN`.
3
+ *
4
+ * Uses `read(i)` — a method on the column *class* — rather than the bulk
5
+ * `toFloat64Array()`. The bulk reader is mounted on the prototype by a
6
+ * side-effect import in pond-ts, which Vite/Rollup production builds tree-shake
7
+ * away (despite the package's `sideEffects` field), so it throws "not a
8
+ * function" in a bundled browser app. See `docs/notes/charts-m1-friction.md`.
9
+ *
10
+ * TODO(charts-perf): restore the bulk typed-array fast-path once the column-API
11
+ * augmentation is bundle-safe in core — that's the columnar throughput win.
12
+ *
13
+ * @throws RangeError if `column` does not exist.
14
+ * @throws TypeError if `column` is not a numeric column.
15
+ */
16
+ function readNumericColumn(series, column) {
17
+ // Runtime-necessary even though it reads as dead code: `column()` returns
18
+ // `undefined` for an unknown name at runtime, but core's public overload
19
+ // currently types the result as non-`undefined` (see F-3 in the M1 friction
20
+ // note). Keep the guard — the "throws on unknown column" test exercises it.
21
+ const col = series.column(column);
22
+ if (col === undefined) {
23
+ throw new RangeError(`unknown column '${column}'`);
24
+ }
25
+ if (col.kind !== 'number') {
26
+ throw new TypeError(`column '${column}' must be numeric (got '${col.kind}')`);
27
+ }
28
+ const length = series.length;
29
+ const out = new Float64Array(length);
30
+ for (let i = 0; i < length; i += 1) {
31
+ const v = col.read(i);
32
+ out[i] = v === undefined ? NaN : v;
33
+ }
34
+ return out;
35
+ }
36
+ /** The key column's `begin` buffer aligned to the logical length (zero-copy). */
37
+ function timeAxis(series) {
38
+ // `begin` may carry trailing capacity beyond the logical length; subarray so
39
+ // it lines up with the value arrays.
40
+ return series.keyColumn().begin.subarray(0, series.length);
41
+ }
42
+ /**
43
+ * The key column's `end` buffer aligned to the logical length (zero-copy). For a
44
+ * point-in-time key the column sets `end === begin`, so this returns the same
45
+ * timestamps as {@link timeAxis} — an interval key gives a distinct span.
46
+ */
47
+ function timeEndAxis(series) {
48
+ return series.keyColumn().end.subarray(0, series.length);
49
+ }
50
+ /**
51
+ * Build a {@link ChartSeries} from a pond `TimeSeries` by reading its columnar
52
+ * buffers directly — no per-event materialization. `column` names a numeric
53
+ * value column; the key column supplies the time axis (`begin`, in ms).
54
+ *
55
+ * @throws RangeError if `column` does not exist.
56
+ * @throws TypeError if `column` is not a numeric column.
57
+ */
58
+ export function fromTimeSeries(series, column) {
59
+ return {
60
+ x: timeAxis(series),
61
+ y: readNumericColumn(series, column),
62
+ length: series.length,
63
+ };
64
+ }
65
+ /**
66
+ * Build a {@link ChartSeries} from a pond `ValueSeries` — the value-axis sibling
67
+ * of {@link fromTimeSeries}. The x axis is the series' monotonic value axis
68
+ * (`axisValues()`, e.g. cumulative distance) instead of time; `column` names a
69
+ * numeric value channel (HR, pace, …). The resulting `ChartSeries` is identical
70
+ * in shape — the chart draws it exactly as it draws a time series, only the x
71
+ * scale differs (a value scale rather than `scaleTime`).
72
+ *
73
+ * `x` is the axis key buffer zero-copy (immutable by contract — do not mutate);
74
+ * `y` is the channel materialized to a `Float64Array`, missing cells as `NaN`
75
+ * (the gap signal, same `Number.isFinite` contract as {@link fromTimeSeries}).
76
+ *
77
+ * @throws RangeError if `column` does not exist.
78
+ * @throws TypeError if `column` is not a numeric column.
79
+ */
80
+ export function fromValueSeries(series, column) {
81
+ const col = series.column(column);
82
+ if (col === undefined) {
83
+ throw new RangeError(`unknown column '${column}'`);
84
+ }
85
+ if (col.kind !== 'number') {
86
+ throw new TypeError(`column '${column}' must be numeric (got '${col.kind}')`);
87
+ }
88
+ const length = series.length;
89
+ const y = new Float64Array(length);
90
+ for (let i = 0; i < length; i += 1) {
91
+ const v = col.read(i);
92
+ y[i] = v === undefined ? NaN : v;
93
+ }
94
+ // axisValues() is the key buffer already trimmed to length (zero-copy).
95
+ return { x: series.axisValues(), y, length };
96
+ }
97
+ /**
98
+ * Build a {@link BandSeries} from a pond `TimeSeries` — two numeric columns for
99
+ * the `lower`/`upper` edges sharing the series' time axis. The edge columns are
100
+ * typically `rollingByColumn` percentiles (e.g. p25/p75); a sample with either
101
+ * edge missing reads as a gap in the fill.
102
+ *
103
+ * @throws RangeError if `lower` or `upper` does not exist.
104
+ * @throws TypeError if `lower` or `upper` is not a numeric column.
105
+ */
106
+ export function bandFromTimeSeries(series, lower, upper) {
107
+ return {
108
+ x: timeAxis(series),
109
+ lower: readNumericColumn(series, lower),
110
+ upper: readNumericColumn(series, upper),
111
+ length: series.length,
112
+ };
113
+ }
114
+ /**
115
+ * Build a {@link BoxSeries} from a pond `TimeSeries` — five numeric quantile
116
+ * columns (`lower`/`q1`/`median`/`q3`/`upper`) sharing the series' interval time
117
+ * axis (`begin`/`end`, the box's horizontal span). The quantile columns are
118
+ * typically `rolling`/`aggregate` percentiles (e.g. p5/p25/p50/p75/p95); a key
119
+ * with any quantile missing reads as a gap (the box draws nothing).
120
+ *
121
+ * @throws RangeError if any quantile column does not exist.
122
+ * @throws TypeError if any quantile column is not a numeric column.
123
+ */
124
+ export function boxFromTimeSeries(series, columns) {
125
+ return {
126
+ x: timeAxis(series),
127
+ xEnd: timeEndAxis(series),
128
+ lower: readNumericColumn(series, columns.lower),
129
+ q1: readNumericColumn(series, columns.q1),
130
+ median: readNumericColumn(series, columns.median),
131
+ q3: readNumericColumn(series, columns.q3),
132
+ upper: readNumericColumn(series, columns.upper),
133
+ length: series.length,
134
+ };
135
+ }
136
+ /**
137
+ * Per-row begin/end buffers for the key column, each aligned to the logical
138
+ * length (zero-copy views). For an interval / timeRange key these are the key's
139
+ * own endpoints; for a point (`time`) key `end === begin`, which
140
+ * {@link barsFromTimeSeries} then widens into a span.
141
+ */
142
+ function keyBeginEnd(series) {
143
+ const key = series.keyColumn();
144
+ const n = series.length;
145
+ // `begin`/`end` may carry trailing capacity beyond the logical length; subarray
146
+ // so they line up with the value array. A `time` key's `end` aliases `begin`
147
+ // (point-in-time), which the caller's point-key fallback replaces.
148
+ return { begin: key.begin.subarray(0, n), end: key.end.subarray(0, n) };
149
+ }
150
+ /**
151
+ * Build a {@link BarSeries} from a pond `TimeSeries` — one bar per event, the
152
+ * key's `[begin, end]` as the x-span and `column` as the height.
153
+ *
154
+ * **Key-shape fallback (point-keyed series).** The primary form is
155
+ * interval / timeRange-keyed, where each key already carries a `[begin, end]`
156
+ * span. A **point-keyed** (`time`) series has `begin === end` (zero width), so
157
+ * this derives a span from neighbour spacing: each bar is centred on its
158
+ * timestamp and reaches **halfway to each neighbour** (a Voronoi cell on the
159
+ * time axis). The first/last bars mirror their single adjacent gap so the row's
160
+ * end bars match their interior width. A lone point (length 1) has no
161
+ * neighbour, so it keeps zero width and falls back to the renderer's `minWidth`.
162
+ *
163
+ * This makes a uniformly-sampled point series render as contiguous bars (the
164
+ * histogram look) without the caller pre-keying to intervals, while an
165
+ * interval-keyed series (e.g. an `aggregate`/`window` rollup) draws its true
166
+ * bucket spans. Detected by `keyColumn().kind === 'time'`.
167
+ *
168
+ * @throws RangeError if `column` does not exist.
169
+ * @throws TypeError if `column` is not a numeric column.
170
+ */
171
+ export function barsFromTimeSeries(series, column) {
172
+ const y = readNumericColumn(series, column);
173
+ const n = series.length;
174
+ const kind = series.keyColumn().kind;
175
+ if (kind !== 'time') {
176
+ // Interval / timeRange: the key's own endpoints are the bar span.
177
+ const { begin, end } = keyBeginEnd(series);
178
+ return { begin, end, y, length: n };
179
+ }
180
+ // Point key (begin === end): synthesize a span from neighbour spacing so the
181
+ // bars have width. Copy into fresh buffers — the key's begin buffer is shared
182
+ // (zero-copy) and must not be mutated.
183
+ const src = series.keyColumn().begin;
184
+ const begin = new Float64Array(n);
185
+ const end = new Float64Array(n);
186
+ for (let i = 0; i < n; i += 1) {
187
+ const t = src[i];
188
+ // Half-gap to the previous point (mirror the next gap at the left edge).
189
+ const prevGap = i > 0 ? t - src[i - 1] : i + 1 < n ? src[i + 1] - t : 0;
190
+ // Half-gap to the next point (mirror the previous gap at the right edge).
191
+ const nextGap = i + 1 < n ? src[i + 1] - t : i > 0 ? t - src[i - 1] : 0;
192
+ begin[i] = t - prevGap / 2;
193
+ end[i] = t + nextGap / 2;
194
+ }
195
+ return { begin, end, y, length: n };
196
+ }
197
+ //# sourceMappingURL=data.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve a y-axis `[lo, hi]` domain from its explicit bounds and the extents of
3
+ * the layers linked to it. An `undefined` bound auto-fits the data: with no
4
+ * finite data the domain is `[0, 1]`, a flat extent gets ±1 of headroom (so a
5
+ * constant line sits mid-row, not on an edge).
6
+ *
7
+ * A **fully auto-fit** domain (both bounds `undefined`) is rounded out to nice
8
+ * boundaries (d3 `.nice()`) — headroom so peaks / whisker caps don't sit on the
9
+ * plot edge, plus rounder tick values. An explicit bound (full or partial) is
10
+ * left **exact**: the caller's number is never nice'd or moved.
11
+ *
12
+ * Guarantees an **ascending, non-degenerate** domain whenever a bound was
13
+ * auto-fit — a partial explicit bound with no (or flat) data on the other side
14
+ * can otherwise invert it (e.g. `min=5` with no data would naively give
15
+ * `[5, 1]`). Two explicit bounds are returned as-is (an inverted explicit domain
16
+ * is a deliberate axis flip; we don't second-guess it).
17
+ */
18
+ export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>): [number, number];
19
+ //# sourceMappingURL=domain.d.ts.map
package/dist/domain.js ADDED
@@ -0,0 +1,61 @@
1
+ import { scaleLinear } from 'd3-scale';
2
+ /**
3
+ * Resolve a y-axis `[lo, hi]` domain from its explicit bounds and the extents of
4
+ * the layers linked to it. An `undefined` bound auto-fits the data: with no
5
+ * finite data the domain is `[0, 1]`, a flat extent gets ±1 of headroom (so a
6
+ * constant line sits mid-row, not on an edge).
7
+ *
8
+ * A **fully auto-fit** domain (both bounds `undefined`) is rounded out to nice
9
+ * boundaries (d3 `.nice()`) — headroom so peaks / whisker caps don't sit on the
10
+ * plot edge, plus rounder tick values. An explicit bound (full or partial) is
11
+ * left **exact**: the caller's number is never nice'd or moved.
12
+ *
13
+ * Guarantees an **ascending, non-degenerate** domain whenever a bound was
14
+ * auto-fit — a partial explicit bound with no (or flat) data on the other side
15
+ * can otherwise invert it (e.g. `min=5` with no data would naively give
16
+ * `[5, 1]`). Two explicit bounds are returned as-is (an inverted explicit domain
17
+ * is a deliberate axis flip; we don't second-guess it).
18
+ */
19
+ export function resolveYDomain(min, max, extents) {
20
+ // Both bounds explicit: trust them verbatim (allows an intentional flip).
21
+ if (min !== undefined && max !== undefined)
22
+ return [min, max];
23
+ let dataMin = Infinity;
24
+ let dataMax = -Infinity;
25
+ for (const e of extents) {
26
+ if (e) {
27
+ if (e[0] < dataMin)
28
+ dataMin = e[0];
29
+ if (e[1] > dataMax)
30
+ dataMax = e[1];
31
+ }
32
+ }
33
+ if (dataMin === Infinity) {
34
+ dataMin = 0; // no finite data yet
35
+ dataMax = 1;
36
+ }
37
+ else if (dataMin === dataMax) {
38
+ dataMin -= 1; // flat — give it room
39
+ dataMax += 1;
40
+ }
41
+ let lo = min ?? dataMin;
42
+ let hi = max ?? dataMax;
43
+ // A partial explicit bound can sit at/above the auto-fit other side (explicit
44
+ // min above empty-data's max, or explicit max below the data). Keep the axis
45
+ // ascending by moving the *auto-fit* side — never discard the caller's
46
+ // explicit bound. Exactly one side is explicit here: both-explicit returned
47
+ // early, and a both-auto domain can't invert after the empty/flat guards.
48
+ if (lo >= hi) {
49
+ if (min === undefined)
50
+ lo = hi - 1; // max is explicit → preserve it
51
+ else
52
+ hi = lo + 1; // min is explicit → preserve it
53
+ }
54
+ // Fully auto-fit → round the domain out for headroom + nicer ticks. A
55
+ // partial/full explicit bound is left exact (returned as-is below).
56
+ if (min === undefined && max === undefined) {
57
+ return scaleLinear().domain([lo, hi]).nice().domain();
58
+ }
59
+ return [lo, hi];
60
+ }
61
+ //# sourceMappingURL=domain.js.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Data-driven point encoding for {@link ScatterChart} — the **deliberate,
3
+ * signed-off exception** to the package's single-styling-channel rule.
4
+ *
5
+ * Every other layer takes one styling input: a semantic `as` token the theme
6
+ * maps to a style. That discipline exists because react-timeseries-charts' free
7
+ * per-component style accessor (a function from a datum to a style object) bred
8
+ * a class of styling bugs. Scatter keeps the base mark on that same channel
9
+ * (`theme.scatter[as]` → the default radius + colour) but **adds** size and
10
+ * colour driven *from the data*: a column value run through a scale. The
11
+ * difference that keeps this safe is that the input is a **column name + a
12
+ * range**, not an arbitrary callback — there is no place to hide a styling bug
13
+ * in `{ column: 'velocity', range: [2, 12] }`.
14
+ *
15
+ * Both encodings build a *linear* scale over the **finite extent** of the named
16
+ * column (NaN / missing cells ignored, so a gap doesn't drag the domain) and map
17
+ * it into the requested output range. The scale is precomputed once per resolve;
18
+ * the returned `radiusAt` / `colorAt` are O(1) per point.
19
+ *
20
+ * Pure + unit-tested (`test/encoding.test.ts`); scatter is the sole consumer.
21
+ */
22
+ import type { ChartSeries } from './data.js';
23
+ /**
24
+ * Per-point **radius** encoding. Either:
25
+ * - a fixed radius in CSS px (every point the same size — the common case), or
26
+ * - `{ column, range }`: map the column's finite extent linearly onto
27
+ * `[minR, maxR]` px. A point whose radius column is non-finite falls back to
28
+ * the base radius (it still draws, at the default size).
29
+ *
30
+ * **Omitted ⇒ the base radius** from the style (`theme.scatter[as].radius`).
31
+ */
32
+ export type RadiusEncoding = number | {
33
+ /** Numeric column whose value drives each point's radius. */
34
+ readonly column: string;
35
+ /** Output radius range in px, `[atColumnMin, atColumnMax]`. */
36
+ readonly range: readonly [number, number];
37
+ };
38
+ /**
39
+ * Per-point **colour** encoding — `{ column, range }`: map the column's finite
40
+ * extent linearly onto a two-stop colour ramp `[atMin, atMax]` (interpolated in
41
+ * sRGB). A point whose colour column is non-finite falls back to the base colour
42
+ * (the single styling channel — `theme.scatter[as].color`).
43
+ *
44
+ * **Omitted ⇒ the base colour** for every point. The `range` stops must be CSS
45
+ * hex (`#rgb` / `#rrggbb`); a non-hex stop disables interpolation for that point
46
+ * (falls back to the base colour) rather than guessing.
47
+ */
48
+ export interface ColorEncoding {
49
+ /** Numeric column whose value drives each point's colour. */
50
+ readonly column: string;
51
+ /** Two-stop colour ramp, `[atColumnMin, atColumnMax]` — CSS hex. */
52
+ readonly range: readonly [string, string];
53
+ }
54
+ /**
55
+ * A resolved encoding: O(1) per-point accessors over a {@link ChartSeries}'
56
+ * index. `radiusAt`/`colorAt` are indexed by the *same* row position as the
57
+ * scatter's `x`/`y` arrays — a point with a non-finite encoding value (or no
58
+ * encoding configured) gets the base radius / colour.
59
+ */
60
+ export interface ResolvedEncoding {
61
+ /** This point's radius in px (base radius if unencoded / non-finite). */
62
+ radiusAt(i: number): number;
63
+ /** This point's fill colour (base colour if unencoded / non-finite). */
64
+ colorAt(i: number): string;
65
+ }
66
+ /** A numeric column read into a `Float64Array` (gaps as NaN), by name. */
67
+ export type ColumnReader = (column: string) => Float64Array;
68
+ /**
69
+ * The `[min, max]` of the **finite** values in `col`, or `null` if none are
70
+ * finite. (Same gap-aware contract as `yExtent`; duplicated here so encoding
71
+ * stays a leaf module with no draw-layer dependency.)
72
+ */
73
+ export declare function finiteExtent(col: Float64Array): [number, number] | null;
74
+ /**
75
+ * Resolve a scatter's data-driven encoding into O(1) per-point accessors.
76
+ *
77
+ * @param cs the scatter's columnar view (its `length` bounds the indices)
78
+ * @param baseRadius the style's base radius (the fixed-size fallback)
79
+ * @param baseColor the style's base colour (the single-styling-channel colour)
80
+ * @param radius the {@link RadiusEncoding} (a number, a `{column,range}`, or omitted)
81
+ * @param color the {@link ColorEncoding} (a `{column,range}`, or omitted)
82
+ * @param readColumn reads a named numeric column to a `Float64Array` (gaps NaN)
83
+ *
84
+ * A `{column}` encoding whose column is entirely non-finite degrades to the base
85
+ * value (no scale to build); an individual non-finite cell likewise falls back,
86
+ * so every point always resolves to a finite radius and a concrete colour.
87
+ */
88
+ export declare function resolveEncoding(cs: ChartSeries, baseRadius: number, baseColor: string, radius: RadiusEncoding | undefined, color: ColorEncoding | undefined, readColumn: ColumnReader): ResolvedEncoding;
89
+ //# sourceMappingURL=encoding.d.ts.map
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Data-driven point encoding for {@link ScatterChart} — the **deliberate,
3
+ * signed-off exception** to the package's single-styling-channel rule.
4
+ *
5
+ * Every other layer takes one styling input: a semantic `as` token the theme
6
+ * maps to a style. That discipline exists because react-timeseries-charts' free
7
+ * per-component style accessor (a function from a datum to a style object) bred
8
+ * a class of styling bugs. Scatter keeps the base mark on that same channel
9
+ * (`theme.scatter[as]` → the default radius + colour) but **adds** size and
10
+ * colour driven *from the data*: a column value run through a scale. The
11
+ * difference that keeps this safe is that the input is a **column name + a
12
+ * range**, not an arbitrary callback — there is no place to hide a styling bug
13
+ * in `{ column: 'velocity', range: [2, 12] }`.
14
+ *
15
+ * Both encodings build a *linear* scale over the **finite extent** of the named
16
+ * column (NaN / missing cells ignored, so a gap doesn't drag the domain) and map
17
+ * it into the requested output range. The scale is precomputed once per resolve;
18
+ * the returned `radiusAt` / `colorAt` are O(1) per point.
19
+ *
20
+ * Pure + unit-tested (`test/encoding.test.ts`); scatter is the sole consumer.
21
+ */
22
+ /**
23
+ * The `[min, max]` of the **finite** values in `col`, or `null` if none are
24
+ * finite. (Same gap-aware contract as `yExtent`; duplicated here so encoding
25
+ * stays a leaf module with no draw-layer dependency.)
26
+ */
27
+ export function finiteExtent(col) {
28
+ let min = Infinity;
29
+ let max = -Infinity;
30
+ for (let i = 0; i < col.length; i += 1) {
31
+ const v = col[i];
32
+ if (Number.isFinite(v)) {
33
+ if (v < min)
34
+ min = v;
35
+ if (v > max)
36
+ max = v;
37
+ }
38
+ }
39
+ return min === Infinity ? null : [min, max];
40
+ }
41
+ /** Linear interpolate `t` (0..1) across `[a, b]`. */
42
+ function lerp(a, b, t) {
43
+ return a + (b - a) * t;
44
+ }
45
+ /** Parse a CSS hex colour to `[r, g, b]` (0–255), or `null` if not hex. */
46
+ function parseHex(color) {
47
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
48
+ if (m === null)
49
+ return null;
50
+ const h = m[1];
51
+ if (h.length === 3) {
52
+ return [
53
+ parseInt(h[0] + h[0], 16),
54
+ parseInt(h[1] + h[1], 16),
55
+ parseInt(h[2] + h[2], 16),
56
+ ];
57
+ }
58
+ return [
59
+ parseInt(h.slice(0, 2), 16),
60
+ parseInt(h.slice(2, 4), 16),
61
+ parseInt(h.slice(4, 6), 16),
62
+ ];
63
+ }
64
+ /** Interpolate two hex colours in sRGB at `t` (0..1) → `rgb(r, g, b)`. */
65
+ function mixHex(from, to, t) {
66
+ const a = parseHex(from);
67
+ const b = parseHex(to);
68
+ if (a === null || b === null)
69
+ return from; // non-hex stop: leave it to caller
70
+ const r = Math.round(lerp(a[0], b[0], t));
71
+ const g = Math.round(lerp(a[1], b[1], t));
72
+ const bl = Math.round(lerp(a[2], b[2], t));
73
+ return `rgb(${r}, ${g}, ${bl})`;
74
+ }
75
+ /** Position of `v` within `[min, max]` as a clamped 0..1 fraction (0 if flat). */
76
+ function normalize(v, min, max) {
77
+ if (max - min < 1e-12)
78
+ return 0; // degenerate extent → everything at the low stop
79
+ const t = (v - min) / (max - min);
80
+ return t < 0 ? 0 : t > 1 ? 1 : t;
81
+ }
82
+ /**
83
+ * Resolve a scatter's data-driven encoding into O(1) per-point accessors.
84
+ *
85
+ * @param cs the scatter's columnar view (its `length` bounds the indices)
86
+ * @param baseRadius the style's base radius (the fixed-size fallback)
87
+ * @param baseColor the style's base colour (the single-styling-channel colour)
88
+ * @param radius the {@link RadiusEncoding} (a number, a `{column,range}`, or omitted)
89
+ * @param color the {@link ColorEncoding} (a `{column,range}`, or omitted)
90
+ * @param readColumn reads a named numeric column to a `Float64Array` (gaps NaN)
91
+ *
92
+ * A `{column}` encoding whose column is entirely non-finite degrades to the base
93
+ * value (no scale to build); an individual non-finite cell likewise falls back,
94
+ * so every point always resolves to a finite radius and a concrete colour.
95
+ */
96
+ export function resolveEncoding(cs, baseRadius, baseColor, radius, color, readColumn) {
97
+ // --- radius ---
98
+ let radiusAt;
99
+ if (radius === undefined || typeof radius === 'number') {
100
+ const r = radius === undefined ? baseRadius : radius;
101
+ radiusAt = () => r;
102
+ }
103
+ else {
104
+ const col = readColumn(radius.column);
105
+ const extent = finiteExtent(col);
106
+ const [minR, maxR] = radius.range;
107
+ if (extent === null) {
108
+ radiusAt = () => baseRadius; // column has no finite values
109
+ }
110
+ else {
111
+ const [lo, hi] = extent;
112
+ radiusAt = (i) => {
113
+ const v = col[i];
114
+ if (!Number.isFinite(v))
115
+ return baseRadius;
116
+ return lerp(minR, maxR, normalize(v, lo, hi));
117
+ };
118
+ }
119
+ }
120
+ // --- colour ---
121
+ let colorAt;
122
+ if (color === undefined) {
123
+ colorAt = () => baseColor;
124
+ }
125
+ else {
126
+ const col = readColumn(color.column);
127
+ const extent = finiteExtent(col);
128
+ const [from, to] = color.range;
129
+ if (extent === null) {
130
+ colorAt = () => baseColor;
131
+ }
132
+ else {
133
+ const [lo, hi] = extent;
134
+ colorAt = (i) => {
135
+ const v = col[i];
136
+ if (!Number.isFinite(v))
137
+ return baseColor;
138
+ return mixHex(from, to, normalize(v, lo, hi));
139
+ };
140
+ }
141
+ }
142
+ return { radiusAt, colorAt };
143
+ }
144
+ //# sourceMappingURL=encoding.js.map