@svgrid/grid 2.2.32 → 2.4.0

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