@svgrid/grid 1.0.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 (213) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +39 -0
  3. package/dist/FlexRender.svelte +96 -0
  4. package/dist/FlexRender.svelte.d.ts +49 -0
  5. package/dist/SvGrid.svelte +8742 -0
  6. package/dist/SvGrid.svelte.d.ts +381 -0
  7. package/dist/SvGridChart.svelte +653 -0
  8. package/dist/SvGridChart.svelte.d.ts +25 -0
  9. package/dist/SvGridChart.test.d.ts +1 -0
  10. package/dist/SvGridChart.test.js +62 -0
  11. package/dist/SvGridDropdown.svelte +675 -0
  12. package/dist/SvGridDropdown.svelte.d.ts +40 -0
  13. package/dist/a11y.contract.test.d.ts +1 -0
  14. package/dist/a11y.contract.test.js +39 -0
  15. package/dist/a11y.d.ts +38 -0
  16. package/dist/a11y.js +37 -0
  17. package/dist/a11y.test.d.ts +1 -0
  18. package/dist/a11y.test.js +44 -0
  19. package/dist/cell-formatting.d.ts +17 -0
  20. package/dist/cell-formatting.js +141 -0
  21. package/dist/cell-formatting.test.d.ts +1 -0
  22. package/dist/cell-formatting.test.js +234 -0
  23. package/dist/chart-export.d.ts +14 -0
  24. package/dist/chart-export.js +112 -0
  25. package/dist/chart.d.ts +226 -0
  26. package/dist/chart.js +732 -0
  27. package/dist/chart.test.d.ts +1 -0
  28. package/dist/chart.test.js +289 -0
  29. package/dist/collaboration.d.ts +74 -0
  30. package/dist/collaboration.js +98 -0
  31. package/dist/collaboration.test.d.ts +1 -0
  32. package/dist/collaboration.test.js +65 -0
  33. package/dist/conditional-formatting.d.ts +91 -0
  34. package/dist/conditional-formatting.js +170 -0
  35. package/dist/conditional-formatting.test.d.ts +1 -0
  36. package/dist/conditional-formatting.test.js +87 -0
  37. package/dist/core.coverage.test.d.ts +1 -0
  38. package/dist/core.coverage.test.js +186 -0
  39. package/dist/core.d.ts +370 -0
  40. package/dist/core.js +568 -0
  41. package/dist/core.performance.test.d.ts +1 -0
  42. package/dist/core.performance.test.js +29 -0
  43. package/dist/createGrid.svelte.d.ts +6 -0
  44. package/dist/createGrid.svelte.js +17 -0
  45. package/dist/createGrid.test.d.ts +1 -0
  46. package/dist/createGrid.test.js +9 -0
  47. package/dist/createGridState.svelte.d.ts +3 -0
  48. package/dist/createGridState.svelte.js +13 -0
  49. package/dist/editors/cell-editors.d.ts +23 -0
  50. package/dist/editors/cell-editors.js +97 -0
  51. package/dist/editors/cell-editors.test.d.ts +1 -0
  52. package/dist/editors/cell-editors.test.js +75 -0
  53. package/dist/fill-patterns.d.ts +30 -0
  54. package/dist/fill-patterns.js +207 -0
  55. package/dist/fill-patterns.test.d.ts +1 -0
  56. package/dist/fill-patterns.test.js +81 -0
  57. package/dist/filtering/excel-filters.d.ts +26 -0
  58. package/dist/filtering/excel-filters.js +64 -0
  59. package/dist/filtering/excel-filters.test.d.ts +1 -0
  60. package/dist/filtering/excel-filters.test.js +116 -0
  61. package/dist/filtering/locale-filter.test.d.ts +13 -0
  62. package/dist/filtering/locale-filter.test.js +189 -0
  63. package/dist/flex-render.d.ts +1 -0
  64. package/dist/flex-render.js +2 -0
  65. package/dist/flex-render.test.d.ts +1 -0
  66. package/dist/flex-render.test.js +142 -0
  67. package/dist/group-aggregate.test.d.ts +1 -0
  68. package/dist/group-aggregate.test.js +32 -0
  69. package/dist/index.d.ts +29 -0
  70. package/dist/index.js +27 -0
  71. package/dist/keyboard.d.ts +8 -0
  72. package/dist/keyboard.js +91 -0
  73. package/dist/keyboard.test.d.ts +1 -0
  74. package/dist/keyboard.test.js +27 -0
  75. package/dist/merge-objects.d.ts +9 -0
  76. package/dist/merge-objects.js +28 -0
  77. package/dist/named-views.d.ts +42 -0
  78. package/dist/named-views.js +80 -0
  79. package/dist/named-views.test.d.ts +1 -0
  80. package/dist/named-views.test.js +57 -0
  81. package/dist/new-features.test.d.ts +1 -0
  82. package/dist/new-features.test.js +183 -0
  83. package/dist/render-component.d.ts +13 -0
  84. package/dist/render-component.js +14 -0
  85. package/dist/render-component.test.d.ts +1 -0
  86. package/dist/render-component.test.js +41 -0
  87. package/dist/server-data-source.d.ts +76 -0
  88. package/dist/server-data-source.js +83 -0
  89. package/dist/server-data-source.test.d.ts +1 -0
  90. package/dist/server-data-source.test.js +81 -0
  91. package/dist/sparkline.d.ts +56 -0
  92. package/dist/sparkline.js +97 -0
  93. package/dist/sparkline.test.d.ts +1 -0
  94. package/dist/sparkline.test.js +52 -0
  95. package/dist/static-functions.d.ts +1 -0
  96. package/dist/static-functions.js +1 -0
  97. package/dist/subscribe.d.ts +9 -0
  98. package/dist/subscribe.js +31 -0
  99. package/dist/subscribe.test.d.ts +1 -0
  100. package/dist/subscribe.test.js +93 -0
  101. package/dist/sv-grid-scrollbar.d.ts +1 -0
  102. package/dist/sv-grid-scrollbar.js +310 -0
  103. package/dist/svgrid-wrapper.types.d.ts +341 -0
  104. package/dist/svgrid-wrapper.types.js +1 -0
  105. package/dist/svgrid.api-extensions.test.d.ts +14 -0
  106. package/dist/svgrid.api-extensions.test.js +334 -0
  107. package/dist/svgrid.api.test.d.ts +15 -0
  108. package/dist/svgrid.api.test.js +169 -0
  109. package/dist/svgrid.behavior.test.d.ts +15 -0
  110. package/dist/svgrid.behavior.test.js +605 -0
  111. package/dist/svgrid.column-reorder.test.d.ts +15 -0
  112. package/dist/svgrid.column-reorder.test.js +224 -0
  113. package/dist/svgrid.features.test.d.ts +1 -0
  114. package/dist/svgrid.features.test.js +129 -0
  115. package/dist/svgrid.interaction.test.d.ts +9 -0
  116. package/dist/svgrid.interaction.test.js +318 -0
  117. package/dist/svgrid.locale-filtering.test.d.ts +15 -0
  118. package/dist/svgrid.locale-filtering.test.js +261 -0
  119. package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
  120. package/dist/svgrid.new-features.wrapper.test.js +136 -0
  121. package/dist/svgrid.row-pinning.test.d.ts +14 -0
  122. package/dist/svgrid.row-pinning.test.js +250 -0
  123. package/dist/svgrid.set-filter.test.d.ts +17 -0
  124. package/dist/svgrid.set-filter.test.js +268 -0
  125. package/dist/svgrid.wrapper.test.d.ts +1 -0
  126. package/dist/svgrid.wrapper.test.js +33 -0
  127. package/dist/test-setup.d.ts +1 -0
  128. package/dist/test-setup.js +29 -0
  129. package/dist/transaction.test.d.ts +1 -0
  130. package/dist/transaction.test.js +89 -0
  131. package/dist/virtualization/column-virtualizer.d.ts +20 -0
  132. package/dist/virtualization/column-virtualizer.js +19 -0
  133. package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
  134. package/dist/virtualization/column-virtualizer.test.js +25 -0
  135. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
  136. package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
  137. package/dist/virtualization/types.d.ts +28 -0
  138. package/dist/virtualization/types.js +1 -0
  139. package/dist/virtualization/virtualizer.d.ts +13 -0
  140. package/dist/virtualization/virtualizer.js +232 -0
  141. package/dist/virtualization/virtualizer.test.d.ts +1 -0
  142. package/dist/virtualization/virtualizer.test.js +41 -0
  143. package/package.json +69 -0
  144. package/src/FlexRender.svelte +96 -0
  145. package/src/SvGrid.svelte +8742 -0
  146. package/src/SvGridChart.svelte +653 -0
  147. package/src/SvGridChart.test.ts +69 -0
  148. package/src/SvGridDropdown.svelte +675 -0
  149. package/src/a11y.contract.test.ts +49 -0
  150. package/src/a11y.test.ts +59 -0
  151. package/src/a11y.ts +59 -0
  152. package/src/cell-formatting.test.ts +273 -0
  153. package/src/cell-formatting.ts +169 -0
  154. package/src/chart-export.ts +144 -0
  155. package/src/chart.test.ts +311 -0
  156. package/src/chart.ts +934 -0
  157. package/src/collaboration.test.ts +74 -0
  158. package/src/collaboration.ts +161 -0
  159. package/src/conditional-formatting.test.ts +107 -0
  160. package/src/conditional-formatting.ts +239 -0
  161. package/src/core.coverage.test.ts +230 -0
  162. package/src/core.performance.test.ts +30 -0
  163. package/src/core.ts +991 -0
  164. package/src/createGrid.svelte.ts +42 -0
  165. package/src/createGrid.test.ts +10 -0
  166. package/src/createGridState.svelte.ts +17 -0
  167. package/src/editors/cell-editors.test.ts +80 -0
  168. package/src/editors/cell-editors.ts +127 -0
  169. package/src/fill-patterns.test.ts +93 -0
  170. package/src/fill-patterns.ts +229 -0
  171. package/src/filtering/excel-filters.test.ts +131 -0
  172. package/src/filtering/excel-filters.ts +101 -0
  173. package/src/filtering/locale-filter.test.ts +220 -0
  174. package/src/flex-render.test.ts +143 -0
  175. package/src/flex-render.ts +3 -0
  176. package/src/group-aggregate.test.ts +48 -0
  177. package/src/index.ts +159 -0
  178. package/src/keyboard.test.ts +59 -0
  179. package/src/keyboard.ts +97 -0
  180. package/src/merge-objects.ts +48 -0
  181. package/src/named-views.test.ts +66 -0
  182. package/src/named-views.ts +120 -0
  183. package/src/new-features.test.ts +217 -0
  184. package/src/render-component.test.ts +51 -0
  185. package/src/render-component.ts +28 -0
  186. package/src/server-data-source.test.ts +88 -0
  187. package/src/server-data-source.ts +163 -0
  188. package/src/sparkline.test.ts +59 -0
  189. package/src/sparkline.ts +164 -0
  190. package/src/static-functions.ts +11 -0
  191. package/src/subscribe.test.ts +103 -0
  192. package/src/subscribe.ts +38 -0
  193. package/src/sv-grid-scrollbar.ts +347 -0
  194. package/src/svgrid-wrapper.types.ts +382 -0
  195. package/src/svgrid.api-extensions.test.ts +362 -0
  196. package/src/svgrid.api.test.ts +192 -0
  197. package/src/svgrid.behavior.test.ts +657 -0
  198. package/src/svgrid.column-reorder.test.ts +234 -0
  199. package/src/svgrid.features.test.ts +157 -0
  200. package/src/svgrid.interaction.test.ts +355 -0
  201. package/src/svgrid.locale-filtering.test.ts +259 -0
  202. package/src/svgrid.new-features.wrapper.test.ts +164 -0
  203. package/src/svgrid.row-pinning.test.ts +266 -0
  204. package/src/svgrid.set-filter.test.ts +270 -0
  205. package/src/svgrid.wrapper.test.ts +35 -0
  206. package/src/test-setup.ts +37 -0
  207. package/src/transaction.test.ts +100 -0
  208. package/src/virtualization/column-virtualizer.test.ts +27 -0
  209. package/src/virtualization/column-virtualizer.ts +30 -0
  210. package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
  211. package/src/virtualization/types.ts +30 -0
  212. package/src/virtualization/virtualizer.test.ts +47 -0
  213. package/src/virtualization/virtualizer.ts +270 -0
package/src/chart.ts ADDED
@@ -0,0 +1,934 @@
1
+ /**
2
+ * Integrated chart geometry. Pure functions that turn categories + numeric
3
+ * series into SVG primitives - the "chart from a grid range" enterprise
4
+ * feature without bundling a charting library. The `<SvGridChart>` component
5
+ * paints the result; this module has no DOM so it is unit-testable.
6
+ *
7
+ * Supports: grouped + stacked bars, line, area, pie/donut, combo charts
8
+ * (per-series type), a secondary (right) Y axis, signed Y domains (negative
9
+ * values drop below a zero baseline), and nice auto-scaled ticks.
10
+ */
11
+ export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'scatter'
12
+
13
+ /** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`. */
14
+ export type ChartSelection = { category: string; series: string; value: number }
15
+
16
+ /** A single scatter / bubble point. */
17
+ export type ScatterPoint = { x: number; y: number; r?: number; label?: string }
18
+
19
+ export type ChartSeries = {
20
+ label: string
21
+ values: number[]
22
+ color?: string
23
+ /** Per-series chart type, for combo charts. Defaults to the spec `type`. */
24
+ type?: 'bar' | 'line' | 'area'
25
+ /** Plot against the left (default) or right Y axis. */
26
+ axis?: 'left' | 'right'
27
+ /** Scatter / bubble points (used when `type === 'scatter'`). */
28
+ points?: ScatterPoint[]
29
+ }
30
+
31
+ /** A horizontal reference / target line drawn across the plot. */
32
+ export type ChartReferenceLine = {
33
+ value: number
34
+ label?: string
35
+ axis?: 'left' | 'right'
36
+ color?: string
37
+ dashed?: boolean
38
+ }
39
+
40
+ export type ChartSpec = {
41
+ /** Default type for series that don't set their own `type`. */
42
+ type: ChartType
43
+ /** X-axis labels (one per data point). */
44
+ categories: string[]
45
+ series: ChartSeries[]
46
+ width?: number
47
+ height?: number
48
+ /** Palette used when a series has no explicit `color`. */
49
+ palette?: string[]
50
+ /** Stack bar / area series (per axis) instead of grouping them. */
51
+ stacked?: boolean
52
+ /** Stack to 100% (each category normalized to its total). Implies stacked. */
53
+ stacked100?: boolean
54
+ /**
55
+ * Bar orientation. `'horizontal'` swaps the axes - categories run down the
56
+ * left, bars grow rightward - which suits long category labels. Only applies
57
+ * when every series is a bar (combo / line / area fall back to vertical).
58
+ */
59
+ orientation?: 'vertical' | 'horizontal'
60
+ /** Pie only: inner radius as a fraction of the outer radius (0..1) -> donut. */
61
+ innerRadius?: number
62
+ /** Horizontal target / goal / average lines. */
63
+ referenceLines?: ChartReferenceLine[]
64
+ /** Treat `categories` as dates -> time-scaled x positions + date ticks. */
65
+ xType?: 'category' | 'time'
66
+ /** Axis titles (reserve gutter space + render). */
67
+ yAxisTitle?: string
68
+ y2AxisTitle?: string
69
+ xAxisTitle?: string
70
+ }
71
+
72
+ export type ChartBar = {
73
+ x: number
74
+ y: number
75
+ w: number
76
+ h: number
77
+ color: string
78
+ /** Category (x label) this bar belongs to - for tooltips + labels. */
79
+ label: string
80
+ /** Series label this bar belongs to. */
81
+ series: string
82
+ value: number
83
+ }
84
+ export type ChartLinePoint = {
85
+ x: number
86
+ y: number
87
+ label: string
88
+ value: number
89
+ /** False for null / NaN values - the line breaks (gap), no dot is drawn. */
90
+ defined: boolean
91
+ }
92
+ export type ChartLine = {
93
+ path: string
94
+ areaPath: string
95
+ color: string
96
+ label: string
97
+ points: ChartLinePoint[]
98
+ }
99
+ export type ChartPieSlice = {
100
+ path: string
101
+ color: string
102
+ label: string
103
+ value: number
104
+ percent: number
105
+ /** Centroid - anchor point for a data label. */
106
+ cx: number
107
+ cy: number
108
+ }
109
+ export type ChartAxisTick = { value: number; y: number; label: string }
110
+ export type ChartCategoryTick = { label: string; x: number }
111
+ export type ChartLegendItem = { label: string; color: string }
112
+ export type ChartRefLineGeo = { y: number; label: string; color: string; dashed: boolean }
113
+ /** A vertical reference line (horizontal bar charts) positioned by `x`. */
114
+ export type ChartRefLineGeoV = { x: number; label: string; color: string; dashed: boolean }
115
+ export type ChartScatterDot = {
116
+ cx: number
117
+ cy: number
118
+ r: number
119
+ color: string
120
+ label: string
121
+ series: string
122
+ x: number
123
+ y: number
124
+ }
125
+
126
+ export type ChartGeometry = {
127
+ type: ChartType
128
+ width: number
129
+ height: number
130
+ plot: { x: number; y: number; w: number; h: number }
131
+ bars: ChartBar[]
132
+ lines: ChartLine[]
133
+ slices: ChartPieSlice[]
134
+ yTicks: ChartAxisTick[]
135
+ /** Right-axis ticks (combo / dual-axis); empty when there's no right axis. */
136
+ y2Ticks: ChartAxisTick[]
137
+ hasRightAxis: boolean
138
+ xTicks: ChartCategoryTick[]
139
+ /** True when x labels are long/many and should be rotated. */
140
+ xLabelRotated: boolean
141
+ legend: ChartLegendItem[]
142
+ /** Donut centre (pie + innerRadius), for a centre total label. */
143
+ donut: { cx: number; cy: number; r: number; total: number } | null
144
+ /** Horizontal reference / target lines. */
145
+ referenceLines: ChartRefLineGeo[]
146
+ /** Scatter / bubble points (type === 'scatter'). */
147
+ scatterPoints: ChartScatterDot[]
148
+ /** Bar orientation. `'horizontal'` uses `valueTicks` / `catTicks` below. */
149
+ orientation: 'vertical' | 'horizontal'
150
+ /** Horizontal bars: value-axis ticks along the bottom (label + x). */
151
+ valueTicks: ChartCategoryTick[]
152
+ /** Horizontal bars: category labels down the left (label + y; value = index). */
153
+ catTicks: ChartAxisTick[]
154
+ /** Horizontal bars: vertical reference / target lines (positioned by x). */
155
+ referenceLinesV: ChartRefLineGeoV[]
156
+ }
157
+
158
+ export const DEFAULT_PALETTE = [
159
+ '#2563eb',
160
+ '#16a34a',
161
+ '#f59e0b',
162
+ '#ef4444',
163
+ '#8b5cf6',
164
+ '#0ea5e9',
165
+ '#ec4899',
166
+ '#14b8a6',
167
+ ]
168
+
169
+ function round(n: number): number {
170
+ return Math.round(n * 100) / 100
171
+ }
172
+
173
+ function niceNum(range: number, roundIt: boolean): number {
174
+ if (range <= 0) return 1
175
+ const exp = Math.floor(Math.log10(range))
176
+ const f = range / Math.pow(10, exp)
177
+ let nf: number
178
+ if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
179
+ else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
180
+ return nf * Math.pow(10, exp)
181
+ }
182
+
183
+ export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
184
+
185
+ /** Round a [min,max] domain out to nice tick boundaries. */
186
+ export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
187
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
188
+ min = 0
189
+ max = 1
190
+ }
191
+ if (min === max) {
192
+ if (min === 0) max = 1
193
+ else {
194
+ min = Math.min(0, min)
195
+ max = Math.max(0, max)
196
+ }
197
+ if (min === max) max = min + 1
198
+ }
199
+ const range = niceNum(max - min, false)
200
+ const step = niceNum(range / Math.max(1, tickCount), true)
201
+ const nMin = Math.floor(min / step) * step
202
+ const nMax = Math.ceil(max / step) * step
203
+ const ticks: number[] = []
204
+ for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
205
+ return { min: nMin, max: nMax, step, ticks }
206
+ }
207
+
208
+ function fmtTick(n: number): string {
209
+ const abs = Math.abs(n)
210
+ if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
211
+ if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
212
+ return String(Math.round(n * 100) / 100)
213
+ }
214
+
215
+ const DAY = 86_400_000
216
+ /** Nice date-tick timestamps across [min, max]. */
217
+ function dateTicks(tMin: number, tMax: number): number[] {
218
+ const span = tMax - tMin
219
+ const step =
220
+ span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY
221
+ const ticks: number[] = []
222
+ for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step) ticks.push(t)
223
+ if (!ticks.length) ticks.push(tMin, tMax)
224
+ return ticks
225
+ }
226
+ function fmtDate(t: number, span: number): string {
227
+ const d = new Date(t)
228
+ if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
229
+ if (span <= 800 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
230
+ return String(d.getFullYear())
231
+ }
232
+
233
+ type ResolvedSeries = ChartSeries & {
234
+ color: string
235
+ kind: 'bar' | 'line' | 'area'
236
+ axis: 'left' | 'right'
237
+ }
238
+
239
+ /** Data domain for one axis, honoring stacking of its bar/area series. */
240
+ function axisDomain(
241
+ list: ResolvedSeries[],
242
+ categories: string[],
243
+ stacked: boolean,
244
+ extra: number[] = [],
245
+ ): NiceScale {
246
+ let dMin = Infinity
247
+ let dMax = -Infinity
248
+ const note = (v: number) => {
249
+ if (!Number.isFinite(v)) return
250
+ if (v < dMin) dMin = v
251
+ if (v > dMax) dMax = v
252
+ }
253
+ for (const v of extra) note(v)
254
+ const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area')
255
+ const lines = list.filter((s) => s.kind === 'line')
256
+ if (stacked && stackable.length) {
257
+ for (let i = 0; i < categories.length; i += 1) {
258
+ let pos = 0
259
+ let neg = 0
260
+ for (const s of stackable) {
261
+ const v = s.values[i] ?? 0
262
+ if (v >= 0) pos += v
263
+ else neg += v
264
+ }
265
+ note(pos)
266
+ note(neg)
267
+ }
268
+ } else {
269
+ for (const s of stackable) for (const v of s.values) note(v)
270
+ }
271
+ for (const s of lines) for (const v of s.values) note(v)
272
+ if (dMin === Infinity) {
273
+ dMin = 0
274
+ dMax = 1
275
+ }
276
+ // Bar / area charts read against a zero baseline, so always include 0.
277
+ if (stackable.length) {
278
+ dMin = Math.min(dMin, 0)
279
+ dMax = Math.max(dMax, 0)
280
+ }
281
+ return niceScale(dMin, dMax)
282
+ }
283
+
284
+ export function buildChart(spec: ChartSpec): ChartGeometry {
285
+ const width = spec.width ?? 520
286
+ const height = spec.height ?? 300
287
+ const palette = spec.palette ?? DEFAULT_PALETTE
288
+
289
+ const stacked = !!(spec.stacked || spec.stacked100)
290
+ const series: ResolvedSeries[] = spec.series.map((s, i) => ({
291
+ ...s,
292
+ color: s.color ?? palette[i % palette.length]!,
293
+ kind: (s.type ?? (spec.type === 'pie' || spec.type === 'scatter' ? 'bar' : spec.type)) as
294
+ | 'bar'
295
+ | 'line'
296
+ | 'area',
297
+ axis: s.axis ?? 'left',
298
+ }))
299
+ const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
300
+
301
+ const empty: ChartGeometry = {
302
+ type: spec.type,
303
+ width,
304
+ height,
305
+ plot: { x: 0, y: 0, w: width, h: height },
306
+ bars: [],
307
+ lines: [],
308
+ slices: [],
309
+ yTicks: [],
310
+ y2Ticks: [],
311
+ hasRightAxis: false,
312
+ xTicks: [],
313
+ xLabelRotated: false,
314
+ legend,
315
+ donut: null,
316
+ referenceLines: [],
317
+ scatterPoints: [],
318
+ orientation: 'vertical',
319
+ valueTicks: [],
320
+ catTicks: [],
321
+ referenceLinesV: [],
322
+ }
323
+
324
+ if (spec.type === 'pie') {
325
+ const s = series[0]
326
+ if (!s) return empty
327
+ const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
328
+ const cx = width / 2
329
+ const cy = height / 2
330
+ const r = Math.min(width, height) / 2 - 10
331
+ const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
332
+ const ir = r * innerFrac
333
+ let angle = -Math.PI / 2
334
+ const slices: ChartPieSlice[] = s.values.map((v, i) => {
335
+ const frac = Math.max(0, v) / total
336
+ const a0 = angle
337
+ const a1 = angle + frac * Math.PI * 2
338
+ angle = a1
339
+ const large = a1 - a0 > Math.PI ? 1 : 0
340
+ const mid = (a0 + a1) / 2
341
+ const labelR = (r + ir) / 2 || r * 0.6
342
+ const ox0 = cx + r * Math.cos(a0)
343
+ const oy0 = cy + r * Math.sin(a0)
344
+ const ox1 = cx + r * Math.cos(a1)
345
+ const oy1 = cy + r * Math.sin(a1)
346
+ let path: string
347
+ if (frac >= 0.999) {
348
+ path = ir
349
+ ? `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z` +
350
+ `M${round(cx - ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx + ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx - ir)},${round(cy)} Z`
351
+ : `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z`
352
+ } else if (ir > 0) {
353
+ const ix0 = cx + ir * Math.cos(a0)
354
+ const iy0 = cy + ir * Math.sin(a0)
355
+ const ix1 = cx + ir * Math.cos(a1)
356
+ const iy1 = cy + ir * Math.sin(a1)
357
+ path =
358
+ `M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
359
+ `L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
360
+ } else {
361
+ path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
362
+ }
363
+ return {
364
+ path,
365
+ color: palette[i % palette.length]!,
366
+ label: spec.categories[i] ?? String(i),
367
+ value: v,
368
+ percent: frac * 100,
369
+ cx: round(cx + labelR * Math.cos(mid)),
370
+ cy: round(cy + labelR * Math.sin(mid)),
371
+ }
372
+ })
373
+ return {
374
+ ...empty,
375
+ slices,
376
+ legend: spec.categories.map((label, i) => ({ label, color: palette[i % palette.length]! })),
377
+ donut: ir > 0 ? { cx: round(cx), cy: round(cy), r: round(ir), total: s.values.reduce((a, b) => a + Math.max(0, b), 0) } : null,
378
+ }
379
+ }
380
+
381
+ if (spec.type === 'scatter') {
382
+ const padL = 48 + (spec.yAxisTitle ? 16 : 0)
383
+ const padR = 12
384
+ const padT = 10
385
+ const padB = 28 + (spec.xAxisTitle ? 16 : 0)
386
+ const plotW = Math.max(1, width - padL - padR)
387
+ const plotH = Math.max(1, height - padT - padB)
388
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
389
+
390
+ let xMin = Infinity
391
+ let xMax = -Infinity
392
+ let yMin = Infinity
393
+ let yMax = -Infinity
394
+ let rMin = Infinity
395
+ let rMax = -Infinity
396
+ for (const s of series) {
397
+ for (const pt of s.points ?? []) {
398
+ if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
399
+ if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
400
+ if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
401
+ }
402
+ }
403
+ if (xMin === Infinity) return { ...empty, plot }
404
+ const xDom = niceScale(xMin, xMax)
405
+ const yDom = niceScale(yMin, yMax)
406
+ const hasR = rMax > rMin
407
+ const xOf = (v: number) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW)
408
+ const yOf = (v: number) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH)
409
+ const rOf = (r?: number) =>
410
+ hasR && r != null && Number.isFinite(r)
411
+ ? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
412
+ : 5
413
+
414
+ const scatterPoints: ChartScatterDot[] = []
415
+ for (const s of series) {
416
+ for (const pt of s.points ?? []) {
417
+ if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue
418
+ scatterPoints.push({
419
+ cx: xOf(pt.x),
420
+ cy: yOf(pt.y),
421
+ r: rOf(pt.r),
422
+ color: s.color,
423
+ label: pt.label ?? '',
424
+ series: s.label,
425
+ x: pt.x,
426
+ y: pt.y,
427
+ })
428
+ }
429
+ }
430
+ const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
431
+ y: yOf(ref.value),
432
+ label: ref.label ?? fmtTick(ref.value),
433
+ color: ref.color ?? '#ef4444',
434
+ dashed: ref.dashed !== false,
435
+ }))
436
+ return {
437
+ ...empty,
438
+ plot,
439
+ scatterPoints,
440
+ referenceLines,
441
+ yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: fmtTick(value) })),
442
+ xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
443
+ }
444
+ }
445
+
446
+ // ---- Horizontal bars ----------------------------------------------------
447
+ // Categories run down the left, bars grow rightward. Bars-only (no combo).
448
+ const horizontal =
449
+ spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar')
450
+ if (horizontal) {
451
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
452
+ const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0)
453
+ const padR = 16
454
+ const padT = 8
455
+ const padB = 26 + (spec.xAxisTitle ? 16 : 0)
456
+ const plotW = Math.max(1, width - padL - padR)
457
+ const plotH = Math.max(1, height - padT - padB)
458
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
459
+
460
+ const refs = (spec.referenceLines ?? []).map((r) => r.value)
461
+ const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs)
462
+ const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
463
+
464
+ const n = spec.categories.length
465
+ const slot = plotH / Math.max(1, n)
466
+ const groupPad = slot * 0.2
467
+ const inner = slot - groupPad
468
+ const bandTop = (i: number) => padT + slot * i + groupPad / 2
469
+ const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max))
470
+
471
+ const bars: ChartBar[] = []
472
+ if (stacked) {
473
+ const totals = spec.stacked100
474
+ ? spec.categories.map(
475
+ (_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
476
+ )
477
+ : null
478
+ const pos = new Array(n).fill(0)
479
+ const neg = new Array(n).fill(0)
480
+ for (const s of series) {
481
+ s.values.forEach((v, i) => {
482
+ if (!Number.isFinite(v)) return
483
+ const vp = totals ? (v / totals[i]!) * 100 : v
484
+ let xL: number
485
+ let xR: number
486
+ if (vp >= 0) {
487
+ xL = xOf(pos[i])
488
+ xR = xOf(pos[i] + vp)
489
+ pos[i] += vp
490
+ } else {
491
+ xL = xOf(neg[i] + vp)
492
+ xR = xOf(neg[i])
493
+ neg[i] += vp
494
+ }
495
+ bars.push({
496
+ x: Math.min(xL, xR),
497
+ y: round(bandTop(i)),
498
+ w: round(Math.abs(xR - xL)),
499
+ h: round(Math.max(1, inner)),
500
+ color: s.color,
501
+ label: spec.categories[i] ?? String(i),
502
+ series: s.label,
503
+ value: v,
504
+ })
505
+ })
506
+ }
507
+ } else {
508
+ const barH = inner / series.length
509
+ series.forEach((s, bi) => {
510
+ s.values.forEach((v, i) => {
511
+ if (!Number.isFinite(v)) return
512
+ const xV = xOf(v)
513
+ bars.push({
514
+ x: Math.min(xV, xBase),
515
+ y: round(bandTop(i) + barH * bi),
516
+ w: round(Math.max(1, Math.abs(xV - xBase))),
517
+ h: round(Math.max(1, barH - 1)),
518
+ color: s.color,
519
+ label: spec.categories[i] ?? String(i),
520
+ series: s.label,
521
+ value: v,
522
+ })
523
+ })
524
+ })
525
+ }
526
+
527
+ const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
528
+ label: spec.stacked100 ? `${fmtTick(value)}%` : fmtTick(value),
529
+ x: xOf(value),
530
+ }))
531
+ const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
532
+ value: i,
533
+ y: round(bandTop(i) + inner / 2),
534
+ label,
535
+ }))
536
+ const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
537
+ x: xOf(ref.value),
538
+ label: ref.label ?? fmtTick(ref.value),
539
+ color: ref.color ?? '#ef4444',
540
+ dashed: ref.dashed !== false,
541
+ }))
542
+
543
+ return {
544
+ ...empty,
545
+ plot,
546
+ bars,
547
+ orientation: 'horizontal',
548
+ valueTicks,
549
+ catTicks,
550
+ referenceLinesV,
551
+ xLabelRotated: false,
552
+ }
553
+ }
554
+
555
+ // ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
556
+ const leftSeries = series.filter((s) => s.axis === 'left')
557
+ const rightSeries = series.filter((s) => s.axis === 'right')
558
+ const hasRightAxis = rightSeries.length > 0
559
+
560
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
561
+ const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
562
+ const padL = 48 + (spec.yAxisTitle ? 16 : 0)
563
+ const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
564
+ const padT = 10
565
+ const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
566
+ const plotW = Math.max(1, width - padL - padR)
567
+ const plotH = Math.max(1, height - padT - padB)
568
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
569
+
570
+ const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
571
+ const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
572
+ const leftDom = spec.stacked100
573
+ ? niceScale(0, 100)
574
+ : axisDomain(leftSeries, spec.categories, stacked, refsLeft)
575
+ const rightDom = hasRightAxis
576
+ ? spec.stacked100
577
+ ? niceScale(0, 100)
578
+ : axisDomain(rightSeries, spec.categories, stacked, refsRight)
579
+ : null
580
+
581
+ const yOf = (dom: NiceScale, v: number) =>
582
+ round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
583
+ const yLeft = (v: number) => yOf(leftDom, v)
584
+ const yRight = (v: number) => yOf(rightDom ?? leftDom, v)
585
+ const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
586
+
587
+ const n = spec.categories.length
588
+ const slot = plotW / Math.max(1, n)
589
+
590
+ // X positions. A time axis spaces points by actual time (irregular gaps);
591
+ // a category axis is uniform. (Bars stay uniform either way.)
592
+ const timeVals =
593
+ spec.xType === 'time' ? spec.categories.map((c) => Date.parse(c)) : null
594
+ const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
595
+ const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
596
+ const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
597
+ const xCenter = (i: number) =>
598
+ timeOk && Number.isFinite(timeVals![i])
599
+ ? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
600
+ : round(padL + slot * i + slot / 2)
601
+ const xTicks: ChartCategoryTick[] = timeOk
602
+ ? dateTicks(tMin, tMin + tSpan).map((t) => ({
603
+ label: fmtDate(t, tSpan),
604
+ x: round(padL + ((t - tMin) / tSpan) * plotW),
605
+ }))
606
+ : spec.categories.map((label, i) => ({ label, x: xCenter(i) }))
607
+
608
+ const barSeries = series.filter((s) => s.kind === 'bar')
609
+ const bars: ChartBar[] = []
610
+ if (barSeries.length) {
611
+ const groupPad = slot * 0.2
612
+ if (stacked) {
613
+ const inner = slot - groupPad
614
+ const x0 = (i: number) => padL + slot * i + groupPad / 2
615
+ // Stack independently per axis so dual-axis stacks line up to their own scale.
616
+ for (const axis of ['left', 'right'] as const) {
617
+ const axisBars = barSeries.filter((s) => s.axis === axis)
618
+ if (!axisBars.length) continue
619
+ const yA = axis === 'right' ? yRight : yLeft
620
+ // 100% mode normalizes each category to its absolute total.
621
+ const totals = spec.stacked100
622
+ ? spec.categories.map(
623
+ (_, i) =>
624
+ axisBars.reduce(
625
+ (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
626
+ 0,
627
+ ) || 1,
628
+ )
629
+ : null
630
+ const pos = new Array(n).fill(0)
631
+ const neg = new Array(n).fill(0)
632
+ for (const s of axisBars) {
633
+ s.values.forEach((v, i) => {
634
+ if (!Number.isFinite(v)) return
635
+ const vp = totals ? (v / totals[i]!) * 100 : v
636
+ let yTop: number
637
+ let yBot: number
638
+ if (vp >= 0) {
639
+ yTop = yA(pos[i] + vp)
640
+ yBot = yA(pos[i])
641
+ pos[i] += vp
642
+ } else {
643
+ yTop = yA(neg[i])
644
+ yBot = yA(neg[i] + vp)
645
+ neg[i] += vp
646
+ }
647
+ bars.push({
648
+ x: round(x0(i)),
649
+ y: Math.min(yTop, yBot),
650
+ w: round(Math.max(1, inner)),
651
+ h: round(Math.abs(yBot - yTop)),
652
+ color: s.color,
653
+ label: spec.categories[i] ?? String(i),
654
+ series: s.label,
655
+ value: v,
656
+ })
657
+ })
658
+ }
659
+ }
660
+ } else {
661
+ const inner = slot - groupPad
662
+ const barW = inner / barSeries.length
663
+ barSeries.forEach((s, bi) => {
664
+ const dom = domOf(s)
665
+ const base = yOf(dom, Math.min(Math.max(0, dom.min), dom.max))
666
+ s.values.forEach((v, i) => {
667
+ if (!Number.isFinite(v)) return
668
+ const x = padL + slot * i + groupPad / 2 + barW * bi
669
+ const yV = yOf(dom, v)
670
+ bars.push({
671
+ x: round(x),
672
+ y: Math.min(yV, base),
673
+ w: round(Math.max(1, barW - 1)),
674
+ h: round(Math.max(1, Math.abs(yV - base))),
675
+ color: s.color,
676
+ label: spec.categories[i] ?? String(i),
677
+ series: s.label,
678
+ value: v,
679
+ })
680
+ })
681
+ })
682
+ }
683
+ }
684
+
685
+ // Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
686
+ const lines: ChartLine[] = []
687
+ const areaCum: Record<'left' | 'right', number[]> = {
688
+ left: new Array(n).fill(0),
689
+ right: new Array(n).fill(0),
690
+ }
691
+ // 100% mode: per-axis per-category totals to normalize stacked areas to 100.
692
+ const areaTotals: Record<'left' | 'right', number[] | null> = { left: null, right: null }
693
+ if (spec.stacked100) {
694
+ for (const axis of ['left', 'right'] as const) {
695
+ const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis)
696
+ if (areaSeries.length) {
697
+ areaTotals[axis] = spec.categories.map(
698
+ (_, i) =>
699
+ areaSeries.reduce(
700
+ (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
701
+ 0,
702
+ ) || 1,
703
+ )
704
+ }
705
+ }
706
+ }
707
+ for (const s of series) {
708
+ if (s.kind === 'bar') continue
709
+ const dom = domOf(s)
710
+ const yA = (v: number) => yOf(dom, v)
711
+ const isStackedArea = stacked && s.kind === 'area'
712
+ const px = (i: number) => xCenter(i)
713
+ let pts: ChartLinePoint[]
714
+ let baselinePts: Array<{ x: number; y: number }> | null = null
715
+ if (isStackedArea) {
716
+ // Stacked areas treat a gap as 0 so the stack stays continuous.
717
+ const cum = areaCum[s.axis]
718
+ const prev = cum.slice()
719
+ const totals = areaTotals[s.axis]
720
+ pts = s.values.map((v, i) => {
721
+ const vv = Number.isFinite(v) ? v : 0
722
+ // 100% mode positions by share of the category total; value stays original.
723
+ const norm = totals ? (vv / totals[i]!) * 100 : vv
724
+ const c = (cum[i] ?? 0) + norm
725
+ cum[i] = c
726
+ return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) }
727
+ })
728
+ baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
729
+ } else {
730
+ pts = s.values.map((v, i) => {
731
+ const ok = Number.isFinite(v)
732
+ return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
733
+ })
734
+ }
735
+ // Build the line in segments, breaking at gaps (undefined points).
736
+ let path = ''
737
+ let pen = false
738
+ for (const p of pts) {
739
+ if (!p.defined) {
740
+ pen = false
741
+ continue
742
+ }
743
+ path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
744
+ pen = true
745
+ }
746
+ path = path.trim()
747
+
748
+ let areaPath = ''
749
+ if (s.kind === 'area' && pts.length) {
750
+ if (baselinePts) {
751
+ const top = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
752
+ const back = baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
753
+ areaPath = `${top} ${back} Z`
754
+ } else {
755
+ // One filled polygon per contiguous run of defined points.
756
+ const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)))
757
+ const runs: ChartLinePoint[][] = []
758
+ let cur: ChartLinePoint[] = []
759
+ for (const p of pts) {
760
+ if (p.defined) cur.push(p)
761
+ else if (cur.length) {
762
+ runs.push(cur)
763
+ cur = []
764
+ }
765
+ }
766
+ if (cur.length) runs.push(cur)
767
+ areaPath = runs
768
+ .map((run) => {
769
+ const top = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
770
+ return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
771
+ })
772
+ .join(' ')
773
+ }
774
+ }
775
+ lines.push({ path, areaPath, color: s.color, label: s.label, points: pts })
776
+ }
777
+
778
+ const tickFor = (dom: NiceScale): ChartAxisTick[] =>
779
+ dom.ticks.map((value) => ({ value, y: yOf(dom, value), label: fmtTick(value) }))
780
+
781
+ const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
782
+ const dom = ref.axis === 'right' ? (rightDom ?? leftDom) : leftDom
783
+ return {
784
+ y: yOf(dom, ref.value),
785
+ label: ref.label ?? fmtTick(ref.value),
786
+ color: ref.color ?? '#ef4444',
787
+ dashed: ref.dashed !== false,
788
+ }
789
+ })
790
+
791
+ return {
792
+ ...empty,
793
+ plot,
794
+ bars,
795
+ lines,
796
+ yTicks: tickFor(leftDom),
797
+ y2Ticks: rightDom ? tickFor(rightDom) : [],
798
+ hasRightAxis,
799
+ xTicks,
800
+ xLabelRotated: timeOk ? false : xLabelRotated,
801
+ referenceLines,
802
+ }
803
+ }
804
+
805
+ /**
806
+ * Aggregate flat rows into a chart spec. Group by a category field, reduce a
807
+ * value field per group. Three multi-series shapes:
808
+ * - `value: 'revenue'` -> one series
809
+ * - `value: ['revenue','cost']` -> one series per value field
810
+ * - `value: 'sales', series: 'region'` -> pivot: one series per distinct
811
+ * value of the `series` field
812
+ */
813
+ export function rowsToChartSpec<T extends Record<string, unknown>>(
814
+ rows: ReadonlyArray<T>,
815
+ opts: {
816
+ type: ChartType
817
+ category: keyof T & string
818
+ value: (keyof T & string) | Array<keyof T & string>
819
+ /** Pivot dimension: one series per distinct value of this field. */
820
+ series?: keyof T & string
821
+ reduce?: 'sum' | 'avg' | 'count'
822
+ seriesLabel?: string
823
+ width?: number
824
+ height?: number
825
+ stacked?: boolean
826
+ stacked100?: boolean
827
+ palette?: string[]
828
+ /** Order categories. Defaults to insertion order (or value-desc when topN). */
829
+ sort?: 'value-desc' | 'value-asc' | 'category' | 'none'
830
+ /** Keep only the top N categories; bucket the rest into "Other". */
831
+ topN?: number
832
+ /** Label for the bucketed remainder. Default "Other". */
833
+ otherLabel?: string
834
+ },
835
+ ): ChartSpec {
836
+ const reduce = opts.reduce ?? 'sum'
837
+ const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value]
838
+ const reduceCell = (sum: number, count: number) =>
839
+ reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
840
+
841
+ const categories: string[] = []
842
+ const catIndex = new Map<string, number>()
843
+ const ensureCat = (key: string) => {
844
+ let idx = catIndex.get(key)
845
+ if (idx === undefined) {
846
+ idx = categories.length
847
+ catIndex.set(key, idx)
848
+ categories.push(key)
849
+ }
850
+ return idx
851
+ }
852
+
853
+ // Series keyed by name -> per-category {sum,count}.
854
+ const seriesMap = new Map<string, { sum: number; count: number }[]>()
855
+ const ensureSeries = (name: string) => {
856
+ let arr = seriesMap.get(name)
857
+ if (!arr) {
858
+ arr = []
859
+ seriesMap.set(name, arr)
860
+ }
861
+ return arr
862
+ }
863
+
864
+ for (const row of rows) {
865
+ const cat = String(row[opts.category] ?? '')
866
+ const ci = ensureCat(cat)
867
+ if (opts.series) {
868
+ const sName = String(row[opts.series] ?? '')
869
+ const arr = ensureSeries(sName)
870
+ const num = Number(row[valueFields[0]!])
871
+ const cell = (arr[ci] ??= { sum: 0, count: 0 })
872
+ if (Number.isFinite(num)) {
873
+ cell.sum += num
874
+ cell.count += 1
875
+ }
876
+ } else {
877
+ for (const vf of valueFields) {
878
+ const arr = ensureSeries(vf)
879
+ const num = Number(row[vf])
880
+ const cell = (arr[ci] ??= { sum: 0, count: 0 })
881
+ if (Number.isFinite(num)) {
882
+ cell.sum += num
883
+ cell.count += 1
884
+ }
885
+ }
886
+ }
887
+ }
888
+
889
+ const entries = [...seriesMap.entries()].map(([name, arr]) => ({
890
+ label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
891
+ values: categories.map((_, i) => {
892
+ const cell = arr[i] ?? { sum: 0, count: 0 }
893
+ return reduceCell(cell.sum, cell.count)
894
+ }),
895
+ }))
896
+
897
+ // ---- Sort + top-N -----------------------------------------------------
898
+ const totals = categories.map((_, i) =>
899
+ entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0),
900
+ )
901
+ const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none')
902
+ let order = categories.map((_, i) => i)
903
+ if (sort === 'value-desc') order.sort((a, b) => totals[b]! - totals[a]!)
904
+ else if (sort === 'value-asc') order.sort((a, b) => totals[a]! - totals[b]!)
905
+ else if (sort === 'category') order.sort((a, b) => categories[a]!.localeCompare(categories[b]!))
906
+
907
+ let finalCategories: string[]
908
+ let finalSeries: ChartSeries[]
909
+ if (opts.topN && order.length > opts.topN) {
910
+ const keep = order.slice(0, opts.topN)
911
+ const rest = order.slice(opts.topN)
912
+ finalCategories = keep.map((i) => categories[i]!).concat(opts.otherLabel ?? 'Other')
913
+ finalSeries = entries.map((e) => ({
914
+ label: e.label,
915
+ values: keep
916
+ .map((i) => e.values[i]!)
917
+ .concat(rest.reduce((sum, i) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0)),
918
+ }))
919
+ } else {
920
+ finalCategories = order.map((i) => categories[i]!)
921
+ finalSeries = entries.map((e) => ({ label: e.label, values: order.map((i) => e.values[i]!) }))
922
+ }
923
+
924
+ return {
925
+ type: opts.type,
926
+ categories: finalCategories,
927
+ series: finalSeries,
928
+ width: opts.width,
929
+ height: opts.height,
930
+ stacked: opts.stacked,
931
+ stacked100: opts.stacked100,
932
+ palette: opts.palette,
933
+ }
934
+ }