@motionscript/charts 0.1.0 → 0.1.1-alpha.1

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 (215) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +98 -28
  3. package/dist/bar-chart/bar-chart.d.ts +242 -119
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +231 -260
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +16 -38
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +59 -68
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +60 -91
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +88 -125
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +5 -13
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +2 -9
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +40 -132
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +70 -158
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-R3Q5EE6E.js +2 -0
  24. package/dist/browser/chunks/chunk-R3Q5EE6E.js.map +7 -0
  25. package/dist/browser/index.js +1 -1
  26. package/dist/browser/index.js.map +4 -4
  27. package/dist/browser/kit.js +1 -1
  28. package/dist/browser/manifest.json +1 -1
  29. package/dist/engine.d.ts +8 -17
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +8 -17
  32. package/dist/engine.js.map +1 -1
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/kit/cartesian.d.ts +49 -35
  38. package/dist/kit/cartesian.d.ts.map +1 -1
  39. package/dist/kit/cartesian.js +80 -42
  40. package/dist/kit/cartesian.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +37 -40
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +53 -47
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +16 -59
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +38 -76
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/kit/entries.d.ts +2 -0
  50. package/dist/kit/entries.d.ts.map +1 -1
  51. package/dist/kit/entries.js +2 -0
  52. package/dist/kit/entries.js.map +1 -1
  53. package/dist/kit/horizontal-axis.d.ts +77 -0
  54. package/dist/kit/horizontal-axis.d.ts.map +1 -0
  55. package/dist/kit/horizontal-axis.js +99 -0
  56. package/dist/kit/horizontal-axis.js.map +1 -0
  57. package/dist/kit/motion.d.ts +16 -3
  58. package/dist/kit/motion.d.ts.map +1 -1
  59. package/dist/kit/motion.js +15 -1
  60. package/dist/kit/motion.js.map +1 -1
  61. package/dist/{pie-chart → kit}/pie-legend.d.ts +14 -29
  62. package/dist/kit/pie-legend.d.ts.map +1 -0
  63. package/dist/kit/pie-legend.js +93 -0
  64. package/dist/kit/pie-legend.js.map +1 -0
  65. package/dist/kit/polar.d.ts +31 -0
  66. package/dist/kit/polar.d.ts.map +1 -0
  67. package/dist/kit/polar.js +33 -0
  68. package/dist/kit/polar.js.map +1 -0
  69. package/dist/kit/shared.d.ts +60 -214
  70. package/dist/kit/shared.d.ts.map +1 -1
  71. package/dist/kit/shared.js +91 -291
  72. package/dist/kit/shared.js.map +1 -1
  73. package/dist/kit/style.d.ts +254 -0
  74. package/dist/kit/style.d.ts.map +1 -0
  75. package/dist/kit/style.js +197 -0
  76. package/dist/kit/style.js.map +1 -0
  77. package/dist/kit/vertical-axis.d.ts +36 -45
  78. package/dist/kit/vertical-axis.d.ts.map +1 -1
  79. package/dist/kit/vertical-axis.js +69 -81
  80. package/dist/kit/vertical-axis.js.map +1 -1
  81. package/dist/line-chart/chart-line.d.ts +107 -40
  82. package/dist/line-chart/chart-line.d.ts.map +1 -1
  83. package/dist/line-chart/chart-line.js +172 -89
  84. package/dist/line-chart/chart-line.js.map +1 -1
  85. package/dist/line-chart/chart-region.d.ts +14 -27
  86. package/dist/line-chart/chart-region.d.ts.map +1 -1
  87. package/dist/line-chart/chart-region.js +13 -28
  88. package/dist/line-chart/chart-region.js.map +1 -1
  89. package/dist/line-chart/index.d.ts +5 -18
  90. package/dist/line-chart/index.d.ts.map +1 -1
  91. package/dist/line-chart/index.js +3 -16
  92. package/dist/line-chart/index.js.map +1 -1
  93. package/dist/line-chart/line-chart.d.ts +272 -119
  94. package/dist/line-chart/line-chart.d.ts.map +1 -1
  95. package/dist/line-chart/line-chart.js +309 -257
  96. package/dist/line-chart/line-chart.js.map +1 -1
  97. package/dist/line-chart/shared.d.ts +73 -130
  98. package/dist/line-chart/shared.d.ts.map +1 -1
  99. package/dist/line-chart/shared.js +195 -156
  100. package/dist/line-chart/shared.js.map +1 -1
  101. package/dist/line-chart/x-scale.d.ts +16 -55
  102. package/dist/line-chart/x-scale.d.ts.map +1 -1
  103. package/dist/line-chart/x-scale.js +44 -111
  104. package/dist/line-chart/x-scale.js.map +1 -1
  105. package/dist/nodes.d.ts +3 -13
  106. package/dist/nodes.d.ts.map +1 -1
  107. package/dist/nodes.js +9 -13
  108. package/dist/nodes.js.map +1 -1
  109. package/dist/pie-chart/index.d.ts +3 -15
  110. package/dist/pie-chart/index.d.ts.map +1 -1
  111. package/dist/pie-chart/index.js +1 -11
  112. package/dist/pie-chart/index.js.map +1 -1
  113. package/dist/pie-chart/pie-chart.d.ts +147 -85
  114. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  115. package/dist/pie-chart/pie-chart.js +65 -86
  116. package/dist/pie-chart/pie-chart.js.map +1 -1
  117. package/dist/pie-chart/pie-ring.d.ts +65 -196
  118. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  119. package/dist/pie-chart/pie-ring.js +154 -292
  120. package/dist/pie-chart/pie-ring.js.map +1 -1
  121. package/dist/pie-chart/shared.d.ts +78 -172
  122. package/dist/pie-chart/shared.d.ts.map +1 -1
  123. package/dist/pie-chart/shared.js +54 -177
  124. package/dist/pie-chart/shared.js.map +1 -1
  125. package/dist/radar-chart/index.d.ts +5 -0
  126. package/dist/radar-chart/index.d.ts.map +1 -0
  127. package/dist/radar-chart/index.js +5 -0
  128. package/dist/radar-chart/index.js.map +1 -0
  129. package/dist/radar-chart/radar-chart.d.ts +218 -0
  130. package/dist/radar-chart/radar-chart.d.ts.map +1 -0
  131. package/dist/radar-chart/radar-chart.js +224 -0
  132. package/dist/radar-chart/radar-chart.js.map +1 -0
  133. package/dist/radar-chart/radar-grid.d.ts +43 -0
  134. package/dist/radar-chart/radar-grid.d.ts.map +1 -0
  135. package/dist/radar-chart/radar-grid.js +61 -0
  136. package/dist/radar-chart/radar-grid.js.map +1 -0
  137. package/dist/radar-chart/radar-series.d.ts +59 -0
  138. package/dist/radar-chart/radar-series.d.ts.map +1 -0
  139. package/dist/radar-chart/radar-series.js +71 -0
  140. package/dist/radar-chart/radar-series.js.map +1 -0
  141. package/dist/radar-chart/shared.d.ts +73 -0
  142. package/dist/radar-chart/shared.d.ts.map +1 -0
  143. package/dist/radar-chart/shared.js +123 -0
  144. package/dist/radar-chart/shared.js.map +1 -0
  145. package/dist/radial-chart/index.d.ts +6 -0
  146. package/dist/radial-chart/index.d.ts.map +1 -0
  147. package/dist/radial-chart/index.js +4 -0
  148. package/dist/radial-chart/index.js.map +1 -0
  149. package/dist/radial-chart/radial-chart.d.ts +216 -0
  150. package/dist/radial-chart/radial-chart.d.ts.map +1 -0
  151. package/dist/radial-chart/radial-chart.js +174 -0
  152. package/dist/radial-chart/radial-chart.js.map +1 -0
  153. package/dist/radial-chart/radial-rings.d.ts +121 -0
  154. package/dist/radial-chart/radial-rings.d.ts.map +1 -0
  155. package/dist/radial-chart/radial-rings.js +273 -0
  156. package/dist/radial-chart/radial-rings.js.map +1 -0
  157. package/dist/radial-chart/shared.d.ts +73 -0
  158. package/dist/radial-chart/shared.d.ts.map +1 -0
  159. package/dist/radial-chart/shared.js +79 -0
  160. package/dist/radial-chart/shared.js.map +1 -0
  161. package/package.json +4 -4
  162. package/registry.json +21 -6
  163. package/src/bar-chart/bar-chart.ts +274 -315
  164. package/src/bar-chart/category-axis.ts +53 -88
  165. package/src/bar-chart/chart-bars.ts +108 -205
  166. package/src/bar-chart/index.ts +8 -19
  167. package/src/bar-chart/shared.ts +81 -309
  168. package/src/engine.ts +8 -17
  169. package/src/index.ts +2 -0
  170. package/src/kit/cartesian.ts +118 -55
  171. package/src/kit/chart-canvas.ts +50 -80
  172. package/src/kit/chart-legend.ts +36 -97
  173. package/src/kit/entries.ts +4 -1
  174. package/src/kit/horizontal-axis.ts +114 -0
  175. package/src/kit/motion.ts +18 -2
  176. package/src/{pie-chart → kit}/pie-legend.ts +25 -65
  177. package/src/kit/polar.ts +54 -0
  178. package/src/kit/shared.ts +104 -487
  179. package/src/kit/style.ts +257 -0
  180. package/src/kit/vertical-axis.ts +81 -115
  181. package/src/line-chart/chart-line.ts +185 -91
  182. package/src/line-chart/chart-region.ts +14 -31
  183. package/src/line-chart/index.ts +14 -22
  184. package/src/line-chart/line-chart.ts +377 -315
  185. package/src/line-chart/shared.ts +229 -304
  186. package/src/line-chart/x-scale.ts +44 -121
  187. package/src/nodes.ts +9 -13
  188. package/src/pie-chart/index.ts +3 -35
  189. package/src/pie-chart/pie-chart.ts +83 -134
  190. package/src/pie-chart/pie-ring.ts +162 -368
  191. package/src/pie-chart/shared.ts +92 -379
  192. package/src/radar-chart/index.ts +4 -0
  193. package/src/radar-chart/radar-chart.ts +287 -0
  194. package/src/radar-chart/radar-grid.ts +78 -0
  195. package/src/radar-chart/radar-series.ts +88 -0
  196. package/src/radar-chart/shared.ts +146 -0
  197. package/src/radial-chart/index.ts +5 -0
  198. package/src/radial-chart/radial-chart.ts +210 -0
  199. package/src/radial-chart/radial-rings.ts +328 -0
  200. package/src/radial-chart/shared.ts +114 -0
  201. package/dist/browser/chunks/chunk-TJNHLQGV.js +0 -2
  202. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +0 -7
  203. package/dist/kit/chart-border.d.ts +0 -27
  204. package/dist/kit/chart-border.d.ts.map +0 -1
  205. package/dist/kit/chart-border.js +0 -28
  206. package/dist/kit/chart-border.js.map +0 -1
  207. package/dist/line-chart/horizontal-axis.d.ts +0 -79
  208. package/dist/line-chart/horizontal-axis.d.ts.map +0 -1
  209. package/dist/line-chart/horizontal-axis.js +0 -129
  210. package/dist/line-chart/horizontal-axis.js.map +0 -1
  211. package/dist/pie-chart/pie-legend.d.ts.map +0 -1
  212. package/dist/pie-chart/pie-legend.js +0 -117
  213. package/dist/pie-chart/pie-legend.js.map +0 -1
  214. package/src/kit/chart-border.ts +0 -50
  215. package/src/line-chart/horizontal-axis.ts +0 -151
@@ -1,109 +1,38 @@
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 `../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 a host's own inspector
14
- * mapping can build a partial {@link ChartTheme} without
15
- * pulling the components in.
16
- */
1
+ import { Types, type DataRecord, type Vector2 } from "@motionscript/sdk"
17
2
 
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/sdk"
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"
3
+ import { niceRange, num, type ChartSeries, type Range } from "../kit/shared"
55
4
  import type { XScale } from "./x-scale"
56
5
 
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
- */
6
+ /** One polyline of a {@link LineChart}: {@link ChartSeries} under the name for what it draws here. */
67
7
  export type LineSeries = ChartSeries
68
8
 
69
- // ---- Plot regions -------------------------------------------------------
70
-
71
9
  /**
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.
10
+ * A rectangular slice of the plot in data units, the input to `spotlightRegion`
11
+ * and `zoomTo`. Either axis may be omitted: `{ x: [60, 100] }` is a full-height
12
+ * band. A reversed range reads the same as a forward one.
79
13
  */
80
14
  export interface PlotRegion {
81
- /** Horizontal span in x-axis data units. Omit to span the full plot width. */
15
+ /** Omit to span the full plot width. */
82
16
  x?: Range
83
- /** Vertical span in y-axis data units. Omit to span the full plot height. */
17
+ /** Omit to span the full plot height. */
84
18
  y?: Range
85
19
  }
86
20
 
87
21
  /**
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.
22
+ * A {@link PlotRegion} in the plot's centred pixel space (origin at the centre,
23
+ * y-up), the frame the series points live in. An axis the author left unbounded
24
+ * has its edges pinned to the plot's own and draws no rule.
94
25
  */
95
26
  export interface RegionBox {
96
27
  left: number
97
28
  right: number
98
29
  bottom: number
99
30
  top: number
100
- /** The author gave an x range — the left/right edges are real data bounds. */
101
31
  xBounded: boolean
102
- /** The author gave a y range — the bottom/top edges are real data bounds. */
103
32
  yBounded: boolean
104
33
  }
105
34
 
106
- /** The empty box, used as the "no region" resting value. */
35
+ /** The "no region" resting value. */
107
36
  export const EMPTY_REGION_BOX: RegionBox = {
108
37
  left: 0,
109
38
  right: 0,
@@ -113,19 +42,15 @@ export const EMPTY_REGION_BOX: RegionBox = {
113
42
  yBounded: false,
114
43
  }
115
44
 
116
- /** Is `box` a real, non-degenerate region — i.e. is there anything to spotlight? */
45
+ /** Whether `box` is non-degenerate, so there is something to spotlight. */
117
46
  export function hasRegion(box: RegionBox): boolean {
118
47
  return box.right > box.left && box.top > box.bottom
119
48
  }
120
49
 
121
50
  /**
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.
51
+ * Resolve a {@link PlotRegion} against the plot rectangle (centred pixel space)
52
+ * and the data ranges its edges span. An omitted axis takes the plot's own
53
+ * edges; a given one is clamped to the plot.
129
54
  */
130
55
  export function resolveRegionBox(
131
56
  region: PlotRegion | null | undefined,
@@ -159,40 +84,31 @@ export function resolveRegionBox(
159
84
  return { left, right, bottom, top, xBounded, yBounded }
160
85
  }
161
86
 
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
87
  /**
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.
88
+ * The parts of `points` inside `box`, as contiguous sub-polylines: a series is
89
+ * drawn dimmed in full, then these runs are re-drawn at full strength. Segments
90
+ * are clipped, not filtered by endpoint, so a line entering mid-segment is cut
91
+ * exactly on the boundary.
175
92
  */
176
93
  export function clipPolylineToBox(
177
- points: Vector2Like[],
94
+ points: Vector2[],
178
95
  box: RegionBox
179
- ): Vector2Like[][] {
96
+ ): Vector2[][] {
180
97
  if (points.length < 2 || !hasRegion(box)) return []
181
98
 
182
- const runs: Vector2Like[][] = []
183
- let run: Vector2Like[] = []
99
+ const runs: Vector2[][] = []
100
+ let run: Vector2[] = []
184
101
 
185
102
  for (let i = 1; i < points.length; i++) {
186
103
  const seg = clipSegment(points[i - 1], points[i], box)
187
104
  if (!seg) {
188
- // Segment misses the box entirely — close whatever run was open.
105
+ // Misses the box: close the open run.
189
106
  if (run.length >= 2) runs.push(run)
190
107
  run = []
191
108
  continue
192
109
  }
193
110
  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.
111
+ // Continues the open run if it starts where the last ended; otherwise the line left and came back.
196
112
  if (run.length > 0 && samePoint(run[run.length - 1], a)) run.push(b)
197
113
  else {
198
114
  if (run.length >= 2) runs.push(run)
@@ -204,25 +120,22 @@ export function clipPolylineToBox(
204
120
  }
205
121
 
206
122
  /** Points within half a pixel are the same point, for run-joining purposes. */
207
- function samePoint(a: Vector2Like, b: Vector2Like): boolean {
123
+ function samePoint(a: Vector2, b: Vector2): boolean {
208
124
  return Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5
209
125
  }
210
126
 
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
- */
127
+ /** Liang-Barsky clip of one segment against `box`: the surviving `[start, end]`, or `null` when wholly outside. */
215
128
  function clipSegment(
216
- a: Vector2Like,
217
- b: Vector2Like,
129
+ a: Vector2,
130
+ b: Vector2,
218
131
  box: RegionBox
219
- ): [Vector2Like, Vector2Like] | null {
132
+ ): [Vector2, Vector2] | null {
220
133
  const dx = b.x - a.x
221
134
  const dy = b.y - a.y
222
135
  let t0 = 0
223
136
  let t1 = 1
224
137
 
225
- // Each edge as (p, q): the segment survives where p·t <= q.
138
+ // Each edge as (p, q): the segment survives where p*t <= q.
226
139
  const edges: [number, number][] = [
227
140
  [-dx, a.x - box.left],
228
141
  [dx, box.right - a.x],
@@ -231,7 +144,7 @@ function clipSegment(
231
144
  ]
232
145
  for (const [p, q] of edges) {
233
146
  if (p === 0) {
234
- // Parallel to this edge: outside it means the whole segment is out.
147
+ // Parallel to this edge: outside it, the whole segment is out.
235
148
  if (q < 0) return null
236
149
  continue
237
150
  }
@@ -250,170 +163,10 @@ function clipSegment(
250
163
  ]
251
164
  }
252
165
 
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` entry's resolve: 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` entry's lerp — 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
166
  /**
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`.
167
+ * One series' `[x, y]` points, sorted along x so the polyline traces left to
168
+ * right. A row missing its y contributes 0 rather than a gap, since dropping it
169
+ * would silently bridge the hole.
417
170
  */
418
171
  export function seriesPoints(
419
172
  data: DataRecord[],
@@ -425,7 +178,6 @@ export function seriesPoints(
425
178
  .sort((a, b) => a[0] - b[0])
426
179
  }
427
180
 
428
- /** Min/max across every series' column, the fallback when unset. */
429
181
  export function yExtent(data: DataRecord[], series: LineSeries[]): Range {
430
182
  let lo = Infinity
431
183
  let hi = -Infinity
@@ -439,11 +191,9 @@ export function yExtent(data: DataRecord[], series: LineSeries[]): Range {
439
191
  }
440
192
 
441
193
  /**
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.
194
+ * The explicit `range`, else the data's y extent nudged out to tidy step bounds.
195
+ * `divisions` (one fewer than the marker count) only shapes the step when
196
+ * `range` is omitted.
447
197
  */
448
198
  export function resolveYScale(
449
199
  data: DataRecord[],
@@ -451,23 +201,198 @@ export function resolveYScale(
451
201
  range: Range | undefined,
452
202
  divisions: number
453
203
  ): Range {
454
- if (range) return range
455
- const [lo, hi] = yExtent(data, series)
456
- const segments = Math.max(1, Math.round(divisions))
457
- if (hi === lo) return constantScale(lo, segments)
458
- const step = niceStep((hi - lo) / segments)
459
- return [Math.floor(lo / step) * step, Math.ceil(hi / step) * step]
204
+ return range ?? niceRange(yExtent(data, series), divisions)
205
+ }
206
+
207
+ /** How the series sit on one another: apart, each on the sum of those before it, or as shares of the row's total. */
208
+ export type LineStack = "none" | "stacked" | "percent"
209
+
210
+ /** One series as drawn: its `[x, y]` points along x, and the line its area fills down to (`null`: the baseline). */
211
+ export interface StackedSeries {
212
+ readonly points: [x: number, y: number][]
213
+ readonly base: [x: number, y: number][] | null
460
214
  }
461
215
 
462
216
  /**
463
- * The scale for data that never moves. A zero-width scale puts every tick on the
464
- * same value and the line on the plot's floor, so it is widened to a few steps
465
- * either side of the value (the step sized to its magnitude, so `5` gets
466
- * `[3, 7]` and `5000` gets `[3000, 7000]`), or to `[0, 1]` for all zeros, as for
467
- * no data at all.
217
+ * Every series' points under `stack`, rows in x order. A series contributes its
218
+ * values times its weight (`1` when missing), so a series sinking out of a stack
219
+ * lowers the ones above it. `percent` reads each row as shares of its total
220
+ * magnitude, so the top of a full stack is 100.
468
221
  */
469
- function constantScale(value: number, segments: number): Range {
470
- if (value === 0) return [0, 1]
471
- const step = niceStep(Math.abs(value) / segments)
472
- return [value - step, value + step]
222
+ export function stackSeries(
223
+ data: DataRecord[],
224
+ x: XScale,
225
+ series: readonly LineSeries[],
226
+ stack: LineStack,
227
+ weights: readonly number[] = []
228
+ ): StackedSeries[] {
229
+ const order = data.map((_, i) => i).sort((a, b) => x.at(a) - x.at(b))
230
+ if (stack !== "stacked" && stack !== "percent") {
231
+ return series.map((s) => ({ points: order.map((r): [number, number] => [x.at(r), num(data[r], s.field)]), base: null }))
232
+ }
233
+ const weight = (i: number) => Math.max(0, weights[i] ?? 1)
234
+ const totals = order.map((r) => series.reduce((sum, s, i) => sum + Math.abs(num(data[r], s.field) * weight(i)), 0))
235
+ const running = order.map(() => 0)
236
+ return series.map((s, i) => {
237
+ const base = order.map((r, k): [number, number] => [x.at(r), running[k]])
238
+ const points = order.map((r, k): [number, number] => {
239
+ const value = num(data[r], s.field) * weight(i)
240
+ running[k] += stack === "percent" ? (totals[k] > 0 ? (value / totals[k]) * 100 : 0) : value
241
+ return [x.at(r), running[k]]
242
+ })
243
+ return { points, base }
244
+ })
245
+ }
246
+
247
+ /** The lowest and highest y anything in `stacks` reaches, its bases included. */
248
+ export function stackExtent(stacks: readonly StackedSeries[]): Range {
249
+ let lo = Infinity
250
+ let hi = -Infinity
251
+ for (const { points, base } of stacks)
252
+ for (const [, y] of [...points, ...(base ?? [])]) {
253
+ if (y < lo) lo = y
254
+ if (y > hi) hi = y
255
+ }
256
+ return lo <= hi ? [lo, hi] : [0, 1]
257
+ }
258
+
259
+ /** How a series' line runs between its points. */
260
+ export type LineCurve = "smooth" | "linear" | "step"
261
+
262
+ /** Points along a cubic Bezier segment, its start left out. */
263
+ const SAMPLES = 16
264
+
265
+ /**
266
+ * The drawn path through `points` (ordered along x) as a polyline: the points
267
+ * themselves for `linear`; for `step`, level runs that change at the midpoint
268
+ * between neighbours; for `smooth`, a monotone cubic (Fritsch-Carlson, as d3's
269
+ * `curveMonotoneX`), which never overshoots the data between two points.
270
+ */
271
+ export function curvePoints(points: readonly Vector2[], curve: LineCurve): Vector2[] {
272
+ if (curve === "step") {
273
+ const out: Vector2[] = points.length > 0 ? [points[0]] : []
274
+ for (let i = 1; i < points.length; i++) {
275
+ const mid = (points[i - 1].x + points[i].x) / 2
276
+ out.push({ x: mid, y: points[i - 1].y }, { x: mid, y: points[i].y }, points[i])
277
+ }
278
+ return out
279
+ }
280
+ if (curve !== "smooth" || points.length < 3) return [...points]
281
+ const tangents = monotoneTangents(points)
282
+ const out: Vector2[] = [points[0]]
283
+ for (let i = 1; i < points.length; i++) {
284
+ const a = points[i - 1]
285
+ const b = points[i]
286
+ const dx = (b.x - a.x) / 3
287
+ const c1 = { x: a.x + dx, y: a.y + dx * tangents[i - 1] }
288
+ const c2 = { x: b.x - dx, y: b.y - dx * tangents[i] }
289
+ for (let k = 1; k <= SAMPLES; k++) {
290
+ const t = k / SAMPLES
291
+ const u = 1 - t
292
+ out.push({
293
+ x: u * u * u * a.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * b.x,
294
+ y: u * u * u * a.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * b.y,
295
+ })
296
+ }
297
+ }
298
+ return out
299
+ }
300
+
301
+ function monotoneTangents(points: readonly Vector2[]): number[] {
302
+ const n = points.length
303
+ const slope = (i: number) => {
304
+ const h = points[i + 1].x - points[i].x
305
+ return h === 0 ? 0 : (points[i + 1].y - points[i].y) / h
306
+ }
307
+ const m = new Array<number>(n).fill(0)
308
+ for (let i = 1; i < n - 1; i++) {
309
+ const h0 = points[i].x - points[i - 1].x
310
+ const h1 = points[i + 1].x - points[i].x
311
+ const s0 = slope(i - 1)
312
+ const s1 = slope(i)
313
+ const p = h0 + h1 === 0 ? 0 : (s0 * h1 + s1 * h0) / (h0 + h1)
314
+ m[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0
315
+ }
316
+ m[0] = endTangent(slope(0), m[1])
317
+ m[n - 1] = endTangent(slope(n - 2), m[n - 2])
318
+ return m
319
+ }
320
+
321
+ /** d3's one-sided end tangent, held to the end segment's own direction and to three times its slope, past which it would overshoot. */
322
+ function endTangent(secant: number, inner: number): number {
323
+ const t = (3 * secant - inner) / 2
324
+ if (secant === 0 || Math.sign(t) !== Math.sign(secant)) return 0
325
+ return Math.abs(t) > 3 * Math.abs(secant) ? 3 * secant : t
326
+ }
327
+
328
+ /** The part of `points` (ordered along x) left of `x`, ending on the point where it crosses `x`. */
329
+ export function cutAt(points: readonly Vector2[], x: number): Vector2[] {
330
+ if (points.length === 0 || x < points[0].x) return []
331
+ const out: Vector2[] = []
332
+ for (let i = 0; i < points.length; i++) {
333
+ const p = points[i]
334
+ if (p.x <= x) {
335
+ out.push(p)
336
+ continue
337
+ }
338
+ const a = points[i - 1]
339
+ if (a && p.x > a.x) out.push({ x, y: a.y + ((p.y - a.y) * (x - a.x)) / (p.x - a.x) })
340
+ break
341
+ }
342
+ return out
343
+ }
344
+
345
+ /**
346
+ * `polygon` clipped to `box` (Sutherland-Hodgman against each edge in turn).
347
+ * The box is convex, so the result is the part inside, possibly with seams of
348
+ * no width where a concave outline leaves and re-enters, which fill as nothing.
349
+ */
350
+ export function clipPolygonToBox(polygon: readonly Vector2[], box: RegionBox): Vector2[] {
351
+ if (polygon.length < 3 || !hasRegion(box)) return []
352
+ const edges: ((p: Vector2) => number)[] = [
353
+ (p) => p.x - box.left,
354
+ (p) => box.right - p.x,
355
+ (p) => p.y - box.bottom,
356
+ (p) => box.top - p.y,
357
+ ]
358
+ let out: Vector2[] = [...polygon]
359
+ for (const inside of edges) {
360
+ const input = out
361
+ out = []
362
+ for (let i = 0; i < input.length; i++) {
363
+ const cur = input[i]
364
+ const prev = input[(i + input.length - 1) % input.length]
365
+ const dc = inside(cur)
366
+ const dp = inside(prev)
367
+ if (dc >= 0) {
368
+ if (dp < 0) out.push(crossing(prev, cur, dp, dc))
369
+ out.push(cur)
370
+ } else if (dp >= 0) out.push(crossing(prev, cur, dp, dc))
371
+ }
372
+ if (out.length === 0) return []
373
+ }
374
+ return out
375
+ }
376
+
377
+ function crossing(a: Vector2, b: Vector2, da: number, db: number): Vector2 {
378
+ const t = da / (da - db)
379
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }
380
+ }
381
+
382
+ /** A line chart's own look: how each series is stroked, dotted and filled. */
383
+ export const LINE_MARKS = {
384
+ strokeWidth: Types.number(4, { min: 0, max: 24, step: 0.5, unit: "px" }),
385
+ curve: Types.oneOf(["smooth", "linear", "step"] as const),
386
+ dots: Types.boolean(false),
387
+ /** Each dot's diameter. */
388
+ dotSize: Types.number(12, { min: 0, max: 48, step: 1, unit: "px" }),
389
+ /** Fill under each line: none, the series colour, or the colour fading to nothing at the baseline. */
390
+ area: Types.oneOf(["none", "solid", "gradient"] as const),
391
+ /** The fill's opacity at the line. */
392
+ areaOpacity: Types.number(0.4, { min: 0, max: 1, step: 0.05 }),
393
+ }
394
+
395
+ export const LINE_LABELS = {
396
+ /** Read as the chart is built: each point's value printed above or below it, or none. */
397
+ values: Types.oneOf(["none", "above", "below"] as const, { animatable: false }),
473
398
  }