@motionscript/charts 0.0.0-stage → 0.1.0-alpha.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 (137) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +201 -0
  3. package/dist/bar-chart/bar-chart.d.ts +286 -0
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -0
  5. package/dist/bar-chart/bar-chart.js +610 -0
  6. package/dist/bar-chart/bar-chart.js.map +1 -0
  7. package/dist/bar-chart/category-axis.d.ts +77 -0
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -0
  9. package/dist/bar-chart/category-axis.js +141 -0
  10. package/dist/bar-chart/category-axis.js.map +1 -0
  11. package/dist/bar-chart/chart-bars.d.ts +149 -0
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -0
  13. package/dist/bar-chart/chart-bars.js +236 -0
  14. package/dist/bar-chart/chart-bars.js.map +1 -0
  15. package/dist/bar-chart/index.d.ts +16 -0
  16. package/dist/bar-chart/index.d.ts.map +1 -0
  17. package/dist/bar-chart/index.js +12 -0
  18. package/dist/bar-chart/index.js.map +1 -0
  19. package/dist/bar-chart/shared.d.ts +146 -0
  20. package/dist/bar-chart/shared.d.ts.map +1 -0
  21. package/dist/bar-chart/shared.js +182 -0
  22. package/dist/bar-chart/shared.js.map +1 -0
  23. package/dist/browser/chunks/chunk-ZMBOBHYL.js +2 -0
  24. package/dist/browser/chunks/chunk-ZMBOBHYL.js.map +7 -0
  25. package/dist/browser/index.js +2 -0
  26. package/dist/browser/index.js.map +7 -0
  27. package/dist/browser/kit.js +2 -0
  28. package/dist/browser/kit.js.map +7 -0
  29. package/dist/browser/manifest.json +12 -0
  30. package/dist/engine.d.ts +27 -0
  31. package/dist/engine.d.ts.map +1 -0
  32. package/dist/engine.js +27 -0
  33. package/dist/engine.js.map +1 -0
  34. package/dist/index.d.ts +6 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +6 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/kit/chart-border.d.ts +28 -0
  39. package/dist/kit/chart-border.d.ts.map +1 -0
  40. package/dist/kit/chart-border.js +45 -0
  41. package/dist/kit/chart-border.js.map +1 -0
  42. package/dist/kit/chart-canvas.d.ts +56 -0
  43. package/dist/kit/chart-canvas.d.ts.map +1 -0
  44. package/dist/kit/chart-canvas.js +125 -0
  45. package/dist/kit/chart-canvas.js.map +1 -0
  46. package/dist/kit/chart-legend.d.ts +126 -0
  47. package/dist/kit/chart-legend.d.ts.map +1 -0
  48. package/dist/kit/chart-legend.js +212 -0
  49. package/dist/kit/chart-legend.js.map +1 -0
  50. package/dist/kit/shared.d.ts +251 -0
  51. package/dist/kit/shared.d.ts.map +1 -0
  52. package/dist/kit/shared.js +401 -0
  53. package/dist/kit/shared.js.map +1 -0
  54. package/dist/kit/vertical-axis.d.ts +99 -0
  55. package/dist/kit/vertical-axis.d.ts.map +1 -0
  56. package/dist/kit/vertical-axis.js +176 -0
  57. package/dist/kit/vertical-axis.js.map +1 -0
  58. package/dist/line-chart/chart-line.d.ts +91 -0
  59. package/dist/line-chart/chart-line.d.ts.map +1 -0
  60. package/dist/line-chart/chart-line.js +156 -0
  61. package/dist/line-chart/chart-line.js.map +1 -0
  62. package/dist/line-chart/chart-region.d.ts +47 -0
  63. package/dist/line-chart/chart-region.d.ts.map +1 -0
  64. package/dist/line-chart/chart-region.js +101 -0
  65. package/dist/line-chart/chart-region.js.map +1 -0
  66. package/dist/line-chart/horizontal-axis.d.ts +85 -0
  67. package/dist/line-chart/horizontal-axis.d.ts.map +1 -0
  68. package/dist/line-chart/horizontal-axis.js +158 -0
  69. package/dist/line-chart/horizontal-axis.js.map +1 -0
  70. package/dist/line-chart/index.d.ts +22 -0
  71. package/dist/line-chart/index.d.ts.map +1 -0
  72. package/dist/line-chart/index.js +19 -0
  73. package/dist/line-chart/index.js.map +1 -0
  74. package/dist/line-chart/line-chart.d.ts +347 -0
  75. package/dist/line-chart/line-chart.d.ts.map +1 -0
  76. package/dist/line-chart/line-chart.js +701 -0
  77. package/dist/line-chart/line-chart.js.map +1 -0
  78. package/dist/line-chart/shared.d.ts +171 -0
  79. package/dist/line-chart/shared.d.ts.map +1 -0
  80. package/dist/line-chart/shared.js +270 -0
  81. package/dist/line-chart/shared.js.map +1 -0
  82. package/dist/line-chart/x-scale.d.ts +84 -0
  83. package/dist/line-chart/x-scale.d.ts.map +1 -0
  84. package/dist/line-chart/x-scale.js +302 -0
  85. package/dist/line-chart/x-scale.js.map +1 -0
  86. package/dist/nodes.d.ts +20 -0
  87. package/dist/nodes.d.ts.map +1 -0
  88. package/dist/nodes.js +20 -0
  89. package/dist/nodes.js.map +1 -0
  90. package/dist/pie-chart/index.d.ts +18 -0
  91. package/dist/pie-chart/index.d.ts.map +1 -0
  92. package/dist/pie-chart/index.js +14 -0
  93. package/dist/pie-chart/index.js.map +1 -0
  94. package/dist/pie-chart/pie-chart.d.ts +170 -0
  95. package/dist/pie-chart/pie-chart.d.ts.map +1 -0
  96. package/dist/pie-chart/pie-chart.js +291 -0
  97. package/dist/pie-chart/pie-chart.js.map +1 -0
  98. package/dist/pie-chart/pie-legend.d.ts +60 -0
  99. package/dist/pie-chart/pie-legend.d.ts.map +1 -0
  100. package/dist/pie-chart/pie-legend.js +131 -0
  101. package/dist/pie-chart/pie-legend.js.map +1 -0
  102. package/dist/pie-chart/pie-ring.d.ts +266 -0
  103. package/dist/pie-chart/pie-ring.d.ts.map +1 -0
  104. package/dist/pie-chart/pie-ring.js +708 -0
  105. package/dist/pie-chart/pie-ring.js.map +1 -0
  106. package/dist/pie-chart/shared.d.ts +210 -0
  107. package/dist/pie-chart/shared.d.ts.map +1 -0
  108. package/dist/pie-chart/shared.js +240 -0
  109. package/dist/pie-chart/shared.js.map +1 -0
  110. package/package.json +69 -3
  111. package/registry.json +23 -0
  112. package/src/bar-chart/bar-chart.ts +804 -0
  113. package/src/bar-chart/category-axis.ts +177 -0
  114. package/src/bar-chart/chart-bars.ts +310 -0
  115. package/src/bar-chart/index.ts +31 -0
  116. package/src/bar-chart/shared.ts +354 -0
  117. package/src/engine.ts +26 -0
  118. package/src/index.ts +5 -0
  119. package/src/kit/chart-border.ts +61 -0
  120. package/src/kit/chart-canvas.ts +157 -0
  121. package/src/kit/chart-legend.ts +250 -0
  122. package/src/kit/shared.ts +675 -0
  123. package/src/kit/vertical-axis.ts +224 -0
  124. package/src/line-chart/chart-line.ts +208 -0
  125. package/src/line-chart/chart-region.ts +135 -0
  126. package/src/line-chart/horizontal-axis.ts +202 -0
  127. package/src/line-chart/index.ts +39 -0
  128. package/src/line-chart/line-chart.ts +894 -0
  129. package/src/line-chart/shared.ts +459 -0
  130. package/src/line-chart/x-scale.ts +367 -0
  131. package/src/nodes.ts +20 -0
  132. package/src/pie-chart/index.ts +37 -0
  133. package/src/pie-chart/pie-chart.ts +374 -0
  134. package/src/pie-chart/pie-legend.ts +159 -0
  135. package/src/pie-chart/pie-ring.ts +903 -0
  136. package/src/pie-chart/shared.ts +490 -0
  137. package/README.md +0 -4
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Types and pure helpers specific to the {@link LineChart} family: the traced
3
+ * polylines, the spotlit region they dim outside of, and the theme those two
4
+ * add to the shared chart vocabulary.
5
+ *
6
+ * Everything a bar or a pie chart would want too — what a series is, how an axis
7
+ * is scaled and formatted, the theme leaf resolvers, the legend and plot-panel
8
+ * branches — lives in `nodes/chart-kit/shared.ts` and is imported here. What's
9
+ * left below is what only a line chart has: points sorted along an x column,
10
+ * a region clipped out of a polyline, and the `region` branch of the theme.
11
+ *
12
+ * Kept renderer-agnostic (no `Node` imports) so every part shares one
13
+ * scale/format/palette/theme vocabulary, and so the studio's own inspector
14
+ * mapping (`line-chart-props.ts`) can build a partial {@link ChartTheme} without
15
+ * pulling the node classes in.
16
+ */
17
+
18
+ import {
19
+ Fills,
20
+ fillOps,
21
+ lerpNumber,
22
+ strokeOps,
23
+ type DataRecord,
24
+ type Fill,
25
+ type FillResolved,
26
+ type Stroke,
27
+ type StrokeResolved,
28
+ } from "@motionscript/core"
29
+
30
+ import {
31
+ CHART_PALETTE,
32
+ DEFAULT_LEGEND,
33
+ DEFAULT_PLOT_AREA,
34
+ DEFAULT_SERIES_STROKES,
35
+ DEFAULT_X_AXIS,
36
+ DEFAULT_Y_AXIS,
37
+ SPOTLIGHT_DIM,
38
+ lerpAxisConfig,
39
+ lerpLegendConfig,
40
+ lerpPlotArea,
41
+ lerpSeriesStrokes,
42
+ niceStep,
43
+ num,
44
+ resolveAxisConfig,
45
+ resolveLegendConfig,
46
+ resolvePlotArea,
47
+ resolveSeriesStrokes,
48
+ type AxisConfig,
49
+ type ChartSeries,
50
+ type LegendConfig,
51
+ type LegendTheme,
52
+ type PlotAreaConfig,
53
+ type Range,
54
+ } from "../kit/shared"
55
+ import type { XScale } from "./x-scale"
56
+
57
+ /**
58
+ * One series in a {@link LineChart}: a polyline traced through the values in one
59
+ * column of the chart's `data`, drawn in `fill` (or the theme's `seriesStyles`
60
+ * palette when omitted) and captioned by `label` in the legend.
61
+ *
62
+ * The family-wide {@link ChartSeries} under a name that says what it draws here;
63
+ * the chart's own `xField` prop names the column carrying the shared x values,
64
+ * so every series is read off the same rows — one row per sample, one column per
65
+ * series.
66
+ */
67
+ export type LineSeries = ChartSeries
68
+
69
+ // ---- Plot regions -------------------------------------------------------
70
+
71
+ /**
72
+ * A rectangular slice of the plot, in **data units** — the author-facing input
73
+ * to {@link LineChart.spotlightRegion} / {@link LineChart.zoomTo}.
74
+ *
75
+ * Either axis may be omitted, and that's the point: `{ x: [60, 100] }` is a
76
+ * vertical band spanning the full height, `{ y: [8, 10] }` a horizontal band
77
+ * spanning the full width, and both together a box. A reversed range is read
78
+ * the same as the forward one — a region is a span, not a direction.
79
+ */
80
+ export interface PlotRegion {
81
+ /** Horizontal span in x-axis data units. Omit to span the full plot width. */
82
+ x?: Range
83
+ /** Vertical span in y-axis data units. Omit to span the full plot height. */
84
+ y?: Range
85
+ }
86
+
87
+ /**
88
+ * A {@link PlotRegion} resolved into the plot's **centred pixel space** (origin
89
+ * at the plot centre, y-up) — the same local frame the series points live in,
90
+ * so a line can be clipped against it directly.
91
+ *
92
+ * `xBounded`/`yBounded` carry which axes the author actually constrained: an
93
+ * unbounded axis has its edges pinned to the plot's own and draws no rule.
94
+ */
95
+ export interface RegionBox {
96
+ left: number
97
+ right: number
98
+ bottom: number
99
+ top: number
100
+ /** The author gave an x range — the left/right edges are real data bounds. */
101
+ xBounded: boolean
102
+ /** The author gave a y range — the bottom/top edges are real data bounds. */
103
+ yBounded: boolean
104
+ }
105
+
106
+ /** The empty box, used as the "no region" resting value. */
107
+ export const EMPTY_REGION_BOX: RegionBox = {
108
+ left: 0,
109
+ right: 0,
110
+ bottom: 0,
111
+ top: 0,
112
+ xBounded: false,
113
+ yBounded: false,
114
+ }
115
+
116
+ /** Is `box` a real, non-degenerate region — i.e. is there anything to spotlight? */
117
+ export function hasRegion(box: RegionBox): boolean {
118
+ return box.right > box.left && box.top > box.bottom
119
+ }
120
+
121
+ /**
122
+ * Resolve a {@link PlotRegion} against the plot's pixel box and the chart's
123
+ * scales. `plot` is the plot rectangle in centred pixel space, and
124
+ * `xScale`/`yScale` the data ranges those edges span.
125
+ *
126
+ * An omitted axis takes the plot's own edges, so the region spans it. A given
127
+ * axis is clamped to the plot, so a range running off the scale still produces
128
+ * a box inside the panel.
129
+ */
130
+ export function resolveRegionBox(
131
+ region: PlotRegion | null | undefined,
132
+ plot: { left: number; right: number; bottom: number; top: number },
133
+ xScale: Range,
134
+ yScale: Range
135
+ ): RegionBox {
136
+ if (!region || (region.x === undefined && region.y === undefined)) {
137
+ return EMPTY_REGION_BOX
138
+ }
139
+
140
+ const span = (
141
+ range: Range | undefined,
142
+ [min, max]: Range,
143
+ lo: number,
144
+ hi: number
145
+ ): [number, number, boolean] => {
146
+ if (range === undefined || max <= min) return [lo, hi, false]
147
+ const a = Math.min(range[0], range[1])
148
+ const b = Math.max(range[0], range[1])
149
+ const toPx = (v: number) => lo + ((v - min) / (max - min)) * (hi - lo)
150
+ return [
151
+ Math.max(lo, Math.min(hi, toPx(a))),
152
+ Math.max(lo, Math.min(hi, toPx(b))),
153
+ true,
154
+ ]
155
+ }
156
+
157
+ const [left, right, xBounded] = span(region.x, xScale, plot.left, plot.right)
158
+ const [bottom, top, yBounded] = span(region.y, yScale, plot.bottom, plot.top)
159
+ return { left, right, bottom, top, xBounded, yBounded }
160
+ }
161
+
162
+ /** A bare `{x, y}` — declared locally so this module stays free of node imports. */
163
+ interface Vector2Like {
164
+ x: number
165
+ y: number
166
+ }
167
+
168
+ /**
169
+ * The parts of `points` that fall **inside** `box`, as a list of contiguous
170
+ * sub-polylines — the geometry behind the region spotlight: a series is drawn
171
+ * dimmed in full, then these runs are re-drawn over it at full strength.
172
+ *
173
+ * Segments are clipped against the box rather than filtered by endpoint, so a
174
+ * line entering the region mid-segment is cut exactly on the boundary.
175
+ */
176
+ export function clipPolylineToBox(
177
+ points: Vector2Like[],
178
+ box: RegionBox
179
+ ): Vector2Like[][] {
180
+ if (points.length < 2 || !hasRegion(box)) return []
181
+
182
+ const runs: Vector2Like[][] = []
183
+ let run: Vector2Like[] = []
184
+
185
+ for (let i = 1; i < points.length; i++) {
186
+ const seg = clipSegment(points[i - 1], points[i], box)
187
+ if (!seg) {
188
+ // Segment misses the box entirely — close whatever run was open.
189
+ if (run.length >= 2) runs.push(run)
190
+ run = []
191
+ continue
192
+ }
193
+ const [a, b] = seg
194
+ // Continue the open run when this clipped segment starts where the last one
195
+ // ended; otherwise the line left the box and came back, which is a new run.
196
+ if (run.length > 0 && samePoint(run[run.length - 1], a)) run.push(b)
197
+ else {
198
+ if (run.length >= 2) runs.push(run)
199
+ run = [a, b]
200
+ }
201
+ }
202
+ if (run.length >= 2) runs.push(run)
203
+ return runs
204
+ }
205
+
206
+ /** Points within half a pixel are the same point, for run-joining purposes. */
207
+ function samePoint(a: Vector2Like, b: Vector2Like): boolean {
208
+ return Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5
209
+ }
210
+
211
+ /**
212
+ * Liang–Barsky clip of one segment against the axis-aligned `box`. Returns the
213
+ * surviving `[start, end]`, or `null` when the segment lies wholly outside.
214
+ */
215
+ function clipSegment(
216
+ a: Vector2Like,
217
+ b: Vector2Like,
218
+ box: RegionBox
219
+ ): [Vector2Like, Vector2Like] | null {
220
+ const dx = b.x - a.x
221
+ const dy = b.y - a.y
222
+ let t0 = 0
223
+ let t1 = 1
224
+
225
+ // Each edge as (p, q): the segment survives where p·t <= q.
226
+ const edges: [number, number][] = [
227
+ [-dx, a.x - box.left],
228
+ [dx, box.right - a.x],
229
+ [-dy, a.y - box.bottom],
230
+ [dy, box.top - a.y],
231
+ ]
232
+ for (const [p, q] of edges) {
233
+ if (p === 0) {
234
+ // Parallel to this edge: outside it means the whole segment is out.
235
+ if (q < 0) return null
236
+ continue
237
+ }
238
+ const r = q / p
239
+ if (p < 0) {
240
+ if (r > t1) return null
241
+ if (r > t0) t0 = r
242
+ } else {
243
+ if (r < t0) return null
244
+ if (r < t1) t1 = r
245
+ }
246
+ }
247
+ return [
248
+ { x: a.x + t0 * dx, y: a.y + t0 * dy },
249
+ { x: a.x + t1 * dx, y: a.y + t1 * dy },
250
+ ]
251
+ }
252
+
253
+ // ---- Theme ---------------------------------------------------------------
254
+
255
+ /**
256
+ * How a spotlit {@link PlotRegion} is drawn — the dashed rules on its bounded
257
+ * edges, the wash filling it, and how far the series fade outside it.
258
+ */
259
+ export interface RegionConfig {
260
+ /** The dashed rule marking each bounded edge. */
261
+ stroke: Stroke
262
+ /** Wash filling the region, drawn under the series lines. */
263
+ fill: Fill
264
+ /** Opacity the stretch of every series *outside* the region fades to. */
265
+ dimOpacity: number
266
+ }
267
+
268
+ /** A {@link LineChart}'s full, resolved visual theme. */
269
+ export interface ChartConfig {
270
+ /**
271
+ * Default per-series line stroke, cycled by series index (`i % length`). A
272
+ * series' own `fill`, when set, overrides just the colour.
273
+ */
274
+ seriesStyles: Stroke[]
275
+ xAxis: AxisConfig
276
+ yAxis: AxisConfig
277
+ /** The plot panel: border, fill, and drop shadow. */
278
+ plotArea: PlotAreaConfig
279
+ legend: LegendConfig
280
+ /** The spotlit-region annotation — see {@link RegionConfig}. */
281
+ region: RegionConfig
282
+ }
283
+
284
+ /**
285
+ * Author-facing partial theme, passed as {@link LineChart}'s `theme` prop —
286
+ * every branch optional and deep-merged onto {@link DEFAULT_CHART_THEME}.
287
+ */
288
+ export type ChartTheme = {
289
+ seriesStyles?: Stroke[]
290
+ xAxis?: Partial<AxisConfig>
291
+ yAxis?: Partial<AxisConfig>
292
+ plotArea?: Partial<PlotAreaConfig>
293
+ legend?: LegendTheme
294
+ region?: Partial<RegionConfig>
295
+ }
296
+
297
+ /**
298
+ * The legend of a line chart: the shared one, with a small rounded-square
299
+ * swatch in place of the wide chip. A bar's or a wedge's swatch stands in for
300
+ * an area of paint and is drawn like one; a line's stands in for a colour, and
301
+ * a square reads as the sample it is.
302
+ */
303
+ const LINE_CHART_LEGEND: LegendConfig = {
304
+ ...DEFAULT_LEGEND,
305
+ markerStyle: {
306
+ ...DEFAULT_LEGEND.markerStyle,
307
+ width: 20,
308
+ height: 20,
309
+ borderRadius: 6,
310
+ },
311
+ }
312
+
313
+ /** The chart's out-of-the-box look. */
314
+ export const DEFAULT_CHART_THEME: ChartConfig = {
315
+ seriesStyles: DEFAULT_SERIES_STROKES,
316
+ xAxis: DEFAULT_X_AXIS,
317
+ yAxis: DEFAULT_Y_AXIS,
318
+ plotArea: DEFAULT_PLOT_AREA,
319
+ legend: LINE_CHART_LEGEND,
320
+ region: {
321
+ // Brighter than the grid and dashed, so the boundary reads as a call-out
322
+ // rather than another axis line; `butt` caps keep the dash lengths exact.
323
+ stroke: {
324
+ weight: 3,
325
+ fill: CHART_PALETTE.regionColor,
326
+ dash: [14, 12],
327
+ cap: "butt",
328
+ },
329
+ // A wash barely above the panel — enough to tint the band, not enough to
330
+ // mute the series drawn over it.
331
+ fill: Fills.color(CHART_PALETTE.regionColor, { opacity: 0.08 }),
332
+ dimOpacity: SPOTLIGHT_DIM,
333
+ },
334
+ }
335
+
336
+ function resolveRegionConfig(
337
+ region: Partial<RegionConfig> | undefined,
338
+ previous: RegionConfig
339
+ ): RegionConfig {
340
+ return {
341
+ stroke: strokeOps.resolve(region?.stroke ?? previous.stroke),
342
+ fill: fillOps.resolve(region?.fill ?? previous.fill),
343
+ dimOpacity: region?.dimOpacity ?? previous.dimOpacity,
344
+ }
345
+ }
346
+
347
+ function lerpRegionConfig(
348
+ from: RegionConfig,
349
+ to: RegionConfig,
350
+ t: number
351
+ ): RegionConfig {
352
+ return {
353
+ stroke: strokeOps.lerp(
354
+ from.stroke as StrokeResolved[],
355
+ to.stroke as StrokeResolved[],
356
+ t
357
+ ),
358
+ fill: fillOps.lerp(
359
+ from.fill as FillResolved[],
360
+ to.fill as FillResolved[],
361
+ t
362
+ ),
363
+ dimOpacity: lerpNumber(from.dimOpacity, to.dimOpacity, t),
364
+ }
365
+ }
366
+
367
+ /**
368
+ * {@link LineChart}'s `theme` @property mapper: merges the author's partial
369
+ * {@link ChartTheme} onto `previous` (falling back to
370
+ * {@link DEFAULT_CHART_THEME} on first resolution), fully resolving every leaf.
371
+ */
372
+ export function resolveChartTheme(
373
+ theme: ChartTheme | undefined,
374
+ previous?: ChartConfig
375
+ ): ChartConfig {
376
+ const base = previous ?? DEFAULT_CHART_THEME
377
+ return {
378
+ seriesStyles: resolveSeriesStrokes(theme?.seriesStyles, base.seriesStyles),
379
+ xAxis: resolveAxisConfig(theme?.xAxis, base.xAxis),
380
+ yAxis: resolveAxisConfig(theme?.yAxis, base.yAxis),
381
+ plotArea: resolvePlotArea(theme?.plotArea, base.plotArea),
382
+ legend: resolveLegendConfig(theme?.legend, base.legend),
383
+ region: resolveRegionConfig(theme?.region, base.region),
384
+ }
385
+ }
386
+
387
+ /** {@link LineChart}'s `theme` @property tween — leaf by leaf. */
388
+ export function lerpChartTheme(
389
+ from: ChartConfig,
390
+ to: ChartConfig,
391
+ t: number
392
+ ): ChartConfig {
393
+ return {
394
+ seriesStyles: lerpSeriesStrokes(from.seriesStyles, to.seriesStyles, t),
395
+ xAxis: lerpAxisConfig(from.xAxis, to.xAxis, t),
396
+ yAxis: lerpAxisConfig(from.yAxis, to.yAxis, t),
397
+ plotArea: lerpPlotArea(from.plotArea, to.plotArea, t),
398
+ legend: lerpLegendConfig(from.legend, to.legend, t),
399
+ region: lerpRegionConfig(from.region, to.region, t),
400
+ }
401
+ }
402
+
403
+ // ---- Scales --------------------------------------------------------------
404
+
405
+ /**
406
+ * Resolve one series' `[x, y]` points out of the chart's rows: each row's
407
+ * position on the {@link XScale} paired with its own `field` column, sorted
408
+ * along x so the polyline traces left-to-right whatever order the rows arrived
409
+ * in.
410
+ *
411
+ * A row missing its y column contributes a 0 rather than a gap — these charts
412
+ * draw continuous lines, so dropping the point would silently bridge the hole.
413
+ *
414
+ * Takes the resolved scale rather than the column *name*, because what a row's x
415
+ * is depends on what kind of column it came out of: a number, an instant, or a
416
+ * place in the file. See `x-scale.ts`.
417
+ */
418
+ export function seriesPoints(
419
+ data: DataRecord[],
420
+ x: XScale,
421
+ series: LineSeries
422
+ ): [x: number, y: number][] {
423
+ return data
424
+ .map((row, i): [number, number] => [x.at(i), num(row, series.field)])
425
+ .sort((a, b) => a[0] - b[0])
426
+ }
427
+
428
+ /** Min/max across every series' column, the fallback when unset. */
429
+ export function yExtent(data: DataRecord[], series: LineSeries[]): Range {
430
+ let lo = Infinity
431
+ let hi = -Infinity
432
+ for (const row of data)
433
+ for (const s of series) {
434
+ const v = num(row, s.field)
435
+ if (v < lo) lo = v
436
+ if (v > hi) hi = v
437
+ }
438
+ return lo <= hi ? [lo, hi] : [0, 1]
439
+ }
440
+
441
+ /**
442
+ * Effective vertical scale: the explicit `range`, else the data's y extent
443
+ * nudged out to tidy step bounds so markers read as round numbers.
444
+ *
445
+ * `divisions` is how many segments the auto-range is stepped into (one fewer
446
+ * than a marker count) — it only shapes the tidy step when `range` is omitted.
447
+ */
448
+ export function resolveYScale(
449
+ data: DataRecord[],
450
+ series: LineSeries[],
451
+ range: Range | undefined,
452
+ divisions: number
453
+ ): Range {
454
+ if (range) return range
455
+ const [lo, hi] = yExtent(data, series)
456
+ const segments = Math.max(1, Math.round(divisions))
457
+ const step = niceStep((hi - lo) / segments || 1)
458
+ return [Math.floor(lo / step) * step, Math.ceil(hi / step) * step]
459
+ }