@svgrid/grid 3.0.0 → 3.0.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 (259) hide show
  1. package/CHANGELOG.md +116 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +181 -181
  5. package/dist/SvAutoComplete.svelte +169 -169
  6. package/dist/SvAvatar.svelte +75 -75
  7. package/dist/SvCalendar.svelte +503 -503
  8. package/dist/SvCarousel.svelte +141 -141
  9. package/dist/SvCheckBox.svelte +102 -102
  10. package/dist/SvCircularProgress.svelte +109 -109
  11. package/dist/SvColorInput.svelte +181 -181
  12. package/dist/SvComboBox.svelte +279 -279
  13. package/dist/SvContextMenu.svelte +116 -116
  14. package/dist/SvCountryInput.svelte +163 -163
  15. package/dist/SvDrawer.svelte +254 -254
  16. package/dist/SvDropDownList.svelte +378 -378
  17. package/dist/SvDurationInput.svelte +126 -126
  18. package/dist/SvField.svelte +293 -293
  19. package/dist/SvForm.svelte +437 -437
  20. package/dist/SvGridCellEditor.svelte +748 -744
  21. package/dist/SvGridChart.svelte +1716 -1716
  22. package/dist/SvGridChartPanel.svelte +485 -485
  23. package/dist/SvGridChartView.svelte +70 -70
  24. package/dist/SvGridDropdown.svelte +728 -728
  25. package/dist/SvGridSelect.svelte +270 -270
  26. package/dist/SvGroupCell.svelte +116 -116
  27. package/dist/SvListBox.svelte +334 -334
  28. package/dist/SvMaskedInput.svelte +122 -122
  29. package/dist/SvMenu.svelte +124 -124
  30. package/dist/SvMenuList.svelte +146 -146
  31. package/dist/SvMultiSelect.svelte +293 -293
  32. package/dist/SvNumberInput.svelte +172 -172
  33. package/dist/SvOtpInput.svelte +158 -158
  34. package/dist/SvPasswordInput.svelte +151 -151
  35. package/dist/SvPhoneInput.svelte +133 -133
  36. package/dist/SvPopover.svelte +197 -197
  37. package/dist/SvProgress.svelte +116 -116
  38. package/dist/SvRadioGroup.svelte +107 -107
  39. package/dist/SvRating.svelte +112 -112
  40. package/dist/SvResult.svelte +73 -73
  41. package/dist/SvRichText.svelte +211 -211
  42. package/dist/SvRowGroupPanel.svelte +170 -170
  43. package/dist/SvScrollArea.svelte +61 -61
  44. package/dist/SvSlider.svelte +203 -203
  45. package/dist/SvSwitchButton.svelte +108 -108
  46. package/dist/SvTagsInput.svelte +115 -115
  47. package/dist/SvTextInput.svelte +147 -147
  48. package/dist/SvTimePicker.svelte +245 -245
  49. package/dist/SvToaster.svelte +159 -159
  50. package/dist/SvToggleButton.svelte +85 -85
  51. package/dist/SvTooltip.svelte +161 -161
  52. package/dist/SvTour.svelte +208 -208
  53. package/dist/SvTree.svelte +444 -444
  54. package/dist/SvTreeSelect.svelte +239 -239
  55. package/dist/cdn/{GridMenus-DMihUtma.js → GridMenus-CYzOvmNQ.js} +3 -3
  56. package/dist/cdn/{GridMenus-nmNDj1a3.js → GridMenus-DFB6iwF8.js} +3 -3
  57. package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
  58. package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
  59. package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
  60. package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
  61. package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
  62. package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
  63. package/dist/cdn/{SvGridChart-CwhFz7GV.js → SvGridChart-BEJmNNx9.js} +1 -1
  64. package/dist/cdn/{SvGridChart-Bs2GIR2Q.js → SvGridChart-BwVos976.js} +1 -1
  65. package/dist/cdn/{SvGridChartPanel-1WXuStzM.js → SvGridChartPanel-BOknOkrP.js} +1 -1
  66. package/dist/cdn/{SvGridChartPanel-e5_nqNP9.js → SvGridChartPanel-D2PjII4O.js} +1 -1
  67. package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-00LPUHL1.js} +1 -1
  68. package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-BhUEJ5ki.js} +1 -1
  69. package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
  70. package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
  71. package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
  72. package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
  73. package/dist/cdn/{src-B_YS5AOc.js → src-C8QjSaq-.js} +42 -44
  74. package/dist/cdn/{src-DYXXpuSk.js → src-D0hO2eYN.js} +41 -43
  75. package/dist/cdn/svgrid.js +7 -7
  76. package/dist/cdn/svgrid.svelte-external.js +7 -7
  77. package/dist/chart-export.js +8 -8
  78. package/dist/editing.js +22 -13
  79. package/package.json +11 -11
  80. package/src/FlexRender.svelte +96 -96
  81. package/src/GridFooter.svelte +181 -181
  82. package/src/SvAutoComplete.svelte +169 -169
  83. package/src/SvAvatar.svelte +75 -75
  84. package/src/SvCalendar.svelte +503 -503
  85. package/src/SvCalendar.test.ts +226 -226
  86. package/src/SvCarousel.svelte +141 -141
  87. package/src/SvCheckBox.svelte +102 -102
  88. package/src/SvCircularProgress.svelte +109 -109
  89. package/src/SvColorInput.svelte +181 -181
  90. package/src/SvComboBox.svelte +279 -279
  91. package/src/SvContextMenu.svelte +116 -116
  92. package/src/SvCountryInput.svelte +163 -163
  93. package/src/SvDrawer.svelte +254 -254
  94. package/src/SvDropDownList.svelte +378 -378
  95. package/src/SvDurationInput.svelte +126 -126
  96. package/src/SvField.svelte +293 -293
  97. package/src/SvForm.svelte +437 -437
  98. package/src/SvForm.test.ts +411 -411
  99. package/src/SvGrid.types.ts +2092 -2092
  100. package/src/SvGridCellEditor.svelte +748 -744
  101. package/src/SvGridChart.svelte +1716 -1716
  102. package/src/SvGridChartPanel.svelte +485 -485
  103. package/src/SvGridChartView.svelte +70 -70
  104. package/src/SvGridDropdown.svelte +728 -728
  105. package/src/SvGridSelect.svelte +270 -270
  106. package/src/SvGroupCell.svelte +116 -116
  107. package/src/SvListBox.svelte +334 -334
  108. package/src/SvMaskedInput.svelte +122 -122
  109. package/src/SvMenu.svelte +124 -124
  110. package/src/SvMenu.test.ts +97 -97
  111. package/src/SvMenuList.svelte +146 -146
  112. package/src/SvMultiSelect.svelte +293 -293
  113. package/src/SvNumberInput.svelte +172 -172
  114. package/src/SvOtpInput.svelte +158 -158
  115. package/src/SvPasswordInput.svelte +151 -151
  116. package/src/SvPhoneInput.svelte +133 -133
  117. package/src/SvPopover.svelte +197 -197
  118. package/src/SvProgress.svelte +116 -116
  119. package/src/SvRadioGroup.svelte +107 -107
  120. package/src/SvRating.svelte +112 -112
  121. package/src/SvResult.svelte +73 -73
  122. package/src/SvRichText.svelte +211 -211
  123. package/src/SvRowGroupPanel.svelte +170 -170
  124. package/src/SvScrollArea.svelte +61 -61
  125. package/src/SvSlider.svelte +203 -203
  126. package/src/SvSwitchButton.svelte +108 -108
  127. package/src/SvTagsInput.svelte +115 -115
  128. package/src/SvTextInput.svelte +147 -147
  129. package/src/SvTimePicker.svelte +245 -245
  130. package/src/SvToaster.svelte +159 -159
  131. package/src/SvToaster.test.ts +95 -95
  132. package/src/SvToggleButton.svelte +85 -85
  133. package/src/SvTooltip.svelte +161 -161
  134. package/src/SvTour.svelte +208 -208
  135. package/src/SvTree.svelte +444 -444
  136. package/src/SvTreeSelect.svelte +239 -239
  137. package/src/a11y/dismissable.test.ts +119 -119
  138. package/src/a11y/dismissable.ts +114 -114
  139. package/src/a11y.contract.test.ts +49 -49
  140. package/src/a11y.test.ts +59 -59
  141. package/src/a11y.ts +61 -61
  142. package/src/ai.test.ts +502 -502
  143. package/src/ai.ts +1419 -1419
  144. package/src/build-api.coverage.test.ts +633 -633
  145. package/src/build-api.ts +846 -846
  146. package/src/builtin-editors.grid.test.ts +83 -83
  147. package/src/cell-formatting.ts +171 -171
  148. package/src/cell-render.test.ts +513 -513
  149. package/src/cell-render.ts +496 -496
  150. package/src/cell-values.ts +148 -148
  151. package/src/chart-export.ts +202 -202
  152. package/src/chart-view.svelte.ts +36 -36
  153. package/src/chart.ts +2321 -2321
  154. package/src/collaboration.test.ts +104 -104
  155. package/src/collaboration.ts +167 -167
  156. package/src/column-groups.ts +78 -78
  157. package/src/core.performance.test.ts +30 -30
  158. package/src/core.ts +1865 -1865
  159. package/src/createAutocomplete.svelte.ts +132 -132
  160. package/src/createCombobox.svelte.ts +191 -191
  161. package/src/createCountryInput.svelte.ts +157 -157
  162. package/src/createDropdownList.svelte.ts +168 -168
  163. package/src/createForm.svelte.ts +386 -386
  164. package/src/createGrid.svelte.ts +42 -42
  165. package/src/createGrid.test.ts +10 -10
  166. package/src/createGridState.svelte.ts +17 -17
  167. package/src/createListbox.svelte.ts +250 -250
  168. package/src/createMenu.svelte.ts +224 -224
  169. package/src/createPopoverSelect.svelte.ts +213 -213
  170. package/src/createSlider.svelte.ts +191 -191
  171. package/src/createTooltip.svelte.ts +144 -144
  172. package/src/createTree.svelte.ts +322 -322
  173. package/src/datetime/date-core.ts +206 -206
  174. package/src/datetime/date-restrict.ts +61 -61
  175. package/src/datetime/timezone.ts +135 -135
  176. package/src/dock-manager-model.ts +596 -596
  177. package/src/dock-model.ts +374 -374
  178. package/src/editing.test.ts +974 -974
  179. package/src/editing.ts +618 -609
  180. package/src/editor-contract.ts +171 -171
  181. package/src/editor-registry.grid.test.ts +144 -144
  182. package/src/editor-registry.ts +122 -122
  183. package/src/export-data-api.test.ts +126 -126
  184. package/src/export-format.test.ts +107 -107
  185. package/src/export-format.ts +601 -601
  186. package/src/filter-operators.ts +160 -160
  187. package/src/filtering/excel-filters.ts +325 -325
  188. package/src/flex-render.ts +3 -3
  189. package/src/form-field.ts +127 -127
  190. package/src/group-display.test.ts +167 -167
  191. package/src/group-display.ts +200 -200
  192. package/src/headless.ts +87 -87
  193. package/src/js-scroller.svelte.ts +173 -173
  194. package/src/keyboard-handlers.ts +270 -270
  195. package/src/keyboard.test.ts +59 -59
  196. package/src/keyboard.ts +97 -97
  197. package/src/list-nav.test.ts +49 -49
  198. package/src/list-nav.ts +29 -29
  199. package/src/list-option.test.ts +56 -56
  200. package/src/list-option.ts +179 -179
  201. package/src/menus.ts +597 -597
  202. package/src/merge-objects.ts +48 -48
  203. package/src/overlays.test.ts +90 -90
  204. package/src/positioning.ts +268 -268
  205. package/src/render-component.ts +28 -28
  206. package/src/row-drag.test.ts +401 -401
  207. package/src/row-drag.ts +419 -419
  208. package/src/row-resize.test.ts +524 -524
  209. package/src/row-resize.ts +228 -228
  210. package/src/scheduler-ical.ts +181 -181
  211. package/src/scheduler-model.test.ts +562 -562
  212. package/src/scheduler-model.ts +873 -873
  213. package/src/selection.test.ts +885 -885
  214. package/src/server-data-source.test.ts +383 -383
  215. package/src/server-data-source.ts +469 -469
  216. package/src/sparkline.test.ts +68 -68
  217. package/src/sparkline.ts +169 -169
  218. package/src/spreadsheet.test.ts +488 -488
  219. package/src/spreadsheet.ts +312 -312
  220. package/src/static-functions.ts +11 -11
  221. package/src/subscribe.ts +38 -38
  222. package/src/summaries.ts +113 -113
  223. package/src/svgrid-wrapper.types.ts +563 -563
  224. package/src/svgrid.async-editor-options.test.ts +273 -273
  225. package/src/svgrid.auto-row-height.test.ts +204 -204
  226. package/src/svgrid.behavior.test.ts +910 -910
  227. package/src/svgrid.charting.test.ts +534 -534
  228. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  229. package/src/svgrid.context-menu.test.ts +147 -147
  230. package/src/svgrid.features.test.ts +157 -157
  231. package/src/svgrid.filter-depth.test.ts +163 -163
  232. package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
  233. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  234. package/src/svgrid.grand-total.test.ts +188 -188
  235. package/src/svgrid.group-display-mode.test.ts +171 -171
  236. package/src/svgrid.group-footers.test.ts +121 -121
  237. package/src/svgrid.group-pagination.test.ts +153 -153
  238. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  239. package/src/svgrid.tree-data.test.ts +186 -186
  240. package/src/svgrid.wrapper.test.ts +63 -63
  241. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  242. package/src/test-setup.ts +62 -62
  243. package/src/themes/index.ts +288 -288
  244. package/src/toast-store.svelte.ts +250 -250
  245. package/src/toast-store.test.ts +147 -147
  246. package/src/tree-row-model.test.ts +168 -168
  247. package/src/ui-buttons.test.ts +144 -144
  248. package/src/ui-inputs.test.ts +118 -118
  249. package/src/ui-localization.test.ts +113 -113
  250. package/src/ui-range.test.ts +70 -70
  251. package/src/ui-selection.test.ts +155 -155
  252. package/src/ui-tier1.test.ts +142 -142
  253. package/src/virtual.test.ts +88 -88
  254. package/src/virtualization/column-virtualizer.test.ts +27 -27
  255. package/src/virtualization/column-virtualizer.ts +30 -30
  256. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  257. package/src/virtualization/types.ts +30 -30
  258. package/src/virtualization/virtualizer.test.ts +47 -47
  259. package/src/virtualization/virtualizer.ts +322 -322
package/src/chart.ts CHANGED
@@ -1,2321 +1,2321 @@
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 =
12
- | 'bar' | 'line' | 'area' | 'pie' | 'scatter'
13
- | 'heatmap' | 'waterfall' | 'funnel' | 'radar'
14
- | 'calendar' | 'gauge' | 'treemap' | 'sankey'
15
-
16
- /** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`.
17
- * `rowIds` is populated when the spec was built from grid rows (via
18
- * `rowsToChartSpec`) and lets a drill handler filter the grid back to the
19
- * source rows for the clicked category / series cell. */
20
- export type ChartSelection = {
21
- category: string
22
- series: string
23
- value: number
24
- rowIds?: Array<string | number>
25
- }
26
-
27
- /** A single scatter / bubble point. */
28
- export type ScatterPoint = { x: number; y: number; r?: number; label?: string }
29
-
30
- /** A statistical / smoothing line drawn on top of a source series.
31
- * - `'linear'`: ordinary least-squares regression line
32
- * - `'sma:N'`: simple moving average over a window of N points
33
- * - `'ema:N'`: exponential moving average with smoothing factor 2/(N+1) */
34
- export type SeriesOverlay = 'linear' | `sma:${number}` | `ema:${number}`
35
-
36
- /** A texture fill applied in addition to (and on top of) the series color.
37
- * Helps colorblind readers distinguish series at a glance. */
38
- export type SeriesPattern = 'solid' | 'stripe' | 'crosshatch' | 'dots' | 'diagonal'
39
-
40
- /** One plotted series: its label, its values (one per category), and how to draw it. */
41
- export type ChartSeries = {
42
- label: string
43
- values: number[]
44
- color?: string
45
- /** Per-series chart type, for combo charts. Defaults to the spec `type`. */
46
- type?: 'bar' | 'line' | 'area'
47
- /** Plot against the left (default) or right Y axis. */
48
- axis?: 'left' | 'right'
49
- /** Scatter / bubble points (used when `type === 'scatter'`). */
50
- points?: ScatterPoint[]
51
- /** Row IDs contributing to each data point - parallel to `values`. When
52
- * present, click handlers receive these in `ChartSelection.rowIds` so
53
- * callers can drill the grid back to the source rows. */
54
- rowIds?: Array<Array<string | number>>
55
- /** Draw a smoothing / trend overlay on top of this series. */
56
- overlay?: SeriesOverlay
57
- /** Color for the overlay line. Defaults to the series color. */
58
- overlayColor?: string
59
- /** Texture fill (e.g. diagonal stripes) layered over the series color.
60
- * Lets colorblind viewers tell two series apart even at the same hue. */
61
- pattern?: SeriesPattern
62
- /** Interpolate the line as a curve instead of polylines. `'monotone'`
63
- * cubic prevents overshoots between points (best default for data);
64
- * `true` is an alias for `'monotone'`. Only meaningful for line/area. */
65
- smooth?: boolean | 'monotone'
66
- /** Upper envelope (e.g. forecast 95th percentile) parallel to `values`.
67
- * When set alongside `lowerValues`, the chart shades the band between
68
- * the two as a translucent fill in the series color. */
69
- upperValues?: number[]
70
- /** Lower envelope; pair with `upperValues` for a confidence band. */
71
- lowerValues?: number[]
72
- }
73
-
74
- /** A pinned label drawn over the plot, anchored to a data point or to an
75
- * arbitrary (x, y) in data space. Useful for "Release v1", "Outage", etc. */
76
- export type ChartAnnotation = {
77
- /** Anchor in data space. Provide either `category` + `axis` for a point on
78
- * an existing series, OR raw `x` / `y` numeric coordinates in data space. */
79
- at:
80
- | { category: string; series?: string }
81
- | { x: number; y?: number }
82
- label: string
83
- color?: string
84
- /** Where the label sits relative to the marker. Defaults to 'top'. */
85
- placement?: 'top' | 'bottom' | 'left' | 'right'
86
- }
87
-
88
- /** A horizontal reference / target line drawn across the plot. */
89
- export type ChartReferenceLine = {
90
- value: number
91
- label?: string
92
- axis?: 'left' | 'right'
93
- color?: string
94
- dashed?: boolean
95
- }
96
-
97
- /**
98
- * What to plot - the input you build and hand to a chart. Categories are the
99
- * x-axis labels and every series supplies one value per category.
100
- *
101
- * {@link buildChart} turns this into a {@link ChartGeometry} for rendering.
102
- */
103
- export type ChartSpec = {
104
- /** Default type for series that don't set their own `type`. */
105
- type: ChartType
106
- /** X-axis labels (one per data point). */
107
- categories: string[]
108
- series: ChartSeries[]
109
- width?: number
110
- height?: number
111
- /** Palette used when a series has no explicit `color`. */
112
- palette?: string[]
113
- /** Per-category color overrides (by category label) - for pie / donut slice
114
- * recolouring, where colour follows the category, not a series. */
115
- categoryColors?: Record<string, string>
116
- /** Number format for the value axis, tooltips, data labels and reference
117
- * lines. Unset = the default compact `1.2k` / `1.2M` style. */
118
- valueFormat?: ChartValueFormat
119
- /** Grouped (nested) category axis: a parent tier spanning consecutive leaf
120
- * categories (spans must sum to `categories.length`). Vertical category
121
- * charts only (ignored for time / horizontal / pie). */
122
- categoryGroups?: Array<{ label: string; span: number }>
123
- /** Stack bar / area series (per axis) instead of grouping them. */
124
- stacked?: boolean
125
- /** Stack to 100% (each category normalized to its total). Implies stacked. */
126
- stacked100?: boolean
127
- /**
128
- * Bar orientation. `'horizontal'` swaps the axes - categories run down the
129
- * left, bars grow rightward - which suits long category labels. Only applies
130
- * when every series is a bar (combo / line / area fall back to vertical).
131
- */
132
- orientation?: 'vertical' | 'horizontal'
133
- /** Pie only: inner radius as a fraction of the outer radius (0..1) -> donut. */
134
- innerRadius?: number
135
- /** Horizontal target / goal / average lines. */
136
- referenceLines?: ChartReferenceLine[]
137
- /** Treat `categories` as dates -> time-scaled x positions + date ticks. */
138
- xType?: 'category' | 'time'
139
- /** Axis titles (reserve gutter space + render). */
140
- yAxisTitle?: string
141
- y2AxisTitle?: string
142
- xAxisTitle?: string
143
- /** Y-axis scale. `'log'` plots base-10 logarithmic - values <= 0 are
144
- * treated as missing. Necessary for wide-range data (money, audience
145
- * size, scientific). Default `'linear'`. */
146
- yScale?: 'linear' | 'log'
147
- /** Right (secondary) Y-axis scale. Default `'linear'`. */
148
- y2Scale?: 'linear' | 'log'
149
- /** Pinned text labels at fixed data-space positions (callouts). */
150
- annotations?: ChartAnnotation[]
151
- /** When true, automatically cycle through pattern fills for every series
152
- * that doesn't set `pattern` explicitly. Useful as a one-flag colorblind
153
- * fallback. Default false. */
154
- patternFallback?: boolean
155
- /** Calendar heatmap: array of date+value samples (one per day). Date
156
- * strings are 'YYYY-MM-DD'. Missing days render as blank cells. */
157
- calendarValues?: Array<{ date: string; value: number }>
158
- /** Calendar heatmap: year window. Default: span the data. */
159
- calendarStart?: string
160
- calendarEnd?: string
161
-
162
- /** Gauge: the value to display. */
163
- gaugeValue?: number
164
- /** Gauge: min/max of the dial scale. Defaults [0, 100]. */
165
- gaugeMin?: number
166
- gaugeMax?: number
167
- /** Gauge: target marker (the line/notch on the arc). */
168
- gaugeTarget?: number
169
- /** Gauge: color bands along the arc (e.g. red/amber/green). */
170
- gaugeRanges?: Array<{ from: number; to: number; color: string }>
171
- /** Gauge: unit / suffix shown next to the value (e.g. '%', 'ms'). */
172
- gaugeUnit?: string
173
-
174
- /** Tree-map: hierarchical root. Leaves have `value`; parents are the
175
- * sum of their children's totals. */
176
- treemap?: TreeNode
177
-
178
- /** Sankey: nodes + flow links between them. Link `source` / `target`
179
- * reference node ids. */
180
- sankeyNodes?: Array<{ id: string; label?: string; color?: string }>
181
- sankeyLinks?: Array<{ source: string; target: string; value: number; color?: string }>
182
-
183
- /** Waterfall: per-category flag marking bars as totals/subtotals that
184
- * reset the running sum and span from 0. Same length as `categories`. */
185
- waterfallTotals?: boolean[]
186
- /** Waterfall: explicit colors for positive/negative/total bars. The
187
- * series color is ignored when this is set. */
188
- waterfallColors?: { positive?: string; negative?: string; total?: string }
189
- /** Heatmap color scale. `'sequential'` maps min->max through one hue,
190
- * `'diverging'` runs cold->neutral->warm around 0. A custom array
191
- * (>=2 hex colors) defines an arbitrary gradient. Default `'sequential'`. */
192
- colorScale?: 'sequential' | 'diverging' | string[]
193
- }
194
-
195
- /** A tree-map / sankey / treemap node spec. Used recursively as a tree. */
196
- export type TreeNode = {
197
- name: string
198
- value?: number
199
- color?: string
200
- children?: TreeNode[]
201
- }
202
-
203
- /** A laid-out tree-map rectangle. */
204
- export type ChartTreemapCell = {
205
- x: number
206
- y: number
207
- w: number
208
- h: number
209
- color: string
210
- textColor: string
211
- name: string
212
- value: number
213
- /** Depth from the root - useful for color cycling per level. */
214
- depth: number
215
- }
216
-
217
- /** A calendar-heatmap cell (one day). */
218
- export type ChartCalendarCell = {
219
- x: number
220
- y: number
221
- size: number
222
- date: string
223
- value: number
224
- /** Defined when a value was supplied for this day; blank otherwise. */
225
- defined: boolean
226
- color: string
227
- }
228
-
229
- /** A gauge dial layout. */
230
- export type ChartGaugeLayout = {
231
- cx: number
232
- cy: number
233
- r: number
234
- /** Track arc path (background grey). */
235
- trackPath: string
236
- /** Value arc path (filled to the current value). */
237
- valuePath: string
238
- /** Optional colored range arcs. */
239
- rangePaths: Array<{ path: string; color: string; from: number; to: number }>
240
- /** Pixel position of the target marker (when set). */
241
- target: { x1: number; y1: number; x2: number; y2: number } | null
242
- /** Tick marks around the dial (major ticks are longer). */
243
- ticks: Array<{ x1: number; y1: number; x2: number; y2: number; major: boolean }>
244
- /** Pointer needle (a kite shape) + its center hub radius. */
245
- needle: { path: string; hubR: number }
246
- /** Status color of the value arc (the band the value falls in), or null to
247
- * fall back to the theme accent. */
248
- valueColor: string | null
249
- /** Scale end labels positioned under the two arc ends. */
250
- minLabel: { x: number; y: number }
251
- maxLabel: { x: number; y: number }
252
- value: number
253
- min: number
254
- max: number
255
- unit: string
256
- }
257
-
258
- /** A sankey node + its laid-out rect + total flow. */
259
- export type ChartSankeyNode = {
260
- id: string
261
- label: string
262
- color: string
263
- x: number
264
- y: number
265
- w: number
266
- h: number
267
- /** Column (depth) the node was assigned to. */
268
- column: number
269
- totalIn: number
270
- totalOut: number
271
- }
272
-
273
- /** A sankey link rendered as a curved ribbon. */
274
- export type ChartSankeyLink = {
275
- path: string
276
- color: string
277
- /** Stroke width = link value scaled to pixels. */
278
- width: number
279
- source: string
280
- target: string
281
- value: number
282
- }
283
-
284
- /** A single funnel segment (trapezoid) in pixel space. */
285
- export type ChartFunnelSegment = {
286
- /** Pre-built SVG path for the trapezoid. */
287
- path: string
288
- color: string
289
- label: string
290
- /** Original value (before any percentile normalisation). */
291
- value: number
292
- /** Conversion vs. first segment, 0..1. */
293
- conversion: number
294
- /** Drop-off from the previous segment, 0..1. */
295
- dropoff: number
296
- /** Centre point (label anchor). */
297
- cx: number
298
- cy: number
299
- /** Auto-picked black/white contrast color for in-segment labels. */
300
- textColor: string
301
- }
302
-
303
- /** A radar series' polygon: axis values + the closed polygon path. */
304
- export type ChartRadarSeries = {
305
- label: string
306
- color: string
307
- path: string
308
- /** Per-axis (x, y) endpoints so callers can draw dots / hit targets. */
309
- points: Array<{ x: number; y: number; value: number; axis: string }>
310
- }
311
-
312
- /** Radar axis spoke + tick info. */
313
- export type ChartRadarAxis = {
314
- label: string
315
- /** Outermost endpoint of the spoke. */
316
- x: number
317
- y: number
318
- }
319
-
320
- /** A single heatmap rectangle in pixel space. */
321
- export type ChartHeatmapCell = {
322
- x: number
323
- y: number
324
- w: number
325
- h: number
326
- color: string
327
- /** Text color picked for contrast against `color`. */
328
- textColor: string
329
- value: number
330
- rowLabel: string
331
- colLabel: string
332
- }
333
-
334
- /** A computed bar rectangle in SVG coordinates. Output of {@link buildChart}, not an input. */
335
- export type ChartBar = {
336
- x: number
337
- y: number
338
- w: number
339
- h: number
340
- color: string
341
- /** Category (x label) this bar belongs to - for tooltips + labels. */
342
- label: string
343
- /** Series label this bar belongs to. */
344
- series: string
345
- value: number
346
- }
347
- /** One computed point on a line, with whether the series has a value there. */
348
- export type ChartLinePoint = {
349
- x: number
350
- y: number
351
- label: string
352
- value: number
353
- /** False for null / NaN values - the line breaks (gap), no dot is drawn. */
354
- defined: boolean
355
- }
356
- /** A computed line series: its points and the path drawn through them. */
357
- export type ChartLine = {
358
- path: string
359
- areaPath: string
360
- color: string
361
- label: string
362
- points: ChartLinePoint[]
363
- /** Confidence-band path (between upperValues + lowerValues) for this
364
- * series, when both arrays are supplied. Empty otherwise. */
365
- bandPath?: string
366
- }
367
- /** A computed pie slice, as an SVG arc plus its label placement. */
368
- export type ChartPieSlice = {
369
- path: string
370
- color: string
371
- label: string
372
- value: number
373
- percent: number
374
- /** Centroid - anchor point for a data label. */
375
- cx: number
376
- cy: number
377
- }
378
- /** A value-axis tick: the number, where it sits vertically, and its label. */
379
- export type ChartAxisTick = { value: number; y: number; label: string }
380
- /** A category-axis tick: the label and its horizontal position. */
381
- export type ChartCategoryTick = { label: string; x: number }
382
- /** One legend entry, paired with the series colour it stands for. */
383
- export type ChartLegendItem = { label: string; color: string }
384
- /** A computed reference line (target, average, threshold) at its plotted height. */
385
- export type ChartRefLineGeo = { y: number; label: string; color: string; dashed: boolean }
386
- /** A vertical reference line (horizontal bar charts) positioned by `x`. */
387
- export type ChartRefLineGeoV = { x: number; label: string; color: string; dashed: boolean }
388
- /** A computed scatter point in SVG coordinates. */
389
- export type ChartScatterDot = {
390
- cx: number
391
- cy: number
392
- r: number
393
- color: string
394
- label: string
395
- series: string
396
- x: number
397
- y: number
398
- }
399
-
400
- /**
401
- * Everything needed to render a chart: the plot rectangle plus every mark
402
- * already positioned in SVG coordinates. Produced by {@link buildChart} from a
403
- * {@link ChartSpec}, so a renderer does no maths of its own.
404
- */
405
- export type ChartGeometry = {
406
- type: ChartType
407
- width: number
408
- height: number
409
- plot: { x: number; y: number; w: number; h: number }
410
- bars: ChartBar[]
411
- lines: ChartLine[]
412
- slices: ChartPieSlice[]
413
- yTicks: ChartAxisTick[]
414
- /** Right-axis ticks (combo / dual-axis); empty when there's no right axis. */
415
- y2Ticks: ChartAxisTick[]
416
- hasRightAxis: boolean
417
- xTicks: ChartCategoryTick[]
418
- /** Grouped category axis parent tier: label + span extent (pixels). Empty
419
- * unless `spec.categoryGroups` is set on a vertical category chart. */
420
- categoryGroupTicks: Array<{ label: string; xCenter: number; x0: number; x1: number }>
421
- /** True when x labels are long/many and should be rotated. */
422
- xLabelRotated: boolean
423
- legend: ChartLegendItem[]
424
- /** Donut centre (pie + innerRadius), for a centre total label. */
425
- donut: { cx: number; cy: number; r: number; total: number } | null
426
- /** Horizontal reference / target lines. */
427
- referenceLines: ChartRefLineGeo[]
428
- /** Scatter / bubble points (type === 'scatter'). */
429
- scatterPoints: ChartScatterDot[]
430
- /** Bar orientation. `'horizontal'` uses `valueTicks` / `catTicks` below. */
431
- orientation: 'vertical' | 'horizontal'
432
- /** Horizontal bars: value-axis ticks along the bottom (label + x). */
433
- valueTicks: ChartCategoryTick[]
434
- /** Horizontal bars: category labels down the left (label + y; value = index). */
435
- catTicks: ChartAxisTick[]
436
- /** Horizontal bars: vertical reference / target lines (positioned by x). */
437
- referenceLinesV: ChartRefLineGeoV[]
438
- /** Trend / moving-average overlay lines (parallel to `lines`). Drawn
439
- * dashed on top of their source series. */
440
- overlays: ChartLine[]
441
- /** Pinned annotation labels with pre-resolved screen coordinates. */
442
- annotations: Array<{ x: number; y: number; label: string; color: string; placement: 'top' | 'bottom' | 'left' | 'right' }>
443
- /** Heatmap cells (type === 'heatmap'). */
444
- heatmapCells: ChartHeatmapCell[]
445
- /** Heatmap row labels with pre-resolved y positions (left gutter). */
446
- heatmapRowTicks: ChartAxisTick[]
447
- /** Heatmap column labels (bottom of plot). */
448
- heatmapColTicks: ChartCategoryTick[]
449
- /** Heatmap color-scale legend: ordered stops with value + color. */
450
- heatmapLegend: Array<{ value: number; color: string; label: string }>
451
- /** Funnel segments (type === 'funnel'). */
452
- funnelSegments: ChartFunnelSegment[]
453
- /** Radar concentric grid rings (centred at `radarCenter`). */
454
- radarRings: number[]
455
- /** Radar axis labels + spoke endpoints. */
456
- radarAxes: ChartRadarAxis[]
457
- /** Radar series polygons. */
458
- radarSeries: ChartRadarSeries[]
459
- /** Centre of the radar / pie. Pre-computed so callers don't re-derive. */
460
- radarCenter: { cx: number; cy: number; r: number } | null
461
- /** Tree-map cells (type === 'treemap'). */
462
- treemapCells: ChartTreemapCell[]
463
- /** Calendar heatmap (type === 'calendar'). */
464
- calendarCells: ChartCalendarCell[]
465
- /** Calendar month labels along the top. */
466
- calendarMonthTicks: ChartCategoryTick[]
467
- /** Calendar legend stops (sequential ramp). */
468
- calendarLegend: Array<{ value: number; color: string; label: string }>
469
- /** Gauge layout (type === 'gauge'). Null when not a gauge. */
470
- gauge: ChartGaugeLayout | null
471
- /** Sankey nodes (type === 'sankey'). */
472
- sankeyNodes: ChartSankeyNode[]
473
- /** Sankey links (type === 'sankey'). */
474
- sankeyLinks: ChartSankeyLink[]
475
- }
476
-
477
- /** Series colours used when a {@link ChartSeries} sets none, in order. */
478
- export const DEFAULT_PALETTE = [
479
- '#2563eb',
480
- '#16a34a',
481
- '#f59e0b',
482
- '#ef4444',
483
- '#8b5cf6',
484
- '#0ea5e9',
485
- '#ec4899',
486
- '#14b8a6',
487
- ]
488
-
489
- function round(n: number): number {
490
- return Math.round(n * 100) / 100
491
- }
492
-
493
- function niceNum(range: number, roundIt: boolean): number {
494
- if (range <= 0) return 1
495
- const exp = Math.floor(Math.log10(range))
496
- const f = range / Math.pow(10, exp)
497
- let nf: number
498
- if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
499
- else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
500
- return nf * Math.pow(10, exp)
501
- }
502
-
503
- /** An axis range rounded to human-friendly bounds and tick spacing. */
504
- export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
505
-
506
- // ---- Color helpers for heatmap / pattern fills ----------------------
507
-
508
- /** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
509
- const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
510
- /** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
511
- const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
512
- /** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
513
- * above the dark grid surface instead of near-white, so empty / low cells read
514
- * as "cold" rather than as glaring white rectangles. */
515
- const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
516
- const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
517
-
518
- function resolveColorScale(
519
- scale: 'sequential' | 'diverging' | string[] | undefined,
520
- vMin: number,
521
- vMax: number,
522
- theme: 'light' | 'dark' = 'light',
523
- ): string[] {
524
- if (Array.isArray(scale) && scale.length >= 2) return scale
525
- const dark = theme === 'dark'
526
- if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
527
- return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
528
- }
529
- return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
530
- }
531
-
532
- /** Sample a hex color from an array of hex stops at fractional position t.
533
- * Linearly interpolates between the two nearest stops in RGB space. */
534
- export function sampleGradient(stops: string[], t: number): string {
535
- if (!stops.length) return '#888'
536
- const clamped = Math.max(0, Math.min(1, t))
537
- if (stops.length === 1) return stops[0]!
538
- const pos = clamped * (stops.length - 1)
539
- const i = Math.floor(pos)
540
- const frac = pos - i
541
- const a = hexToRgb(stops[i]!)
542
- const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
543
- if (!a || !b) return stops[i] ?? '#888'
544
- const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
545
- const toHex = (n: number) => n.toString(16).padStart(2, '0')
546
- return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
547
- }
548
-
549
- function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
550
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
551
- if (!m) return null
552
- const n = parseInt(m[1]!, 16)
553
- return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
554
- }
555
-
556
- /** Pick a black or white text color that has the better contrast against
557
- * the given background. Uses the WCAG relative-luminance heuristic. */
558
- export function pickContrastText(bgHex: string): string {
559
- const rgb = hexToRgb(bgHex)
560
- if (!rgb) return '#0f172a'
561
- const lin = (c: number) => {
562
- const s = c / 255
563
- return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
564
- }
565
- const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
566
- return L > 0.5 ? '#0f172a' : '#ffffff'
567
- }
568
-
569
- /** Pick the largest power of 10 that fits at the bottom of [min,max], and
570
- * the smallest that covers the top, then enumerate decade boundaries. Used
571
- * by log-scale axes (yScale: 'log'). */
572
- export function niceLogScale(min: number, max: number): NiceScale {
573
- // Only positive values are plottable on a log scale; callers should
574
- // strip non-positive values before passing them in.
575
- if (!Number.isFinite(min) || min <= 0) min = 1
576
- if (!Number.isFinite(max) || max <= min) max = min * 10
577
- const lo = Math.floor(Math.log10(min))
578
- const hi = Math.ceil(Math.log10(max))
579
- const ticks: number[] = []
580
- for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
581
- return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
582
- }
583
-
584
- /** Map a value to a fractional position [0..1] across the axis domain.
585
- * Pass the appropriate fn into projection code so linear / log share the
586
- * same plumbing. Returns null for non-positive values on log. */
587
- function project(value: number, min: number, max: number, isLog: boolean): number | null {
588
- if (!Number.isFinite(value)) return null
589
- if (isLog) {
590
- if (value <= 0 || min <= 0) return null
591
- return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
592
- }
593
- return (value - min) / (max - min)
594
- }
595
-
596
- // ---- Overlay math: trendline + moving averages -----------------------
597
-
598
- /** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
599
- * monotone cubic interpolation (preserves local extrema - no overshoots).
600
- * Breaks the path at `defined === false` gaps. */
601
- export function buildLinePath(
602
- pts: Array<{ x: number; y: number; defined: boolean }>,
603
- smooth: boolean,
604
- ): string {
605
- if (!smooth) {
606
- let path = ''
607
- let pen = false
608
- for (const p of pts) {
609
- if (!p.defined) { pen = false; continue }
610
- path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
611
- pen = true
612
- }
613
- return path.trim()
614
- }
615
- // Group defined-only runs; each run is smoothed independently.
616
- const runs: Array<Array<{ x: number; y: number }>> = []
617
- let cur: Array<{ x: number; y: number }> = []
618
- for (const p of pts) {
619
- if (p.defined) cur.push({ x: p.x, y: p.y })
620
- else if (cur.length) { runs.push(cur); cur = [] }
621
- }
622
- if (cur.length) runs.push(cur)
623
- return runs.map(monotoneCubicPath).filter(Boolean).join(' ')
624
- }
625
-
626
- /** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
627
- * Slope at each point chosen so the curve passes through every (xi, yi)
628
- * AND stays monotonic between them; control points sit 1/3 of the way
629
- * to the neighbours along that tangent. */
630
- function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
631
- const n = pts.length
632
- if (n === 0) return ''
633
- if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
634
- if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
635
- // Secant slopes between adjacent points.
636
- const dx: number[] = new Array(n - 1)
637
- const m: number[] = new Array(n - 1)
638
- for (let i = 0; i < n - 1; i += 1) {
639
- const d = pts[i + 1]!.x - pts[i]!.x
640
- dx[i] = d
641
- m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
642
- }
643
- // Tangent at each point: average of neighbouring slopes, with sign
644
- // checks that flatten the tangent when slopes change sign.
645
- const tan: number[] = new Array(n)
646
- tan[0] = m[0]!
647
- tan[n - 1] = m[n - 2]!
648
- for (let i = 1; i < n - 1; i += 1) {
649
- if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
650
- else tan[i] = (m[i - 1]! + m[i]!) / 2
651
- }
652
- // Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
653
- for (let i = 0; i < n - 1; i += 1) {
654
- if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
655
- const a = tan[i]! / m[i]!
656
- const b = tan[i + 1]! / m[i]!
657
- const h = Math.hypot(a, b)
658
- if (h > 3) {
659
- tan[i] = (3 / h) * a * m[i]!
660
- tan[i + 1] = (3 / h) * b * m[i]!
661
- }
662
- }
663
- // Build the Bezier path. Each segment: control points at 1/3 of dx.
664
- let path = `M${pts[0]!.x},${pts[0]!.y}`
665
- for (let i = 0; i < n - 1; i += 1) {
666
- const h = dx[i]!
667
- const c1x = pts[i]!.x + h / 3
668
- const c1y = pts[i]!.y + (tan[i]! * h) / 3
669
- const c2x = pts[i + 1]!.x - h / 3
670
- const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
671
- path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
672
- }
673
- return path
674
- }
675
-
676
- /** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
677
- * Returns the fitted value at each x index, or NaN where the source value
678
- * was non-finite. */
679
- export function linearTrend(values: number[]): number[] {
680
- let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
681
- for (let i = 0; i < values.length; i += 1) {
682
- const y = values[i]!
683
- if (!Number.isFinite(y)) continue
684
- n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
685
- }
686
- if (n < 2) return values.map(() => NaN)
687
- const denom = n * sumXX - sumX * sumX
688
- if (denom === 0) return values.map(() => sumY / n)
689
- const slope = (n * sumXY - sumX * sumY) / denom
690
- const intercept = (sumY - slope * sumX) / n
691
- return values.map((_, i) => slope * i + intercept)
692
- }
693
-
694
- /** Simple moving average over a window of `period` values. Window centres
695
- * trail to the right (typical for time-series). NaN for points before the
696
- * window is full. */
697
- export function simpleMovingAverage(values: number[], period: number): number[] {
698
- if (period < 1) return values.slice()
699
- const out: number[] = new Array(values.length).fill(NaN)
700
- let sum = 0, count = 0
701
- for (let i = 0; i < values.length; i += 1) {
702
- const v = values[i]!
703
- if (Number.isFinite(v)) { sum += v; count += 1 }
704
- if (i >= period) {
705
- const drop = values[i - period]!
706
- if (Number.isFinite(drop)) { sum -= drop; count -= 1 }
707
- }
708
- if (i >= period - 1 && count > 0) out[i] = sum / count
709
- }
710
- return out
711
- }
712
-
713
- /** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
714
- export function exponentialMovingAverage(values: number[], period: number): number[] {
715
- const alpha = 2 / (Math.max(1, period) + 1)
716
- const out: number[] = new Array(values.length).fill(NaN)
717
- let prev: number | null = null
718
- for (let i = 0; i < values.length; i += 1) {
719
- const v = values[i]!
720
- if (!Number.isFinite(v)) { out[i] = prev ?? NaN; continue }
721
- prev = prev == null ? v : alpha * v + (1 - alpha) * prev
722
- out[i] = prev
723
- }
724
- return out
725
- }
726
-
727
- /** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
728
- export function computeOverlay(values: number[], spec: SeriesOverlay): number[] {
729
- if (spec === 'linear') return linearTrend(values)
730
- const m = /^(sma|ema):(\d+)$/.exec(spec)
731
- if (!m) return values.map(() => NaN)
732
- const period = Number(m[2])
733
- return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period)
734
- }
735
-
736
- /** Round a [min,max] domain out to nice tick boundaries. */
737
- export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
738
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
739
- min = 0
740
- max = 1
741
- }
742
- if (min === max) {
743
- if (min === 0) max = 1
744
- else {
745
- min = Math.min(0, min)
746
- max = Math.max(0, max)
747
- }
748
- if (min === max) max = min + 1
749
- }
750
- const range = niceNum(max - min, false)
751
- const step = niceNum(range / Math.max(1, tickCount), true)
752
- const nMin = Math.floor(min / step) * step
753
- const nMax = Math.ceil(max / step) * step
754
- const ticks: number[] = []
755
- for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
756
- return { min: nMin, max: nMax, step, ticks }
757
- }
758
-
759
- function fmtTick(n: number): string {
760
- const abs = Math.abs(n)
761
- if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
762
- if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
763
- return String(Math.round(n * 100) / 100)
764
- }
765
-
766
- /** Value-axis / tooltip / label number format. */
767
- export type ChartValueFormat = 'number' | 'currency' | 'percent' | 'compact'
768
-
769
- /**
770
- * Format a numeric value for display, honouring an optional `valueFormat`.
771
- * Builds on the compact `1.2k` / `1.2M` base: currency prefixes `$` (sign
772
- * outside), percent multiplies by 100 and suffixes `%`. Unset / `'number'` /
773
- * `'compact'` = the plain compact form.
774
- */
775
- export function formatChartValue(n: number, format?: ChartValueFormat): string {
776
- if (!Number.isFinite(n)) return ''
777
- if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
778
- if (format === 'percent') {
779
- const p = n * 100
780
- return `${Math.round(p * 10) / 10}%`
781
- }
782
- return fmtTick(n)
783
- }
784
-
785
- const DAY = 86_400_000
786
- /** Nice date-tick timestamps across [min, max]. */
787
- function dateTicks(tMin: number, tMax: number): number[] {
788
- const span = tMax - tMin
789
- const step =
790
- span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY
791
- const ticks: number[] = []
792
- for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step) ticks.push(t)
793
- if (!ticks.length) ticks.push(tMin, tMax)
794
- return ticks
795
- }
796
- function fmtDate(t: number, span: number): string {
797
- const d = new Date(t)
798
- if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
799
- if (span <= 800 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
800
- return String(d.getFullYear())
801
- }
802
-
803
- type ResolvedSeries = ChartSeries & {
804
- color: string
805
- kind: 'bar' | 'line' | 'area'
806
- axis: 'left' | 'right'
807
- }
808
-
809
- /** Data domain for one axis, honoring stacking of its bar/area series.
810
- * When `isLog` is true, non-positive values are discarded (log undefined)
811
- * and the domain is rounded to decade boundaries instead of nice steps. */
812
- function axisDomain(
813
- list: ResolvedSeries[],
814
- categories: string[],
815
- stacked: boolean,
816
- extra: number[] = [],
817
- isLog = false,
818
- ): NiceScale {
819
- let dMin = Infinity
820
- let dMax = -Infinity
821
- const note = (v: number) => {
822
- if (!Number.isFinite(v)) return
823
- if (isLog && v <= 0) return
824
- if (v < dMin) dMin = v
825
- if (v > dMax) dMax = v
826
- }
827
- for (const v of extra) note(v)
828
- const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area')
829
- const lines = list.filter((s) => s.kind === 'line')
830
- if (stacked && stackable.length) {
831
- for (let i = 0; i < categories.length; i += 1) {
832
- let pos = 0
833
- let neg = 0
834
- for (const s of stackable) {
835
- const v = s.values[i] ?? 0
836
- if (v >= 0) pos += v
837
- else neg += v
838
- }
839
- note(pos)
840
- note(neg)
841
- }
842
- } else {
843
- for (const s of stackable) for (const v of s.values) note(v)
844
- }
845
- for (const s of lines) for (const v of s.values) note(v)
846
- if (dMin === Infinity) {
847
- dMin = isLog ? 1 : 0
848
- dMax = isLog ? 10 : 1
849
- }
850
- // Bar / area charts read against a zero baseline, so always include 0
851
- // - but only on linear axes (0 is invalid in log).
852
- if (stackable.length && !isLog) {
853
- dMin = Math.min(dMin, 0)
854
- dMax = Math.max(dMax, 0)
855
- }
856
- return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax)
857
- }
858
-
859
- /**
860
- * Lay out a {@link ChartSpec} into renderable {@link ChartGeometry} - scales,
861
- * ticks, and the position of every bar, line, slice and dot. Pure: no DOM, so
862
- * it runs during SSR and can be unit-tested directly.
863
- */
864
- export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'): ChartGeometry {
865
- const width = spec.width ?? 520
866
- const height = spec.height ?? 300
867
- const palette = spec.palette ?? DEFAULT_PALETTE
868
-
869
- const stacked = !!(spec.stacked || spec.stacked100)
870
- const series: ResolvedSeries[] = spec.series.map((s, i) => ({
871
- ...s,
872
- color: s.color ?? palette[i % palette.length]!,
873
- kind: (s.type ?? (spec.type === 'pie' || spec.type === 'scatter' ? 'bar' : spec.type)) as
874
- | 'bar'
875
- | 'line'
876
- | 'area',
877
- axis: s.axis ?? 'left',
878
- }))
879
- const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
880
-
881
- const empty: ChartGeometry = {
882
- type: spec.type,
883
- width,
884
- height,
885
- plot: { x: 0, y: 0, w: width, h: height },
886
- bars: [],
887
- lines: [],
888
- slices: [],
889
- yTicks: [],
890
- y2Ticks: [],
891
- hasRightAxis: false,
892
- xTicks: [],
893
- categoryGroupTicks: [],
894
- xLabelRotated: false,
895
- legend,
896
- donut: null,
897
- referenceLines: [],
898
- scatterPoints: [],
899
- orientation: 'vertical',
900
- valueTicks: [],
901
- catTicks: [],
902
- referenceLinesV: [],
903
- overlays: [],
904
- annotations: [],
905
- heatmapCells: [],
906
- heatmapRowTicks: [],
907
- heatmapColTicks: [],
908
- heatmapLegend: [],
909
- funnelSegments: [],
910
- radarRings: [],
911
- radarAxes: [],
912
- radarSeries: [],
913
- radarCenter: null,
914
- treemapCells: [],
915
- calendarCells: [],
916
- calendarMonthTicks: [],
917
- calendarLegend: [],
918
- gauge: null,
919
- sankeyNodes: [],
920
- sankeyLinks: [],
921
- }
922
-
923
- // ---- Waterfall ----------------------------------------------------
924
- // First series provides the values. Each non-total bar starts at the
925
- // running cumulative sum; total bars (waterfallTotals[i]) reset and
926
- // span from 0 to that sum. Color is derived from sign + total flag, with
927
- // optional palette overrides via spec.waterfallColors.
928
- if (spec.type === 'waterfall') {
929
- const src = series[0]
930
- if (!src) return { ...empty }
931
- const colors = spec.waterfallColors ?? {}
932
- const positive = colors.positive ?? '#16a34a'
933
- const negative = colors.negative ?? '#ef4444'
934
- const total = colors.total ?? '#475569'
935
-
936
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
937
- const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
938
- const padL = 48 + (spec.yAxisTitle ? 16 : 0)
939
- const padR = 12
940
- const padT = 10
941
- const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
942
- const plotW = Math.max(1, width - padL - padR)
943
- const plotH = Math.max(1, height - padT - padB)
944
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
945
-
946
- // Compute the running cumulative + per-bar (from, to) pairs.
947
- const totals = spec.waterfallTotals ?? []
948
- const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
949
- let cum = 0
950
- spec.categories.forEach((_, i) => {
951
- const v = src.values[i] ?? 0
952
- const isTotal = !!totals[i]
953
- if (isTotal) {
954
- pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
955
- } else {
956
- pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
957
- cum += v
958
- }
959
- })
960
- // Y-axis domain spans every visited level (including 0).
961
- let dMin = 0, dMax = 0
962
- for (const p of pairs) {
963
- if (p.from < dMin) dMin = p.from
964
- if (p.to < dMin) dMin = p.to
965
- if (p.from > dMax) dMax = p.from
966
- if (p.to > dMax) dMax = p.to
967
- }
968
- const dom = niceScale(dMin, dMax)
969
- const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
970
-
971
- const slotW = plotW / Math.max(1, spec.categories.length)
972
- const barPad = slotW * 0.2
973
- const barW = Math.max(1, slotW - barPad)
974
- const bars: ChartBar[] = pairs.map((p, i) => {
975
- const yTop = yOfW(Math.max(p.from, p.to))
976
- const yBot = yOfW(Math.min(p.from, p.to))
977
- const x = padL + slotW * i + barPad / 2
978
- const color = p.isTotal ? total : p.value >= 0 ? positive : negative
979
- return {
980
- x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
981
- color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
982
- }
983
- })
984
- // Thin connector lines between bar tops -> running total reads cleanly.
985
- const connectors: ChartLine[] = [{
986
- path: pairs
987
- .map((p, i) => {
988
- const x0 = padL + slotW * i + barPad / 2 + barW
989
- const y = yOfW(p.to)
990
- const x1 = padL + slotW * (i + 1) + barPad / 2
991
- // Skip the final connector beyond the last bar.
992
- return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
993
- })
994
- .filter(Boolean)
995
- .join(' '),
996
- areaPath: '',
997
- color: 'var(--sg-muted, #94a3b8)',
998
- label: '',
999
- points: [],
1000
- }]
1001
- const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
1002
- label,
1003
- x: round(padL + slotW * i + slotW / 2),
1004
- }))
1005
- const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
1006
- value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat),
1007
- }))
1008
- return {
1009
- ...empty,
1010
- plot,
1011
- bars,
1012
- lines: connectors,
1013
- yTicks,
1014
- xTicks,
1015
- xLabelRotated,
1016
- }
1017
- }
1018
-
1019
- // ---- Funnel -------------------------------------------------------
1020
- // One series of strictly-decreasing values gets rendered as a stack
1021
- // of horizontal trapezoids: each level's width is proportional to its
1022
- // value relative to the largest, slope automatically links level N+1
1023
- // narrower than level N. Labels show value, conversion vs. top, and
1024
- // step drop-off.
1025
- if (spec.type === 'funnel') {
1026
- const src = series[0]
1027
- if (!src || !src.values.length) return { ...empty }
1028
- const padL = 20, padR = 20, padT = 16, padB = 16
1029
- const plotW = Math.max(1, width - padL - padR)
1030
- const plotH = Math.max(1, height - padT - padB)
1031
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1032
- const n = src.values.length
1033
- const stepH = plotH / n
1034
- const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
1035
- const top = src.values[0] ?? 0
1036
- const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
1037
- const cx = padL + plotW / 2
1038
- const palette = spec.palette ?? DEFAULT_PALETTE
1039
- const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
1040
- const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
1041
- const w0 = widthAt(v)
1042
- const w1 = widthAt(next)
1043
- const y0 = padT + stepH * i
1044
- const y1 = y0 + stepH
1045
- const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
1046
- const color = src.color ?? palette[i % palette.length]!
1047
- return {
1048
- path, color,
1049
- label: spec.categories[i] ?? src.label,
1050
- value: v,
1051
- conversion: top > 0 ? v / top : 0,
1052
- dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
1053
- cx,
1054
- cy: (y0 + y1) / 2,
1055
- textColor: pickContrastText(color),
1056
- }
1057
- })
1058
- return {
1059
- ...empty,
1060
- plot,
1061
- funnelSegments: segments,
1062
- }
1063
- }
1064
-
1065
- // ---- Radar --------------------------------------------------------
1066
- // Polar coordinates: each `category` is a spoke (axis); each `series`
1067
- // contributes a polygon connecting its values across the spokes. All
1068
- // series share the same scale (max across every value). Concentric
1069
- // ring count derived from data, capped at 5 for legibility.
1070
- if (spec.type === 'radar') {
1071
- if (!series.length || !spec.categories.length) return { ...empty }
1072
- const padL = 30, padR = 30, padT = 24, padB = 24
1073
- const plotW = Math.max(1, width - padL - padR)
1074
- const plotH = Math.max(1, height - padT - padB)
1075
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1076
- const cx = padL + plotW / 2
1077
- const cy = padT + plotH / 2
1078
- const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20)
1079
- const axes = spec.categories
1080
- const k = axes.length
1081
- let vMax = 0
1082
- for (const s of series) for (const v of s.values) {
1083
- if (Number.isFinite(v) && v > vMax) vMax = v
1084
- }
1085
- if (vMax === 0) vMax = 1
1086
- const ringCount = 5
1087
- const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax)
1088
- /** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
1089
- * proceed clockwise so axes lay out left-to-right when k <= 4. */
1090
- const angleAt = (i: number) => -Math.PI / 2 + (i / k) * Math.PI * 2
1091
- const pointAt = (i: number, v: number) => {
1092
- const t = v / vMax
1093
- const a = angleAt(i)
1094
- return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) }
1095
- }
1096
- const radarAxes: ChartRadarAxis[] = axes.map((label, i) => {
1097
- const p = pointAt(i, vMax)
1098
- return { label, x: p.x, y: p.y }
1099
- })
1100
- const radarSeriesGeo: ChartRadarSeries[] = series.map((s, si) => {
1101
- const pts = s.values.map((v, i) => {
1102
- const safe = Number.isFinite(v) ? v : 0
1103
- const p = pointAt(i, safe)
1104
- return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' }
1105
- })
1106
- const path = pts.length
1107
- ? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
1108
- : ''
1109
- const palette = spec.palette ?? DEFAULT_PALETTE
1110
- return { label: s.label, color: s.color ?? palette[si % palette.length]!, path, points: pts }
1111
- })
1112
- return {
1113
- ...empty,
1114
- plot,
1115
- radarRings: ringValues,
1116
- radarAxes,
1117
- radarSeries: radarSeriesGeo,
1118
- radarCenter: { cx, cy, r },
1119
- }
1120
- }
1121
-
1122
- // ---- Calendar heatmap --------------------------------------------
1123
- // GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
1124
- // cell is a small square shaded by `calendarValues[i].value` via the
1125
- // sequential color scale. Days with no value render blank (border only)
1126
- // so missing data is visually obvious.
1127
- if (spec.type === 'calendar') {
1128
- const values = spec.calendarValues ?? []
1129
- if (!values.length && !spec.calendarStart) return { ...empty }
1130
- // Build a value lookup + figure out the date range.
1131
- const valueByDate = new Map<string, number>()
1132
- let vMin = Infinity, vMax = -Infinity
1133
- for (const v of values) {
1134
- valueByDate.set(v.date, v.value)
1135
- if (Number.isFinite(v.value)) {
1136
- if (v.value < vMin) vMin = v.value
1137
- if (v.value > vMax) vMax = v.value
1138
- }
1139
- }
1140
- if (vMin === Infinity) { vMin = 0; vMax = 1 }
1141
- if (vMin === vMax) vMax = vMin + 1
1142
- const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
1143
- const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
1144
- // Determine date range. If calendarStart/End set, use them, otherwise
1145
- // span the data + round to whole weeks (Sun..Sat).
1146
- const sorted = values.map((v) => v.date).sort()
1147
- const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
1148
- const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
1149
- const start = new Date(startStr + 'T00:00:00Z')
1150
- const end = new Date(endStr + 'T00:00:00Z')
1151
- // Roll start back to its Sunday, end forward to its Saturday.
1152
- start.setUTCDate(start.getUTCDate() - start.getUTCDay())
1153
- end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
1154
- const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
1155
- const weeks = Math.ceil(totalDays / 7)
1156
- const padL = 36, padR = 80, padT = 26, padB = 16
1157
- const plotW = Math.max(1, width - padL - padR)
1158
- const plotH = Math.max(1, height - padT - padB)
1159
- // Cell size: fit weeks across width, 7 rows down height.
1160
- const cellW = Math.floor(plotW / weeks)
1161
- const cellH = Math.floor(plotH / 7)
1162
- const cellSize = Math.max(6, Math.min(cellW, cellH))
1163
- const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
1164
- const cells: ChartCalendarCell[] = []
1165
- let lastMonth = -1
1166
- const monthTicks: ChartCategoryTick[] = []
1167
- for (let i = 0; i < totalDays; i += 1) {
1168
- const day = new Date(start.getTime() + i * 86_400_000)
1169
- const col = Math.floor(i / 7)
1170
- const row = i % 7
1171
- const date = day.toISOString().slice(0, 10)
1172
- const has = valueByDate.has(date)
1173
- const v = valueByDate.get(date) ?? 0
1174
- cells.push({
1175
- x: padL + col * cellSize,
1176
- y: padT + row * cellSize,
1177
- size: cellSize,
1178
- date, value: v,
1179
- defined: has,
1180
- color: has ? colorAt(v) : 'transparent',
1181
- })
1182
- if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
1183
- lastMonth = day.getUTCMonth()
1184
- monthTicks.push({
1185
- label: day.toLocaleDateString(undefined, { month: 'short' }),
1186
- x: padL + col * cellSize,
1187
- })
1188
- }
1189
- }
1190
- const legend = Array.from({ length: 5 }, (_, i) => {
1191
- const t = i / 4
1192
- const value = vMin + (vMax - vMin) * t
1193
- return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
1194
- })
1195
- return {
1196
- ...empty,
1197
- plot,
1198
- calendarCells: cells,
1199
- calendarMonthTicks: monthTicks,
1200
- calendarLegend: legend,
1201
- }
1202
- }
1203
-
1204
- // ---- Gauge --------------------------------------------------------
1205
- // Semicircle dial: track arc + value arc + optional colored range bands
1206
- // + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
1207
- if (spec.type === 'gauge') {
1208
- const min = spec.gaugeMin ?? 0
1209
- const max = spec.gaugeMax ?? 100
1210
- const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0))
1211
- const target = spec.gaugeTarget
1212
- const cx = width / 2
1213
- const cy = height * 0.78
1214
- const r = Math.min(width * 0.42, height * 0.65)
1215
- // Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
1216
- const A0 = Math.PI
1217
- const A1 = 2 * Math.PI
1218
- const angleAt = (v: number) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0)
1219
- const arc = (a0: number, a1: number, radius: number): string => {
1220
- const x1 = cx + radius * Math.cos(a0)
1221
- const y1 = cy + radius * Math.sin(a0)
1222
- const x2 = cx + radius * Math.cos(a1)
1223
- const y2 = cy + radius * Math.sin(a1)
1224
- const large = a1 - a0 > Math.PI ? 1 : 0
1225
- return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`
1226
- }
1227
- const trackPath = arc(A0, A1, r)
1228
- const valuePath = arc(A0, angleAt(value), r)
1229
- const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
1230
- path: arc(angleAt(band.from), angleAt(band.to), r - 9),
1231
- color: band.color, from: band.from, to: band.to,
1232
- }))
1233
- let targetPx: ChartGaugeLayout['target'] = null
1234
- if (target != null && Number.isFinite(target)) {
1235
- const a = angleAt(Math.max(min, Math.min(max, target)))
1236
- const inner = r - 12
1237
- const outer = r + 4
1238
- targetPx = {
1239
- x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1240
- x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1241
- }
1242
- }
1243
- // Tick marks just outside the track: a major tick every 1/4 of the scale,
1244
- // with 4 minor ticks between each. Gives the dial a measured, instrument feel.
1245
- const ticks: ChartGaugeLayout['ticks'] = []
1246
- const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR
1247
- for (let i = 0; i <= TICK_TOTAL; i++) {
1248
- const a = A0 + (i / TICK_TOTAL) * (A1 - A0)
1249
- const major = i % TICK_MINOR === 0
1250
- const inner = r + 9
1251
- const outer = r + (major ? 17 : 13)
1252
- ticks.push({
1253
- x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1254
- x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1255
- major,
1256
- })
1257
- }
1258
- // Pointer needle: a kite (long tip toward the value, short counterweight
1259
- // tail) pivoting on a center hub.
1260
- const aV = angleAt(value)
1261
- const tipR = r - 16, tailR = 18, baseR = 6
1262
- const aPerp = aV + Math.PI / 2
1263
- const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
1264
- const needlePath =
1265
- `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
1266
- // Color the value arc by the band the value currently sits in.
1267
- let valueColor: string | null = null
1268
- for (const band of spec.gaugeRanges ?? []) {
1269
- if (value >= band.from && value <= band.to) valueColor = band.color
1270
- }
1271
- return {
1272
- ...empty,
1273
- plot: { x: 0, y: 0, w: width, h: height },
1274
- gauge: {
1275
- cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
1276
- ticks, needle: { path: needlePath, hubR: 7 }, valueColor,
1277
- minLabel: { x: cx - r, y: cy + 20 },
1278
- maxLabel: { x: cx + r, y: cy + 20 },
1279
- value, min, max, unit: spec.gaugeUnit ?? '',
1280
- },
1281
- }
1282
- }
1283
-
1284
- // ---- Tree-map -----------------------------------------------------
1285
- // Squarified tree-map (Bruls et al. 2000): each level recursively
1286
- // partitions its rectangle in proportion to its children, picking the
1287
- // split orientation that keeps aspect ratios closest to 1.
1288
- if (spec.type === 'treemap') {
1289
- const root = spec.treemap
1290
- if (!root) return { ...empty }
1291
- const padL = 4, padR = 4, padT = 4, padB = 4
1292
- const plotW = Math.max(1, width - padL - padR)
1293
- const plotH = Math.max(1, height - padT - padB)
1294
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1295
- const palette = spec.palette ?? DEFAULT_PALETTE
1296
- const cells: ChartTreemapCell[] = []
1297
- function totalOf(n: TreeNode): number {
1298
- if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
1299
- return Math.max(0, n.value ?? 0)
1300
- }
1301
- function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
1302
- if (!items.length || w <= 0 || h <= 0) return
1303
- const totals = items.map(totalOf)
1304
- const sum = totals.reduce((a, b) => a + b, 0)
1305
- if (sum <= 0) return
1306
- // Process largest-first so big items dominate the first row.
1307
- const ordered = items
1308
- .map((n, i) => ({ node: n, value: totals[i]! }))
1309
- .sort((a, b) => b.value - a.value)
1310
- let cx = x, cy = y, cw = w, ch = h, remaining = sum
1311
- let row: typeof ordered = []
1312
- const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
1313
- if (rowSum <= 0) return Infinity
1314
- const rowArea = rowSum * scale
1315
- const rowSide = rowArea / shortSide
1316
- let worst = 0
1317
- for (const v of vals) {
1318
- const cell = v * scale
1319
- const long = cell / rowSide
1320
- const r = Math.max(shortSide / long, long / shortSide)
1321
- if (r > worst) worst = r
1322
- }
1323
- return worst
1324
- }
1325
- function flushRow() {
1326
- if (!row.length) return
1327
- const rowSum = row.reduce((a, b) => a + b.value, 0)
1328
- const scale = (cw * ch) / remaining
1329
- const horizontal = cw >= ch
1330
- const shortSide = horizontal ? ch : cw
1331
- const rowSide = (rowSum * scale) / shortSide
1332
- let offset = 0
1333
- for (const it of row) {
1334
- const cellSize = (it.value * scale) / rowSide
1335
- const cx2 = horizontal ? cx : cx + offset
1336
- const cy2 = horizontal ? cy + offset : cy
1337
- const ww = horizontal ? rowSide : cellSize
1338
- const hh = horizontal ? cellSize : rowSide
1339
- const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
1340
- // Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
1341
- if (it.node.children?.length) {
1342
- cells.push({
1343
- x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1344
- color, textColor: pickContrastText(color),
1345
- name: it.node.name, value: it.value, depth,
1346
- })
1347
- const labelH = Math.min(18, hh * 0.25)
1348
- squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
1349
- } else {
1350
- cells.push({
1351
- x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1352
- color, textColor: pickContrastText(color),
1353
- name: it.node.name, value: it.value, depth,
1354
- })
1355
- }
1356
- offset += cellSize
1357
- }
1358
- // Shrink the remaining strip.
1359
- if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
1360
- remaining -= rowSum
1361
- row = []
1362
- }
1363
- for (const it of ordered) {
1364
- const scale = (cw * ch) / remaining
1365
- const shortSide = Math.min(cw, ch)
1366
- const rowSum = row.reduce((a, b) => a + b.value, 0)
1367
- const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
1368
- const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
1369
- if (row.length && nextWorst > currWorst) {
1370
- flushRow()
1371
- }
1372
- row.push(it)
1373
- }
1374
- flushRow()
1375
- }
1376
- const seedItems = root.children ?? [root]
1377
- squarify(seedItems, padL, padT, plotW, plotH, 0)
1378
- return { ...empty, plot, treemapCells: cells }
1379
- }
1380
-
1381
- // ---- Sankey -------------------------------------------------------
1382
- // Multi-column flow layout. Each node assigned to a column by longest
1383
- // path from any source. Within a column, nodes are stacked vertically;
1384
- // height proportional to max(totalIn, totalOut). Links render as
1385
- // bezier ribbons whose width is the link value (in pixels).
1386
- if (spec.type === 'sankey') {
1387
- const nodes = spec.sankeyNodes ?? []
1388
- const links = spec.sankeyLinks ?? []
1389
- if (!nodes.length || !links.length) return { ...empty }
1390
- const padL = 10, padR = 10, padT = 14, padB = 14
1391
- const plotW = Math.max(1, width - padL - padR)
1392
- const plotH = Math.max(1, height - padT - padB)
1393
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1394
- const palette = spec.palette ?? DEFAULT_PALETTE
1395
- const nodeById = new Map(nodes.map((n) => [n.id, n]))
1396
- // Column = longest path from any node with no incoming edges.
1397
- const targets = new Set(links.map((l) => l.target))
1398
- const sources = nodes.filter((n) => !targets.has(n.id))
1399
- const column = new Map<string, number>()
1400
- function visit(id: string, depth: number, seen: Set<string>) {
1401
- if (seen.has(id)) return
1402
- seen.add(id)
1403
- const cur = column.get(id) ?? 0
1404
- if (depth > cur || !column.has(id)) column.set(id, depth)
1405
- for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
1406
- seen.delete(id)
1407
- }
1408
- for (const s of sources) visit(s.id, 0, new Set())
1409
- // Cover any nodes with no path from a source (orphan rings).
1410
- for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
1411
- const maxCol = Math.max(...column.values())
1412
- const cols = maxCol + 1
1413
- const nodeW = 14
1414
- const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
1415
- // Totals per node.
1416
- const totalIn = new Map<string, number>()
1417
- const totalOut = new Map<string, number>()
1418
- for (const l of links) {
1419
- totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
1420
- totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
1421
- }
1422
- // Per-column groups + max total in that column.
1423
- const byCol: Map<number, string[]> = new Map()
1424
- for (const n of nodes) {
1425
- const c = column.get(n.id) ?? 0
1426
- const arr = byCol.get(c) ?? []
1427
- arr.push(n.id); byCol.set(c, arr)
1428
- }
1429
- // Per-column total height + node height scale.
1430
- let maxColTotal = 0
1431
- for (const ids of byCol.values()) {
1432
- const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
1433
- if (t > maxColTotal) maxColTotal = t
1434
- }
1435
- if (maxColTotal === 0) return { ...empty, plot }
1436
- const nodeGapPx = 8
1437
- const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
1438
- const placed: ChartSankeyNode[] = []
1439
- for (const [c, ids] of byCol) {
1440
- const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
1441
- const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
1442
- let yCursor = padT + (plotH - totalH) / 2
1443
- const xCol = padL + c * (nodeW + gapBetweenColumns)
1444
- ids.forEach((id, idx) => {
1445
- const node = nodeById.get(id)!
1446
- const h = heights[idx]!
1447
- placed.push({
1448
- id,
1449
- label: node.label ?? id,
1450
- color: node.color ?? palette[(placed.length) % palette.length]!,
1451
- x: xCol, y: yCursor, w: nodeW, h,
1452
- column: c,
1453
- totalIn: totalIn.get(id) ?? 0,
1454
- totalOut: totalOut.get(id) ?? 0,
1455
- })
1456
- yCursor += h + nodeGapPx
1457
- })
1458
- }
1459
- const placedById = new Map(placed.map((n) => [n.id, n]))
1460
- // Per-node sub-cursor so multiple links from one node stack vertically.
1461
- const inCursor = new Map<string, number>()
1462
- const outCursor = new Map<string, number>()
1463
- const builtLinks: ChartSankeyLink[] = []
1464
- // Sort links so wider ribbons render first (so thin ribbons stack on top).
1465
- const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
1466
- for (const link of sortedLinks) {
1467
- const a = placedById.get(link.source)
1468
- const b = placedById.get(link.target)
1469
- if (!a || !b) continue
1470
- const linkH = Math.max(1, link.value * heightScale)
1471
- const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
1472
- const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
1473
- outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
1474
- inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
1475
- const x0 = a.x + a.w
1476
- const x1 = b.x
1477
- const mid = (x0 + x1) / 2
1478
- const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
1479
- builtLinks.push({
1480
- path, color: link.color ?? a.color, width: linkH,
1481
- source: link.source, target: link.target, value: link.value,
1482
- })
1483
- }
1484
- return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
1485
- }
1486
-
1487
- // ---- Heatmap ------------------------------------------------------
1488
- // Each series is one row, series.values are the cells across categories.
1489
- // Color comes from a sequential/diverging/custom palette mapped to the
1490
- // global value range. Cell text contrasts black/white against the cell.
1491
- if (spec.type === 'heatmap') {
1492
- if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
1493
- // Layout: left gutter for row labels, bottom for column labels.
1494
- const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
1495
- const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
1496
- const padR = 64 // room for the right-side legend bar
1497
- const padT = 12
1498
- const padB = 32
1499
- const plotW = Math.max(1, width - padL - padR)
1500
- const plotH = Math.max(1, height - padT - padB)
1501
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1502
- const cellW = plotW / spec.categories.length
1503
- const cellH = plotH / series.length
1504
- // Resolve value range across the whole matrix.
1505
- let vMin = Infinity, vMax = -Infinity
1506
- for (const s of series) for (const v of s.values) {
1507
- if (!Number.isFinite(v)) continue
1508
- if (v < vMin) vMin = v
1509
- if (v > vMax) vMax = v
1510
- }
1511
- if (vMin === Infinity) { vMin = 0; vMax = 1 }
1512
- if (vMin === vMax) vMax = vMin + 1
1513
- // Pick the palette stops.
1514
- const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
1515
- const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
1516
- const heatmapCells: ChartHeatmapCell[] = []
1517
- series.forEach((s, ri) => {
1518
- s.values.forEach((v, ci) => {
1519
- if (!Number.isFinite(v)) return
1520
- const color = colorAt(v)
1521
- heatmapCells.push({
1522
- x: round(padL + cellW * ci),
1523
- y: round(padT + cellH * ri),
1524
- w: round(cellW),
1525
- h: round(cellH),
1526
- color,
1527
- textColor: pickContrastText(color),
1528
- value: v,
1529
- rowLabel: s.label,
1530
- colLabel: spec.categories[ci] ?? '',
1531
- })
1532
- })
1533
- })
1534
- const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
1535
- value: i,
1536
- y: round(padT + cellH * i + cellH / 2),
1537
- label: s.label,
1538
- }))
1539
- const heatmapColTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
1540
- label,
1541
- x: round(padL + cellW * i + cellW / 2),
1542
- }))
1543
- // Legend: sample 5 stops across the range.
1544
- const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
1545
- const t = i / 4
1546
- const value = vMin + (vMax - vMin) * t
1547
- return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
1548
- })
1549
- return {
1550
- ...empty,
1551
- plot,
1552
- heatmapCells,
1553
- heatmapRowTicks,
1554
- heatmapColTicks,
1555
- heatmapLegend,
1556
- }
1557
- }
1558
-
1559
- if (spec.type === 'pie') {
1560
- const s = series[0]
1561
- if (!s) return empty
1562
- const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
1563
- const cx = width / 2
1564
- const cy = height / 2
1565
- const r = Math.min(width, height) / 2 - 10
1566
- const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
1567
- const ir = r * innerFrac
1568
- let angle = -Math.PI / 2
1569
- const slices: ChartPieSlice[] = s.values.map((v, i) => {
1570
- const frac = Math.max(0, v) / total
1571
- const a0 = angle
1572
- const a1 = angle + frac * Math.PI * 2
1573
- angle = a1
1574
- const large = a1 - a0 > Math.PI ? 1 : 0
1575
- const mid = (a0 + a1) / 2
1576
- const labelR = (r + ir) / 2 || r * 0.6
1577
- const ox0 = cx + r * Math.cos(a0)
1578
- const oy0 = cy + r * Math.sin(a0)
1579
- const ox1 = cx + r * Math.cos(a1)
1580
- const oy1 = cy + r * Math.sin(a1)
1581
- let path: string
1582
- if (frac >= 0.999) {
1583
- path = ir
1584
- ? `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` +
1585
- `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`
1586
- : `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`
1587
- } else if (ir > 0) {
1588
- const ix0 = cx + ir * Math.cos(a0)
1589
- const iy0 = cy + ir * Math.sin(a0)
1590
- const ix1 = cx + ir * Math.cos(a1)
1591
- const iy1 = cy + ir * Math.sin(a1)
1592
- path =
1593
- `M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
1594
- `L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
1595
- } else {
1596
- path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
1597
- }
1598
- const catLabel = spec.categories[i] ?? String(i)
1599
- return {
1600
- path,
1601
- color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length]!,
1602
- label: catLabel,
1603
- value: v,
1604
- percent: frac * 100,
1605
- cx: round(cx + labelR * Math.cos(mid)),
1606
- cy: round(cy + labelR * Math.sin(mid)),
1607
- }
1608
- })
1609
- return {
1610
- ...empty,
1611
- slices,
1612
- legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! })),
1613
- 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,
1614
- }
1615
- }
1616
-
1617
- if (spec.type === 'scatter') {
1618
- const padL = 48 + (spec.yAxisTitle ? 16 : 0)
1619
- const padR = 12
1620
- const padT = 10
1621
- const padB = 28 + (spec.xAxisTitle ? 16 : 0)
1622
- const plotW = Math.max(1, width - padL - padR)
1623
- const plotH = Math.max(1, height - padT - padB)
1624
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1625
-
1626
- let xMin = Infinity
1627
- let xMax = -Infinity
1628
- let yMin = Infinity
1629
- let yMax = -Infinity
1630
- let rMin = Infinity
1631
- let rMax = -Infinity
1632
- for (const s of series) {
1633
- for (const pt of s.points ?? []) {
1634
- if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
1635
- if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
1636
- if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
1637
- }
1638
- }
1639
- if (xMin === Infinity) return { ...empty, plot }
1640
- const xDom = niceScale(xMin, xMax)
1641
- const yDom = niceScale(yMin, yMax)
1642
- const hasR = rMax > rMin
1643
- const xOf = (v: number) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW)
1644
- const yOf = (v: number) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH)
1645
- const rOf = (r?: number) =>
1646
- hasR && r != null && Number.isFinite(r)
1647
- ? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
1648
- : 5
1649
-
1650
- const scatterPoints: ChartScatterDot[] = []
1651
- for (const s of series) {
1652
- for (const pt of s.points ?? []) {
1653
- if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue
1654
- scatterPoints.push({
1655
- cx: xOf(pt.x),
1656
- cy: yOf(pt.y),
1657
- r: rOf(pt.r),
1658
- color: s.color,
1659
- label: pt.label ?? '',
1660
- series: s.label,
1661
- x: pt.x,
1662
- y: pt.y,
1663
- })
1664
- }
1665
- }
1666
- const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
1667
- y: yOf(ref.value),
1668
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
1669
- color: ref.color ?? '#ef4444',
1670
- dashed: ref.dashed !== false,
1671
- }))
1672
- return {
1673
- ...empty,
1674
- plot,
1675
- scatterPoints,
1676
- referenceLines,
1677
- yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat) })),
1678
- xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
1679
- }
1680
- }
1681
-
1682
- // ---- Horizontal bars ----------------------------------------------------
1683
- // Categories run down the left, bars grow rightward. Bars-only (no combo).
1684
- const horizontal =
1685
- spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar')
1686
- if (horizontal) {
1687
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
1688
- const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0)
1689
- const padR = 16
1690
- const padT = 8
1691
- const padB = 26 + (spec.xAxisTitle ? 16 : 0)
1692
- const plotW = Math.max(1, width - padL - padR)
1693
- const plotH = Math.max(1, height - padT - padB)
1694
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1695
-
1696
- const refs = (spec.referenceLines ?? []).map((r) => r.value)
1697
- const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs)
1698
- const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
1699
-
1700
- const n = spec.categories.length
1701
- const slot = plotH / Math.max(1, n)
1702
- const groupPad = slot * 0.2
1703
- const inner = slot - groupPad
1704
- const bandTop = (i: number) => padT + slot * i + groupPad / 2
1705
- const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max))
1706
-
1707
- const bars: ChartBar[] = []
1708
- if (stacked) {
1709
- const totals = spec.stacked100
1710
- ? spec.categories.map(
1711
- (_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
1712
- )
1713
- : null
1714
- const pos = new Array(n).fill(0)
1715
- const neg = new Array(n).fill(0)
1716
- for (const s of series) {
1717
- s.values.forEach((v, i) => {
1718
- if (!Number.isFinite(v)) return
1719
- const vp = totals ? (v / totals[i]!) * 100 : v
1720
- let xL: number
1721
- let xR: number
1722
- if (vp >= 0) {
1723
- xL = xOf(pos[i])
1724
- xR = xOf(pos[i] + vp)
1725
- pos[i] += vp
1726
- } else {
1727
- xL = xOf(neg[i] + vp)
1728
- xR = xOf(neg[i])
1729
- neg[i] += vp
1730
- }
1731
- bars.push({
1732
- x: Math.min(xL, xR),
1733
- y: round(bandTop(i)),
1734
- w: round(Math.abs(xR - xL)),
1735
- h: round(Math.max(1, inner)),
1736
- color: s.color,
1737
- label: spec.categories[i] ?? String(i),
1738
- series: s.label,
1739
- value: v,
1740
- })
1741
- })
1742
- }
1743
- } else {
1744
- const barH = inner / series.length
1745
- series.forEach((s, bi) => {
1746
- s.values.forEach((v, i) => {
1747
- if (!Number.isFinite(v)) return
1748
- const xV = xOf(v)
1749
- bars.push({
1750
- x: Math.min(xV, xBase),
1751
- y: round(bandTop(i) + barH * bi),
1752
- w: round(Math.max(1, Math.abs(xV - xBase))),
1753
- h: round(Math.max(1, barH - 1)),
1754
- color: s.color,
1755
- label: spec.categories[i] ?? String(i),
1756
- series: s.label,
1757
- value: v,
1758
- })
1759
- })
1760
- })
1761
- }
1762
-
1763
- const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
1764
- label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat),
1765
- x: xOf(value),
1766
- }))
1767
- const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
1768
- value: i,
1769
- y: round(bandTop(i) + inner / 2),
1770
- label,
1771
- }))
1772
- const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
1773
- x: xOf(ref.value),
1774
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
1775
- color: ref.color ?? '#ef4444',
1776
- dashed: ref.dashed !== false,
1777
- }))
1778
-
1779
- return {
1780
- ...empty,
1781
- plot,
1782
- bars,
1783
- orientation: 'horizontal',
1784
- valueTicks,
1785
- catTicks,
1786
- referenceLinesV,
1787
- xLabelRotated: false,
1788
- }
1789
- }
1790
-
1791
- // ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
1792
- const leftSeries = series.filter((s) => s.axis === 'left')
1793
- const rightSeries = series.filter((s) => s.axis === 'right')
1794
- const hasRightAxis = rightSeries.length > 0
1795
-
1796
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
1797
- const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
1798
- // Grouped (nested) category axis: valid only when the spans cover every leaf.
1799
- const validGroups =
1800
- spec.categoryGroups &&
1801
- spec.categoryGroups.length > 0 &&
1802
- spec.xType !== 'time' &&
1803
- spec.orientation !== 'horizontal' &&
1804
- spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
1805
- ? spec.categoryGroups
1806
- : null
1807
- const groupTierH = validGroups ? 18 : 0
1808
- const padL = 48 + (spec.yAxisTitle ? 16 : 0)
1809
- const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
1810
- const padT = 10
1811
- const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH
1812
- const plotW = Math.max(1, width - padL - padR)
1813
- const plotH = Math.max(1, height - padT - padB)
1814
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1815
-
1816
- const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
1817
- const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
1818
- const leftLog = spec.yScale === 'log'
1819
- const rightLog = spec.y2Scale === 'log'
1820
- const leftDom = spec.stacked100
1821
- ? niceScale(0, 100)
1822
- : axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog)
1823
- const rightDom = hasRightAxis
1824
- ? spec.stacked100
1825
- ? niceScale(0, 100)
1826
- : axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
1827
- : null
1828
-
1829
- /** Map a data value to a y pixel. Returns NaN for non-positive values on
1830
- * a log axis so callers can drop the point (line gap / missing bar). */
1831
- const yOf = (dom: NiceScale, v: number, isLog = false) => {
1832
- const t = project(v, dom.min, dom.max, isLog)
1833
- if (t === null) return NaN
1834
- return round(padT + plotH - t * plotH)
1835
- }
1836
- const yLeft = (v: number) => yOf(leftDom, v, leftLog)
1837
- const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog)
1838
- const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
1839
- const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
1840
-
1841
- const n = spec.categories.length
1842
- const slot = plotW / Math.max(1, n)
1843
-
1844
- // X positions. A time axis spaces points by actual time (irregular gaps);
1845
- // a category axis is uniform. (Bars stay uniform either way.)
1846
- const timeVals =
1847
- spec.xType === 'time' ? spec.categories.map((c) => Date.parse(c)) : null
1848
- const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
1849
- const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
1850
- const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
1851
- const xCenter = (i: number) =>
1852
- timeOk && Number.isFinite(timeVals![i])
1853
- ? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
1854
- : round(padL + slot * i + slot / 2)
1855
- const xTicks: ChartCategoryTick[] = timeOk
1856
- ? dateTicks(tMin, tMin + tSpan).map((t) => ({
1857
- label: fmtDate(t, tSpan),
1858
- x: round(padL + ((t - tMin) / tSpan) * plotW),
1859
- }))
1860
- : spec.categories.map((label, i) => ({ label, x: xCenter(i) }))
1861
-
1862
- // Parent-tier ticks for a grouped category axis: each spans its leaves.
1863
- const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
1864
- if (validGroups && !timeOk) {
1865
- let start = 0
1866
- for (const g of validGroups) {
1867
- const x0 = round(padL + slot * start)
1868
- const x1 = round(padL + slot * (start + g.span))
1869
- categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
1870
- start += g.span
1871
- }
1872
- }
1873
-
1874
- const barSeries = series.filter((s) => s.kind === 'bar')
1875
- const bars: ChartBar[] = []
1876
- if (barSeries.length) {
1877
- const groupPad = slot * 0.2
1878
- if (stacked) {
1879
- const inner = slot - groupPad
1880
- const x0 = (i: number) => padL + slot * i + groupPad / 2
1881
- // Stack independently per axis so dual-axis stacks line up to their own scale.
1882
- for (const axis of ['left', 'right'] as const) {
1883
- const axisBars = barSeries.filter((s) => s.axis === axis)
1884
- if (!axisBars.length) continue
1885
- const yA = axis === 'right' ? yRight : yLeft
1886
- // 100% mode normalizes each category to its absolute total.
1887
- const totals = spec.stacked100
1888
- ? spec.categories.map(
1889
- (_, i) =>
1890
- axisBars.reduce(
1891
- (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
1892
- 0,
1893
- ) || 1,
1894
- )
1895
- : null
1896
- const pos = new Array(n).fill(0)
1897
- const neg = new Array(n).fill(0)
1898
- for (const s of axisBars) {
1899
- s.values.forEach((v, i) => {
1900
- if (!Number.isFinite(v)) return
1901
- const vp = totals ? (v / totals[i]!) * 100 : v
1902
- let yTop: number
1903
- let yBot: number
1904
- if (vp >= 0) {
1905
- yTop = yA(pos[i] + vp)
1906
- yBot = yA(pos[i])
1907
- pos[i] += vp
1908
- } else {
1909
- yTop = yA(neg[i])
1910
- yBot = yA(neg[i] + vp)
1911
- neg[i] += vp
1912
- }
1913
- bars.push({
1914
- x: round(x0(i)),
1915
- y: Math.min(yTop, yBot),
1916
- w: round(Math.max(1, inner)),
1917
- h: round(Math.abs(yBot - yTop)),
1918
- color: s.color,
1919
- label: spec.categories[i] ?? String(i),
1920
- series: s.label,
1921
- value: v,
1922
- })
1923
- })
1924
- }
1925
- }
1926
- } else {
1927
- const inner = slot - groupPad
1928
- const barW = inner / barSeries.length
1929
- barSeries.forEach((s, bi) => {
1930
- const dom = domOf(s)
1931
- const log = isLogOf(s)
1932
- // Log axis: bars grow from the axis floor (dom.min) up to v rather
1933
- // than from 0, since 0 is invalid in log space.
1934
- const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log)
1935
- s.values.forEach((v, i) => {
1936
- if (!Number.isFinite(v)) return
1937
- if (log && v <= 0) return
1938
- const x = padL + slot * i + groupPad / 2 + barW * bi
1939
- const yV = yOf(dom, v, log)
1940
- bars.push({
1941
- x: round(x),
1942
- y: Math.min(yV, base),
1943
- w: round(Math.max(1, barW - 1)),
1944
- h: round(Math.max(1, Math.abs(yV - base))),
1945
- color: s.color,
1946
- label: spec.categories[i] ?? String(i),
1947
- series: s.label,
1948
- value: v,
1949
- })
1950
- })
1951
- })
1952
- }
1953
- }
1954
-
1955
- // Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
1956
- const lines: ChartLine[] = []
1957
- const areaCum: Record<'left' | 'right', number[]> = {
1958
- left: new Array(n).fill(0),
1959
- right: new Array(n).fill(0),
1960
- }
1961
- // 100% mode: per-axis per-category totals to normalize stacked areas to 100.
1962
- const areaTotals: Record<'left' | 'right', number[] | null> = { left: null, right: null }
1963
- if (spec.stacked100) {
1964
- for (const axis of ['left', 'right'] as const) {
1965
- const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis)
1966
- if (areaSeries.length) {
1967
- areaTotals[axis] = spec.categories.map(
1968
- (_, i) =>
1969
- areaSeries.reduce(
1970
- (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
1971
- 0,
1972
- ) || 1,
1973
- )
1974
- }
1975
- }
1976
- }
1977
- for (const s of series) {
1978
- if (s.kind === 'bar') continue
1979
- const dom = domOf(s)
1980
- const log = isLogOf(s)
1981
- const yA = (v: number) => yOf(dom, v, log)
1982
- const isStackedArea = stacked && s.kind === 'area'
1983
- const px = (i: number) => xCenter(i)
1984
- let pts: ChartLinePoint[]
1985
- let baselinePts: Array<{ x: number; y: number }> | null = null
1986
- if (isStackedArea) {
1987
- // Stacked areas treat a gap as 0 so the stack stays continuous.
1988
- const cum = areaCum[s.axis]
1989
- const prev = cum.slice()
1990
- const totals = areaTotals[s.axis]
1991
- pts = s.values.map((v, i) => {
1992
- const vv = Number.isFinite(v) ? v : 0
1993
- // 100% mode positions by share of the category total; value stays original.
1994
- const norm = totals ? (vv / totals[i]!) * 100 : vv
1995
- const c = (cum[i] ?? 0) + norm
1996
- cum[i] = c
1997
- return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) }
1998
- })
1999
- baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
2000
- } else {
2001
- pts = s.values.map((v, i) => {
2002
- const ok = Number.isFinite(v)
2003
- return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
2004
- })
2005
- }
2006
- // Build the line - smoothed via monotone cubic when requested, else
2007
- // straight polylines. Either way, gaps break the path cleanly.
2008
- const smooth = !!s.smooth
2009
- const path = buildLinePath(pts, smooth)
2010
-
2011
- let areaPath = ''
2012
- if (s.kind === 'area' && pts.length) {
2013
- if (baselinePts) {
2014
- const top = smooth
2015
- ? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
2016
- : pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2017
- const back = smooth
2018
- ? `L${baselinePts[baselinePts.length - 1]!.x},${baselinePts[baselinePts.length - 1]!.y} ` +
2019
- monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
2020
- : baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
2021
- areaPath = `${top} ${back} Z`
2022
- } else {
2023
- // One filled polygon per contiguous run of defined points.
2024
- const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)))
2025
- const runs: ChartLinePoint[][] = []
2026
- let cur: ChartLinePoint[] = []
2027
- for (const p of pts) {
2028
- if (p.defined) cur.push(p)
2029
- else if (cur.length) {
2030
- runs.push(cur)
2031
- cur = []
2032
- }
2033
- }
2034
- if (cur.length) runs.push(cur)
2035
- areaPath = runs
2036
- .map((run) => {
2037
- const top = smooth
2038
- ? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
2039
- : run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2040
- return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
2041
- })
2042
- .join(' ')
2043
- }
2044
- }
2045
-
2046
- // Confidence band: shaded envelope between upperValues / lowerValues.
2047
- // Both arrays must be present and aligned to the value array.
2048
- let bandPath = ''
2049
- if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
2050
- const upperPts: Array<{ x: number; y: number }> = []
2051
- const lowerPts: Array<{ x: number; y: number }> = []
2052
- for (let i = 0; i < s.values.length; i += 1) {
2053
- const u = s.upperValues[i]!
2054
- const lo = s.lowerValues[i]!
2055
- if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
2056
- if (log && (u <= 0 || lo <= 0)) continue
2057
- upperPts.push({ x: px(i), y: yA(u) })
2058
- lowerPts.push({ x: px(i), y: yA(lo) })
2059
- }
2060
- if (upperPts.length >= 2) {
2061
- const top = smooth
2062
- ? monotoneCubicPath(upperPts)
2063
- : upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2064
- const back = smooth
2065
- ? `L${lowerPts[lowerPts.length - 1]!.x},${lowerPts[lowerPts.length - 1]!.y} ` +
2066
- monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
2067
- : lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
2068
- bandPath = `${top} ${back} Z`
2069
- }
2070
- }
2071
-
2072
- lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath })
2073
- }
2074
-
2075
- const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
2076
- dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: formatChartValue(value, spec.valueFormat) }))
2077
-
2078
- const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
2079
- const onRight = ref.axis === 'right'
2080
- const dom = onRight ? (rightDom ?? leftDom) : leftDom
2081
- const log = onRight ? rightLog : leftLog
2082
- return {
2083
- y: yOf(dom, ref.value, log),
2084
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
2085
- color: ref.color ?? '#ef4444',
2086
- dashed: ref.dashed !== false,
2087
- }
2088
- })
2089
-
2090
- // ---- Overlays: trendline / moving average ------------------------
2091
- // For every series with an `overlay`, compute the smoothed values and
2092
- // render as a dashed line in the source series' color (or overlayColor).
2093
- const overlays: ChartLine[] = []
2094
- for (const s of series) {
2095
- if (!s.overlay) continue
2096
- const dom = domOf(s)
2097
- const log = isLogOf(s)
2098
- const overlayVals = computeOverlay(s.values, s.overlay)
2099
- const color = s.overlayColor ?? s.color
2100
- const pts: ChartLinePoint[] = overlayVals.map((v, i) => {
2101
- const ok = Number.isFinite(v) && (!log || v > 0)
2102
- return {
2103
- x: xCenter(i),
2104
- y: ok ? yOf(dom, v, log) : NaN,
2105
- label: spec.categories[i] ?? String(i),
2106
- value: v,
2107
- defined: ok,
2108
- }
2109
- })
2110
- const path = buildLinePath(pts, !!s.smooth)
2111
- overlays.push({
2112
- path,
2113
- areaPath: '',
2114
- color,
2115
- label: `${s.label} (${s.overlay})`,
2116
- points: pts,
2117
- })
2118
- }
2119
-
2120
- // ---- Annotations: resolve data-space anchors to pixel coords ------
2121
- const annotations: ChartGeometry['annotations'] = []
2122
- for (const a of (spec.annotations ?? [])) {
2123
- let ax: number | null = null
2124
- let ay: number | null = null
2125
- if ('category' in a.at) {
2126
- const ci = spec.categories.indexOf(a.at.category)
2127
- if (ci < 0) continue
2128
- ax = xCenter(ci)
2129
- // Anchor to the named series' value at that category, else just
2130
- // mid-plot. Picks the first matching series if `series` is set.
2131
- const seriesName = a.at.series
2132
- const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
2133
- if (s) {
2134
- const v = s.values[ci]
2135
- if (Number.isFinite(v)) ay = yOf(domOf(s), v as number, isLogOf(s))
2136
- }
2137
- if (ay == null) ay = padT + plotH / 2
2138
- } else {
2139
- // Raw x/y in data space (x ignored for category x-axis; takes the
2140
- // mid-plot in that case). y projects through the left axis.
2141
- ax = padL + plotW / 2
2142
- if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog)
2143
- else ay = padT + plotH / 2
2144
- }
2145
- if (ax != null && ay != null && Number.isFinite(ay)) {
2146
- annotations.push({
2147
- x: ax,
2148
- y: ay,
2149
- label: a.label,
2150
- color: a.color ?? '#0f172a',
2151
- placement: a.placement ?? 'top',
2152
- })
2153
- }
2154
- }
2155
-
2156
- return {
2157
- ...empty,
2158
- plot,
2159
- bars,
2160
- lines,
2161
- yTicks: tickFor(leftDom, leftLog),
2162
- y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
2163
- hasRightAxis,
2164
- xTicks,
2165
- categoryGroupTicks,
2166
- xLabelRotated: timeOk ? false : xLabelRotated,
2167
- referenceLines,
2168
- overlays,
2169
- annotations,
2170
- }
2171
- }
2172
-
2173
- /**
2174
- * Aggregate flat rows into a chart spec. Group by a category field, reduce a
2175
- * value field per group. Three multi-series shapes:
2176
- * - `value: 'revenue'` -> one series
2177
- * - `value: ['revenue','cost']` -> one series per value field
2178
- * - `value: 'sales', series: 'region'` -> pivot: one series per distinct
2179
- * value of the `series` field
2180
- */
2181
- export function rowsToChartSpec<T extends Record<string, unknown>>(
2182
- rows: ReadonlyArray<T>,
2183
- opts: {
2184
- type: ChartType
2185
- category: keyof T & string
2186
- value: (keyof T & string) | Array<keyof T & string>
2187
- /** Pivot dimension: one series per distinct value of this field. */
2188
- series?: keyof T & string
2189
- reduce?: 'sum' | 'avg' | 'count'
2190
- seriesLabel?: string
2191
- width?: number
2192
- height?: number
2193
- stacked?: boolean
2194
- stacked100?: boolean
2195
- palette?: string[]
2196
- /** Order categories. Defaults to insertion order (or value-desc when topN). */
2197
- sort?: 'value-desc' | 'value-asc' | 'category' | 'none'
2198
- /** Keep only the top N categories; bucket the rest into "Other". */
2199
- topN?: number
2200
- /** Label for the bucketed remainder. Default "Other". */
2201
- otherLabel?: string
2202
- /** Field carrying each row's stable id. When set, the resulting spec's
2203
- * series carry `rowIds` arrays so click handlers can drill back to
2204
- * the source rows. */
2205
- idField?: keyof T & string
2206
- },
2207
- ): ChartSpec {
2208
- const reduce = opts.reduce ?? 'sum'
2209
- const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value]
2210
- const reduceCell = (sum: number, count: number) =>
2211
- reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
2212
-
2213
- const categories: string[] = []
2214
- const catIndex = new Map<string, number>()
2215
- const ensureCat = (key: string) => {
2216
- let idx = catIndex.get(key)
2217
- if (idx === undefined) {
2218
- idx = categories.length
2219
- catIndex.set(key, idx)
2220
- categories.push(key)
2221
- }
2222
- return idx
2223
- }
2224
-
2225
- // Series keyed by name -> per-category {sum,count,rowIds}.
2226
- type Cell = { sum: number; count: number; rowIds: Array<string | number> }
2227
- const seriesMap = new Map<string, Cell[]>()
2228
- const ensureSeries = (name: string) => {
2229
- let arr = seriesMap.get(name)
2230
- if (!arr) {
2231
- arr = []
2232
- seriesMap.set(name, arr)
2233
- }
2234
- return arr
2235
- }
2236
-
2237
- const trackIds = opts.idField !== undefined
2238
- for (const row of rows) {
2239
- const cat = String(row[opts.category] ?? '')
2240
- const ci = ensureCat(cat)
2241
- const rowId = trackIds ? (row[opts.idField as keyof T] as string | number) : undefined
2242
- if (opts.series) {
2243
- const sName = String(row[opts.series] ?? '')
2244
- const arr = ensureSeries(sName)
2245
- const num = Number(row[valueFields[0]!])
2246
- const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
2247
- if (Number.isFinite(num)) {
2248
- cell.sum += num
2249
- cell.count += 1
2250
- if (rowId !== undefined) cell.rowIds.push(rowId)
2251
- }
2252
- } else {
2253
- for (const vf of valueFields) {
2254
- const arr = ensureSeries(vf)
2255
- const num = Number(row[vf])
2256
- const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
2257
- if (Number.isFinite(num)) {
2258
- cell.sum += num
2259
- cell.count += 1
2260
- if (rowId !== undefined) cell.rowIds.push(rowId)
2261
- }
2262
- }
2263
- }
2264
- }
2265
-
2266
- const entries = [...seriesMap.entries()].map(([name, arr]) => ({
2267
- label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
2268
- values: categories.map((_, i) => {
2269
- const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] as Array<string | number> }
2270
- return reduceCell(cell.sum, cell.count)
2271
- }),
2272
- rowIds: trackIds
2273
- ? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
2274
- : undefined,
2275
- }))
2276
-
2277
- // ---- Sort + top-N -----------------------------------------------------
2278
- const totals = categories.map((_, i) =>
2279
- entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0),
2280
- )
2281
- const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none')
2282
- const order = categories.map((_, i) => i)
2283
- if (sort === 'value-desc') order.sort((a, b) => totals[b]! - totals[a]!)
2284
- else if (sort === 'value-asc') order.sort((a, b) => totals[a]! - totals[b]!)
2285
- else if (sort === 'category') order.sort((a, b) => categories[a]!.localeCompare(categories[b]!))
2286
-
2287
- let finalCategories: string[]
2288
- let finalSeries: ChartSeries[]
2289
- if (opts.topN && order.length > opts.topN) {
2290
- const keep = order.slice(0, opts.topN)
2291
- const rest = order.slice(opts.topN)
2292
- finalCategories = keep.map((i) => categories[i]!).concat(opts.otherLabel ?? 'Other')
2293
- finalSeries = entries.map((e) => ({
2294
- label: e.label,
2295
- values: keep
2296
- .map((i) => e.values[i]!)
2297
- .concat(rest.reduce((sum, i) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0)),
2298
- rowIds: e.rowIds
2299
- ? keep.map((i) => e.rowIds![i]!).concat([rest.flatMap((i) => e.rowIds![i] ?? [])])
2300
- : undefined,
2301
- }))
2302
- } else {
2303
- finalCategories = order.map((i) => categories[i]!)
2304
- finalSeries = entries.map((e) => ({
2305
- label: e.label,
2306
- values: order.map((i) => e.values[i]!),
2307
- rowIds: e.rowIds ? order.map((i) => e.rowIds![i]!) : undefined,
2308
- }))
2309
- }
2310
-
2311
- return {
2312
- type: opts.type,
2313
- categories: finalCategories,
2314
- series: finalSeries,
2315
- width: opts.width,
2316
- height: opts.height,
2317
- stacked: opts.stacked,
2318
- stacked100: opts.stacked100,
2319
- palette: opts.palette,
2320
- }
2321
- }
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 =
12
+ | 'bar' | 'line' | 'area' | 'pie' | 'scatter'
13
+ | 'heatmap' | 'waterfall' | 'funnel' | 'radar'
14
+ | 'calendar' | 'gauge' | 'treemap' | 'sankey'
15
+
16
+ /** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`.
17
+ * `rowIds` is populated when the spec was built from grid rows (via
18
+ * `rowsToChartSpec`) and lets a drill handler filter the grid back to the
19
+ * source rows for the clicked category / series cell. */
20
+ export type ChartSelection = {
21
+ category: string
22
+ series: string
23
+ value: number
24
+ rowIds?: Array<string | number>
25
+ }
26
+
27
+ /** A single scatter / bubble point. */
28
+ export type ScatterPoint = { x: number; y: number; r?: number; label?: string }
29
+
30
+ /** A statistical / smoothing line drawn on top of a source series.
31
+ * - `'linear'`: ordinary least-squares regression line
32
+ * - `'sma:N'`: simple moving average over a window of N points
33
+ * - `'ema:N'`: exponential moving average with smoothing factor 2/(N+1) */
34
+ export type SeriesOverlay = 'linear' | `sma:${number}` | `ema:${number}`
35
+
36
+ /** A texture fill applied in addition to (and on top of) the series color.
37
+ * Helps colorblind readers distinguish series at a glance. */
38
+ export type SeriesPattern = 'solid' | 'stripe' | 'crosshatch' | 'dots' | 'diagonal'
39
+
40
+ /** One plotted series: its label, its values (one per category), and how to draw it. */
41
+ export type ChartSeries = {
42
+ label: string
43
+ values: number[]
44
+ color?: string
45
+ /** Per-series chart type, for combo charts. Defaults to the spec `type`. */
46
+ type?: 'bar' | 'line' | 'area'
47
+ /** Plot against the left (default) or right Y axis. */
48
+ axis?: 'left' | 'right'
49
+ /** Scatter / bubble points (used when `type === 'scatter'`). */
50
+ points?: ScatterPoint[]
51
+ /** Row IDs contributing to each data point - parallel to `values`. When
52
+ * present, click handlers receive these in `ChartSelection.rowIds` so
53
+ * callers can drill the grid back to the source rows. */
54
+ rowIds?: Array<Array<string | number>>
55
+ /** Draw a smoothing / trend overlay on top of this series. */
56
+ overlay?: SeriesOverlay
57
+ /** Color for the overlay line. Defaults to the series color. */
58
+ overlayColor?: string
59
+ /** Texture fill (e.g. diagonal stripes) layered over the series color.
60
+ * Lets colorblind viewers tell two series apart even at the same hue. */
61
+ pattern?: SeriesPattern
62
+ /** Interpolate the line as a curve instead of polylines. `'monotone'`
63
+ * cubic prevents overshoots between points (best default for data);
64
+ * `true` is an alias for `'monotone'`. Only meaningful for line/area. */
65
+ smooth?: boolean | 'monotone'
66
+ /** Upper envelope (e.g. forecast 95th percentile) parallel to `values`.
67
+ * When set alongside `lowerValues`, the chart shades the band between
68
+ * the two as a translucent fill in the series color. */
69
+ upperValues?: number[]
70
+ /** Lower envelope; pair with `upperValues` for a confidence band. */
71
+ lowerValues?: number[]
72
+ }
73
+
74
+ /** A pinned label drawn over the plot, anchored to a data point or to an
75
+ * arbitrary (x, y) in data space. Useful for "Release v1", "Outage", etc. */
76
+ export type ChartAnnotation = {
77
+ /** Anchor in data space. Provide either `category` + `axis` for a point on
78
+ * an existing series, OR raw `x` / `y` numeric coordinates in data space. */
79
+ at:
80
+ | { category: string; series?: string }
81
+ | { x: number; y?: number }
82
+ label: string
83
+ color?: string
84
+ /** Where the label sits relative to the marker. Defaults to 'top'. */
85
+ placement?: 'top' | 'bottom' | 'left' | 'right'
86
+ }
87
+
88
+ /** A horizontal reference / target line drawn across the plot. */
89
+ export type ChartReferenceLine = {
90
+ value: number
91
+ label?: string
92
+ axis?: 'left' | 'right'
93
+ color?: string
94
+ dashed?: boolean
95
+ }
96
+
97
+ /**
98
+ * What to plot - the input you build and hand to a chart. Categories are the
99
+ * x-axis labels and every series supplies one value per category.
100
+ *
101
+ * {@link buildChart} turns this into a {@link ChartGeometry} for rendering.
102
+ */
103
+ export type ChartSpec = {
104
+ /** Default type for series that don't set their own `type`. */
105
+ type: ChartType
106
+ /** X-axis labels (one per data point). */
107
+ categories: string[]
108
+ series: ChartSeries[]
109
+ width?: number
110
+ height?: number
111
+ /** Palette used when a series has no explicit `color`. */
112
+ palette?: string[]
113
+ /** Per-category color overrides (by category label) - for pie / donut slice
114
+ * recolouring, where colour follows the category, not a series. */
115
+ categoryColors?: Record<string, string>
116
+ /** Number format for the value axis, tooltips, data labels and reference
117
+ * lines. Unset = the default compact `1.2k` / `1.2M` style. */
118
+ valueFormat?: ChartValueFormat
119
+ /** Grouped (nested) category axis: a parent tier spanning consecutive leaf
120
+ * categories (spans must sum to `categories.length`). Vertical category
121
+ * charts only (ignored for time / horizontal / pie). */
122
+ categoryGroups?: Array<{ label: string; span: number }>
123
+ /** Stack bar / area series (per axis) instead of grouping them. */
124
+ stacked?: boolean
125
+ /** Stack to 100% (each category normalized to its total). Implies stacked. */
126
+ stacked100?: boolean
127
+ /**
128
+ * Bar orientation. `'horizontal'` swaps the axes - categories run down the
129
+ * left, bars grow rightward - which suits long category labels. Only applies
130
+ * when every series is a bar (combo / line / area fall back to vertical).
131
+ */
132
+ orientation?: 'vertical' | 'horizontal'
133
+ /** Pie only: inner radius as a fraction of the outer radius (0..1) -> donut. */
134
+ innerRadius?: number
135
+ /** Horizontal target / goal / average lines. */
136
+ referenceLines?: ChartReferenceLine[]
137
+ /** Treat `categories` as dates -> time-scaled x positions + date ticks. */
138
+ xType?: 'category' | 'time'
139
+ /** Axis titles (reserve gutter space + render). */
140
+ yAxisTitle?: string
141
+ y2AxisTitle?: string
142
+ xAxisTitle?: string
143
+ /** Y-axis scale. `'log'` plots base-10 logarithmic - values <= 0 are
144
+ * treated as missing. Necessary for wide-range data (money, audience
145
+ * size, scientific). Default `'linear'`. */
146
+ yScale?: 'linear' | 'log'
147
+ /** Right (secondary) Y-axis scale. Default `'linear'`. */
148
+ y2Scale?: 'linear' | 'log'
149
+ /** Pinned text labels at fixed data-space positions (callouts). */
150
+ annotations?: ChartAnnotation[]
151
+ /** When true, automatically cycle through pattern fills for every series
152
+ * that doesn't set `pattern` explicitly. Useful as a one-flag colorblind
153
+ * fallback. Default false. */
154
+ patternFallback?: boolean
155
+ /** Calendar heatmap: array of date+value samples (one per day). Date
156
+ * strings are 'YYYY-MM-DD'. Missing days render as blank cells. */
157
+ calendarValues?: Array<{ date: string; value: number }>
158
+ /** Calendar heatmap: year window. Default: span the data. */
159
+ calendarStart?: string
160
+ calendarEnd?: string
161
+
162
+ /** Gauge: the value to display. */
163
+ gaugeValue?: number
164
+ /** Gauge: min/max of the dial scale. Defaults [0, 100]. */
165
+ gaugeMin?: number
166
+ gaugeMax?: number
167
+ /** Gauge: target marker (the line/notch on the arc). */
168
+ gaugeTarget?: number
169
+ /** Gauge: color bands along the arc (e.g. red/amber/green). */
170
+ gaugeRanges?: Array<{ from: number; to: number; color: string }>
171
+ /** Gauge: unit / suffix shown next to the value (e.g. '%', 'ms'). */
172
+ gaugeUnit?: string
173
+
174
+ /** Tree-map: hierarchical root. Leaves have `value`; parents are the
175
+ * sum of their children's totals. */
176
+ treemap?: TreeNode
177
+
178
+ /** Sankey: nodes + flow links between them. Link `source` / `target`
179
+ * reference node ids. */
180
+ sankeyNodes?: Array<{ id: string; label?: string; color?: string }>
181
+ sankeyLinks?: Array<{ source: string; target: string; value: number; color?: string }>
182
+
183
+ /** Waterfall: per-category flag marking bars as totals/subtotals that
184
+ * reset the running sum and span from 0. Same length as `categories`. */
185
+ waterfallTotals?: boolean[]
186
+ /** Waterfall: explicit colors for positive/negative/total bars. The
187
+ * series color is ignored when this is set. */
188
+ waterfallColors?: { positive?: string; negative?: string; total?: string }
189
+ /** Heatmap color scale. `'sequential'` maps min->max through one hue,
190
+ * `'diverging'` runs cold->neutral->warm around 0. A custom array
191
+ * (>=2 hex colors) defines an arbitrary gradient. Default `'sequential'`. */
192
+ colorScale?: 'sequential' | 'diverging' | string[]
193
+ }
194
+
195
+ /** A tree-map / sankey / treemap node spec. Used recursively as a tree. */
196
+ export type TreeNode = {
197
+ name: string
198
+ value?: number
199
+ color?: string
200
+ children?: TreeNode[]
201
+ }
202
+
203
+ /** A laid-out tree-map rectangle. */
204
+ export type ChartTreemapCell = {
205
+ x: number
206
+ y: number
207
+ w: number
208
+ h: number
209
+ color: string
210
+ textColor: string
211
+ name: string
212
+ value: number
213
+ /** Depth from the root - useful for color cycling per level. */
214
+ depth: number
215
+ }
216
+
217
+ /** A calendar-heatmap cell (one day). */
218
+ export type ChartCalendarCell = {
219
+ x: number
220
+ y: number
221
+ size: number
222
+ date: string
223
+ value: number
224
+ /** Defined when a value was supplied for this day; blank otherwise. */
225
+ defined: boolean
226
+ color: string
227
+ }
228
+
229
+ /** A gauge dial layout. */
230
+ export type ChartGaugeLayout = {
231
+ cx: number
232
+ cy: number
233
+ r: number
234
+ /** Track arc path (background grey). */
235
+ trackPath: string
236
+ /** Value arc path (filled to the current value). */
237
+ valuePath: string
238
+ /** Optional colored range arcs. */
239
+ rangePaths: Array<{ path: string; color: string; from: number; to: number }>
240
+ /** Pixel position of the target marker (when set). */
241
+ target: { x1: number; y1: number; x2: number; y2: number } | null
242
+ /** Tick marks around the dial (major ticks are longer). */
243
+ ticks: Array<{ x1: number; y1: number; x2: number; y2: number; major: boolean }>
244
+ /** Pointer needle (a kite shape) + its center hub radius. */
245
+ needle: { path: string; hubR: number }
246
+ /** Status color of the value arc (the band the value falls in), or null to
247
+ * fall back to the theme accent. */
248
+ valueColor: string | null
249
+ /** Scale end labels positioned under the two arc ends. */
250
+ minLabel: { x: number; y: number }
251
+ maxLabel: { x: number; y: number }
252
+ value: number
253
+ min: number
254
+ max: number
255
+ unit: string
256
+ }
257
+
258
+ /** A sankey node + its laid-out rect + total flow. */
259
+ export type ChartSankeyNode = {
260
+ id: string
261
+ label: string
262
+ color: string
263
+ x: number
264
+ y: number
265
+ w: number
266
+ h: number
267
+ /** Column (depth) the node was assigned to. */
268
+ column: number
269
+ totalIn: number
270
+ totalOut: number
271
+ }
272
+
273
+ /** A sankey link rendered as a curved ribbon. */
274
+ export type ChartSankeyLink = {
275
+ path: string
276
+ color: string
277
+ /** Stroke width = link value scaled to pixels. */
278
+ width: number
279
+ source: string
280
+ target: string
281
+ value: number
282
+ }
283
+
284
+ /** A single funnel segment (trapezoid) in pixel space. */
285
+ export type ChartFunnelSegment = {
286
+ /** Pre-built SVG path for the trapezoid. */
287
+ path: string
288
+ color: string
289
+ label: string
290
+ /** Original value (before any percentile normalisation). */
291
+ value: number
292
+ /** Conversion vs. first segment, 0..1. */
293
+ conversion: number
294
+ /** Drop-off from the previous segment, 0..1. */
295
+ dropoff: number
296
+ /** Centre point (label anchor). */
297
+ cx: number
298
+ cy: number
299
+ /** Auto-picked black/white contrast color for in-segment labels. */
300
+ textColor: string
301
+ }
302
+
303
+ /** A radar series' polygon: axis values + the closed polygon path. */
304
+ export type ChartRadarSeries = {
305
+ label: string
306
+ color: string
307
+ path: string
308
+ /** Per-axis (x, y) endpoints so callers can draw dots / hit targets. */
309
+ points: Array<{ x: number; y: number; value: number; axis: string }>
310
+ }
311
+
312
+ /** Radar axis spoke + tick info. */
313
+ export type ChartRadarAxis = {
314
+ label: string
315
+ /** Outermost endpoint of the spoke. */
316
+ x: number
317
+ y: number
318
+ }
319
+
320
+ /** A single heatmap rectangle in pixel space. */
321
+ export type ChartHeatmapCell = {
322
+ x: number
323
+ y: number
324
+ w: number
325
+ h: number
326
+ color: string
327
+ /** Text color picked for contrast against `color`. */
328
+ textColor: string
329
+ value: number
330
+ rowLabel: string
331
+ colLabel: string
332
+ }
333
+
334
+ /** A computed bar rectangle in SVG coordinates. Output of {@link buildChart}, not an input. */
335
+ export type ChartBar = {
336
+ x: number
337
+ y: number
338
+ w: number
339
+ h: number
340
+ color: string
341
+ /** Category (x label) this bar belongs to - for tooltips + labels. */
342
+ label: string
343
+ /** Series label this bar belongs to. */
344
+ series: string
345
+ value: number
346
+ }
347
+ /** One computed point on a line, with whether the series has a value there. */
348
+ export type ChartLinePoint = {
349
+ x: number
350
+ y: number
351
+ label: string
352
+ value: number
353
+ /** False for null / NaN values - the line breaks (gap), no dot is drawn. */
354
+ defined: boolean
355
+ }
356
+ /** A computed line series: its points and the path drawn through them. */
357
+ export type ChartLine = {
358
+ path: string
359
+ areaPath: string
360
+ color: string
361
+ label: string
362
+ points: ChartLinePoint[]
363
+ /** Confidence-band path (between upperValues + lowerValues) for this
364
+ * series, when both arrays are supplied. Empty otherwise. */
365
+ bandPath?: string
366
+ }
367
+ /** A computed pie slice, as an SVG arc plus its label placement. */
368
+ export type ChartPieSlice = {
369
+ path: string
370
+ color: string
371
+ label: string
372
+ value: number
373
+ percent: number
374
+ /** Centroid - anchor point for a data label. */
375
+ cx: number
376
+ cy: number
377
+ }
378
+ /** A value-axis tick: the number, where it sits vertically, and its label. */
379
+ export type ChartAxisTick = { value: number; y: number; label: string }
380
+ /** A category-axis tick: the label and its horizontal position. */
381
+ export type ChartCategoryTick = { label: string; x: number }
382
+ /** One legend entry, paired with the series colour it stands for. */
383
+ export type ChartLegendItem = { label: string; color: string }
384
+ /** A computed reference line (target, average, threshold) at its plotted height. */
385
+ export type ChartRefLineGeo = { y: number; label: string; color: string; dashed: boolean }
386
+ /** A vertical reference line (horizontal bar charts) positioned by `x`. */
387
+ export type ChartRefLineGeoV = { x: number; label: string; color: string; dashed: boolean }
388
+ /** A computed scatter point in SVG coordinates. */
389
+ export type ChartScatterDot = {
390
+ cx: number
391
+ cy: number
392
+ r: number
393
+ color: string
394
+ label: string
395
+ series: string
396
+ x: number
397
+ y: number
398
+ }
399
+
400
+ /**
401
+ * Everything needed to render a chart: the plot rectangle plus every mark
402
+ * already positioned in SVG coordinates. Produced by {@link buildChart} from a
403
+ * {@link ChartSpec}, so a renderer does no maths of its own.
404
+ */
405
+ export type ChartGeometry = {
406
+ type: ChartType
407
+ width: number
408
+ height: number
409
+ plot: { x: number; y: number; w: number; h: number }
410
+ bars: ChartBar[]
411
+ lines: ChartLine[]
412
+ slices: ChartPieSlice[]
413
+ yTicks: ChartAxisTick[]
414
+ /** Right-axis ticks (combo / dual-axis); empty when there's no right axis. */
415
+ y2Ticks: ChartAxisTick[]
416
+ hasRightAxis: boolean
417
+ xTicks: ChartCategoryTick[]
418
+ /** Grouped category axis parent tier: label + span extent (pixels). Empty
419
+ * unless `spec.categoryGroups` is set on a vertical category chart. */
420
+ categoryGroupTicks: Array<{ label: string; xCenter: number; x0: number; x1: number }>
421
+ /** True when x labels are long/many and should be rotated. */
422
+ xLabelRotated: boolean
423
+ legend: ChartLegendItem[]
424
+ /** Donut centre (pie + innerRadius), for a centre total label. */
425
+ donut: { cx: number; cy: number; r: number; total: number } | null
426
+ /** Horizontal reference / target lines. */
427
+ referenceLines: ChartRefLineGeo[]
428
+ /** Scatter / bubble points (type === 'scatter'). */
429
+ scatterPoints: ChartScatterDot[]
430
+ /** Bar orientation. `'horizontal'` uses `valueTicks` / `catTicks` below. */
431
+ orientation: 'vertical' | 'horizontal'
432
+ /** Horizontal bars: value-axis ticks along the bottom (label + x). */
433
+ valueTicks: ChartCategoryTick[]
434
+ /** Horizontal bars: category labels down the left (label + y; value = index). */
435
+ catTicks: ChartAxisTick[]
436
+ /** Horizontal bars: vertical reference / target lines (positioned by x). */
437
+ referenceLinesV: ChartRefLineGeoV[]
438
+ /** Trend / moving-average overlay lines (parallel to `lines`). Drawn
439
+ * dashed on top of their source series. */
440
+ overlays: ChartLine[]
441
+ /** Pinned annotation labels with pre-resolved screen coordinates. */
442
+ annotations: Array<{ x: number; y: number; label: string; color: string; placement: 'top' | 'bottom' | 'left' | 'right' }>
443
+ /** Heatmap cells (type === 'heatmap'). */
444
+ heatmapCells: ChartHeatmapCell[]
445
+ /** Heatmap row labels with pre-resolved y positions (left gutter). */
446
+ heatmapRowTicks: ChartAxisTick[]
447
+ /** Heatmap column labels (bottom of plot). */
448
+ heatmapColTicks: ChartCategoryTick[]
449
+ /** Heatmap color-scale legend: ordered stops with value + color. */
450
+ heatmapLegend: Array<{ value: number; color: string; label: string }>
451
+ /** Funnel segments (type === 'funnel'). */
452
+ funnelSegments: ChartFunnelSegment[]
453
+ /** Radar concentric grid rings (centred at `radarCenter`). */
454
+ radarRings: number[]
455
+ /** Radar axis labels + spoke endpoints. */
456
+ radarAxes: ChartRadarAxis[]
457
+ /** Radar series polygons. */
458
+ radarSeries: ChartRadarSeries[]
459
+ /** Centre of the radar / pie. Pre-computed so callers don't re-derive. */
460
+ radarCenter: { cx: number; cy: number; r: number } | null
461
+ /** Tree-map cells (type === 'treemap'). */
462
+ treemapCells: ChartTreemapCell[]
463
+ /** Calendar heatmap (type === 'calendar'). */
464
+ calendarCells: ChartCalendarCell[]
465
+ /** Calendar month labels along the top. */
466
+ calendarMonthTicks: ChartCategoryTick[]
467
+ /** Calendar legend stops (sequential ramp). */
468
+ calendarLegend: Array<{ value: number; color: string; label: string }>
469
+ /** Gauge layout (type === 'gauge'). Null when not a gauge. */
470
+ gauge: ChartGaugeLayout | null
471
+ /** Sankey nodes (type === 'sankey'). */
472
+ sankeyNodes: ChartSankeyNode[]
473
+ /** Sankey links (type === 'sankey'). */
474
+ sankeyLinks: ChartSankeyLink[]
475
+ }
476
+
477
+ /** Series colours used when a {@link ChartSeries} sets none, in order. */
478
+ export const DEFAULT_PALETTE = [
479
+ '#2563eb',
480
+ '#16a34a',
481
+ '#f59e0b',
482
+ '#ef4444',
483
+ '#8b5cf6',
484
+ '#0ea5e9',
485
+ '#ec4899',
486
+ '#14b8a6',
487
+ ]
488
+
489
+ function round(n: number): number {
490
+ return Math.round(n * 100) / 100
491
+ }
492
+
493
+ function niceNum(range: number, roundIt: boolean): number {
494
+ if (range <= 0) return 1
495
+ const exp = Math.floor(Math.log10(range))
496
+ const f = range / Math.pow(10, exp)
497
+ let nf: number
498
+ if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
499
+ else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
500
+ return nf * Math.pow(10, exp)
501
+ }
502
+
503
+ /** An axis range rounded to human-friendly bounds and tick spacing. */
504
+ export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
505
+
506
+ // ---- Color helpers for heatmap / pattern fills ----------------------
507
+
508
+ /** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
509
+ const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
510
+ /** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
511
+ const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
512
+ /** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
513
+ * above the dark grid surface instead of near-white, so empty / low cells read
514
+ * as "cold" rather than as glaring white rectangles. */
515
+ const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
516
+ const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
517
+
518
+ function resolveColorScale(
519
+ scale: 'sequential' | 'diverging' | string[] | undefined,
520
+ vMin: number,
521
+ vMax: number,
522
+ theme: 'light' | 'dark' = 'light',
523
+ ): string[] {
524
+ if (Array.isArray(scale) && scale.length >= 2) return scale
525
+ const dark = theme === 'dark'
526
+ if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
527
+ return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
528
+ }
529
+ return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
530
+ }
531
+
532
+ /** Sample a hex color from an array of hex stops at fractional position t.
533
+ * Linearly interpolates between the two nearest stops in RGB space. */
534
+ export function sampleGradient(stops: string[], t: number): string {
535
+ if (!stops.length) return '#888'
536
+ const clamped = Math.max(0, Math.min(1, t))
537
+ if (stops.length === 1) return stops[0]!
538
+ const pos = clamped * (stops.length - 1)
539
+ const i = Math.floor(pos)
540
+ const frac = pos - i
541
+ const a = hexToRgb(stops[i]!)
542
+ const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
543
+ if (!a || !b) return stops[i] ?? '#888'
544
+ const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
545
+ const toHex = (n: number) => n.toString(16).padStart(2, '0')
546
+ return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
547
+ }
548
+
549
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
550
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
551
+ if (!m) return null
552
+ const n = parseInt(m[1]!, 16)
553
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
554
+ }
555
+
556
+ /** Pick a black or white text color that has the better contrast against
557
+ * the given background. Uses the WCAG relative-luminance heuristic. */
558
+ export function pickContrastText(bgHex: string): string {
559
+ const rgb = hexToRgb(bgHex)
560
+ if (!rgb) return '#0f172a'
561
+ const lin = (c: number) => {
562
+ const s = c / 255
563
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
564
+ }
565
+ const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
566
+ return L > 0.5 ? '#0f172a' : '#ffffff'
567
+ }
568
+
569
+ /** Pick the largest power of 10 that fits at the bottom of [min,max], and
570
+ * the smallest that covers the top, then enumerate decade boundaries. Used
571
+ * by log-scale axes (yScale: 'log'). */
572
+ export function niceLogScale(min: number, max: number): NiceScale {
573
+ // Only positive values are plottable on a log scale; callers should
574
+ // strip non-positive values before passing them in.
575
+ if (!Number.isFinite(min) || min <= 0) min = 1
576
+ if (!Number.isFinite(max) || max <= min) max = min * 10
577
+ const lo = Math.floor(Math.log10(min))
578
+ const hi = Math.ceil(Math.log10(max))
579
+ const ticks: number[] = []
580
+ for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
581
+ return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
582
+ }
583
+
584
+ /** Map a value to a fractional position [0..1] across the axis domain.
585
+ * Pass the appropriate fn into projection code so linear / log share the
586
+ * same plumbing. Returns null for non-positive values on log. */
587
+ function project(value: number, min: number, max: number, isLog: boolean): number | null {
588
+ if (!Number.isFinite(value)) return null
589
+ if (isLog) {
590
+ if (value <= 0 || min <= 0) return null
591
+ return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
592
+ }
593
+ return (value - min) / (max - min)
594
+ }
595
+
596
+ // ---- Overlay math: trendline + moving averages -----------------------
597
+
598
+ /** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
599
+ * monotone cubic interpolation (preserves local extrema - no overshoots).
600
+ * Breaks the path at `defined === false` gaps. */
601
+ export function buildLinePath(
602
+ pts: Array<{ x: number; y: number; defined: boolean }>,
603
+ smooth: boolean,
604
+ ): string {
605
+ if (!smooth) {
606
+ let path = ''
607
+ let pen = false
608
+ for (const p of pts) {
609
+ if (!p.defined) { pen = false; continue }
610
+ path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
611
+ pen = true
612
+ }
613
+ return path.trim()
614
+ }
615
+ // Group defined-only runs; each run is smoothed independently.
616
+ const runs: Array<Array<{ x: number; y: number }>> = []
617
+ let cur: Array<{ x: number; y: number }> = []
618
+ for (const p of pts) {
619
+ if (p.defined) cur.push({ x: p.x, y: p.y })
620
+ else if (cur.length) { runs.push(cur); cur = [] }
621
+ }
622
+ if (cur.length) runs.push(cur)
623
+ return runs.map(monotoneCubicPath).filter(Boolean).join(' ')
624
+ }
625
+
626
+ /** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
627
+ * Slope at each point chosen so the curve passes through every (xi, yi)
628
+ * AND stays monotonic between them; control points sit 1/3 of the way
629
+ * to the neighbours along that tangent. */
630
+ function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
631
+ const n = pts.length
632
+ if (n === 0) return ''
633
+ if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
634
+ if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
635
+ // Secant slopes between adjacent points.
636
+ const dx: number[] = new Array(n - 1)
637
+ const m: number[] = new Array(n - 1)
638
+ for (let i = 0; i < n - 1; i += 1) {
639
+ const d = pts[i + 1]!.x - pts[i]!.x
640
+ dx[i] = d
641
+ m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
642
+ }
643
+ // Tangent at each point: average of neighbouring slopes, with sign
644
+ // checks that flatten the tangent when slopes change sign.
645
+ const tan: number[] = new Array(n)
646
+ tan[0] = m[0]!
647
+ tan[n - 1] = m[n - 2]!
648
+ for (let i = 1; i < n - 1; i += 1) {
649
+ if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
650
+ else tan[i] = (m[i - 1]! + m[i]!) / 2
651
+ }
652
+ // Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
653
+ for (let i = 0; i < n - 1; i += 1) {
654
+ if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
655
+ const a = tan[i]! / m[i]!
656
+ const b = tan[i + 1]! / m[i]!
657
+ const h = Math.hypot(a, b)
658
+ if (h > 3) {
659
+ tan[i] = (3 / h) * a * m[i]!
660
+ tan[i + 1] = (3 / h) * b * m[i]!
661
+ }
662
+ }
663
+ // Build the Bezier path. Each segment: control points at 1/3 of dx.
664
+ let path = `M${pts[0]!.x},${pts[0]!.y}`
665
+ for (let i = 0; i < n - 1; i += 1) {
666
+ const h = dx[i]!
667
+ const c1x = pts[i]!.x + h / 3
668
+ const c1y = pts[i]!.y + (tan[i]! * h) / 3
669
+ const c2x = pts[i + 1]!.x - h / 3
670
+ const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
671
+ path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
672
+ }
673
+ return path
674
+ }
675
+
676
+ /** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
677
+ * Returns the fitted value at each x index, or NaN where the source value
678
+ * was non-finite. */
679
+ export function linearTrend(values: number[]): number[] {
680
+ let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
681
+ for (let i = 0; i < values.length; i += 1) {
682
+ const y = values[i]!
683
+ if (!Number.isFinite(y)) continue
684
+ n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
685
+ }
686
+ if (n < 2) return values.map(() => NaN)
687
+ const denom = n * sumXX - sumX * sumX
688
+ if (denom === 0) return values.map(() => sumY / n)
689
+ const slope = (n * sumXY - sumX * sumY) / denom
690
+ const intercept = (sumY - slope * sumX) / n
691
+ return values.map((_, i) => slope * i + intercept)
692
+ }
693
+
694
+ /** Simple moving average over a window of `period` values. Window centres
695
+ * trail to the right (typical for time-series). NaN for points before the
696
+ * window is full. */
697
+ export function simpleMovingAverage(values: number[], period: number): number[] {
698
+ if (period < 1) return values.slice()
699
+ const out: number[] = new Array(values.length).fill(NaN)
700
+ let sum = 0, count = 0
701
+ for (let i = 0; i < values.length; i += 1) {
702
+ const v = values[i]!
703
+ if (Number.isFinite(v)) { sum += v; count += 1 }
704
+ if (i >= period) {
705
+ const drop = values[i - period]!
706
+ if (Number.isFinite(drop)) { sum -= drop; count -= 1 }
707
+ }
708
+ if (i >= period - 1 && count > 0) out[i] = sum / count
709
+ }
710
+ return out
711
+ }
712
+
713
+ /** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
714
+ export function exponentialMovingAverage(values: number[], period: number): number[] {
715
+ const alpha = 2 / (Math.max(1, period) + 1)
716
+ const out: number[] = new Array(values.length).fill(NaN)
717
+ let prev: number | null = null
718
+ for (let i = 0; i < values.length; i += 1) {
719
+ const v = values[i]!
720
+ if (!Number.isFinite(v)) { out[i] = prev ?? NaN; continue }
721
+ prev = prev == null ? v : alpha * v + (1 - alpha) * prev
722
+ out[i] = prev
723
+ }
724
+ return out
725
+ }
726
+
727
+ /** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
728
+ export function computeOverlay(values: number[], spec: SeriesOverlay): number[] {
729
+ if (spec === 'linear') return linearTrend(values)
730
+ const m = /^(sma|ema):(\d+)$/.exec(spec)
731
+ if (!m) return values.map(() => NaN)
732
+ const period = Number(m[2])
733
+ return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period)
734
+ }
735
+
736
+ /** Round a [min,max] domain out to nice tick boundaries. */
737
+ export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
738
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
739
+ min = 0
740
+ max = 1
741
+ }
742
+ if (min === max) {
743
+ if (min === 0) max = 1
744
+ else {
745
+ min = Math.min(0, min)
746
+ max = Math.max(0, max)
747
+ }
748
+ if (min === max) max = min + 1
749
+ }
750
+ const range = niceNum(max - min, false)
751
+ const step = niceNum(range / Math.max(1, tickCount), true)
752
+ const nMin = Math.floor(min / step) * step
753
+ const nMax = Math.ceil(max / step) * step
754
+ const ticks: number[] = []
755
+ for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
756
+ return { min: nMin, max: nMax, step, ticks }
757
+ }
758
+
759
+ function fmtTick(n: number): string {
760
+ const abs = Math.abs(n)
761
+ if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
762
+ if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
763
+ return String(Math.round(n * 100) / 100)
764
+ }
765
+
766
+ /** Value-axis / tooltip / label number format. */
767
+ export type ChartValueFormat = 'number' | 'currency' | 'percent' | 'compact'
768
+
769
+ /**
770
+ * Format a numeric value for display, honouring an optional `valueFormat`.
771
+ * Builds on the compact `1.2k` / `1.2M` base: currency prefixes `$` (sign
772
+ * outside), percent multiplies by 100 and suffixes `%`. Unset / `'number'` /
773
+ * `'compact'` = the plain compact form.
774
+ */
775
+ export function formatChartValue(n: number, format?: ChartValueFormat): string {
776
+ if (!Number.isFinite(n)) return ''
777
+ if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
778
+ if (format === 'percent') {
779
+ const p = n * 100
780
+ return `${Math.round(p * 10) / 10}%`
781
+ }
782
+ return fmtTick(n)
783
+ }
784
+
785
+ const DAY = 86_400_000
786
+ /** Nice date-tick timestamps across [min, max]. */
787
+ function dateTicks(tMin: number, tMax: number): number[] {
788
+ const span = tMax - tMin
789
+ const step =
790
+ span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY
791
+ const ticks: number[] = []
792
+ for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step) ticks.push(t)
793
+ if (!ticks.length) ticks.push(tMin, tMax)
794
+ return ticks
795
+ }
796
+ function fmtDate(t: number, span: number): string {
797
+ const d = new Date(t)
798
+ if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
799
+ if (span <= 800 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
800
+ return String(d.getFullYear())
801
+ }
802
+
803
+ type ResolvedSeries = ChartSeries & {
804
+ color: string
805
+ kind: 'bar' | 'line' | 'area'
806
+ axis: 'left' | 'right'
807
+ }
808
+
809
+ /** Data domain for one axis, honoring stacking of its bar/area series.
810
+ * When `isLog` is true, non-positive values are discarded (log undefined)
811
+ * and the domain is rounded to decade boundaries instead of nice steps. */
812
+ function axisDomain(
813
+ list: ResolvedSeries[],
814
+ categories: string[],
815
+ stacked: boolean,
816
+ extra: number[] = [],
817
+ isLog = false,
818
+ ): NiceScale {
819
+ let dMin = Infinity
820
+ let dMax = -Infinity
821
+ const note = (v: number) => {
822
+ if (!Number.isFinite(v)) return
823
+ if (isLog && v <= 0) return
824
+ if (v < dMin) dMin = v
825
+ if (v > dMax) dMax = v
826
+ }
827
+ for (const v of extra) note(v)
828
+ const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area')
829
+ const lines = list.filter((s) => s.kind === 'line')
830
+ if (stacked && stackable.length) {
831
+ for (let i = 0; i < categories.length; i += 1) {
832
+ let pos = 0
833
+ let neg = 0
834
+ for (const s of stackable) {
835
+ const v = s.values[i] ?? 0
836
+ if (v >= 0) pos += v
837
+ else neg += v
838
+ }
839
+ note(pos)
840
+ note(neg)
841
+ }
842
+ } else {
843
+ for (const s of stackable) for (const v of s.values) note(v)
844
+ }
845
+ for (const s of lines) for (const v of s.values) note(v)
846
+ if (dMin === Infinity) {
847
+ dMin = isLog ? 1 : 0
848
+ dMax = isLog ? 10 : 1
849
+ }
850
+ // Bar / area charts read against a zero baseline, so always include 0
851
+ // - but only on linear axes (0 is invalid in log).
852
+ if (stackable.length && !isLog) {
853
+ dMin = Math.min(dMin, 0)
854
+ dMax = Math.max(dMax, 0)
855
+ }
856
+ return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax)
857
+ }
858
+
859
+ /**
860
+ * Lay out a {@link ChartSpec} into renderable {@link ChartGeometry} - scales,
861
+ * ticks, and the position of every bar, line, slice and dot. Pure: no DOM, so
862
+ * it runs during SSR and can be unit-tested directly.
863
+ */
864
+ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'): ChartGeometry {
865
+ const width = spec.width ?? 520
866
+ const height = spec.height ?? 300
867
+ const palette = spec.palette ?? DEFAULT_PALETTE
868
+
869
+ const stacked = !!(spec.stacked || spec.stacked100)
870
+ const series: ResolvedSeries[] = spec.series.map((s, i) => ({
871
+ ...s,
872
+ color: s.color ?? palette[i % palette.length]!,
873
+ kind: (s.type ?? (spec.type === 'pie' || spec.type === 'scatter' ? 'bar' : spec.type)) as
874
+ | 'bar'
875
+ | 'line'
876
+ | 'area',
877
+ axis: s.axis ?? 'left',
878
+ }))
879
+ const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
880
+
881
+ const empty: ChartGeometry = {
882
+ type: spec.type,
883
+ width,
884
+ height,
885
+ plot: { x: 0, y: 0, w: width, h: height },
886
+ bars: [],
887
+ lines: [],
888
+ slices: [],
889
+ yTicks: [],
890
+ y2Ticks: [],
891
+ hasRightAxis: false,
892
+ xTicks: [],
893
+ categoryGroupTicks: [],
894
+ xLabelRotated: false,
895
+ legend,
896
+ donut: null,
897
+ referenceLines: [],
898
+ scatterPoints: [],
899
+ orientation: 'vertical',
900
+ valueTicks: [],
901
+ catTicks: [],
902
+ referenceLinesV: [],
903
+ overlays: [],
904
+ annotations: [],
905
+ heatmapCells: [],
906
+ heatmapRowTicks: [],
907
+ heatmapColTicks: [],
908
+ heatmapLegend: [],
909
+ funnelSegments: [],
910
+ radarRings: [],
911
+ radarAxes: [],
912
+ radarSeries: [],
913
+ radarCenter: null,
914
+ treemapCells: [],
915
+ calendarCells: [],
916
+ calendarMonthTicks: [],
917
+ calendarLegend: [],
918
+ gauge: null,
919
+ sankeyNodes: [],
920
+ sankeyLinks: [],
921
+ }
922
+
923
+ // ---- Waterfall ----------------------------------------------------
924
+ // First series provides the values. Each non-total bar starts at the
925
+ // running cumulative sum; total bars (waterfallTotals[i]) reset and
926
+ // span from 0 to that sum. Color is derived from sign + total flag, with
927
+ // optional palette overrides via spec.waterfallColors.
928
+ if (spec.type === 'waterfall') {
929
+ const src = series[0]
930
+ if (!src) return { ...empty }
931
+ const colors = spec.waterfallColors ?? {}
932
+ const positive = colors.positive ?? '#16a34a'
933
+ const negative = colors.negative ?? '#ef4444'
934
+ const total = colors.total ?? '#475569'
935
+
936
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
937
+ const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
938
+ const padL = 48 + (spec.yAxisTitle ? 16 : 0)
939
+ const padR = 12
940
+ const padT = 10
941
+ const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
942
+ const plotW = Math.max(1, width - padL - padR)
943
+ const plotH = Math.max(1, height - padT - padB)
944
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
945
+
946
+ // Compute the running cumulative + per-bar (from, to) pairs.
947
+ const totals = spec.waterfallTotals ?? []
948
+ const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
949
+ let cum = 0
950
+ spec.categories.forEach((_, i) => {
951
+ const v = src.values[i] ?? 0
952
+ const isTotal = !!totals[i]
953
+ if (isTotal) {
954
+ pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
955
+ } else {
956
+ pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
957
+ cum += v
958
+ }
959
+ })
960
+ // Y-axis domain spans every visited level (including 0).
961
+ let dMin = 0, dMax = 0
962
+ for (const p of pairs) {
963
+ if (p.from < dMin) dMin = p.from
964
+ if (p.to < dMin) dMin = p.to
965
+ if (p.from > dMax) dMax = p.from
966
+ if (p.to > dMax) dMax = p.to
967
+ }
968
+ const dom = niceScale(dMin, dMax)
969
+ const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
970
+
971
+ const slotW = plotW / Math.max(1, spec.categories.length)
972
+ const barPad = slotW * 0.2
973
+ const barW = Math.max(1, slotW - barPad)
974
+ const bars: ChartBar[] = pairs.map((p, i) => {
975
+ const yTop = yOfW(Math.max(p.from, p.to))
976
+ const yBot = yOfW(Math.min(p.from, p.to))
977
+ const x = padL + slotW * i + barPad / 2
978
+ const color = p.isTotal ? total : p.value >= 0 ? positive : negative
979
+ return {
980
+ x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
981
+ color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
982
+ }
983
+ })
984
+ // Thin connector lines between bar tops -> running total reads cleanly.
985
+ const connectors: ChartLine[] = [{
986
+ path: pairs
987
+ .map((p, i) => {
988
+ const x0 = padL + slotW * i + barPad / 2 + barW
989
+ const y = yOfW(p.to)
990
+ const x1 = padL + slotW * (i + 1) + barPad / 2
991
+ // Skip the final connector beyond the last bar.
992
+ return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
993
+ })
994
+ .filter(Boolean)
995
+ .join(' '),
996
+ areaPath: '',
997
+ color: 'var(--sg-muted, #94a3b8)',
998
+ label: '',
999
+ points: [],
1000
+ }]
1001
+ const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
1002
+ label,
1003
+ x: round(padL + slotW * i + slotW / 2),
1004
+ }))
1005
+ const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
1006
+ value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat),
1007
+ }))
1008
+ return {
1009
+ ...empty,
1010
+ plot,
1011
+ bars,
1012
+ lines: connectors,
1013
+ yTicks,
1014
+ xTicks,
1015
+ xLabelRotated,
1016
+ }
1017
+ }
1018
+
1019
+ // ---- Funnel -------------------------------------------------------
1020
+ // One series of strictly-decreasing values gets rendered as a stack
1021
+ // of horizontal trapezoids: each level's width is proportional to its
1022
+ // value relative to the largest, slope automatically links level N+1
1023
+ // narrower than level N. Labels show value, conversion vs. top, and
1024
+ // step drop-off.
1025
+ if (spec.type === 'funnel') {
1026
+ const src = series[0]
1027
+ if (!src || !src.values.length) return { ...empty }
1028
+ const padL = 20, padR = 20, padT = 16, padB = 16
1029
+ const plotW = Math.max(1, width - padL - padR)
1030
+ const plotH = Math.max(1, height - padT - padB)
1031
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1032
+ const n = src.values.length
1033
+ const stepH = plotH / n
1034
+ const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
1035
+ const top = src.values[0] ?? 0
1036
+ const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
1037
+ const cx = padL + plotW / 2
1038
+ const palette = spec.palette ?? DEFAULT_PALETTE
1039
+ const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
1040
+ const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
1041
+ const w0 = widthAt(v)
1042
+ const w1 = widthAt(next)
1043
+ const y0 = padT + stepH * i
1044
+ const y1 = y0 + stepH
1045
+ const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
1046
+ const color = src.color ?? palette[i % palette.length]!
1047
+ return {
1048
+ path, color,
1049
+ label: spec.categories[i] ?? src.label,
1050
+ value: v,
1051
+ conversion: top > 0 ? v / top : 0,
1052
+ dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
1053
+ cx,
1054
+ cy: (y0 + y1) / 2,
1055
+ textColor: pickContrastText(color),
1056
+ }
1057
+ })
1058
+ return {
1059
+ ...empty,
1060
+ plot,
1061
+ funnelSegments: segments,
1062
+ }
1063
+ }
1064
+
1065
+ // ---- Radar --------------------------------------------------------
1066
+ // Polar coordinates: each `category` is a spoke (axis); each `series`
1067
+ // contributes a polygon connecting its values across the spokes. All
1068
+ // series share the same scale (max across every value). Concentric
1069
+ // ring count derived from data, capped at 5 for legibility.
1070
+ if (spec.type === 'radar') {
1071
+ if (!series.length || !spec.categories.length) return { ...empty }
1072
+ const padL = 30, padR = 30, padT = 24, padB = 24
1073
+ const plotW = Math.max(1, width - padL - padR)
1074
+ const plotH = Math.max(1, height - padT - padB)
1075
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1076
+ const cx = padL + plotW / 2
1077
+ const cy = padT + plotH / 2
1078
+ const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20)
1079
+ const axes = spec.categories
1080
+ const k = axes.length
1081
+ let vMax = 0
1082
+ for (const s of series) for (const v of s.values) {
1083
+ if (Number.isFinite(v) && v > vMax) vMax = v
1084
+ }
1085
+ if (vMax === 0) vMax = 1
1086
+ const ringCount = 5
1087
+ const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax)
1088
+ /** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
1089
+ * proceed clockwise so axes lay out left-to-right when k <= 4. */
1090
+ const angleAt = (i: number) => -Math.PI / 2 + (i / k) * Math.PI * 2
1091
+ const pointAt = (i: number, v: number) => {
1092
+ const t = v / vMax
1093
+ const a = angleAt(i)
1094
+ return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) }
1095
+ }
1096
+ const radarAxes: ChartRadarAxis[] = axes.map((label, i) => {
1097
+ const p = pointAt(i, vMax)
1098
+ return { label, x: p.x, y: p.y }
1099
+ })
1100
+ const radarSeriesGeo: ChartRadarSeries[] = series.map((s, si) => {
1101
+ const pts = s.values.map((v, i) => {
1102
+ const safe = Number.isFinite(v) ? v : 0
1103
+ const p = pointAt(i, safe)
1104
+ return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' }
1105
+ })
1106
+ const path = pts.length
1107
+ ? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
1108
+ : ''
1109
+ const palette = spec.palette ?? DEFAULT_PALETTE
1110
+ return { label: s.label, color: s.color ?? palette[si % palette.length]!, path, points: pts }
1111
+ })
1112
+ return {
1113
+ ...empty,
1114
+ plot,
1115
+ radarRings: ringValues,
1116
+ radarAxes,
1117
+ radarSeries: radarSeriesGeo,
1118
+ radarCenter: { cx, cy, r },
1119
+ }
1120
+ }
1121
+
1122
+ // ---- Calendar heatmap --------------------------------------------
1123
+ // GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
1124
+ // cell is a small square shaded by `calendarValues[i].value` via the
1125
+ // sequential color scale. Days with no value render blank (border only)
1126
+ // so missing data is visually obvious.
1127
+ if (spec.type === 'calendar') {
1128
+ const values = spec.calendarValues ?? []
1129
+ if (!values.length && !spec.calendarStart) return { ...empty }
1130
+ // Build a value lookup + figure out the date range.
1131
+ const valueByDate = new Map<string, number>()
1132
+ let vMin = Infinity, vMax = -Infinity
1133
+ for (const v of values) {
1134
+ valueByDate.set(v.date, v.value)
1135
+ if (Number.isFinite(v.value)) {
1136
+ if (v.value < vMin) vMin = v.value
1137
+ if (v.value > vMax) vMax = v.value
1138
+ }
1139
+ }
1140
+ if (vMin === Infinity) { vMin = 0; vMax = 1 }
1141
+ if (vMin === vMax) vMax = vMin + 1
1142
+ const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
1143
+ const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
1144
+ // Determine date range. If calendarStart/End set, use them, otherwise
1145
+ // span the data + round to whole weeks (Sun..Sat).
1146
+ const sorted = values.map((v) => v.date).sort()
1147
+ const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
1148
+ const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
1149
+ const start = new Date(startStr + 'T00:00:00Z')
1150
+ const end = new Date(endStr + 'T00:00:00Z')
1151
+ // Roll start back to its Sunday, end forward to its Saturday.
1152
+ start.setUTCDate(start.getUTCDate() - start.getUTCDay())
1153
+ end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
1154
+ const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
1155
+ const weeks = Math.ceil(totalDays / 7)
1156
+ const padL = 36, padR = 80, padT = 26, padB = 16
1157
+ const plotW = Math.max(1, width - padL - padR)
1158
+ const plotH = Math.max(1, height - padT - padB)
1159
+ // Cell size: fit weeks across width, 7 rows down height.
1160
+ const cellW = Math.floor(plotW / weeks)
1161
+ const cellH = Math.floor(plotH / 7)
1162
+ const cellSize = Math.max(6, Math.min(cellW, cellH))
1163
+ const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
1164
+ const cells: ChartCalendarCell[] = []
1165
+ let lastMonth = -1
1166
+ const monthTicks: ChartCategoryTick[] = []
1167
+ for (let i = 0; i < totalDays; i += 1) {
1168
+ const day = new Date(start.getTime() + i * 86_400_000)
1169
+ const col = Math.floor(i / 7)
1170
+ const row = i % 7
1171
+ const date = day.toISOString().slice(0, 10)
1172
+ const has = valueByDate.has(date)
1173
+ const v = valueByDate.get(date) ?? 0
1174
+ cells.push({
1175
+ x: padL + col * cellSize,
1176
+ y: padT + row * cellSize,
1177
+ size: cellSize,
1178
+ date, value: v,
1179
+ defined: has,
1180
+ color: has ? colorAt(v) : 'transparent',
1181
+ })
1182
+ if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
1183
+ lastMonth = day.getUTCMonth()
1184
+ monthTicks.push({
1185
+ label: day.toLocaleDateString(undefined, { month: 'short' }),
1186
+ x: padL + col * cellSize,
1187
+ })
1188
+ }
1189
+ }
1190
+ const legend = Array.from({ length: 5 }, (_, i) => {
1191
+ const t = i / 4
1192
+ const value = vMin + (vMax - vMin) * t
1193
+ return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
1194
+ })
1195
+ return {
1196
+ ...empty,
1197
+ plot,
1198
+ calendarCells: cells,
1199
+ calendarMonthTicks: monthTicks,
1200
+ calendarLegend: legend,
1201
+ }
1202
+ }
1203
+
1204
+ // ---- Gauge --------------------------------------------------------
1205
+ // Semicircle dial: track arc + value arc + optional colored range bands
1206
+ // + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
1207
+ if (spec.type === 'gauge') {
1208
+ const min = spec.gaugeMin ?? 0
1209
+ const max = spec.gaugeMax ?? 100
1210
+ const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0))
1211
+ const target = spec.gaugeTarget
1212
+ const cx = width / 2
1213
+ const cy = height * 0.78
1214
+ const r = Math.min(width * 0.42, height * 0.65)
1215
+ // Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
1216
+ const A0 = Math.PI
1217
+ const A1 = 2 * Math.PI
1218
+ const angleAt = (v: number) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0)
1219
+ const arc = (a0: number, a1: number, radius: number): string => {
1220
+ const x1 = cx + radius * Math.cos(a0)
1221
+ const y1 = cy + radius * Math.sin(a0)
1222
+ const x2 = cx + radius * Math.cos(a1)
1223
+ const y2 = cy + radius * Math.sin(a1)
1224
+ const large = a1 - a0 > Math.PI ? 1 : 0
1225
+ return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`
1226
+ }
1227
+ const trackPath = arc(A0, A1, r)
1228
+ const valuePath = arc(A0, angleAt(value), r)
1229
+ const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
1230
+ path: arc(angleAt(band.from), angleAt(band.to), r - 9),
1231
+ color: band.color, from: band.from, to: band.to,
1232
+ }))
1233
+ let targetPx: ChartGaugeLayout['target'] = null
1234
+ if (target != null && Number.isFinite(target)) {
1235
+ const a = angleAt(Math.max(min, Math.min(max, target)))
1236
+ const inner = r - 12
1237
+ const outer = r + 4
1238
+ targetPx = {
1239
+ x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1240
+ x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1241
+ }
1242
+ }
1243
+ // Tick marks just outside the track: a major tick every 1/4 of the scale,
1244
+ // with 4 minor ticks between each. Gives the dial a measured, instrument feel.
1245
+ const ticks: ChartGaugeLayout['ticks'] = []
1246
+ const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR
1247
+ for (let i = 0; i <= TICK_TOTAL; i++) {
1248
+ const a = A0 + (i / TICK_TOTAL) * (A1 - A0)
1249
+ const major = i % TICK_MINOR === 0
1250
+ const inner = r + 9
1251
+ const outer = r + (major ? 17 : 13)
1252
+ ticks.push({
1253
+ x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1254
+ x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1255
+ major,
1256
+ })
1257
+ }
1258
+ // Pointer needle: a kite (long tip toward the value, short counterweight
1259
+ // tail) pivoting on a center hub.
1260
+ const aV = angleAt(value)
1261
+ const tipR = r - 16, tailR = 18, baseR = 6
1262
+ const aPerp = aV + Math.PI / 2
1263
+ const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
1264
+ const needlePath =
1265
+ `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
1266
+ // Color the value arc by the band the value currently sits in.
1267
+ let valueColor: string | null = null
1268
+ for (const band of spec.gaugeRanges ?? []) {
1269
+ if (value >= band.from && value <= band.to) valueColor = band.color
1270
+ }
1271
+ return {
1272
+ ...empty,
1273
+ plot: { x: 0, y: 0, w: width, h: height },
1274
+ gauge: {
1275
+ cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
1276
+ ticks, needle: { path: needlePath, hubR: 7 }, valueColor,
1277
+ minLabel: { x: cx - r, y: cy + 20 },
1278
+ maxLabel: { x: cx + r, y: cy + 20 },
1279
+ value, min, max, unit: spec.gaugeUnit ?? '',
1280
+ },
1281
+ }
1282
+ }
1283
+
1284
+ // ---- Tree-map -----------------------------------------------------
1285
+ // Squarified tree-map (Bruls et al. 2000): each level recursively
1286
+ // partitions its rectangle in proportion to its children, picking the
1287
+ // split orientation that keeps aspect ratios closest to 1.
1288
+ if (spec.type === 'treemap') {
1289
+ const root = spec.treemap
1290
+ if (!root) return { ...empty }
1291
+ const padL = 4, padR = 4, padT = 4, padB = 4
1292
+ const plotW = Math.max(1, width - padL - padR)
1293
+ const plotH = Math.max(1, height - padT - padB)
1294
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1295
+ const palette = spec.palette ?? DEFAULT_PALETTE
1296
+ const cells: ChartTreemapCell[] = []
1297
+ function totalOf(n: TreeNode): number {
1298
+ if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
1299
+ return Math.max(0, n.value ?? 0)
1300
+ }
1301
+ function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
1302
+ if (!items.length || w <= 0 || h <= 0) return
1303
+ const totals = items.map(totalOf)
1304
+ const sum = totals.reduce((a, b) => a + b, 0)
1305
+ if (sum <= 0) return
1306
+ // Process largest-first so big items dominate the first row.
1307
+ const ordered = items
1308
+ .map((n, i) => ({ node: n, value: totals[i]! }))
1309
+ .sort((a, b) => b.value - a.value)
1310
+ let cx = x, cy = y, cw = w, ch = h, remaining = sum
1311
+ let row: typeof ordered = []
1312
+ const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
1313
+ if (rowSum <= 0) return Infinity
1314
+ const rowArea = rowSum * scale
1315
+ const rowSide = rowArea / shortSide
1316
+ let worst = 0
1317
+ for (const v of vals) {
1318
+ const cell = v * scale
1319
+ const long = cell / rowSide
1320
+ const r = Math.max(shortSide / long, long / shortSide)
1321
+ if (r > worst) worst = r
1322
+ }
1323
+ return worst
1324
+ }
1325
+ function flushRow() {
1326
+ if (!row.length) return
1327
+ const rowSum = row.reduce((a, b) => a + b.value, 0)
1328
+ const scale = (cw * ch) / remaining
1329
+ const horizontal = cw >= ch
1330
+ const shortSide = horizontal ? ch : cw
1331
+ const rowSide = (rowSum * scale) / shortSide
1332
+ let offset = 0
1333
+ for (const it of row) {
1334
+ const cellSize = (it.value * scale) / rowSide
1335
+ const cx2 = horizontal ? cx : cx + offset
1336
+ const cy2 = horizontal ? cy + offset : cy
1337
+ const ww = horizontal ? rowSide : cellSize
1338
+ const hh = horizontal ? cellSize : rowSide
1339
+ const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
1340
+ // Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
1341
+ if (it.node.children?.length) {
1342
+ cells.push({
1343
+ x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1344
+ color, textColor: pickContrastText(color),
1345
+ name: it.node.name, value: it.value, depth,
1346
+ })
1347
+ const labelH = Math.min(18, hh * 0.25)
1348
+ squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
1349
+ } else {
1350
+ cells.push({
1351
+ x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1352
+ color, textColor: pickContrastText(color),
1353
+ name: it.node.name, value: it.value, depth,
1354
+ })
1355
+ }
1356
+ offset += cellSize
1357
+ }
1358
+ // Shrink the remaining strip.
1359
+ if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
1360
+ remaining -= rowSum
1361
+ row = []
1362
+ }
1363
+ for (const it of ordered) {
1364
+ const scale = (cw * ch) / remaining
1365
+ const shortSide = Math.min(cw, ch)
1366
+ const rowSum = row.reduce((a, b) => a + b.value, 0)
1367
+ const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
1368
+ const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
1369
+ if (row.length && nextWorst > currWorst) {
1370
+ flushRow()
1371
+ }
1372
+ row.push(it)
1373
+ }
1374
+ flushRow()
1375
+ }
1376
+ const seedItems = root.children ?? [root]
1377
+ squarify(seedItems, padL, padT, plotW, plotH, 0)
1378
+ return { ...empty, plot, treemapCells: cells }
1379
+ }
1380
+
1381
+ // ---- Sankey -------------------------------------------------------
1382
+ // Multi-column flow layout. Each node assigned to a column by longest
1383
+ // path from any source. Within a column, nodes are stacked vertically;
1384
+ // height proportional to max(totalIn, totalOut). Links render as
1385
+ // bezier ribbons whose width is the link value (in pixels).
1386
+ if (spec.type === 'sankey') {
1387
+ const nodes = spec.sankeyNodes ?? []
1388
+ const links = spec.sankeyLinks ?? []
1389
+ if (!nodes.length || !links.length) return { ...empty }
1390
+ const padL = 10, padR = 10, padT = 14, padB = 14
1391
+ const plotW = Math.max(1, width - padL - padR)
1392
+ const plotH = Math.max(1, height - padT - padB)
1393
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1394
+ const palette = spec.palette ?? DEFAULT_PALETTE
1395
+ const nodeById = new Map(nodes.map((n) => [n.id, n]))
1396
+ // Column = longest path from any node with no incoming edges.
1397
+ const targets = new Set(links.map((l) => l.target))
1398
+ const sources = nodes.filter((n) => !targets.has(n.id))
1399
+ const column = new Map<string, number>()
1400
+ function visit(id: string, depth: number, seen: Set<string>) {
1401
+ if (seen.has(id)) return
1402
+ seen.add(id)
1403
+ const cur = column.get(id) ?? 0
1404
+ if (depth > cur || !column.has(id)) column.set(id, depth)
1405
+ for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
1406
+ seen.delete(id)
1407
+ }
1408
+ for (const s of sources) visit(s.id, 0, new Set())
1409
+ // Cover any nodes with no path from a source (orphan rings).
1410
+ for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
1411
+ const maxCol = Math.max(...column.values())
1412
+ const cols = maxCol + 1
1413
+ const nodeW = 14
1414
+ const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
1415
+ // Totals per node.
1416
+ const totalIn = new Map<string, number>()
1417
+ const totalOut = new Map<string, number>()
1418
+ for (const l of links) {
1419
+ totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
1420
+ totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
1421
+ }
1422
+ // Per-column groups + max total in that column.
1423
+ const byCol: Map<number, string[]> = new Map()
1424
+ for (const n of nodes) {
1425
+ const c = column.get(n.id) ?? 0
1426
+ const arr = byCol.get(c) ?? []
1427
+ arr.push(n.id); byCol.set(c, arr)
1428
+ }
1429
+ // Per-column total height + node height scale.
1430
+ let maxColTotal = 0
1431
+ for (const ids of byCol.values()) {
1432
+ const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
1433
+ if (t > maxColTotal) maxColTotal = t
1434
+ }
1435
+ if (maxColTotal === 0) return { ...empty, plot }
1436
+ const nodeGapPx = 8
1437
+ const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
1438
+ const placed: ChartSankeyNode[] = []
1439
+ for (const [c, ids] of byCol) {
1440
+ const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
1441
+ const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
1442
+ let yCursor = padT + (plotH - totalH) / 2
1443
+ const xCol = padL + c * (nodeW + gapBetweenColumns)
1444
+ ids.forEach((id, idx) => {
1445
+ const node = nodeById.get(id)!
1446
+ const h = heights[idx]!
1447
+ placed.push({
1448
+ id,
1449
+ label: node.label ?? id,
1450
+ color: node.color ?? palette[(placed.length) % palette.length]!,
1451
+ x: xCol, y: yCursor, w: nodeW, h,
1452
+ column: c,
1453
+ totalIn: totalIn.get(id) ?? 0,
1454
+ totalOut: totalOut.get(id) ?? 0,
1455
+ })
1456
+ yCursor += h + nodeGapPx
1457
+ })
1458
+ }
1459
+ const placedById = new Map(placed.map((n) => [n.id, n]))
1460
+ // Per-node sub-cursor so multiple links from one node stack vertically.
1461
+ const inCursor = new Map<string, number>()
1462
+ const outCursor = new Map<string, number>()
1463
+ const builtLinks: ChartSankeyLink[] = []
1464
+ // Sort links so wider ribbons render first (so thin ribbons stack on top).
1465
+ const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
1466
+ for (const link of sortedLinks) {
1467
+ const a = placedById.get(link.source)
1468
+ const b = placedById.get(link.target)
1469
+ if (!a || !b) continue
1470
+ const linkH = Math.max(1, link.value * heightScale)
1471
+ const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
1472
+ const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
1473
+ outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
1474
+ inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
1475
+ const x0 = a.x + a.w
1476
+ const x1 = b.x
1477
+ const mid = (x0 + x1) / 2
1478
+ const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
1479
+ builtLinks.push({
1480
+ path, color: link.color ?? a.color, width: linkH,
1481
+ source: link.source, target: link.target, value: link.value,
1482
+ })
1483
+ }
1484
+ return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
1485
+ }
1486
+
1487
+ // ---- Heatmap ------------------------------------------------------
1488
+ // Each series is one row, series.values are the cells across categories.
1489
+ // Color comes from a sequential/diverging/custom palette mapped to the
1490
+ // global value range. Cell text contrasts black/white against the cell.
1491
+ if (spec.type === 'heatmap') {
1492
+ if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
1493
+ // Layout: left gutter for row labels, bottom for column labels.
1494
+ const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
1495
+ const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
1496
+ const padR = 64 // room for the right-side legend bar
1497
+ const padT = 12
1498
+ const padB = 32
1499
+ const plotW = Math.max(1, width - padL - padR)
1500
+ const plotH = Math.max(1, height - padT - padB)
1501
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1502
+ const cellW = plotW / spec.categories.length
1503
+ const cellH = plotH / series.length
1504
+ // Resolve value range across the whole matrix.
1505
+ let vMin = Infinity, vMax = -Infinity
1506
+ for (const s of series) for (const v of s.values) {
1507
+ if (!Number.isFinite(v)) continue
1508
+ if (v < vMin) vMin = v
1509
+ if (v > vMax) vMax = v
1510
+ }
1511
+ if (vMin === Infinity) { vMin = 0; vMax = 1 }
1512
+ if (vMin === vMax) vMax = vMin + 1
1513
+ // Pick the palette stops.
1514
+ const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
1515
+ const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
1516
+ const heatmapCells: ChartHeatmapCell[] = []
1517
+ series.forEach((s, ri) => {
1518
+ s.values.forEach((v, ci) => {
1519
+ if (!Number.isFinite(v)) return
1520
+ const color = colorAt(v)
1521
+ heatmapCells.push({
1522
+ x: round(padL + cellW * ci),
1523
+ y: round(padT + cellH * ri),
1524
+ w: round(cellW),
1525
+ h: round(cellH),
1526
+ color,
1527
+ textColor: pickContrastText(color),
1528
+ value: v,
1529
+ rowLabel: s.label,
1530
+ colLabel: spec.categories[ci] ?? '',
1531
+ })
1532
+ })
1533
+ })
1534
+ const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
1535
+ value: i,
1536
+ y: round(padT + cellH * i + cellH / 2),
1537
+ label: s.label,
1538
+ }))
1539
+ const heatmapColTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
1540
+ label,
1541
+ x: round(padL + cellW * i + cellW / 2),
1542
+ }))
1543
+ // Legend: sample 5 stops across the range.
1544
+ const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
1545
+ const t = i / 4
1546
+ const value = vMin + (vMax - vMin) * t
1547
+ return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
1548
+ })
1549
+ return {
1550
+ ...empty,
1551
+ plot,
1552
+ heatmapCells,
1553
+ heatmapRowTicks,
1554
+ heatmapColTicks,
1555
+ heatmapLegend,
1556
+ }
1557
+ }
1558
+
1559
+ if (spec.type === 'pie') {
1560
+ const s = series[0]
1561
+ if (!s) return empty
1562
+ const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
1563
+ const cx = width / 2
1564
+ const cy = height / 2
1565
+ const r = Math.min(width, height) / 2 - 10
1566
+ const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
1567
+ const ir = r * innerFrac
1568
+ let angle = -Math.PI / 2
1569
+ const slices: ChartPieSlice[] = s.values.map((v, i) => {
1570
+ const frac = Math.max(0, v) / total
1571
+ const a0 = angle
1572
+ const a1 = angle + frac * Math.PI * 2
1573
+ angle = a1
1574
+ const large = a1 - a0 > Math.PI ? 1 : 0
1575
+ const mid = (a0 + a1) / 2
1576
+ const labelR = (r + ir) / 2 || r * 0.6
1577
+ const ox0 = cx + r * Math.cos(a0)
1578
+ const oy0 = cy + r * Math.sin(a0)
1579
+ const ox1 = cx + r * Math.cos(a1)
1580
+ const oy1 = cy + r * Math.sin(a1)
1581
+ let path: string
1582
+ if (frac >= 0.999) {
1583
+ path = ir
1584
+ ? `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` +
1585
+ `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`
1586
+ : `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`
1587
+ } else if (ir > 0) {
1588
+ const ix0 = cx + ir * Math.cos(a0)
1589
+ const iy0 = cy + ir * Math.sin(a0)
1590
+ const ix1 = cx + ir * Math.cos(a1)
1591
+ const iy1 = cy + ir * Math.sin(a1)
1592
+ path =
1593
+ `M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
1594
+ `L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
1595
+ } else {
1596
+ path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
1597
+ }
1598
+ const catLabel = spec.categories[i] ?? String(i)
1599
+ return {
1600
+ path,
1601
+ color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length]!,
1602
+ label: catLabel,
1603
+ value: v,
1604
+ percent: frac * 100,
1605
+ cx: round(cx + labelR * Math.cos(mid)),
1606
+ cy: round(cy + labelR * Math.sin(mid)),
1607
+ }
1608
+ })
1609
+ return {
1610
+ ...empty,
1611
+ slices,
1612
+ legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! })),
1613
+ 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,
1614
+ }
1615
+ }
1616
+
1617
+ if (spec.type === 'scatter') {
1618
+ const padL = 48 + (spec.yAxisTitle ? 16 : 0)
1619
+ const padR = 12
1620
+ const padT = 10
1621
+ const padB = 28 + (spec.xAxisTitle ? 16 : 0)
1622
+ const plotW = Math.max(1, width - padL - padR)
1623
+ const plotH = Math.max(1, height - padT - padB)
1624
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1625
+
1626
+ let xMin = Infinity
1627
+ let xMax = -Infinity
1628
+ let yMin = Infinity
1629
+ let yMax = -Infinity
1630
+ let rMin = Infinity
1631
+ let rMax = -Infinity
1632
+ for (const s of series) {
1633
+ for (const pt of s.points ?? []) {
1634
+ if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
1635
+ if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
1636
+ if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
1637
+ }
1638
+ }
1639
+ if (xMin === Infinity) return { ...empty, plot }
1640
+ const xDom = niceScale(xMin, xMax)
1641
+ const yDom = niceScale(yMin, yMax)
1642
+ const hasR = rMax > rMin
1643
+ const xOf = (v: number) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW)
1644
+ const yOf = (v: number) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH)
1645
+ const rOf = (r?: number) =>
1646
+ hasR && r != null && Number.isFinite(r)
1647
+ ? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
1648
+ : 5
1649
+
1650
+ const scatterPoints: ChartScatterDot[] = []
1651
+ for (const s of series) {
1652
+ for (const pt of s.points ?? []) {
1653
+ if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue
1654
+ scatterPoints.push({
1655
+ cx: xOf(pt.x),
1656
+ cy: yOf(pt.y),
1657
+ r: rOf(pt.r),
1658
+ color: s.color,
1659
+ label: pt.label ?? '',
1660
+ series: s.label,
1661
+ x: pt.x,
1662
+ y: pt.y,
1663
+ })
1664
+ }
1665
+ }
1666
+ const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
1667
+ y: yOf(ref.value),
1668
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
1669
+ color: ref.color ?? '#ef4444',
1670
+ dashed: ref.dashed !== false,
1671
+ }))
1672
+ return {
1673
+ ...empty,
1674
+ plot,
1675
+ scatterPoints,
1676
+ referenceLines,
1677
+ yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat) })),
1678
+ xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
1679
+ }
1680
+ }
1681
+
1682
+ // ---- Horizontal bars ----------------------------------------------------
1683
+ // Categories run down the left, bars grow rightward. Bars-only (no combo).
1684
+ const horizontal =
1685
+ spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar')
1686
+ if (horizontal) {
1687
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
1688
+ const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0)
1689
+ const padR = 16
1690
+ const padT = 8
1691
+ const padB = 26 + (spec.xAxisTitle ? 16 : 0)
1692
+ const plotW = Math.max(1, width - padL - padR)
1693
+ const plotH = Math.max(1, height - padT - padB)
1694
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1695
+
1696
+ const refs = (spec.referenceLines ?? []).map((r) => r.value)
1697
+ const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs)
1698
+ const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
1699
+
1700
+ const n = spec.categories.length
1701
+ const slot = plotH / Math.max(1, n)
1702
+ const groupPad = slot * 0.2
1703
+ const inner = slot - groupPad
1704
+ const bandTop = (i: number) => padT + slot * i + groupPad / 2
1705
+ const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max))
1706
+
1707
+ const bars: ChartBar[] = []
1708
+ if (stacked) {
1709
+ const totals = spec.stacked100
1710
+ ? spec.categories.map(
1711
+ (_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
1712
+ )
1713
+ : null
1714
+ const pos = new Array(n).fill(0)
1715
+ const neg = new Array(n).fill(0)
1716
+ for (const s of series) {
1717
+ s.values.forEach((v, i) => {
1718
+ if (!Number.isFinite(v)) return
1719
+ const vp = totals ? (v / totals[i]!) * 100 : v
1720
+ let xL: number
1721
+ let xR: number
1722
+ if (vp >= 0) {
1723
+ xL = xOf(pos[i])
1724
+ xR = xOf(pos[i] + vp)
1725
+ pos[i] += vp
1726
+ } else {
1727
+ xL = xOf(neg[i] + vp)
1728
+ xR = xOf(neg[i])
1729
+ neg[i] += vp
1730
+ }
1731
+ bars.push({
1732
+ x: Math.min(xL, xR),
1733
+ y: round(bandTop(i)),
1734
+ w: round(Math.abs(xR - xL)),
1735
+ h: round(Math.max(1, inner)),
1736
+ color: s.color,
1737
+ label: spec.categories[i] ?? String(i),
1738
+ series: s.label,
1739
+ value: v,
1740
+ })
1741
+ })
1742
+ }
1743
+ } else {
1744
+ const barH = inner / series.length
1745
+ series.forEach((s, bi) => {
1746
+ s.values.forEach((v, i) => {
1747
+ if (!Number.isFinite(v)) return
1748
+ const xV = xOf(v)
1749
+ bars.push({
1750
+ x: Math.min(xV, xBase),
1751
+ y: round(bandTop(i) + barH * bi),
1752
+ w: round(Math.max(1, Math.abs(xV - xBase))),
1753
+ h: round(Math.max(1, barH - 1)),
1754
+ color: s.color,
1755
+ label: spec.categories[i] ?? String(i),
1756
+ series: s.label,
1757
+ value: v,
1758
+ })
1759
+ })
1760
+ })
1761
+ }
1762
+
1763
+ const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
1764
+ label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat),
1765
+ x: xOf(value),
1766
+ }))
1767
+ const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
1768
+ value: i,
1769
+ y: round(bandTop(i) + inner / 2),
1770
+ label,
1771
+ }))
1772
+ const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
1773
+ x: xOf(ref.value),
1774
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
1775
+ color: ref.color ?? '#ef4444',
1776
+ dashed: ref.dashed !== false,
1777
+ }))
1778
+
1779
+ return {
1780
+ ...empty,
1781
+ plot,
1782
+ bars,
1783
+ orientation: 'horizontal',
1784
+ valueTicks,
1785
+ catTicks,
1786
+ referenceLinesV,
1787
+ xLabelRotated: false,
1788
+ }
1789
+ }
1790
+
1791
+ // ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
1792
+ const leftSeries = series.filter((s) => s.axis === 'left')
1793
+ const rightSeries = series.filter((s) => s.axis === 'right')
1794
+ const hasRightAxis = rightSeries.length > 0
1795
+
1796
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
1797
+ const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
1798
+ // Grouped (nested) category axis: valid only when the spans cover every leaf.
1799
+ const validGroups =
1800
+ spec.categoryGroups &&
1801
+ spec.categoryGroups.length > 0 &&
1802
+ spec.xType !== 'time' &&
1803
+ spec.orientation !== 'horizontal' &&
1804
+ spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
1805
+ ? spec.categoryGroups
1806
+ : null
1807
+ const groupTierH = validGroups ? 18 : 0
1808
+ const padL = 48 + (spec.yAxisTitle ? 16 : 0)
1809
+ const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
1810
+ const padT = 10
1811
+ const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH
1812
+ const plotW = Math.max(1, width - padL - padR)
1813
+ const plotH = Math.max(1, height - padT - padB)
1814
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1815
+
1816
+ const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
1817
+ const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
1818
+ const leftLog = spec.yScale === 'log'
1819
+ const rightLog = spec.y2Scale === 'log'
1820
+ const leftDom = spec.stacked100
1821
+ ? niceScale(0, 100)
1822
+ : axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog)
1823
+ const rightDom = hasRightAxis
1824
+ ? spec.stacked100
1825
+ ? niceScale(0, 100)
1826
+ : axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
1827
+ : null
1828
+
1829
+ /** Map a data value to a y pixel. Returns NaN for non-positive values on
1830
+ * a log axis so callers can drop the point (line gap / missing bar). */
1831
+ const yOf = (dom: NiceScale, v: number, isLog = false) => {
1832
+ const t = project(v, dom.min, dom.max, isLog)
1833
+ if (t === null) return NaN
1834
+ return round(padT + plotH - t * plotH)
1835
+ }
1836
+ const yLeft = (v: number) => yOf(leftDom, v, leftLog)
1837
+ const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog)
1838
+ const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
1839
+ const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
1840
+
1841
+ const n = spec.categories.length
1842
+ const slot = plotW / Math.max(1, n)
1843
+
1844
+ // X positions. A time axis spaces points by actual time (irregular gaps);
1845
+ // a category axis is uniform. (Bars stay uniform either way.)
1846
+ const timeVals =
1847
+ spec.xType === 'time' ? spec.categories.map((c) => Date.parse(c)) : null
1848
+ const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
1849
+ const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
1850
+ const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
1851
+ const xCenter = (i: number) =>
1852
+ timeOk && Number.isFinite(timeVals![i])
1853
+ ? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
1854
+ : round(padL + slot * i + slot / 2)
1855
+ const xTicks: ChartCategoryTick[] = timeOk
1856
+ ? dateTicks(tMin, tMin + tSpan).map((t) => ({
1857
+ label: fmtDate(t, tSpan),
1858
+ x: round(padL + ((t - tMin) / tSpan) * plotW),
1859
+ }))
1860
+ : spec.categories.map((label, i) => ({ label, x: xCenter(i) }))
1861
+
1862
+ // Parent-tier ticks for a grouped category axis: each spans its leaves.
1863
+ const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
1864
+ if (validGroups && !timeOk) {
1865
+ let start = 0
1866
+ for (const g of validGroups) {
1867
+ const x0 = round(padL + slot * start)
1868
+ const x1 = round(padL + slot * (start + g.span))
1869
+ categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
1870
+ start += g.span
1871
+ }
1872
+ }
1873
+
1874
+ const barSeries = series.filter((s) => s.kind === 'bar')
1875
+ const bars: ChartBar[] = []
1876
+ if (barSeries.length) {
1877
+ const groupPad = slot * 0.2
1878
+ if (stacked) {
1879
+ const inner = slot - groupPad
1880
+ const x0 = (i: number) => padL + slot * i + groupPad / 2
1881
+ // Stack independently per axis so dual-axis stacks line up to their own scale.
1882
+ for (const axis of ['left', 'right'] as const) {
1883
+ const axisBars = barSeries.filter((s) => s.axis === axis)
1884
+ if (!axisBars.length) continue
1885
+ const yA = axis === 'right' ? yRight : yLeft
1886
+ // 100% mode normalizes each category to its absolute total.
1887
+ const totals = spec.stacked100
1888
+ ? spec.categories.map(
1889
+ (_, i) =>
1890
+ axisBars.reduce(
1891
+ (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
1892
+ 0,
1893
+ ) || 1,
1894
+ )
1895
+ : null
1896
+ const pos = new Array(n).fill(0)
1897
+ const neg = new Array(n).fill(0)
1898
+ for (const s of axisBars) {
1899
+ s.values.forEach((v, i) => {
1900
+ if (!Number.isFinite(v)) return
1901
+ const vp = totals ? (v / totals[i]!) * 100 : v
1902
+ let yTop: number
1903
+ let yBot: number
1904
+ if (vp >= 0) {
1905
+ yTop = yA(pos[i] + vp)
1906
+ yBot = yA(pos[i])
1907
+ pos[i] += vp
1908
+ } else {
1909
+ yTop = yA(neg[i])
1910
+ yBot = yA(neg[i] + vp)
1911
+ neg[i] += vp
1912
+ }
1913
+ bars.push({
1914
+ x: round(x0(i)),
1915
+ y: Math.min(yTop, yBot),
1916
+ w: round(Math.max(1, inner)),
1917
+ h: round(Math.abs(yBot - yTop)),
1918
+ color: s.color,
1919
+ label: spec.categories[i] ?? String(i),
1920
+ series: s.label,
1921
+ value: v,
1922
+ })
1923
+ })
1924
+ }
1925
+ }
1926
+ } else {
1927
+ const inner = slot - groupPad
1928
+ const barW = inner / barSeries.length
1929
+ barSeries.forEach((s, bi) => {
1930
+ const dom = domOf(s)
1931
+ const log = isLogOf(s)
1932
+ // Log axis: bars grow from the axis floor (dom.min) up to v rather
1933
+ // than from 0, since 0 is invalid in log space.
1934
+ const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log)
1935
+ s.values.forEach((v, i) => {
1936
+ if (!Number.isFinite(v)) return
1937
+ if (log && v <= 0) return
1938
+ const x = padL + slot * i + groupPad / 2 + barW * bi
1939
+ const yV = yOf(dom, v, log)
1940
+ bars.push({
1941
+ x: round(x),
1942
+ y: Math.min(yV, base),
1943
+ w: round(Math.max(1, barW - 1)),
1944
+ h: round(Math.max(1, Math.abs(yV - base))),
1945
+ color: s.color,
1946
+ label: spec.categories[i] ?? String(i),
1947
+ series: s.label,
1948
+ value: v,
1949
+ })
1950
+ })
1951
+ })
1952
+ }
1953
+ }
1954
+
1955
+ // Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
1956
+ const lines: ChartLine[] = []
1957
+ const areaCum: Record<'left' | 'right', number[]> = {
1958
+ left: new Array(n).fill(0),
1959
+ right: new Array(n).fill(0),
1960
+ }
1961
+ // 100% mode: per-axis per-category totals to normalize stacked areas to 100.
1962
+ const areaTotals: Record<'left' | 'right', number[] | null> = { left: null, right: null }
1963
+ if (spec.stacked100) {
1964
+ for (const axis of ['left', 'right'] as const) {
1965
+ const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis)
1966
+ if (areaSeries.length) {
1967
+ areaTotals[axis] = spec.categories.map(
1968
+ (_, i) =>
1969
+ areaSeries.reduce(
1970
+ (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
1971
+ 0,
1972
+ ) || 1,
1973
+ )
1974
+ }
1975
+ }
1976
+ }
1977
+ for (const s of series) {
1978
+ if (s.kind === 'bar') continue
1979
+ const dom = domOf(s)
1980
+ const log = isLogOf(s)
1981
+ const yA = (v: number) => yOf(dom, v, log)
1982
+ const isStackedArea = stacked && s.kind === 'area'
1983
+ const px = (i: number) => xCenter(i)
1984
+ let pts: ChartLinePoint[]
1985
+ let baselinePts: Array<{ x: number; y: number }> | null = null
1986
+ if (isStackedArea) {
1987
+ // Stacked areas treat a gap as 0 so the stack stays continuous.
1988
+ const cum = areaCum[s.axis]
1989
+ const prev = cum.slice()
1990
+ const totals = areaTotals[s.axis]
1991
+ pts = s.values.map((v, i) => {
1992
+ const vv = Number.isFinite(v) ? v : 0
1993
+ // 100% mode positions by share of the category total; value stays original.
1994
+ const norm = totals ? (vv / totals[i]!) * 100 : vv
1995
+ const c = (cum[i] ?? 0) + norm
1996
+ cum[i] = c
1997
+ return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) }
1998
+ })
1999
+ baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
2000
+ } else {
2001
+ pts = s.values.map((v, i) => {
2002
+ const ok = Number.isFinite(v)
2003
+ return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
2004
+ })
2005
+ }
2006
+ // Build the line - smoothed via monotone cubic when requested, else
2007
+ // straight polylines. Either way, gaps break the path cleanly.
2008
+ const smooth = !!s.smooth
2009
+ const path = buildLinePath(pts, smooth)
2010
+
2011
+ let areaPath = ''
2012
+ if (s.kind === 'area' && pts.length) {
2013
+ if (baselinePts) {
2014
+ const top = smooth
2015
+ ? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
2016
+ : pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2017
+ const back = smooth
2018
+ ? `L${baselinePts[baselinePts.length - 1]!.x},${baselinePts[baselinePts.length - 1]!.y} ` +
2019
+ monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
2020
+ : baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
2021
+ areaPath = `${top} ${back} Z`
2022
+ } else {
2023
+ // One filled polygon per contiguous run of defined points.
2024
+ const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)))
2025
+ const runs: ChartLinePoint[][] = []
2026
+ let cur: ChartLinePoint[] = []
2027
+ for (const p of pts) {
2028
+ if (p.defined) cur.push(p)
2029
+ else if (cur.length) {
2030
+ runs.push(cur)
2031
+ cur = []
2032
+ }
2033
+ }
2034
+ if (cur.length) runs.push(cur)
2035
+ areaPath = runs
2036
+ .map((run) => {
2037
+ const top = smooth
2038
+ ? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
2039
+ : run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2040
+ return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
2041
+ })
2042
+ .join(' ')
2043
+ }
2044
+ }
2045
+
2046
+ // Confidence band: shaded envelope between upperValues / lowerValues.
2047
+ // Both arrays must be present and aligned to the value array.
2048
+ let bandPath = ''
2049
+ if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
2050
+ const upperPts: Array<{ x: number; y: number }> = []
2051
+ const lowerPts: Array<{ x: number; y: number }> = []
2052
+ for (let i = 0; i < s.values.length; i += 1) {
2053
+ const u = s.upperValues[i]!
2054
+ const lo = s.lowerValues[i]!
2055
+ if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
2056
+ if (log && (u <= 0 || lo <= 0)) continue
2057
+ upperPts.push({ x: px(i), y: yA(u) })
2058
+ lowerPts.push({ x: px(i), y: yA(lo) })
2059
+ }
2060
+ if (upperPts.length >= 2) {
2061
+ const top = smooth
2062
+ ? monotoneCubicPath(upperPts)
2063
+ : upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2064
+ const back = smooth
2065
+ ? `L${lowerPts[lowerPts.length - 1]!.x},${lowerPts[lowerPts.length - 1]!.y} ` +
2066
+ monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
2067
+ : lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
2068
+ bandPath = `${top} ${back} Z`
2069
+ }
2070
+ }
2071
+
2072
+ lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath })
2073
+ }
2074
+
2075
+ const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
2076
+ dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: formatChartValue(value, spec.valueFormat) }))
2077
+
2078
+ const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
2079
+ const onRight = ref.axis === 'right'
2080
+ const dom = onRight ? (rightDom ?? leftDom) : leftDom
2081
+ const log = onRight ? rightLog : leftLog
2082
+ return {
2083
+ y: yOf(dom, ref.value, log),
2084
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
2085
+ color: ref.color ?? '#ef4444',
2086
+ dashed: ref.dashed !== false,
2087
+ }
2088
+ })
2089
+
2090
+ // ---- Overlays: trendline / moving average ------------------------
2091
+ // For every series with an `overlay`, compute the smoothed values and
2092
+ // render as a dashed line in the source series' color (or overlayColor).
2093
+ const overlays: ChartLine[] = []
2094
+ for (const s of series) {
2095
+ if (!s.overlay) continue
2096
+ const dom = domOf(s)
2097
+ const log = isLogOf(s)
2098
+ const overlayVals = computeOverlay(s.values, s.overlay)
2099
+ const color = s.overlayColor ?? s.color
2100
+ const pts: ChartLinePoint[] = overlayVals.map((v, i) => {
2101
+ const ok = Number.isFinite(v) && (!log || v > 0)
2102
+ return {
2103
+ x: xCenter(i),
2104
+ y: ok ? yOf(dom, v, log) : NaN,
2105
+ label: spec.categories[i] ?? String(i),
2106
+ value: v,
2107
+ defined: ok,
2108
+ }
2109
+ })
2110
+ const path = buildLinePath(pts, !!s.smooth)
2111
+ overlays.push({
2112
+ path,
2113
+ areaPath: '',
2114
+ color,
2115
+ label: `${s.label} (${s.overlay})`,
2116
+ points: pts,
2117
+ })
2118
+ }
2119
+
2120
+ // ---- Annotations: resolve data-space anchors to pixel coords ------
2121
+ const annotations: ChartGeometry['annotations'] = []
2122
+ for (const a of (spec.annotations ?? [])) {
2123
+ let ax: number | null = null
2124
+ let ay: number | null = null
2125
+ if ('category' in a.at) {
2126
+ const ci = spec.categories.indexOf(a.at.category)
2127
+ if (ci < 0) continue
2128
+ ax = xCenter(ci)
2129
+ // Anchor to the named series' value at that category, else just
2130
+ // mid-plot. Picks the first matching series if `series` is set.
2131
+ const seriesName = a.at.series
2132
+ const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
2133
+ if (s) {
2134
+ const v = s.values[ci]
2135
+ if (Number.isFinite(v)) ay = yOf(domOf(s), v as number, isLogOf(s))
2136
+ }
2137
+ if (ay == null) ay = padT + plotH / 2
2138
+ } else {
2139
+ // Raw x/y in data space (x ignored for category x-axis; takes the
2140
+ // mid-plot in that case). y projects through the left axis.
2141
+ ax = padL + plotW / 2
2142
+ if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog)
2143
+ else ay = padT + plotH / 2
2144
+ }
2145
+ if (ax != null && ay != null && Number.isFinite(ay)) {
2146
+ annotations.push({
2147
+ x: ax,
2148
+ y: ay,
2149
+ label: a.label,
2150
+ color: a.color ?? '#0f172a',
2151
+ placement: a.placement ?? 'top',
2152
+ })
2153
+ }
2154
+ }
2155
+
2156
+ return {
2157
+ ...empty,
2158
+ plot,
2159
+ bars,
2160
+ lines,
2161
+ yTicks: tickFor(leftDom, leftLog),
2162
+ y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
2163
+ hasRightAxis,
2164
+ xTicks,
2165
+ categoryGroupTicks,
2166
+ xLabelRotated: timeOk ? false : xLabelRotated,
2167
+ referenceLines,
2168
+ overlays,
2169
+ annotations,
2170
+ }
2171
+ }
2172
+
2173
+ /**
2174
+ * Aggregate flat rows into a chart spec. Group by a category field, reduce a
2175
+ * value field per group. Three multi-series shapes:
2176
+ * - `value: 'revenue'` -> one series
2177
+ * - `value: ['revenue','cost']` -> one series per value field
2178
+ * - `value: 'sales', series: 'region'` -> pivot: one series per distinct
2179
+ * value of the `series` field
2180
+ */
2181
+ export function rowsToChartSpec<T extends Record<string, unknown>>(
2182
+ rows: ReadonlyArray<T>,
2183
+ opts: {
2184
+ type: ChartType
2185
+ category: keyof T & string
2186
+ value: (keyof T & string) | Array<keyof T & string>
2187
+ /** Pivot dimension: one series per distinct value of this field. */
2188
+ series?: keyof T & string
2189
+ reduce?: 'sum' | 'avg' | 'count'
2190
+ seriesLabel?: string
2191
+ width?: number
2192
+ height?: number
2193
+ stacked?: boolean
2194
+ stacked100?: boolean
2195
+ palette?: string[]
2196
+ /** Order categories. Defaults to insertion order (or value-desc when topN). */
2197
+ sort?: 'value-desc' | 'value-asc' | 'category' | 'none'
2198
+ /** Keep only the top N categories; bucket the rest into "Other". */
2199
+ topN?: number
2200
+ /** Label for the bucketed remainder. Default "Other". */
2201
+ otherLabel?: string
2202
+ /** Field carrying each row's stable id. When set, the resulting spec's
2203
+ * series carry `rowIds` arrays so click handlers can drill back to
2204
+ * the source rows. */
2205
+ idField?: keyof T & string
2206
+ },
2207
+ ): ChartSpec {
2208
+ const reduce = opts.reduce ?? 'sum'
2209
+ const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value]
2210
+ const reduceCell = (sum: number, count: number) =>
2211
+ reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
2212
+
2213
+ const categories: string[] = []
2214
+ const catIndex = new Map<string, number>()
2215
+ const ensureCat = (key: string) => {
2216
+ let idx = catIndex.get(key)
2217
+ if (idx === undefined) {
2218
+ idx = categories.length
2219
+ catIndex.set(key, idx)
2220
+ categories.push(key)
2221
+ }
2222
+ return idx
2223
+ }
2224
+
2225
+ // Series keyed by name -> per-category {sum,count,rowIds}.
2226
+ type Cell = { sum: number; count: number; rowIds: Array<string | number> }
2227
+ const seriesMap = new Map<string, Cell[]>()
2228
+ const ensureSeries = (name: string) => {
2229
+ let arr = seriesMap.get(name)
2230
+ if (!arr) {
2231
+ arr = []
2232
+ seriesMap.set(name, arr)
2233
+ }
2234
+ return arr
2235
+ }
2236
+
2237
+ const trackIds = opts.idField !== undefined
2238
+ for (const row of rows) {
2239
+ const cat = String(row[opts.category] ?? '')
2240
+ const ci = ensureCat(cat)
2241
+ const rowId = trackIds ? (row[opts.idField as keyof T] as string | number) : undefined
2242
+ if (opts.series) {
2243
+ const sName = String(row[opts.series] ?? '')
2244
+ const arr = ensureSeries(sName)
2245
+ const num = Number(row[valueFields[0]!])
2246
+ const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
2247
+ if (Number.isFinite(num)) {
2248
+ cell.sum += num
2249
+ cell.count += 1
2250
+ if (rowId !== undefined) cell.rowIds.push(rowId)
2251
+ }
2252
+ } else {
2253
+ for (const vf of valueFields) {
2254
+ const arr = ensureSeries(vf)
2255
+ const num = Number(row[vf])
2256
+ const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
2257
+ if (Number.isFinite(num)) {
2258
+ cell.sum += num
2259
+ cell.count += 1
2260
+ if (rowId !== undefined) cell.rowIds.push(rowId)
2261
+ }
2262
+ }
2263
+ }
2264
+ }
2265
+
2266
+ const entries = [...seriesMap.entries()].map(([name, arr]) => ({
2267
+ label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
2268
+ values: categories.map((_, i) => {
2269
+ const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] as Array<string | number> }
2270
+ return reduceCell(cell.sum, cell.count)
2271
+ }),
2272
+ rowIds: trackIds
2273
+ ? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
2274
+ : undefined,
2275
+ }))
2276
+
2277
+ // ---- Sort + top-N -----------------------------------------------------
2278
+ const totals = categories.map((_, i) =>
2279
+ entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0),
2280
+ )
2281
+ const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none')
2282
+ const order = categories.map((_, i) => i)
2283
+ if (sort === 'value-desc') order.sort((a, b) => totals[b]! - totals[a]!)
2284
+ else if (sort === 'value-asc') order.sort((a, b) => totals[a]! - totals[b]!)
2285
+ else if (sort === 'category') order.sort((a, b) => categories[a]!.localeCompare(categories[b]!))
2286
+
2287
+ let finalCategories: string[]
2288
+ let finalSeries: ChartSeries[]
2289
+ if (opts.topN && order.length > opts.topN) {
2290
+ const keep = order.slice(0, opts.topN)
2291
+ const rest = order.slice(opts.topN)
2292
+ finalCategories = keep.map((i) => categories[i]!).concat(opts.otherLabel ?? 'Other')
2293
+ finalSeries = entries.map((e) => ({
2294
+ label: e.label,
2295
+ values: keep
2296
+ .map((i) => e.values[i]!)
2297
+ .concat(rest.reduce((sum, i) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0)),
2298
+ rowIds: e.rowIds
2299
+ ? keep.map((i) => e.rowIds![i]!).concat([rest.flatMap((i) => e.rowIds![i] ?? [])])
2300
+ : undefined,
2301
+ }))
2302
+ } else {
2303
+ finalCategories = order.map((i) => categories[i]!)
2304
+ finalSeries = entries.map((e) => ({
2305
+ label: e.label,
2306
+ values: order.map((i) => e.values[i]!),
2307
+ rowIds: e.rowIds ? order.map((i) => e.rowIds![i]!) : undefined,
2308
+ }))
2309
+ }
2310
+
2311
+ return {
2312
+ type: opts.type,
2313
+ categories: finalCategories,
2314
+ series: finalSeries,
2315
+ width: opts.width,
2316
+ height: opts.height,
2317
+ stacked: opts.stacked,
2318
+ stacked100: opts.stacked100,
2319
+ palette: opts.palette,
2320
+ }
2321
+ }