@svgrid/grid 3.0.2 → 3.0.4

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 (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
package/src/chart.ts CHANGED
@@ -8,1331 +8,178 @@
8
8
  * (per-series type), a secondary (right) Y axis, signed Y domains (negative
9
9
  * values drop below a zero baseline), and nice auto-scaled ticks.
10
10
  */
11
- // The one import this module has. `Intl.NumberFormat` is expensive enough to
12
- // construct that a chart with 8 axis ticks and 200 data labels would rebuild it
13
- // 200 times a frame, and the grid already solved that for cells - so reuse that
14
- // cache rather than starting a second one. Still DOM-free: it is plain TS.
15
- import { getNumberFormatter } from './cell-formatting'
16
-
17
- /** Every mark this engine can draw. `bar`, `line` and `area` compose (a series
18
- * can override the spec's type for a combo chart); the rest are whole-chart
19
- * types that ignore per-series overrides. */
20
- export type ChartType =
21
- | 'bar' | 'line' | 'area' | 'pie' | 'scatter'
22
- | 'heatmap' | 'waterfall' | 'funnel' | 'radar'
23
- | 'calendar' | 'gauge' | 'treemap' | 'sankey'
24
- | 'candlestick' | 'ohlc' | 'boxplot'
25
-
26
- /** One open / high / low / close bar. */
27
- export type OhlcBar = { o: number; h: number; l: number; c: number }
28
-
29
- /** A five-number summary: one box, its whiskers, and anything past them.
30
- * `min` / `max` are the WHISKER ENDS, not the extremes of the sample - with
31
- * the usual 1.5 IQR rule those differ, and the points beyond go in
32
- * `outliers` so they can be drawn individually. */
33
- export type BoxStats = {
34
- min: number
35
- q1: number
36
- median: number
37
- q3: number
38
- max: number
39
- /** Values outside the whiskers, drawn as individual points. */
40
- outliers?: number[]
41
- }
42
-
43
- /** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`.
44
- * `rowIds` is populated when the spec was built from grid rows (via
45
- * `rowsToChartSpec`) and lets a drill handler filter the grid back to the
46
- * source rows for the clicked category / series cell. */
47
- export type ChartSelection = {
48
- category: string
49
- series: string
50
- value: number
51
- rowIds?: Array<string | number>
52
- }
53
-
54
- /** A single scatter / bubble point. */
55
- export type ScatterPoint = { x: number; y: number; r?: number; label?: string }
56
-
57
- /** A statistical / smoothing line drawn on top of a source series.
58
- * - `'linear'`: ordinary least-squares regression line
59
- * - `'sma:N'`: simple moving average over a window of N points
60
- * - `'ema:N'`: exponential moving average with smoothing factor 2/(N+1) */
61
- export type SeriesOverlay = 'linear' | `sma:${number}` | `ema:${number}`
62
-
63
- /** A texture fill applied in addition to (and on top of) the series color.
64
- * Helps colorblind readers distinguish series at a glance. */
65
- export type SeriesPattern = 'solid' | 'stripe' | 'crosshatch' | 'dots' | 'diagonal'
66
-
67
- /** One plotted series: its label, its values (one per category), and how to draw it. */
68
- export type ChartSeries = {
69
- label: string
70
- values: number[]
71
- color?: string
72
- /** Per-series chart type, for combo charts. Defaults to the spec `type`. */
73
- type?: 'bar' | 'line' | 'area' | 'candlestick' | 'ohlc' | 'boxplot'
74
- /**
75
- * Open / high / low / close per category, parallel to `categories`. `null`
76
- * is a gap (a day with no session) and draws nothing.
77
- *
78
- * Set `values` to the CLOSING prices alongside this. Everything that reads a
79
- * series generically reads `values` - the tooltip rows, the CSV export, the
80
- * screen-reader table, and `overlay` - so filling it in is what lets a
81
- * candlestick series carry a moving average (`overlay: 'sma:20'`) or export
82
- * to CSV without a single line of candle-specific code.
83
- */
84
- ohlc?: Array<OhlcBar | null>
85
- /**
86
- * Five-number summaries per category, parallel to `categories`. `null` is a
87
- * gap and draws nothing.
88
- *
89
- * Set `values` to the MEDIANS alongside this, for exactly the reason `ohlc`
90
- * sets them to the closes: everything that reads a series generically reads
91
- * `values`, so the tooltip rows, the CSV export, the screen-reader table and
92
- * `overlay` all keep working with no box-specific code.
93
- *
94
- * `boxStats()` turns a raw sample into one of these.
95
- */
96
- boxes?: Array<BoxStats | null>
97
- /**
98
- * Symmetric or asymmetric error bars, parallel to `values`. A number is a
99
- * symmetric +/- margin; a pair is an explicit low/high; `null` draws nothing.
100
- *
101
- * These are an ANNOTATION on an existing mark rather than a mark of their
102
- * own, so they compose: a bar, line, area or scatter series can carry them
103
- * without changing its type. That is the whole reason they are not a
104
- * `ChartType` - "bar chart with error bars" should not be a different chart.
105
- */
106
- errors?: Array<number | { lo: number; hi: number } | null>
107
- /** Plot against the left (default) or right Y axis. */
108
- axis?: 'left' | 'right'
109
- /** Scatter / bubble points (used when `type === 'scatter'`). */
110
- points?: ScatterPoint[]
111
- /** Row IDs contributing to each data point - parallel to `values`. When
112
- * present, click handlers receive these in `ChartSelection.rowIds` so
113
- * callers can drill the grid back to the source rows. */
114
- rowIds?: Array<Array<string | number>>
115
- /** Draw a smoothing / trend overlay on top of this series. */
116
- overlay?: SeriesOverlay
117
- /** Color for the overlay line. Defaults to the series color. */
118
- overlayColor?: string
119
- /** Texture fill (e.g. diagonal stripes) layered over the series color.
120
- * Lets colorblind viewers tell two series apart even at the same hue. */
121
- pattern?: SeriesPattern
122
- /** Interpolate the line as a curve instead of polylines. `'monotone'`
123
- * cubic prevents overshoots between points (best default for data);
124
- * `true` is an alias for `'monotone'`. Only meaningful for line/area. */
125
- smooth?: boolean | 'monotone'
126
- /** Upper envelope (e.g. forecast 95th percentile) parallel to `values`.
127
- * When set alongside `lowerValues`, the chart shades the band between
128
- * the two as a translucent fill in the series color. */
129
- upperValues?: number[]
130
- /** Lower envelope; pair with `upperValues` for a confidence band. */
131
- lowerValues?: number[]
132
- }
133
-
134
- /** A pinned label drawn over the plot, anchored to a data point or to an
135
- * arbitrary (x, y) in data space. Useful for "Release v1", "Outage", etc. */
136
- export type ChartAnnotation = {
137
- /** Anchor in data space. Provide either `category` + `axis` for a point on
138
- * an existing series, OR raw `x` / `y` numeric coordinates in data space. */
139
- at:
140
- | { category: string; series?: string }
141
- | { x: number; y?: number }
142
- label: string
143
- color?: string
144
- /** Where the label sits relative to the marker. Defaults to 'top'. */
145
- placement?: 'top' | 'bottom' | 'left' | 'right'
146
- }
147
-
148
- /** A horizontal reference / target line drawn across the plot. */
149
- export type ChartReferenceLine = {
150
- value: number
151
- label?: string
152
- axis?: 'left' | 'right'
153
- color?: string
154
- dashed?: boolean
155
- }
156
-
157
- /**
158
- * What to plot - the input you build and hand to a chart. Categories are the
159
- * x-axis labels and every series supplies one value per category.
160
- *
161
- * {@link buildChart} turns this into a {@link ChartGeometry} for rendering.
162
- */
163
- export type ChartSpec = {
164
- /** Default type for series that don't set their own `type`. */
165
- type: ChartType
166
- /** X-axis labels (one per data point). */
167
- categories: string[]
168
- series: ChartSeries[]
169
- width?: number
170
- height?: number
171
- /** Palette used when a series has no explicit `color`. */
172
- palette?: string[]
173
- /** Per-category color overrides (by category label) - for pie / donut slice
174
- * recolouring, where colour follows the category, not a series. */
175
- categoryColors?: Record<string, string>
176
- /** Number format for the value axis, tooltips, data labels and reference
177
- * lines. Unset = the default compact `1.2k` / `1.2M` style. */
178
- valueFormat?: ChartValueFormat
179
- /** BCP-47 locale for value formatting. Setting this (or `currency`) switches
180
- * formatting to `Intl.NumberFormat`, so thousands separators, the decimal
181
- * mark and the compact suffixes follow the locale rather than the built-in
182
- * English `1.2k` / `1.2M`. Unset = the locale-free default, which is why the
183
- * default output has never changed under anyone's feet. */
184
- locale?: string | ReadonlyArray<string>;
185
- /** ISO 4217 code for `valueFormat: 'currency'` (`'EUR'`, `'JPY'`, ...). Unset
186
- * means the axis reads `$`, which is wrong everywhere outside the dollar
187
- * zone and was the only currency this chart could draw for a long time. */
188
- currency?: string
189
- /** Grouped (nested) category axis: a parent tier spanning consecutive leaf
190
- * categories (spans must sum to `categories.length`). Vertical category
191
- * charts only (ignored for time / horizontal / pie). */
192
- categoryGroups?: Array<{ label: string; span: number }>
193
- /** Stack bar / area series (per axis) instead of grouping them. */
194
- stacked?: boolean
195
- /** Stack to 100% (each category normalized to its total). Implies stacked. */
196
- stacked100?: boolean
197
- /**
198
- * Bar orientation. `'horizontal'` swaps the axes - categories run down the
199
- * left, bars grow rightward - which suits long category labels. Only applies
200
- * when every series is a bar (combo / line / area fall back to vertical).
201
- */
202
- orientation?: 'vertical' | 'horizontal'
203
- /** Pie only: inner radius as a fraction of the outer radius (0..1) -> donut. */
204
- innerRadius?: number
205
- /** Horizontal target / goal / average lines. */
206
- referenceLines?: ChartReferenceLine[]
207
- /**
208
- * How to read `categories` along the x axis.
209
- *
210
- * - `'category'` (default): evenly spaced labels, taken literally.
211
- * - `'time'`: parsed as dates and positioned by ACTUAL elapsed time, so an
212
- * irregular gap renders as a proportional gap.
213
- * - `'ordinal-time'`: parsed as dates but spaced EVENLY, with date-derived
214
- * ticks. This is what a series of trading sessions or business days needs:
215
- * on a true time axis every weekend opens a hole a third as wide as the
216
- * working week, which is noise rather than information.
217
- */
218
- xType?: 'category' | 'time' | 'ordinal-time'
219
- /** Axis titles (reserve gutter space + render). */
220
- yAxisTitle?: string
221
- y2AxisTitle?: string
222
- xAxisTitle?: string
223
- /** Y-axis scale. `'log'` plots base-10 logarithmic - values <= 0 are
224
- * treated as missing. Necessary for wide-range data (money, audience
225
- * size, scientific). Default `'linear'`. */
226
- yScale?: 'linear' | 'log'
227
- /** Right (secondary) Y-axis scale. Default `'linear'`. */
228
- y2Scale?: 'linear' | 'log'
229
- /** Pinned text labels at fixed data-space positions (callouts). */
230
- annotations?: ChartAnnotation[]
231
- /** When true, automatically cycle through pattern fills for every series
232
- * that doesn't set `pattern` explicitly. Useful as a one-flag colorblind
233
- * fallback. Default false. */
234
- patternFallback?: boolean
235
- /** Calendar heatmap: array of date+value samples (one per day). Date
236
- * strings are 'YYYY-MM-DD'. Missing days render as blank cells. */
237
- calendarValues?: Array<{ date: string; value: number }>
238
- /** Calendar heatmap: year window. Default: span the data. */
239
- calendarStart?: string
240
- calendarEnd?: string
241
-
242
- /** Gauge: the value to display. */
243
- gaugeValue?: number
244
- /** Gauge: min/max of the dial scale. Defaults [0, 100]. */
245
- gaugeMin?: number
246
- gaugeMax?: number
247
- /** Gauge: target marker (the line/notch on the arc). */
248
- gaugeTarget?: number
249
- /** Gauge: color bands along the arc (e.g. red/amber/green). */
250
- gaugeRanges?: Array<{ from: number; to: number; color: string }>
251
- /** Gauge: unit / suffix shown next to the value (e.g. '%', 'ms'). */
252
- gaugeUnit?: string
253
-
254
- /** Tree-map: hierarchical root. Leaves have `value`; parents are the
255
- * sum of their children's totals. */
256
- treemap?: TreeNode
257
-
258
- /** Sankey: nodes + flow links between them. Link `source` / `target`
259
- * reference node ids. */
260
- sankeyNodes?: Array<{ id: string; label?: string; color?: string }>
261
- sankeyLinks?: Array<{ source: string; target: string; value: number; color?: string }>
262
-
263
- /** Waterfall: per-category flag marking bars as totals/subtotals that
264
- * reset the running sum and span from 0. Same length as `categories`. */
265
- waterfallTotals?: boolean[]
266
- /** Waterfall: explicit colors for positive/negative/total bars. The
267
- * series color is ignored when this is set. */
268
- waterfallColors?: { positive?: string; negative?: string; total?: string }
269
- /** Candlestick / OHLC colors. Direction beats series identity here, the same
270
- * way `waterfallColors` overrides the series color. Defaults to the green /
271
- * red pair from the palette's own vocabulary. */
272
- candleColors?: { up?: string; down?: string }
273
- /** Heatmap color scale. `'sequential'` maps min->max through one hue,
274
- * `'diverging'` runs cold->neutral->warm around 0. A custom array
275
- * (>=2 hex colors) defines an arbitrary gradient. Default `'sequential'`. */
276
- colorScale?: 'sequential' | 'diverging' | string[]
277
- }
278
-
279
- /** A tree-map / sankey / treemap node spec. Used recursively as a tree. */
280
- export type TreeNode = {
281
- name: string
282
- value?: number
283
- color?: string
284
- children?: TreeNode[]
285
- }
286
-
287
- /** A laid-out tree-map rectangle. */
288
- export type ChartTreemapCell = {
289
- x: number
290
- y: number
291
- w: number
292
- h: number
293
- color: string
294
- textColor: string
295
- name: string
296
- value: number
297
- /** Depth from the root - useful for color cycling per level. */
298
- depth: number
299
- }
300
-
301
- /** A calendar-heatmap cell (one day). */
302
- export type ChartCalendarCell = {
303
- x: number
304
- y: number
305
- size: number
306
- date: string
307
- value: number
308
- /** Defined when a value was supplied for this day; blank otherwise. */
309
- defined: boolean
310
- color: string
311
- }
312
-
313
- /** A gauge dial layout. */
314
- export type ChartGaugeLayout = {
315
- cx: number
316
- cy: number
317
- r: number
318
- /** Track arc path (background grey). */
319
- trackPath: string
320
- /** Value arc path (filled to the current value). */
321
- valuePath: string
322
- /** Optional colored range arcs. */
323
- rangePaths: Array<{ path: string; color: string; from: number; to: number }>
324
- /** Pixel position of the target marker (when set). */
325
- target: { x1: number; y1: number; x2: number; y2: number } | null
326
- /** Tick marks around the dial (major ticks are longer). */
327
- ticks: Array<{ x1: number; y1: number; x2: number; y2: number; major: boolean }>
328
- /** Pointer needle (a kite shape) + its center hub radius. */
329
- needle: { path: string; hubR: number }
330
- /** Status color of the value arc (the band the value falls in), or null to
331
- * fall back to the theme accent. */
332
- valueColor: string | null
333
- /** Scale end labels positioned under the two arc ends. */
334
- minLabel: { x: number; y: number }
335
- maxLabel: { x: number; y: number }
336
- value: number
337
- min: number
338
- max: number
339
- unit: string
340
- }
341
-
342
- /** A sankey node + its laid-out rect + total flow. */
343
- export type ChartSankeyNode = {
344
- id: string
345
- label: string
346
- color: string
347
- x: number
348
- y: number
349
- w: number
350
- h: number
351
- /** Column (depth) the node was assigned to. */
352
- column: number
353
- totalIn: number
354
- totalOut: number
355
- }
356
-
357
- /** A sankey link rendered as a curved ribbon. */
358
- export type ChartSankeyLink = {
359
- path: string
360
- color: string
361
- /** Stroke width = link value scaled to pixels. */
362
- width: number
363
- source: string
364
- target: string
365
- value: number
366
- }
367
-
368
- /** A single funnel segment (trapezoid) in pixel space. */
369
- export type ChartFunnelSegment = {
370
- /** Pre-built SVG path for the trapezoid. */
371
- path: string
372
- color: string
373
- label: string
374
- /** Original value (before any percentile normalisation). */
375
- value: number
376
- /** Conversion vs. first segment, 0..1. */
377
- conversion: number
378
- /** Drop-off from the previous segment, 0..1. */
379
- dropoff: number
380
- /** Centre point (label anchor). */
381
- cx: number
382
- cy: number
383
- /** Auto-picked black/white contrast color for in-segment labels. */
384
- textColor: string
385
- }
386
-
387
- /** A radar series' polygon: axis values + the closed polygon path. */
388
- export type ChartRadarSeries = {
389
- label: string
390
- color: string
391
- path: string
392
- /** Per-axis (x, y) endpoints so callers can draw dots / hit targets. */
393
- points: Array<{ x: number; y: number; value: number; axis: string }>
394
- }
395
-
396
- /** Radar axis spoke + tick info. */
397
- export type ChartRadarAxis = {
398
- label: string
399
- /** Outermost endpoint of the spoke. */
400
- x: number
401
- y: number
402
- }
403
-
404
- /** A single heatmap rectangle in pixel space. */
405
- export type ChartHeatmapCell = {
406
- x: number
407
- y: number
408
- w: number
409
- h: number
410
- color: string
411
- /** Text color picked for contrast against `color`. */
412
- textColor: string
413
- value: number
414
- rowLabel: string
415
- colLabel: string
416
- }
417
-
418
- /**
419
- * One candlestick / OHLC bar in SVG coordinates.
420
- *
421
- * Kept apart from {@link ChartBar} rather than folded into it: bars pick up
422
- * series pattern fills, data labels and the brush mini-map, and all three are
423
- * wrong for a candle. A separate array means every existing loop over `bars`
424
- * keeps working untouched, which is the point of this flat geometry.
425
- */
426
- export type ChartCandle = {
427
- /** Body rect left edge and width. In OHLC mode, the span of the two ticks. */
428
- x: number
429
- w: number
430
- /** Wick line, and the OHLC bar's vertical. */
431
- xCenter: number
432
- yOpen: number
433
- yClose: number
434
- yHigh: number
435
- yLow: number
436
- /** Body rect, pre-ordered so the renderer does no min/max of its own. */
437
- bodyY: number
438
- bodyH: number
439
- /** Close at or above open. Drives colour and hollow-vs-filled. */
440
- up: boolean
441
- color: string
442
- label: string
443
- series: string
444
- o: number
445
- h: number
446
- l: number
447
- c: number
448
- }
449
-
450
- /**
451
- * A laid-out box plot, in SVG coordinates. Its own array for the same reason
452
- * candles have one: `bars` carries pattern fills, data labels and the brush
453
- * mini-map, none of which mean anything for a box.
454
- */
455
- export type ChartBox = {
456
- /** Box rect left edge and width. */
457
- x: number
458
- w: number
459
- /** Whisker line and the caps, centred on the slot. */
460
- xCenter: number
461
- yMin: number
462
- yQ1: number
463
- yMedian: number
464
- yQ3: number
465
- yMax: number
466
- /** Box rect, pre-ordered so the renderer does no min/max of its own. */
467
- boxY: number
468
- boxH: number
469
- /** Points beyond the whiskers, already positioned. */
470
- outliers: Array<{ y: number; value: number }>
471
- color: string
472
- label: string
473
- series: string
474
- min: number
475
- q1: number
476
- median: number
477
- q3: number
478
- max: number
479
- }
480
-
481
- /** One positioned error bar: a vertical span with caps, centred on its mark. */
482
- export type ChartErrorBar = {
483
- xCenter: number
484
- yLo: number
485
- yHi: number
486
- /** Cap half-width, so the renderer draws the same T at both ends. */
487
- cap: number
488
- color: string
489
- label: string
490
- series: string
491
- lo: number
492
- hi: number
493
- }
494
-
495
- /**
496
- * Five-number summary of a raw sample, with the 1.5 IQR whisker rule.
497
- *
498
- * Whiskers stop at the last observation INSIDE the fence rather than at the
499
- * fence itself, which is what makes them read as real data; anything past them
500
- * comes back in `outliers`. Quartiles use linear interpolation between the two
501
- * neighbouring order statistics.
502
- *
503
- * Returns `null` for an empty sample, so a category with no observations is a
504
- * gap rather than a box drawn at zero.
505
- */
506
- export function boxStats(sample: ReadonlyArray<number>, whisker = 1.5): BoxStats | null {
507
- const v = sample.filter((n) => Number.isFinite(n)).slice().sort((a, b) => a - b)
508
- if (!v.length) return null
509
- const q = (p: number) => {
510
- const pos = (v.length - 1) * p
511
- const lo = Math.floor(pos)
512
- const hi = Math.ceil(pos)
513
- return lo === hi ? v[lo]! : v[lo]! + (v[hi]! - v[lo]!) * (pos - lo)
514
- }
515
- const q1 = q(0.25)
516
- const median = q(0.5)
517
- const q3 = q(0.75)
518
- const fenceLo = q1 - whisker * (q3 - q1)
519
- const fenceHi = q3 + whisker * (q3 - q1)
520
- const inside = v.filter((n) => n >= fenceLo && n <= fenceHi)
521
- const outliers = v.filter((n) => n < fenceLo || n > fenceHi)
522
- return {
523
- // `inside` can only be empty if every point is an outlier, which the fence
524
- // rule makes impossible (q1 and q3 are always within it) - but a degenerate
525
- // sample should still produce a box rather than `undefined` coordinates.
526
- min: inside.length ? inside[0]! : v[0]!,
527
- q1,
528
- median,
529
- q3,
530
- max: inside.length ? inside[inside.length - 1]! : v[v.length - 1]!,
531
- ...(outliers.length ? { outliers } : {}),
532
- }
533
- }
534
-
535
- /**
536
- * Category-axis labels, thinned so they do not overlap.
537
- *
538
- * A category axis used to emit one tick per category however many there were.
539
- * At 5000 categories that is 5000 `<text>` nodes stacked into an unreadable
540
- * grey band - the labels were the single biggest thing the renderer had to put
541
- * in the DOM, and none of them could be read. The time and ordinal-time axes
542
- * already thinned themselves (`dateTicks` / `ordinalDateTicks`); this brings
543
- * the plain category axis in line.
544
- *
545
- * The first category always gets a label, so a thinned axis still starts where
546
- * the data does.
547
- */
548
- function thinCategoryTicks(
549
- categories: string[],
550
- xCenter: (i: number) => number,
551
- slot: number,
552
- rotated: boolean,
553
- ): ChartCategoryTick[] {
554
- // Rotated labels run diagonally and pack far tighter than upright ones. 18px
555
- // is deliberately just under the spacing a 40-category chart at 800px already
556
- // had, so charts that read fine before are untouched and only genuinely
557
- // overlapping axes get thinned.
558
- const minPx = rotated ? 18 : 60
559
- const step = Math.max(1, Math.ceil(minPx / Math.max(slot, 0.001)))
560
- const out: ChartCategoryTick[] = []
561
- for (let i = 0; i < categories.length; i += step) out.push({ label: categories[i]!, x: xCenter(i) })
562
- return out
563
- }
564
-
565
- /** Normalize one `errors` entry to an absolute low/high pair around `value`. */
566
- function errorSpan(
567
- e: number | { lo: number; hi: number } | null | undefined,
568
- value: number,
569
- ): { lo: number; hi: number } | null {
570
- if (e == null) return null
571
- if (typeof e === 'number') {
572
- if (!Number.isFinite(e)) return null
573
- const m = Math.abs(e)
574
- return { lo: value - m, hi: value + m }
575
- }
576
- if (!Number.isFinite(e.lo) || !Number.isFinite(e.hi)) return null
577
- return { lo: Math.min(e.lo, e.hi), hi: Math.max(e.lo, e.hi) }
578
- }
579
-
580
- /** A computed bar rectangle in SVG coordinates. Output of {@link buildChart}, not an input. */
581
- export type ChartBar = {
582
- x: number
583
- y: number
584
- w: number
585
- h: number
586
- color: string
587
- /** Category (x label) this bar belongs to - for tooltips + labels. */
588
- label: string
589
- /** Series label this bar belongs to. */
590
- series: string
591
- value: number
592
- }
593
- /** One computed point on a line, with whether the series has a value there. */
594
- export type ChartLinePoint = {
595
- x: number
596
- y: number
597
- label: string
598
- value: number
599
- /** False for null / NaN values - the line breaks (gap), no dot is drawn. */
600
- defined: boolean
601
- }
602
- /** A computed line series: its points and the path drawn through them. */
603
- export type ChartLine = {
604
- path: string
605
- areaPath: string
606
- color: string
607
- label: string
608
- points: ChartLinePoint[]
609
- /** Confidence-band path (between upperValues + lowerValues) for this
610
- * series, when both arrays are supplied. Empty otherwise. */
611
- bandPath?: string
612
- }
613
- /** A computed pie slice, as an SVG arc plus its label placement. */
614
- export type ChartPieSlice = {
615
- path: string
616
- color: string
617
- label: string
618
- value: number
619
- percent: number
620
- /** Centroid - anchor point for a data label. */
621
- cx: number
622
- cy: number
623
- }
624
- /** A value-axis tick: the number, where it sits vertically, and its label. */
625
- export type ChartAxisTick = { value: number; y: number; label: string }
626
- /** A category-axis tick: the label and its horizontal position. */
627
- export type ChartCategoryTick = { label: string; x: number }
628
- /** One legend entry, paired with the series colour it stands for. */
629
- export type ChartLegendItem = { label: string; color: string }
630
- /** A computed reference line (target, average, threshold) at its plotted height. */
631
- export type ChartRefLineGeo = { y: number; label: string; color: string; dashed: boolean }
632
- /** A vertical reference line (horizontal bar charts) positioned by `x`. */
633
- export type ChartRefLineGeoV = { x: number; label: string; color: string; dashed: boolean }
634
- /** A computed scatter point in SVG coordinates. */
635
- export type ChartScatterDot = {
636
- cx: number
637
- cy: number
638
- r: number
639
- color: string
640
- label: string
641
- series: string
642
- x: number
643
- y: number
644
- }
11
+ // This file is the public facade over the chart engine. The types live in
12
+ // chart-types.ts, scales and formatting in chart-scale.ts, statistics in
13
+ // chart-stats.ts, axis resolution in chart-axes.ts and each family's layout in
14
+ // chart-cartesian / chart-polar / chart-flow / chart-grid / chart-hierarchy.
15
+ // buildChart below is the single entry: it resolves the series once and
16
+ // dispatches to the family. Everything a consumer imports from './chart' is
17
+ // re-exported here so the split is invisible from outside.
18
+ import type {
19
+ ChartResponsiveRule,
20
+ ChartStyle,
21
+ BoxStats,
22
+ ChartAxisConfig,
23
+ ChartGeometry,
24
+ ChartLegendItem,
25
+ ChartMarkerShape,
26
+ ChartReducer,
27
+ ChartSeries,
28
+ ChartSpec,
29
+ ChartType,
30
+ ChartValueFormat,
31
+ LayoutCtx,
32
+ ResolvedSeries,
33
+ ScatterPoint,
34
+ TreeNode,
35
+ } from './chart-types'
36
+ import { DEFAULT_PALETTE, niceScale } from './chart-scale'
37
+ import { binValues, boxStats, bucketStart, reducerNeedsSamples, reduceValues, type ChartTimeBucket } from './chart-stats'
38
+ import { chartFrame, kindOf, resolveAxes } from './chart-axes'
39
+ import { pickCategories } from './chart-decimate'
40
+ import { layoutBullet, layoutCartesian, layoutHorizontalBars, layoutScatter } from './chart-cartesian'
41
+ import { layoutChord, layoutGauge, layoutPie, layoutRadar, layoutRadialBar, layoutRadialColumn, layoutSunburst } from './chart-polar'
42
+ import { layoutFunnel, layoutSankey, layoutWaterfall } from './chart-flow'
43
+ import { layoutCalendar, layoutHeatmap } from './chart-grid'
44
+ import { layoutTreemap } from './chart-hierarchy'
45
+
46
+ export type * from './chart-types'
47
+ export {
48
+ DEFAULT_PALETTE,
49
+ formatChartValue,
50
+ niceLogScale,
51
+ niceScale,
52
+ ordinalDateTicks,
53
+ pickContrastText,
54
+ sampleGradient,
55
+ } from './chart-scale'
56
+ export { chartScales } from './chart-axes'
57
+ export { decimateSpec, lttb, minMaxIndices, pickCategories, PER_CATEGORY_SERIES_KEYS, PER_CATEGORY_SPEC_KEYS } from './chart-decimate'
58
+ export { buildLinePath, layoutDataLabels, markerPath, streamBaseline, type ChartDataLabel } from './chart-cartesian'
59
+ export { arcPath } from './chart-polar'
60
+ export { pivotResultToChartSpec, pivotChartType, pivotFilterColumn, bucketsToChartSpec, type PivotResultChartOptions, type PivotResultLike } from './chart-pivot'
61
+ export { heikinAshi, resampleOhlc, rowsToOhlcSpec, guessOhlcColumns, splitPanelIndicators, ohlcDirectOptions, type OhlcColumns, type ChartPanelIndicator } from './chart-financial'
62
+ import { rowsToOhlcSpec } from './chart-financial'
63
+ export {
64
+ binValues,
65
+ boxStats,
66
+ bucketStart,
67
+ percentile,
68
+ reduceValues,
69
+ type ChartBins,
70
+ type ChartTimeBucket,
71
+ exponentialMovingAverage,
72
+ linearTrend,
73
+ linearFit,
74
+ polynomialFit,
75
+ exponentialFit,
76
+ logarithmicFit,
77
+ powerFit,
78
+ rSquared,
79
+ pearson,
80
+ type RegressionFit,
81
+ simpleMovingAverage,
82
+ } from './chart-stats'
83
+ export {
84
+ computeOverlay,
85
+ computeOverlayFit,
86
+ regressionFit,
87
+ overlayName,
88
+ bollingerBands,
89
+ rsi,
90
+ macd,
91
+ vwap,
92
+ atr,
93
+ stochastic,
94
+ wma,
95
+ obv,
96
+ indicatorPane,
97
+ type ChartIndicatorSpec,
98
+ } from './chart-indicators'
645
99
 
646
100
  /**
647
- * Everything needed to render a chart: the plot rectangle plus every mark
648
- * already positioned in SVG coordinates. Produced by {@link buildChart} from a
649
- * {@link ChartSpec}, so a renderer does no maths of its own.
101
+ * The CSS custom properties `spec.style` sets on the chart's host, as one
102
+ * inline style string (empty when nothing is set). `background` becomes the
103
+ * chart's `--sg-bg` (exports read it as the page colour), `textColor` the
104
+ * text tokens, `gridColor` the border token, `fontSize` a scale every label
105
+ * multiplies (12px is 1), and `fontFamily` the host's font.
650
106
  */
651
- export type ChartGeometry = {
652
- type: ChartType
653
- width: number
654
- height: number
655
- plot: { x: number; y: number; w: number; h: number }
656
- /**
657
- * The value domain each axis was actually drawn against, and the slot width
658
- * of the category axis. Plain data, deliberately: the geometry stays a
659
- * serialisable value object, and `chartScales(geo)` turns this into the
660
- * `xOf` / `yOf` functions a custom mark needs.
661
- *
662
- * Null on the types with no cartesian axes (pie, gauge, treemap, sankey,
663
- * calendar, radar, funnel), which is also how a caller can tell whether
664
- * drawing into plot coordinates means anything.
665
- */
666
- axes: {
667
- y: { min: number; max: number; log: boolean }
668
- /** The right axis, when a series is plotted against one. */
669
- y2: { min: number; max: number; log: boolean } | null
670
- /** Horizontal room per category, in px. */
671
- slot: number
672
- /** Number of categories the axis was laid out for. */
673
- count: number
674
- } | null
675
- bars: ChartBar[]
676
- /** Candlestick / OHLC bars. Empty for every other chart type. */
677
- candles: ChartCandle[]
678
- /** Box plots. Empty for every other chart type. */
679
- boxes: ChartBox[]
680
- /** Error bars, from any series carrying `errors`. Empty when none do - they
681
- * annotate whatever mark the series already draws. */
682
- errorBars: ChartErrorBar[]
683
- lines: ChartLine[]
684
- slices: ChartPieSlice[]
685
- yTicks: ChartAxisTick[]
686
- /** Right-axis ticks (combo / dual-axis); empty when there's no right axis. */
687
- y2Ticks: ChartAxisTick[]
688
- hasRightAxis: boolean
689
- xTicks: ChartCategoryTick[]
690
- /** Grouped category axis parent tier: label + span extent (pixels). Empty
691
- * unless `spec.categoryGroups` is set on a vertical category chart. */
692
- categoryGroupTicks: Array<{ label: string; xCenter: number; x0: number; x1: number }>
693
- /** True when x labels are long/many and should be rotated. */
694
- xLabelRotated: boolean
695
- legend: ChartLegendItem[]
696
- /** Donut centre (pie + innerRadius), for a centre total label. */
697
- donut: { cx: number; cy: number; r: number; total: number } | null
698
- /** Horizontal reference / target lines. */
699
- referenceLines: ChartRefLineGeo[]
700
- /** Scatter / bubble points (type === 'scatter'). */
701
- scatterPoints: ChartScatterDot[]
702
- /** Bar orientation. `'horizontal'` uses `valueTicks` / `catTicks` below. */
703
- orientation: 'vertical' | 'horizontal'
704
- /** Horizontal bars: value-axis ticks along the bottom (label + x). */
705
- valueTicks: ChartCategoryTick[]
706
- /** Horizontal bars: category labels down the left (label + y; value = index). */
707
- catTicks: ChartAxisTick[]
708
- /** Horizontal bars: vertical reference / target lines (positioned by x). */
709
- referenceLinesV: ChartRefLineGeoV[]
710
- /** Trend / moving-average overlay lines (parallel to `lines`). Drawn
711
- * dashed on top of their source series. */
712
- overlays: ChartLine[]
713
- /** Pinned annotation labels with pre-resolved screen coordinates. */
714
- annotations: Array<{ x: number; y: number; label: string; color: string; placement: 'top' | 'bottom' | 'left' | 'right' }>
715
- /** Heatmap cells (type === 'heatmap'). */
716
- heatmapCells: ChartHeatmapCell[]
717
- /** Heatmap row labels with pre-resolved y positions (left gutter). */
718
- heatmapRowTicks: ChartAxisTick[]
719
- /** Heatmap column labels (bottom of plot). */
720
- heatmapColTicks: ChartCategoryTick[]
721
- /** Heatmap color-scale legend: ordered stops with value + color. */
722
- heatmapLegend: Array<{ value: number; color: string; label: string }>
723
- /** Funnel segments (type === 'funnel'). */
724
- funnelSegments: ChartFunnelSegment[]
725
- /** Radar concentric grid rings (centred at `radarCenter`). */
726
- radarRings: number[]
727
- /** Radar axis labels + spoke endpoints. */
728
- radarAxes: ChartRadarAxis[]
729
- /** Radar series polygons. */
730
- radarSeries: ChartRadarSeries[]
731
- /** Centre of the radar / pie. Pre-computed so callers don't re-derive. */
732
- radarCenter: { cx: number; cy: number; r: number } | null
733
- /** Tree-map cells (type === 'treemap'). */
734
- treemapCells: ChartTreemapCell[]
735
- /** Calendar heatmap (type === 'calendar'). */
736
- calendarCells: ChartCalendarCell[]
737
- /** Calendar month labels along the top. */
738
- calendarMonthTicks: ChartCategoryTick[]
739
- /** Calendar legend stops (sequential ramp). */
740
- calendarLegend: Array<{ value: number; color: string; label: string }>
741
- /** Gauge layout (type === 'gauge'). Null when not a gauge. */
742
- gauge: ChartGaugeLayout | null
743
- /** Sankey nodes (type === 'sankey'). */
744
- sankeyNodes: ChartSankeyNode[]
745
- /** Sankey links (type === 'sankey'). */
746
- sankeyLinks: ChartSankeyLink[]
747
- }
748
-
749
- /** Series colours used when a {@link ChartSeries} sets none, in order. */
750
- export const DEFAULT_PALETTE = [
751
- '#2563eb',
752
- '#16a34a',
753
- '#f59e0b',
754
- '#ef4444',
755
- '#8b5cf6',
756
- '#0ea5e9',
757
- '#ec4899',
758
- '#14b8a6',
759
- ]
760
-
761
- function round(n: number): number {
762
- return Math.round(n * 100) / 100
763
- }
764
-
765
- function niceNum(range: number, roundIt: boolean): number {
766
- if (range <= 0) return 1
767
- const exp = Math.floor(Math.log10(range))
768
- const f = range / Math.pow(10, exp)
769
- let nf: number
770
- if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
771
- else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
772
- return nf * Math.pow(10, exp)
773
- }
774
-
775
- /** An axis range rounded to human-friendly bounds and tick spacing. */
776
- export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
777
-
778
- // ---- Color helpers for heatmap / pattern fills ----------------------
779
-
780
- /** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
781
- const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
782
- /** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
783
- const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
784
- /** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
785
- * above the dark grid surface instead of near-white, so empty / low cells read
786
- * as "cold" rather than as glaring white rectangles. */
787
- const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
788
- const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
789
-
790
- function resolveColorScale(
791
- scale: 'sequential' | 'diverging' | string[] | undefined,
792
- vMin: number,
793
- vMax: number,
794
- theme: 'light' | 'dark' = 'light',
795
- ): string[] {
796
- if (Array.isArray(scale) && scale.length >= 2) return scale
797
- const dark = theme === 'dark'
798
- if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
799
- return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
800
- }
801
- return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
802
- }
803
-
804
- /** Sample a hex color from an array of hex stops at fractional position t.
805
- * Linearly interpolates between the two nearest stops in RGB space. */
806
- export function sampleGradient(stops: string[], t: number): string {
807
- if (!stops.length) return '#888'
808
- const clamped = Math.max(0, Math.min(1, t))
809
- if (stops.length === 1) return stops[0]!
810
- const pos = clamped * (stops.length - 1)
811
- const i = Math.floor(pos)
812
- const frac = pos - i
813
- const a = hexToRgb(stops[i]!)
814
- const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
815
- if (!a || !b) return stops[i] ?? '#888'
816
- const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
817
- const toHex = (n: number) => n.toString(16).padStart(2, '0')
818
- return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
819
- }
820
-
821
- function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
822
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
823
- if (!m) return null
824
- const n = parseInt(m[1]!, 16)
825
- return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
826
- }
827
-
828
- /** Pick a black or white text color that has the better contrast against
829
- * the given background. Uses the WCAG relative-luminance heuristic. */
830
- export function pickContrastText(bgHex: string): string {
831
- const rgb = hexToRgb(bgHex)
832
- if (!rgb) return '#0f172a'
833
- const lin = (c: number) => {
834
- const s = c / 255
835
- return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
836
- }
837
- const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
838
- return L > 0.5 ? '#0f172a' : '#ffffff'
839
- }
840
-
841
- /** Pick the largest power of 10 that fits at the bottom of [min,max], and
842
- * the smallest that covers the top, then enumerate decade boundaries. Used
843
- * by log-scale axes (yScale: 'log'). */
844
- export function niceLogScale(min: number, max: number): NiceScale {
845
- // Only positive values are plottable on a log scale; callers should
846
- // strip non-positive values before passing them in.
847
- if (!Number.isFinite(min) || min <= 0) min = 1
848
- if (!Number.isFinite(max) || max <= min) max = min * 10
849
- const lo = Math.floor(Math.log10(min))
850
- const hi = Math.ceil(Math.log10(max))
851
- const ticks: number[] = []
852
- for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
853
- return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
854
- }
855
-
856
- /** Map a value to a fractional position [0..1] across the axis domain.
857
- * Pass the appropriate fn into projection code so linear / log share the
858
- * same plumbing. Returns null for non-positive values on log. */
859
- function project(value: number, min: number, max: number, isLog: boolean): number | null {
860
- if (!Number.isFinite(value)) return null
861
- if (isLog) {
862
- if (value <= 0 || min <= 0) return null
863
- return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
864
- }
865
- return (value - min) / (max - min)
866
- }
867
-
868
- /** The scale functions for a laid-out cartesian chart. See {@link chartScales}. */
869
- export type ChartScales = {
870
- /** Pixel x at the centre of category `i`. Fractional indices interpolate,
871
- * so `xOf(2.5)` is the midpoint between the third and fourth categories. */
872
- xOf: (index: number) => number
873
- /** Pixel y for a value on the left axis (or the right, when asked). Returns
874
- * NaN for a value the axis cannot express, e.g. zero on a log scale. */
875
- yOf: (value: number, axis?: 'left' | 'right') => number
876
- /** The inverse of `xOf`: which category a pixel x falls on. Clamped. */
877
- xInvert: (px: number) => number
878
- /** The inverse of `yOf`. */
879
- yInvert: (px: number, axis?: 'left' | 'right') => number
107
+ export function chartStyleVars(style: ChartStyle | undefined): string {
108
+ if (!style) return ''
109
+ const out: string[] = []
110
+ if (style.background) out.push(`--sg-chart-bg:${style.background}`, `--sg-bg:${style.background}`)
111
+ if (style.textColor) out.push(`--sg-fg:${style.textColor}`, `--sg-muted:${style.textColor}`)
112
+ if (style.gridColor) out.push(`--sg-border:${style.gridColor}`)
113
+ if (style.fontSize && Number.isFinite(style.fontSize) && style.fontSize > 0) out.push(`--sg-chart-font-scale:${Math.round((style.fontSize / 12) * 1000) / 1000}`)
114
+ if (style.fontFamily) out.push(`font-family:${style.fontFamily}`)
115
+ return out.join(';')
880
116
  }
881
117
 
882
118
  /**
883
- * Turn a laid-out chart's axes into functions, so a caller can draw its own
884
- * marks in the same coordinates the built-in ones use.
885
- *
886
- * This is the custom-series seam. Rather than a registry of mark types, the
887
- * chart hands over its geometry and its scales and lets the caller render
888
- * whatever SVG it likes into the plot - which is the Svelte-shaped answer, and
889
- * means a custom mark is ordinary markup rather than a plugin.
890
- *
891
- * Deriving the scale from `geo.axes` matters: the domain a chart drew against
892
- * is the NICE-ROUNDED one, stretched to include zero for bar charts and any
893
- * reference lines. Recomputing it from the data outside would land custom marks
894
- * a few pixels off the built-in ones, in a way that looks like a rendering bug.
895
- *
896
- * Returns `null` for a chart with no cartesian axes (pie, gauge, treemap,
897
- * sankey, calendar, radar, funnel), where plot coordinates mean nothing.
119
+ * The responsive rules that apply at a rendered size, in spec order. Pure:
120
+ * `SvChart` uses it for the legend field, {@link resolveResponsive} for the
121
+ * spec patches.
898
122
  */
899
- export function chartScales(geo: ChartGeometry): ChartScales | null {
900
- const a = geo.axes
901
- if (!a) return null
902
- const { x: px, y: py, w: pw, h: ph } = geo.plot
903
- const yFor = (dom: { min: number; max: number; log: boolean }) => (value: number) => {
904
- const t = project(value, dom.min, dom.max, dom.log)
905
- return t === null ? Number.NaN : py + ph - t * ph
906
- }
907
- const left = yFor(a.y)
908
- const right = a.y2 ? yFor(a.y2) : left
909
- const invFor = (dom: { min: number; max: number; log: boolean }) => (y: number) => {
910
- const t = ph === 0 ? 0 : (py + ph - y) / ph
911
- if (!dom.log) return dom.min + t * (dom.max - dom.min)
912
- const lo = Math.log10(dom.min)
913
- return 10 ** (lo + t * (Math.log10(dom.max) - lo))
914
- }
915
- return {
916
- xOf: (i) => px + a.slot * i + a.slot / 2,
917
- yOf: (value, axis) => (axis === 'right' ? right(value) : left(value)),
918
- xInvert: (x) =>
919
- Math.max(0, Math.min(a.count - 1, Math.floor((x - px) / (a.slot || 1)))),
920
- yInvert: (y, axis) => (axis === 'right' && a.y2 ? invFor(a.y2)(y) : invFor(a.y)(y)),
921
- }
922
- }
923
-
924
- // ---- Overlay math: trendline + moving averages -----------------------
925
-
926
- /** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
927
- * monotone cubic interpolation (preserves local extrema - no overshoots).
928
- * Breaks the path at `defined === false` gaps. */
929
- export function buildLinePath(
930
- pts: Array<{ x: number; y: number; defined: boolean }>,
931
- smooth: boolean,
932
- ): string {
933
- if (!smooth) {
934
- let path = ''
935
- let pen = false
936
- for (const p of pts) {
937
- if (!p.defined) { pen = false; continue }
938
- path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
939
- pen = true
940
- }
941
- return path.trim()
942
- }
943
- // Group defined-only runs; each run is smoothed independently.
944
- const runs: Array<Array<{ x: number; y: number }>> = []
945
- let cur: Array<{ x: number; y: number }> = []
946
- for (const p of pts) {
947
- if (p.defined) cur.push({ x: p.x, y: p.y })
948
- else if (cur.length) { runs.push(cur); cur = [] }
949
- }
950
- if (cur.length) runs.push(cur)
951
- return runs.map(monotoneCubicPath).filter(Boolean).join(' ')
952
- }
953
-
954
- /** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
955
- * Slope at each point chosen so the curve passes through every (xi, yi)
956
- * AND stays monotonic between them; control points sit 1/3 of the way
957
- * to the neighbours along that tangent. */
958
- function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
959
- const n = pts.length
960
- if (n === 0) return ''
961
- if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
962
- if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
963
- // Secant slopes between adjacent points.
964
- const dx: number[] = new Array(n - 1)
965
- const m: number[] = new Array(n - 1)
966
- for (let i = 0; i < n - 1; i += 1) {
967
- const d = pts[i + 1]!.x - pts[i]!.x
968
- dx[i] = d
969
- m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
970
- }
971
- // Tangent at each point: average of neighbouring slopes, with sign
972
- // checks that flatten the tangent when slopes change sign.
973
- const tan: number[] = new Array(n)
974
- tan[0] = m[0]!
975
- tan[n - 1] = m[n - 2]!
976
- for (let i = 1; i < n - 1; i += 1) {
977
- if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
978
- else tan[i] = (m[i - 1]! + m[i]!) / 2
979
- }
980
- // Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
981
- for (let i = 0; i < n - 1; i += 1) {
982
- if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
983
- const a = tan[i]! / m[i]!
984
- const b = tan[i + 1]! / m[i]!
985
- const h = Math.hypot(a, b)
986
- if (h > 3) {
987
- tan[i] = (3 / h) * a * m[i]!
988
- tan[i + 1] = (3 / h) * b * m[i]!
989
- }
990
- }
991
- // Build the Bezier path. Each segment: control points at 1/3 of dx.
992
- let path = `M${pts[0]!.x},${pts[0]!.y}`
993
- for (let i = 0; i < n - 1; i += 1) {
994
- const h = dx[i]!
995
- const c1x = pts[i]!.x + h / 3
996
- const c1y = pts[i]!.y + (tan[i]! * h) / 3
997
- const c2x = pts[i + 1]!.x - h / 3
998
- const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
999
- path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
1000
- }
1001
- return path
1002
- }
1003
-
1004
- /** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
1005
- * Returns the fitted value at each x index, or NaN where the source value
1006
- * was non-finite. */
1007
- export function linearTrend(values: number[]): number[] {
1008
- let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
1009
- for (let i = 0; i < values.length; i += 1) {
1010
- const y = values[i]!
1011
- if (!Number.isFinite(y)) continue
1012
- n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
1013
- }
1014
- if (n < 2) return values.map(() => NaN)
1015
- const denom = n * sumXX - sumX * sumX
1016
- if (denom === 0) return values.map(() => sumY / n)
1017
- const slope = (n * sumXY - sumX * sumY) / denom
1018
- const intercept = (sumY - slope * sumX) / n
1019
- return values.map((_, i) => slope * i + intercept)
1020
- }
1021
-
1022
- /** Simple moving average over a window of `period` values. Window centres
1023
- * trail to the right (typical for time-series). NaN for points before the
1024
- * window is full. */
1025
- export function simpleMovingAverage(values: number[], period: number): number[] {
1026
- if (period < 1) return values.slice()
1027
- const out: number[] = new Array(values.length).fill(NaN)
1028
- let sum = 0, count = 0
1029
- for (let i = 0; i < values.length; i += 1) {
1030
- const v = values[i]!
1031
- if (Number.isFinite(v)) { sum += v; count += 1 }
1032
- if (i >= period) {
1033
- const drop = values[i - period]!
1034
- if (Number.isFinite(drop)) { sum -= drop; count -= 1 }
1035
- }
1036
- if (i >= period - 1 && count > 0) out[i] = sum / count
1037
- }
1038
- return out
1039
- }
1040
-
1041
- /** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
1042
- export function exponentialMovingAverage(values: number[], period: number): number[] {
1043
- const alpha = 2 / (Math.max(1, period) + 1)
1044
- const out: number[] = new Array(values.length).fill(NaN)
1045
- let prev: number | null = null
1046
- for (let i = 0; i < values.length; i += 1) {
1047
- const v = values[i]!
1048
- if (!Number.isFinite(v)) { out[i] = prev ?? NaN; continue }
1049
- prev = prev == null ? v : alpha * v + (1 - alpha) * prev
1050
- out[i] = prev
1051
- }
1052
- return out
1053
- }
1054
-
1055
- /** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
1056
- export function computeOverlay(values: number[], spec: SeriesOverlay): number[] {
1057
- if (spec === 'linear') return linearTrend(values)
1058
- const m = /^(sma|ema):(\d+)$/.exec(spec)
1059
- if (!m) return values.map(() => NaN)
1060
- const period = Number(m[2])
1061
- return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period)
1062
- }
1063
-
1064
- /** Round a [min,max] domain out to nice tick boundaries. */
1065
- export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
1066
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
1067
- min = 0
1068
- max = 1
1069
- }
1070
- if (min === max) {
1071
- if (min === 0) max = 1
1072
- else {
1073
- min = Math.min(0, min)
1074
- max = Math.max(0, max)
1075
- }
1076
- if (min === max) max = min + 1
1077
- }
1078
- const range = niceNum(max - min, false)
1079
- const step = niceNum(range / Math.max(1, tickCount), true)
1080
- const nMin = Math.floor(min / step) * step
1081
- const nMax = Math.ceil(max / step) * step
1082
- const ticks: number[] = []
1083
- for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
1084
- return { min: nMin, max: nMax, step, ticks }
1085
- }
1086
-
1087
- function fmtTick(n: number): string {
1088
- const abs = Math.abs(n)
1089
- if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
1090
- if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
1091
- return String(Math.round(n * 100) / 100)
1092
- }
1093
-
1094
- /** Value-axis / tooltip / label number format. */
1095
- export type ChartValueFormat = 'number' | 'currency' | 'percent' | 'compact'
1096
-
1097
- /** Locale-aware formatting options, a structural subset of `ChartSpec` so a
1098
- * caller inside the engine can pass the spec straight through. */
1099
- export type ChartFormatLocale = { locale?: string | ReadonlyArray<string>; currency?: string }
1100
-
1101
- /**
1102
- * Format a numeric value for display, honouring an optional `valueFormat`.
1103
- *
1104
- * Two modes, on purpose. With no `locale` and no `currency` this is the original
1105
- * locale-free output: the compact `1.2k` / `1.2M` base, currency prefixed with
1106
- * `$` (sign outside), percent multiplied by 100 and suffixed `%`. Set either one
1107
- * and it switches to `Intl.NumberFormat`, so separators, the decimal mark and the
1108
- * compact suffixes all follow the locale.
1109
- *
1110
- * Keeping the old path as the default is deliberate rather than lazy. `Intl`'s
1111
- * compact notation is not the same string even for `en-US` (`1.2K`, capital),
1112
- * so formatting everything through it would silently restyle every axis in every
1113
- * existing chart. Opting in is the only version of this that is not a surprise.
1114
- */
1115
- export function formatChartValue(
1116
- n: number,
1117
- format?: ChartValueFormat,
1118
- opts?: ChartFormatLocale,
1119
- ): string {
1120
- if (!Number.isFinite(n)) return ''
1121
- const localized = opts && (opts.locale || opts.currency)
1122
- if (localized) {
1123
- // Compact notation because these are axis ticks and data labels, where a
1124
- // full-precision number is what makes an axis unreadable.
1125
- const style = format === 'currency' ? 'currency' : format === 'percent' ? 'percent' : 'decimal'
1126
- const o: Intl.NumberFormatOptions = { notation: 'compact', maximumFractionDigits: 1 }
1127
- if (style === 'currency') {
1128
- o.style = 'currency'
1129
- // `style: 'currency'` throws without a code, so fall back to the symbol
1130
- // this used to hard-code rather than refusing to draw the chart.
1131
- o.currency = opts!.currency || 'USD'
1132
- } else if (style === 'percent') {
1133
- o.style = 'percent'
1134
- }
1135
- return getNumberFormatter(opts!.locale, o).format(n)
1136
- }
1137
- if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
1138
- if (format === 'percent') {
1139
- const p = n * 100
1140
- return `${Math.round(p * 10) / 10}%`
1141
- }
1142
- return fmtTick(n)
123
+ export function matchResponsiveRules(rules: ChartResponsiveRule[] | undefined, width: number, height: number): ChartResponsiveRule[] {
124
+ if (!rules?.length) return []
125
+ return rules.filter(
126
+ (r) =>
127
+ (r.maxWidth == null || width <= r.maxWidth) &&
128
+ (r.minWidth == null || width >= r.minWidth) &&
129
+ (r.maxHeight == null || height <= r.maxHeight),
130
+ )
1143
131
  }
1144
132
 
1145
- const DAY = 86_400_000
1146
- /** Nice date-tick timestamps across [min, max]. */
1147
- function dateTicks(tMin: number, tMax: number): number[] {
1148
- const span = tMax - tMin
1149
- const step =
1150
- span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY
1151
- const ticks: number[] = []
1152
- for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step) ticks.push(t)
1153
- if (!ticks.length) ticks.push(tMin, tMax)
1154
- return ticks
1155
- }
1156
133
  /**
1157
- * Tick positions for an ordinal (evenly spaced) date axis, as INDICES into
1158
- * `times`.
1159
- *
1160
- * A time axis can put a tick anywhere, because x is a function of the
1161
- * timestamp. An ordinal axis cannot: x is a function of the index, so a tick
1162
- * has to land on a point that exists. This picks the first point of each
1163
- * calendar unit - day, week, month, year, whichever gets closest to `target`
1164
- * ticks without going over - so labels sit on real sessions and a weekend or a
1165
- * holiday never stretches the spacing.
134
+ * Apply `spec.responsive` for a rendered size: every matching rule's `spec`
135
+ * is merged over the input in order (later wins), the three axis objects one
136
+ * level deep. Returns the input itself when nothing matches, so callers can
137
+ * compare by identity. `width`, `height` and `responsive` in a rule are
138
+ * ignored; the size is the caller's.
1166
139
  */
1167
- export function ordinalDateTicks(times: number[], target = 6): number[] {
1168
- if (times.length <= 1) return times.length ? [0] : []
1169
- const keyOf: Record<string, (d: Date) => number | string> = {
1170
- day: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`,
1171
- week: (d) => Math.floor((Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / DAY + 4) / 7),
1172
- month: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}`,
1173
- year: (d) => d.getUTCFullYear(),
1174
- }
1175
- let chosen: number[] | null = null
1176
- for (const unit of ['day', 'week', 'month', 'year'] as const) {
1177
- const at: number[] = []
1178
- let prev: number | string | null = null
1179
- for (let i = 0; i < times.length; i += 1) {
1180
- const t = times[i]!
1181
- if (!Number.isFinite(t)) continue
1182
- const k = keyOf[unit]!(new Date(t))
1183
- if (k !== prev) at.push(i)
1184
- prev = k
140
+ export function resolveResponsive(spec: ChartSpec, width: number, height: number): ChartSpec {
141
+ const hits = matchResponsiveRules(spec.responsive, width, height)
142
+ if (!hits.length) return spec
143
+ let out: ChartSpec = spec
144
+ for (const rule of hits) {
145
+ if (!rule.spec) continue
146
+ const { width: _w, height: _h, responsive: _r, ...patch } = rule.spec
147
+ const merged: ChartSpec = { ...out, ...patch }
148
+ for (const k of ['xAxis', 'yAxis', 'y2Axis'] as const) {
149
+ if (patch[k]) merged[k] = { ...(out[k] ?? {}), ...patch[k] }
1185
150
  }
1186
- chosen = at
1187
- if (at.length <= target * 2) break
1188
- }
1189
- let out = chosen ?? []
1190
- // Even the coarsest unit can be too dense (a decade of yearly points), and a
1191
- // single trading day yields one boundary. Thin, or fall back to plain strides.
1192
- if (out.length > target) {
1193
- const stride = Math.ceil(out.length / target)
1194
- out = out.filter((_, i) => i % stride === 0)
1195
- }
1196
- if (out.length < 2) {
1197
- const stride = Math.max(1, Math.ceil(times.length / target))
1198
- out = times.map((_, i) => i).filter((i) => i % stride === 0)
151
+ out = merged
1199
152
  }
1200
153
  return out
1201
154
  }
1202
155
 
1203
- function fmtDate(t: number, span: number): string {
1204
- const d = new Date(t)
1205
- if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
1206
- if (span <= 800 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
1207
- return String(d.getFullYear())
1208
- }
1209
-
1210
- type ResolvedSeries = ChartSeries & {
1211
- color: string
1212
- kind: 'bar' | 'line' | 'area' | 'candle' | 'box'
1213
- axis: 'left' | 'right'
1214
- }
1215
-
1216
- /**
1217
- * Which mark a series draws.
1218
- *
1219
- * A series counts as a candle when it says so OR when it carries `ohlc` data,
1220
- * so a spec typed `'candlestick'` can still hold a plain volume bar series
1221
- * beside the prices. Boxes work the same way. Anything unrecognised falls back
1222
- * to `'bar'`, which is what pie and scatter have always relied on.
1223
- */
1224
- function kindOf(s: ChartSeries, specType: ChartType): ResolvedSeries['kind'] {
1225
- const t = s.type ?? specType
1226
- if (t === 'candlestick' || t === 'ohlc' || s.ohlc) return 'candle'
1227
- if (t === 'boxplot' || s.boxes) return 'box'
1228
- if (t === 'line' || t === 'area') return t
1229
- return 'bar'
1230
- }
1231
-
1232
- /** Data domain for one axis, honoring stacking of its bar/area series.
1233
- * When `isLog` is true, non-positive values are discarded (log undefined)
1234
- * and the domain is rounded to decade boundaries instead of nice steps. */
1235
- function axisDomain(
1236
- list: ResolvedSeries[],
1237
- categories: string[],
1238
- stacked: boolean,
1239
- extra: number[] = [],
1240
- isLog = false,
1241
- ): NiceScale {
1242
- let dMin = Infinity
1243
- let dMax = -Infinity
1244
- const note = (v: number) => {
1245
- if (!Number.isFinite(v)) return
1246
- if (isLog && v <= 0) return
1247
- if (v < dMin) dMin = v
1248
- if (v > dMax) dMax = v
1249
- }
1250
- for (const v of extra) note(v)
1251
- const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area')
1252
- const lines = list.filter((s) => s.kind === 'line')
1253
- if (stacked && stackable.length) {
1254
- for (let i = 0; i < categories.length; i += 1) {
1255
- let pos = 0
1256
- let neg = 0
1257
- for (const s of stackable) {
1258
- const v = s.values[i] ?? 0
1259
- if (v >= 0) pos += v
1260
- else neg += v
1261
- }
1262
- note(pos)
1263
- note(neg)
1264
- }
1265
- } else {
1266
- for (const s of stackable) for (const v of s.values) note(v)
1267
- }
1268
- for (const s of lines) for (const v of s.values) note(v)
1269
- // Candles: note the HIGH and the LOW, not `values` (the closes), or every
1270
- // wick clips at the body. Note also that candles are deliberately absent
1271
- // from `stackable`, so the zero-baseline rule below does not fire for them:
1272
- // a price series running 180 to 195 keeps a readable domain instead of being
1273
- // flattened against zero.
1274
- for (const s of list) {
1275
- if (s.kind !== 'candle') continue
1276
- for (const k of s.ohlc ?? []) {
1277
- if (!k) continue
1278
- note(k.h)
1279
- note(k.l)
1280
- }
1281
- }
1282
- // Boxes: the whisker ends and any outlier, for the same reason - `values`
1283
- // holds the medians, so a domain built from those alone would clip half of
1284
- // every box. Boxes are also absent from `stackable`, so a sample that never
1285
- // goes near zero keeps a readable domain.
1286
- for (const s of list) {
1287
- if (s.kind !== 'box') continue
1288
- for (const b of s.boxes ?? []) {
1289
- if (!b) continue
1290
- note(b.min)
1291
- note(b.max)
1292
- for (const o of b.outliers ?? []) note(o)
1293
- }
1294
- }
1295
- // Error bars extend past their own mark, so a whisker that leaves the plot is
1296
- // the same defect as a clipped candle wick.
1297
- for (const s of list) {
1298
- if (!s.errors) continue
1299
- s.errors.forEach((e, i) => {
1300
- const span = errorSpan(e, s.values[i] ?? 0)
1301
- if (!span) return
1302
- note(span.lo)
1303
- note(span.hi)
1304
- })
1305
- }
1306
- if (dMin === Infinity) {
1307
- dMin = isLog ? 1 : 0
1308
- dMax = isLog ? 10 : 1
1309
- }
1310
- // Bar / area charts read against a zero baseline, so always include 0
1311
- // - but only on linear axes (0 is invalid in log).
1312
- if (stackable.length && !isLog) {
1313
- dMin = Math.min(dMin, 0)
1314
- dMax = Math.max(dMax, 0)
1315
- }
1316
- return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax)
1317
- }
1318
-
1319
156
  /**
1320
157
  * Lay out a {@link ChartSpec} into renderable {@link ChartGeometry} - scales,
1321
158
  * ticks, and the position of every bar, line, slice and dot. Pure: no DOM, so
1322
159
  * it runs during SSR and can be unit-tested directly.
1323
160
  */
1324
- export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'): ChartGeometry {
1325
- const width = spec.width ?? 520
1326
- const height = spec.height ?? 300
161
+ export function buildChart(input: ChartSpec, theme: 'light' | 'dark' = 'light'): ChartGeometry {
162
+ const width = input.width ?? 520
163
+ const height = input.height ?? 300
164
+ // Size rules first, so every family sees the patched spec.
165
+ const spec = resolveResponsive(input, width, height)
166
+ // A pareto is a bar chart with a cumulative-share line on the right axis,
167
+ // sorted by value. Build that spec and lay it out as the combo it is.
168
+ if (spec.type === 'pareto') return buildChart(paretoSpec(spec), theme)
1327
169
  const palette = spec.palette ?? DEFAULT_PALETTE
1328
170
 
1329
171
  const stacked = !!(spec.stacked || spec.stacked100)
1330
172
  const series: ResolvedSeries[] = spec.series.map((s, i) => ({
1331
173
  ...s,
174
+ // A null is a gap unless the series (or the spec) says it is a zero. Done
175
+ // here, once, so every family and every overlay sees the same numbers.
176
+ values: (s.nullAs ?? spec.nullAs) === 'zero' ? s.values.map((v) => (Number.isFinite(v) ? v : 0)) : s.values,
1332
177
  color: s.color ?? palette[i % palette.length]!,
1333
178
  kind: kindOf(s, spec.type),
1334
179
  axis: s.axis ?? 'left',
1335
180
  }))
181
+ const frame = chartFrame(spec, width, height)
182
+ const axes = resolveAxes(spec)
1336
183
  const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
1337
184
 
1338
185
  const empty: ChartGeometry = {
@@ -1341,10 +188,21 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1341
188
  height,
1342
189
  plot: { x: 0, y: 0, w: width, h: height },
1343
190
  axes: null,
191
+ frame,
192
+ grid: { x: false, y: false },
193
+ referenceBands: [],
1344
194
  bars: [],
1345
195
  candles: [],
1346
196
  boxes: [],
1347
197
  errorBars: [],
198
+ stems: [],
199
+ arcs: [],
200
+ chordRibbons: [],
201
+ bullets: [],
202
+ drawings: [],
203
+ seriesLabels: [],
204
+ polarAxes: [],
205
+ polarRings: [],
1348
206
  lines: [],
1349
207
  slices: [],
1350
208
  yTicks: [],
@@ -1353,6 +211,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1353
211
  xTicks: [],
1354
212
  categoryGroupTicks: [],
1355
213
  xLabelRotated: false,
214
+ xLabelAngle: 0,
1356
215
  legend,
1357
216
  donut: null,
1358
217
  referenceLines: [],
@@ -1381,1437 +240,29 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1381
240
  sankeyLinks: [],
1382
241
  }
1383
242
 
1384
- // ---- Waterfall ----------------------------------------------------
1385
- // First series provides the values. Each non-total bar starts at the
1386
- // running cumulative sum; total bars (waterfallTotals[i]) reset and
1387
- // span from 0 to that sum. Color is derived from sign + total flag, with
1388
- // optional palette overrides via spec.waterfallColors.
1389
- if (spec.type === 'waterfall') {
1390
- const src = series[0]
1391
- if (!src) return { ...empty }
1392
- const colors = spec.waterfallColors ?? {}
1393
- const positive = colors.positive ?? '#16a34a'
1394
- const negative = colors.negative ?? '#ef4444'
1395
- const total = colors.total ?? '#475569'
1396
-
1397
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
1398
- const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
1399
- const padL = 48 + (spec.yAxisTitle ? 16 : 0)
1400
- const padR = 12
1401
- const padT = 10
1402
- const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
1403
- const plotW = Math.max(1, width - padL - padR)
1404
- const plotH = Math.max(1, height - padT - padB)
1405
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1406
-
1407
- // Compute the running cumulative + per-bar (from, to) pairs.
1408
- const totals = spec.waterfallTotals ?? []
1409
- const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
1410
- let cum = 0
1411
- spec.categories.forEach((_, i) => {
1412
- const v = src.values[i] ?? 0
1413
- const isTotal = !!totals[i]
1414
- if (isTotal) {
1415
- pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
1416
- } else {
1417
- pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
1418
- cum += v
1419
- }
1420
- })
1421
- // Y-axis domain spans every visited level (including 0).
1422
- let dMin = 0, dMax = 0
1423
- for (const p of pairs) {
1424
- if (p.from < dMin) dMin = p.from
1425
- if (p.to < dMin) dMin = p.to
1426
- if (p.from > dMax) dMax = p.from
1427
- if (p.to > dMax) dMax = p.to
1428
- }
1429
- const dom = niceScale(dMin, dMax)
1430
- const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
1431
-
1432
- const slotW = plotW / Math.max(1, spec.categories.length)
1433
- const barPad = slotW * 0.2
1434
- const barW = Math.max(1, slotW - barPad)
1435
- const bars: ChartBar[] = pairs.map((p, i) => {
1436
- const yTop = yOfW(Math.max(p.from, p.to))
1437
- const yBot = yOfW(Math.min(p.from, p.to))
1438
- const x = padL + slotW * i + barPad / 2
1439
- const color = p.isTotal ? total : p.value >= 0 ? positive : negative
1440
- return {
1441
- x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
1442
- color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
1443
- }
1444
- })
1445
- // Thin connector lines between bar tops -> running total reads cleanly.
1446
- const connectors: ChartLine[] = [{
1447
- path: pairs
1448
- .map((p, i) => {
1449
- const x0 = padL + slotW * i + barPad / 2 + barW
1450
- const y = yOfW(p.to)
1451
- const x1 = padL + slotW * (i + 1) + barPad / 2
1452
- // Skip the final connector beyond the last bar.
1453
- return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
1454
- })
1455
- .filter(Boolean)
1456
- .join(' '),
1457
- areaPath: '',
1458
- color: 'var(--sg-muted, #94a3b8)',
1459
- label: '',
1460
- points: [],
1461
- }]
1462
- const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
1463
- label,
1464
- x: round(padL + slotW * i + slotW / 2),
1465
- }))
1466
- const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
1467
- value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
1468
- }))
1469
- return {
1470
- ...empty,
1471
- plot,
1472
- bars,
1473
- lines: connectors,
1474
- yTicks,
1475
- xTicks,
1476
- xLabelRotated,
1477
- }
1478
- }
1479
-
1480
- // ---- Funnel -------------------------------------------------------
1481
- // One series of strictly-decreasing values gets rendered as a stack
1482
- // of horizontal trapezoids: each level's width is proportional to its
1483
- // value relative to the largest, slope automatically links level N+1
1484
- // narrower than level N. Labels show value, conversion vs. top, and
1485
- // step drop-off.
1486
- if (spec.type === 'funnel') {
1487
- const src = series[0]
1488
- if (!src || !src.values.length) return { ...empty }
1489
- const padL = 20, padR = 20, padT = 16, padB = 16
1490
- const plotW = Math.max(1, width - padL - padR)
1491
- const plotH = Math.max(1, height - padT - padB)
1492
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1493
- const n = src.values.length
1494
- const stepH = plotH / n
1495
- const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
1496
- const top = src.values[0] ?? 0
1497
- const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
1498
- const cx = padL + plotW / 2
1499
- const palette = spec.palette ?? DEFAULT_PALETTE
1500
- const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
1501
- const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
1502
- const w0 = widthAt(v)
1503
- const w1 = widthAt(next)
1504
- const y0 = padT + stepH * i
1505
- const y1 = y0 + stepH
1506
- const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
1507
- const color = src.color ?? palette[i % palette.length]!
1508
- return {
1509
- path, color,
1510
- label: spec.categories[i] ?? src.label,
1511
- value: v,
1512
- conversion: top > 0 ? v / top : 0,
1513
- dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
1514
- cx,
1515
- cy: (y0 + y1) / 2,
1516
- textColor: pickContrastText(color),
1517
- }
1518
- })
1519
- return {
1520
- ...empty,
1521
- plot,
1522
- funnelSegments: segments,
1523
- }
1524
- }
1525
-
1526
- // ---- Radar --------------------------------------------------------
1527
- // Polar coordinates: each `category` is a spoke (axis); each `series`
1528
- // contributes a polygon connecting its values across the spokes. All
1529
- // series share the same scale (max across every value). Concentric
1530
- // ring count derived from data, capped at 5 for legibility.
1531
- if (spec.type === 'radar') {
1532
- if (!series.length || !spec.categories.length) return { ...empty }
1533
- const padL = 30, padR = 30, padT = 24, padB = 24
1534
- const plotW = Math.max(1, width - padL - padR)
1535
- const plotH = Math.max(1, height - padT - padB)
1536
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1537
- const cx = padL + plotW / 2
1538
- const cy = padT + plotH / 2
1539
- const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20)
1540
- const axes = spec.categories
1541
- const k = axes.length
1542
- let vMax = 0
1543
- for (const s of series) for (const v of s.values) {
1544
- if (Number.isFinite(v) && v > vMax) vMax = v
1545
- }
1546
- if (vMax === 0) vMax = 1
1547
- const ringCount = 5
1548
- const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax)
1549
- /** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
1550
- * proceed clockwise so axes lay out left-to-right when k <= 4. */
1551
- const angleAt = (i: number) => -Math.PI / 2 + (i / k) * Math.PI * 2
1552
- const pointAt = (i: number, v: number) => {
1553
- const t = v / vMax
1554
- const a = angleAt(i)
1555
- return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) }
1556
- }
1557
- const radarAxes: ChartRadarAxis[] = axes.map((label, i) => {
1558
- const p = pointAt(i, vMax)
1559
- return { label, x: p.x, y: p.y }
1560
- })
1561
- const radarSeriesGeo: ChartRadarSeries[] = series.map((s, si) => {
1562
- const pts = s.values.map((v, i) => {
1563
- const safe = Number.isFinite(v) ? v : 0
1564
- const p = pointAt(i, safe)
1565
- return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' }
1566
- })
1567
- const path = pts.length
1568
- ? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
1569
- : ''
1570
- const palette = spec.palette ?? DEFAULT_PALETTE
1571
- return { label: s.label, color: s.color ?? palette[si % palette.length]!, path, points: pts }
1572
- })
1573
- return {
1574
- ...empty,
1575
- plot,
1576
- radarRings: ringValues,
1577
- radarAxes,
1578
- radarSeries: radarSeriesGeo,
1579
- radarCenter: { cx, cy, r },
1580
- }
1581
- }
1582
-
1583
- // ---- Calendar heatmap --------------------------------------------
1584
- // GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
1585
- // cell is a small square shaded by `calendarValues[i].value` via the
1586
- // sequential color scale. Days with no value render blank (border only)
1587
- // so missing data is visually obvious.
1588
- if (spec.type === 'calendar') {
1589
- const values = spec.calendarValues ?? []
1590
- if (!values.length && !spec.calendarStart) return { ...empty }
1591
- // Build a value lookup + figure out the date range.
1592
- const valueByDate = new Map<string, number>()
1593
- let vMin = Infinity, vMax = -Infinity
1594
- for (const v of values) {
1595
- valueByDate.set(v.date, v.value)
1596
- if (Number.isFinite(v.value)) {
1597
- if (v.value < vMin) vMin = v.value
1598
- if (v.value > vMax) vMax = v.value
1599
- }
1600
- }
1601
- if (vMin === Infinity) { vMin = 0; vMax = 1 }
1602
- if (vMin === vMax) vMax = vMin + 1
1603
- const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
1604
- const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
1605
- // Determine date range. If calendarStart/End set, use them, otherwise
1606
- // span the data + round to whole weeks (Sun..Sat).
1607
- const sorted = values.map((v) => v.date).sort()
1608
- const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
1609
- const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
1610
- const start = new Date(startStr + 'T00:00:00Z')
1611
- const end = new Date(endStr + 'T00:00:00Z')
1612
- // Roll start back to its Sunday, end forward to its Saturday.
1613
- start.setUTCDate(start.getUTCDate() - start.getUTCDay())
1614
- end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
1615
- const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
1616
- const weeks = Math.ceil(totalDays / 7)
1617
- const padL = 36, padR = 80, padT = 26, padB = 16
1618
- const plotW = Math.max(1, width - padL - padR)
1619
- const plotH = Math.max(1, height - padT - padB)
1620
- // Cell size: fit weeks across width, 7 rows down height.
1621
- const cellW = Math.floor(plotW / weeks)
1622
- const cellH = Math.floor(plotH / 7)
1623
- const cellSize = Math.max(6, Math.min(cellW, cellH))
1624
- const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
1625
- const cells: ChartCalendarCell[] = []
1626
- let lastMonth = -1
1627
- const monthTicks: ChartCategoryTick[] = []
1628
- for (let i = 0; i < totalDays; i += 1) {
1629
- const day = new Date(start.getTime() + i * 86_400_000)
1630
- const col = Math.floor(i / 7)
1631
- const row = i % 7
1632
- const date = day.toISOString().slice(0, 10)
1633
- const has = valueByDate.has(date)
1634
- const v = valueByDate.get(date) ?? 0
1635
- cells.push({
1636
- x: padL + col * cellSize,
1637
- y: padT + row * cellSize,
1638
- size: cellSize,
1639
- date, value: v,
1640
- defined: has,
1641
- color: has ? colorAt(v) : 'transparent',
1642
- })
1643
- if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
1644
- lastMonth = day.getUTCMonth()
1645
- monthTicks.push({
1646
- label: day.toLocaleDateString(undefined, { month: 'short' }),
1647
- x: padL + col * cellSize,
1648
- })
1649
- }
1650
- }
1651
- const legend = Array.from({ length: 5 }, (_, i) => {
1652
- const t = i / 4
1653
- const value = vMin + (vMax - vMin) * t
1654
- return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
1655
- })
1656
- return {
1657
- ...empty,
1658
- plot,
1659
- calendarCells: cells,
1660
- calendarMonthTicks: monthTicks,
1661
- calendarLegend: legend,
1662
- }
1663
- }
1664
-
1665
- // ---- Gauge --------------------------------------------------------
1666
- // Semicircle dial: track arc + value arc + optional colored range bands
1667
- // + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
1668
- if (spec.type === 'gauge') {
1669
- const min = spec.gaugeMin ?? 0
1670
- const max = spec.gaugeMax ?? 100
1671
- const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0))
1672
- const target = spec.gaugeTarget
1673
- const cx = width / 2
1674
- const cy = height * 0.78
1675
- const r = Math.min(width * 0.42, height * 0.65)
1676
- // Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
1677
- const A0 = Math.PI
1678
- const A1 = 2 * Math.PI
1679
- const angleAt = (v: number) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0)
1680
- const arc = (a0: number, a1: number, radius: number): string => {
1681
- const x1 = cx + radius * Math.cos(a0)
1682
- const y1 = cy + radius * Math.sin(a0)
1683
- const x2 = cx + radius * Math.cos(a1)
1684
- const y2 = cy + radius * Math.sin(a1)
1685
- const large = a1 - a0 > Math.PI ? 1 : 0
1686
- return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`
1687
- }
1688
- const trackPath = arc(A0, A1, r)
1689
- const valuePath = arc(A0, angleAt(value), r)
1690
- // Bands sit on their own inner ring, well clear of the value arc's 16px
1691
- // stroke at `r`. They are context, not the reading: a band covering most
1692
- // of the scale (an error-rate dial where anything above 0.45 is red) used
1693
- // to out-shout the value arc completely, so the dial looked pegged at
1694
- // maximum when the actual value was 9 percent.
1695
- const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
1696
- path: arc(angleAt(band.from), angleAt(band.to), r - 16),
1697
- color: band.color, from: band.from, to: band.to,
1698
- }))
1699
- let targetPx: ChartGaugeLayout['target'] = null
1700
- if (target != null && Number.isFinite(target)) {
1701
- const a = angleAt(Math.max(min, Math.min(max, target)))
1702
- const inner = r - 12
1703
- const outer = r + 4
1704
- targetPx = {
1705
- x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1706
- x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1707
- }
1708
- }
1709
- // Tick marks just outside the track: a major tick every 1/4 of the scale,
1710
- // with 4 minor ticks between each. Gives the dial a measured, instrument feel.
1711
- const ticks: ChartGaugeLayout['ticks'] = []
1712
- const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR
1713
- for (let i = 0; i <= TICK_TOTAL; i++) {
1714
- const a = A0 + (i / TICK_TOTAL) * (A1 - A0)
1715
- const major = i % TICK_MINOR === 0
1716
- const inner = r + 9
1717
- const outer = r + (major ? 17 : 13)
1718
- ticks.push({
1719
- x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1720
- x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1721
- major,
1722
- })
1723
- }
1724
- // Pointer needle: a kite (long tip toward the value, short counterweight
1725
- // tail) pivoting on a center hub.
1726
- //
1727
- // The proportions matter more than they look, and the counterweight was the
1728
- // whole problem. A tail sticking out past the hub is a short, solid triangle;
1729
- // the pointer is a 130px taper that thins to nothing. The compact shape wins
1730
- // the eye, so on a near-full dial the needle read as an arrow pointing at the
1731
- // MINIMUM. Keeping the tail inside the hub radius removes the competing
1732
- // point without giving up the pivot, and the shoulders sit outside the hub so
1733
- // the needle actually has a visible base to taper from.
1734
- const aV = angleAt(value)
1735
- const tipR = r - 14, tailR = 5, baseR = 9
1736
- const aPerp = aV + Math.PI / 2
1737
- const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
1738
- const needlePath =
1739
- `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
1740
- // Color the value arc by the band the value currently sits in.
1741
- // First match wins, and bands are half-open [from, to). Bands normally
1742
- // share endpoints - green 0..0.3, amber 0.3..0.45, red 0.45..5 - and with
1743
- // an inclusive `to` plus last-match-wins, a value sitting exactly ON a
1744
- // boundary took the colour of the band ABOVE it. An error rate of 0.45
1745
- // against a 0.45 amber ceiling read as red.
1746
- let valueColor: string | null = null
1747
- const bands = spec.gaugeRanges ?? []
1748
- for (const band of bands) {
1749
- if (value >= band.from && value < band.to) { valueColor = band.color; break }
1750
- }
1751
- // The very top of the scale belongs to the last band that reaches it,
1752
- // which the half-open test above would otherwise exclude.
1753
- if (valueColor == null) {
1754
- for (const band of bands) if (value >= band.from && value <= band.to) valueColor = band.color
1755
- }
1756
- return {
1757
- ...empty,
1758
- plot: { x: 0, y: 0, w: width, h: height },
1759
- gauge: {
1760
- cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
1761
- ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
1762
- minLabel: { x: cx - r, y: cy + 20 },
1763
- maxLabel: { x: cx + r, y: cy + 20 },
1764
- value, min, max, unit: spec.gaugeUnit ?? '',
1765
- },
1766
- }
1767
- }
1768
-
1769
- // ---- Tree-map -----------------------------------------------------
1770
- // Squarified tree-map (Bruls et al. 2000): each level recursively
1771
- // partitions its rectangle in proportion to its children, picking the
1772
- // split orientation that keeps aspect ratios closest to 1.
1773
- if (spec.type === 'treemap') {
1774
- const root = spec.treemap
1775
- if (!root) return { ...empty }
1776
- const padL = 4, padR = 4, padT = 4, padB = 4
1777
- const plotW = Math.max(1, width - padL - padR)
1778
- const plotH = Math.max(1, height - padT - padB)
1779
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1780
- const palette = spec.palette ?? DEFAULT_PALETTE
1781
- const cells: ChartTreemapCell[] = []
1782
- function totalOf(n: TreeNode): number {
1783
- if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
1784
- return Math.max(0, n.value ?? 0)
1785
- }
1786
- function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
1787
- if (!items.length || w <= 0 || h <= 0) return
1788
- const totals = items.map(totalOf)
1789
- const sum = totals.reduce((a, b) => a + b, 0)
1790
- if (sum <= 0) return
1791
- // Process largest-first so big items dominate the first row.
1792
- const ordered = items
1793
- .map((n, i) => ({ node: n, value: totals[i]! }))
1794
- .sort((a, b) => b.value - a.value)
1795
- let cx = x, cy = y, cw = w, ch = h, remaining = sum
1796
- let row: typeof ordered = []
1797
- const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
1798
- if (rowSum <= 0) return Infinity
1799
- const rowArea = rowSum * scale
1800
- const rowSide = rowArea / shortSide
1801
- let worst = 0
1802
- for (const v of vals) {
1803
- const cell = v * scale
1804
- const long = cell / rowSide
1805
- const r = Math.max(shortSide / long, long / shortSide)
1806
- if (r > worst) worst = r
1807
- }
1808
- return worst
1809
- }
1810
- function flushRow() {
1811
- if (!row.length) return
1812
- const rowSum = row.reduce((a, b) => a + b.value, 0)
1813
- const scale = (cw * ch) / remaining
1814
- const horizontal = cw >= ch
1815
- const shortSide = horizontal ? ch : cw
1816
- const rowSide = (rowSum * scale) / shortSide
1817
- let offset = 0
1818
- for (const it of row) {
1819
- const cellSize = (it.value * scale) / rowSide
1820
- const cx2 = horizontal ? cx : cx + offset
1821
- const cy2 = horizontal ? cy + offset : cy
1822
- const ww = horizontal ? rowSide : cellSize
1823
- const hh = horizontal ? cellSize : rowSide
1824
- const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
1825
- // Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
1826
- if (it.node.children?.length) {
1827
- cells.push({
1828
- x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1829
- color, textColor: pickContrastText(color),
1830
- name: it.node.name, value: it.value, depth,
1831
- })
1832
- const labelH = Math.min(18, hh * 0.25)
1833
- squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
1834
- } else {
1835
- cells.push({
1836
- x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1837
- color, textColor: pickContrastText(color),
1838
- name: it.node.name, value: it.value, depth,
1839
- })
1840
- }
1841
- offset += cellSize
1842
- }
1843
- // Shrink the remaining strip.
1844
- if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
1845
- remaining -= rowSum
1846
- row = []
1847
- }
1848
- for (const it of ordered) {
1849
- const scale = (cw * ch) / remaining
1850
- const shortSide = Math.min(cw, ch)
1851
- const rowSum = row.reduce((a, b) => a + b.value, 0)
1852
- const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
1853
- const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
1854
- if (row.length && nextWorst > currWorst) {
1855
- flushRow()
1856
- }
1857
- row.push(it)
1858
- }
1859
- flushRow()
1860
- }
1861
- const seedItems = root.children ?? [root]
1862
- squarify(seedItems, padL, padT, plotW, plotH, 0)
1863
- return { ...empty, plot, treemapCells: cells }
1864
- }
1865
-
1866
- // ---- Sankey -------------------------------------------------------
1867
- // Multi-column flow layout. Each node assigned to a column by longest
1868
- // path from any source. Within a column, nodes are stacked vertically;
1869
- // height proportional to max(totalIn, totalOut). Links render as
1870
- // bezier ribbons whose width is the link value (in pixels).
1871
- if (spec.type === 'sankey') {
1872
- const nodes = spec.sankeyNodes ?? []
1873
- const links = spec.sankeyLinks ?? []
1874
- if (!nodes.length || !links.length) return { ...empty }
1875
- const padL = 10, padR = 10, padT = 14, padB = 14
1876
- const plotW = Math.max(1, width - padL - padR)
1877
- const plotH = Math.max(1, height - padT - padB)
1878
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1879
- const palette = spec.palette ?? DEFAULT_PALETTE
1880
- const nodeById = new Map(nodes.map((n) => [n.id, n]))
1881
- // Column = longest path from any node with no incoming edges.
1882
- const targets = new Set(links.map((l) => l.target))
1883
- const sources = nodes.filter((n) => !targets.has(n.id))
1884
- const column = new Map<string, number>()
1885
- function visit(id: string, depth: number, seen: Set<string>) {
1886
- if (seen.has(id)) return
1887
- seen.add(id)
1888
- const cur = column.get(id) ?? 0
1889
- if (depth > cur || !column.has(id)) column.set(id, depth)
1890
- for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
1891
- seen.delete(id)
1892
- }
1893
- for (const s of sources) visit(s.id, 0, new Set())
1894
- // Cover any nodes with no path from a source (orphan rings).
1895
- for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
1896
- const maxCol = Math.max(...column.values())
1897
- const cols = maxCol + 1
1898
- const nodeW = 14
1899
- const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
1900
- // Totals per node.
1901
- const totalIn = new Map<string, number>()
1902
- const totalOut = new Map<string, number>()
1903
- for (const l of links) {
1904
- totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
1905
- totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
1906
- }
1907
- // Per-column groups + max total in that column.
1908
- const byCol: Map<number, string[]> = new Map()
1909
- for (const n of nodes) {
1910
- const c = column.get(n.id) ?? 0
1911
- const arr = byCol.get(c) ?? []
1912
- arr.push(n.id); byCol.set(c, arr)
1913
- }
1914
- // Per-column total height + node height scale.
1915
- let maxColTotal = 0
1916
- for (const ids of byCol.values()) {
1917
- const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
1918
- if (t > maxColTotal) maxColTotal = t
1919
- }
1920
- if (maxColTotal === 0) return { ...empty, plot }
1921
- const nodeGapPx = 8
1922
- const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
1923
- const placed: ChartSankeyNode[] = []
1924
- for (const [c, ids] of byCol) {
1925
- const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
1926
- const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
1927
- let yCursor = padT + (plotH - totalH) / 2
1928
- const xCol = padL + c * (nodeW + gapBetweenColumns)
1929
- ids.forEach((id, idx) => {
1930
- const node = nodeById.get(id)!
1931
- const h = heights[idx]!
1932
- placed.push({
1933
- id,
1934
- label: node.label ?? id,
1935
- color: node.color ?? palette[(placed.length) % palette.length]!,
1936
- x: xCol, y: yCursor, w: nodeW, h,
1937
- column: c,
1938
- totalIn: totalIn.get(id) ?? 0,
1939
- totalOut: totalOut.get(id) ?? 0,
1940
- })
1941
- yCursor += h + nodeGapPx
1942
- })
1943
- }
1944
- const placedById = new Map(placed.map((n) => [n.id, n]))
1945
- // Per-node sub-cursor so multiple links from one node stack vertically.
1946
- const inCursor = new Map<string, number>()
1947
- const outCursor = new Map<string, number>()
1948
- const builtLinks: ChartSankeyLink[] = []
1949
- // Sort links so wider ribbons render first (so thin ribbons stack on top).
1950
- const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
1951
- for (const link of sortedLinks) {
1952
- const a = placedById.get(link.source)
1953
- const b = placedById.get(link.target)
1954
- if (!a || !b) continue
1955
- const linkH = Math.max(1, link.value * heightScale)
1956
- const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
1957
- const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
1958
- outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
1959
- inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
1960
- const x0 = a.x + a.w
1961
- const x1 = b.x
1962
- const mid = (x0 + x1) / 2
1963
- const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
1964
- builtLinks.push({
1965
- path, color: link.color ?? a.color, width: linkH,
1966
- source: link.source, target: link.target, value: link.value,
1967
- })
1968
- }
1969
- return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
1970
- }
1971
-
1972
- // ---- Heatmap ------------------------------------------------------
1973
- // Each series is one row, series.values are the cells across categories.
1974
- // Color comes from a sequential/diverging/custom palette mapped to the
1975
- // global value range. Cell text contrasts black/white against the cell.
1976
- if (spec.type === 'heatmap') {
1977
- if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
1978
- // Layout: left gutter for row labels, bottom for column labels.
1979
- const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
1980
- const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
1981
- const padR = 64 // room for the right-side legend bar
1982
- const padT = 12
1983
- const padB = 32
1984
- const plotW = Math.max(1, width - padL - padR)
1985
- const plotH = Math.max(1, height - padT - padB)
1986
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
1987
- const cellW = plotW / spec.categories.length
1988
- const cellH = plotH / series.length
1989
- // Resolve value range across the whole matrix.
1990
- let vMin = Infinity, vMax = -Infinity
1991
- for (const s of series) for (const v of s.values) {
1992
- if (!Number.isFinite(v)) continue
1993
- if (v < vMin) vMin = v
1994
- if (v > vMax) vMax = v
1995
- }
1996
- if (vMin === Infinity) { vMin = 0; vMax = 1 }
1997
- if (vMin === vMax) vMax = vMin + 1
1998
- // Pick the palette stops.
1999
- const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
2000
- const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
2001
- const heatmapCells: ChartHeatmapCell[] = []
2002
- series.forEach((s, ri) => {
2003
- s.values.forEach((v, ci) => {
2004
- if (!Number.isFinite(v)) return
2005
- const color = colorAt(v)
2006
- heatmapCells.push({
2007
- x: round(padL + cellW * ci),
2008
- y: round(padT + cellH * ri),
2009
- w: round(cellW),
2010
- h: round(cellH),
2011
- color,
2012
- textColor: pickContrastText(color),
2013
- value: v,
2014
- rowLabel: s.label,
2015
- colLabel: spec.categories[ci] ?? '',
2016
- })
2017
- })
2018
- })
2019
- const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
2020
- value: i,
2021
- y: round(padT + cellH * i + cellH / 2),
2022
- label: s.label,
2023
- }))
2024
- const heatmapColTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
2025
- label,
2026
- x: round(padL + cellW * i + cellW / 2),
2027
- }))
2028
- // Legend: sample 5 stops across the range.
2029
- const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
2030
- const t = i / 4
2031
- const value = vMin + (vMax - vMin) * t
2032
- return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
2033
- })
2034
- return {
2035
- ...empty,
2036
- plot,
2037
- heatmapCells,
2038
- heatmapRowTicks,
2039
- heatmapColTicks,
2040
- heatmapLegend,
2041
- }
2042
- }
2043
-
2044
- if (spec.type === 'pie') {
2045
- const s = series[0]
2046
- if (!s) return empty
2047
- const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
2048
- const cx = width / 2
2049
- const cy = height / 2
2050
- const r = Math.min(width, height) / 2 - 10
2051
- const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
2052
- const ir = r * innerFrac
2053
- let angle = -Math.PI / 2
2054
- const slices: ChartPieSlice[] = s.values.map((v, i) => {
2055
- const frac = Math.max(0, v) / total
2056
- const a0 = angle
2057
- const a1 = angle + frac * Math.PI * 2
2058
- angle = a1
2059
- const large = a1 - a0 > Math.PI ? 1 : 0
2060
- const mid = (a0 + a1) / 2
2061
- const labelR = (r + ir) / 2 || r * 0.6
2062
- const ox0 = cx + r * Math.cos(a0)
2063
- const oy0 = cy + r * Math.sin(a0)
2064
- const ox1 = cx + r * Math.cos(a1)
2065
- const oy1 = cy + r * Math.sin(a1)
2066
- let path: string
2067
- if (frac >= 0.999) {
2068
- path = ir
2069
- ? `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` +
2070
- `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`
2071
- : `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`
2072
- } else if (ir > 0) {
2073
- const ix0 = cx + ir * Math.cos(a0)
2074
- const iy0 = cy + ir * Math.sin(a0)
2075
- const ix1 = cx + ir * Math.cos(a1)
2076
- const iy1 = cy + ir * Math.sin(a1)
2077
- path =
2078
- `M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
2079
- `L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
2080
- } else {
2081
- path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
2082
- }
2083
- const catLabel = spec.categories[i] ?? String(i)
2084
- return {
2085
- path,
2086
- color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length]!,
2087
- label: catLabel,
2088
- value: v,
2089
- percent: frac * 100,
2090
- cx: round(cx + labelR * Math.cos(mid)),
2091
- cy: round(cy + labelR * Math.sin(mid)),
2092
- }
2093
- })
2094
- return {
2095
- ...empty,
2096
- slices,
2097
- legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! })),
2098
- 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,
2099
- }
2100
- }
2101
-
2102
- if (spec.type === 'scatter') {
2103
- const padL = 48 + (spec.yAxisTitle ? 16 : 0)
2104
- const padR = 12
2105
- const padT = 10
2106
- const padB = 28 + (spec.xAxisTitle ? 16 : 0)
2107
- const plotW = Math.max(1, width - padL - padR)
2108
- const plotH = Math.max(1, height - padT - padB)
2109
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
2110
-
2111
- let xMin = Infinity
2112
- let xMax = -Infinity
2113
- let yMin = Infinity
2114
- let yMax = -Infinity
2115
- let rMin = Infinity
2116
- let rMax = -Infinity
2117
- for (const s of series) {
2118
- for (const pt of s.points ?? []) {
2119
- if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
2120
- if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
2121
- if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
2122
- }
2123
- }
2124
- if (xMin === Infinity) return { ...empty, plot }
2125
- const xDom = niceScale(xMin, xMax)
2126
- const yDom = niceScale(yMin, yMax)
2127
- const hasR = rMax > rMin
2128
- const xOf = (v: number) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW)
2129
- const yOf = (v: number) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH)
2130
- const rOf = (r?: number) =>
2131
- hasR && r != null && Number.isFinite(r)
2132
- ? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
2133
- : 5
2134
-
2135
- const scatterPoints: ChartScatterDot[] = []
2136
- for (const s of series) {
2137
- for (const pt of s.points ?? []) {
2138
- if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue
2139
- scatterPoints.push({
2140
- cx: xOf(pt.x),
2141
- cy: yOf(pt.y),
2142
- r: rOf(pt.r),
2143
- color: s.color,
2144
- label: pt.label ?? '',
2145
- series: s.label,
2146
- x: pt.x,
2147
- y: pt.y,
2148
- })
2149
- }
2150
- }
2151
- const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
2152
- y: yOf(ref.value),
2153
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
2154
- color: ref.color ?? '#ef4444',
2155
- dashed: ref.dashed !== false,
2156
- }))
2157
- return {
2158
- ...empty,
2159
- plot,
2160
- scatterPoints,
2161
- referenceLines,
2162
- yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat, spec) })),
2163
- xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
2164
- }
2165
- }
243
+ const ctx: LayoutCtx = { spec, theme, width, height, palette, stacked, series, legend, empty, frame, axes }
244
+ if (spec.type === 'waterfall') return layoutWaterfall(ctx)
245
+ if (spec.type === 'funnel') return layoutFunnel(ctx)
246
+ if (spec.type === 'radar') return layoutRadar(ctx)
247
+ if (spec.type === 'calendar') return layoutCalendar(ctx)
248
+ if (spec.type === 'gauge') return layoutGauge(ctx)
249
+ if (spec.type === 'treemap') return layoutTreemap(ctx)
250
+ if (spec.type === 'sankey') return layoutSankey(ctx)
251
+ if (spec.type === 'heatmap') return layoutHeatmap(ctx)
252
+ if (spec.type === 'pie') return layoutPie(ctx)
253
+ if (spec.type === 'scatter') return layoutScatter(ctx)
254
+ if (spec.type === 'sunburst') return layoutSunburst(ctx)
255
+ if (spec.type === 'radial-bar') return layoutRadialBar(ctx)
256
+ if (spec.type === 'radial-column' || spec.type === 'nightingale') return layoutRadialColumn(ctx)
257
+ if (spec.type === 'chord') return layoutChord(ctx)
258
+ if (spec.type === 'bullet') return layoutBullet(ctx)
2166
259
 
2167
260
  // ---- Horizontal bars ----------------------------------------------------
2168
261
  // Categories run down the left, bars grow rightward. Bars-only (no combo).
2169
262
  const horizontal =
2170
- spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar')
2171
- if (horizontal) {
2172
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
2173
- const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0)
2174
- const padR = 16
2175
- const padT = 8
2176
- const padB = 26 + (spec.xAxisTitle ? 16 : 0)
2177
- const plotW = Math.max(1, width - padL - padR)
2178
- const plotH = Math.max(1, height - padT - padB)
2179
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
2180
-
2181
- const refs = (spec.referenceLines ?? []).map((r) => r.value)
2182
- const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs)
2183
- const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
2184
-
2185
- const n = spec.categories.length
2186
- const slot = plotH / Math.max(1, n)
2187
- const groupPad = slot * 0.2
2188
- const inner = slot - groupPad
2189
- const bandTop = (i: number) => padT + slot * i + groupPad / 2
2190
- const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max))
2191
-
2192
- const bars: ChartBar[] = []
2193
- if (stacked) {
2194
- const totals = spec.stacked100
2195
- ? spec.categories.map(
2196
- (_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
2197
- )
2198
- : null
2199
- const pos = new Array(n).fill(0)
2200
- const neg = new Array(n).fill(0)
2201
- for (const s of series) {
2202
- s.values.forEach((v, i) => {
2203
- if (!Number.isFinite(v)) return
2204
- const vp = totals ? (v / totals[i]!) * 100 : v
2205
- let xL: number
2206
- let xR: number
2207
- if (vp >= 0) {
2208
- xL = xOf(pos[i])
2209
- xR = xOf(pos[i] + vp)
2210
- pos[i] += vp
2211
- } else {
2212
- xL = xOf(neg[i] + vp)
2213
- xR = xOf(neg[i])
2214
- neg[i] += vp
2215
- }
2216
- bars.push({
2217
- x: Math.min(xL, xR),
2218
- y: round(bandTop(i)),
2219
- w: round(Math.abs(xR - xL)),
2220
- h: round(Math.max(1, inner)),
2221
- color: s.color,
2222
- label: spec.categories[i] ?? String(i),
2223
- series: s.label,
2224
- value: v,
2225
- })
2226
- })
2227
- }
2228
- } else {
2229
- const barH = inner / series.length
2230
- series.forEach((s, bi) => {
2231
- s.values.forEach((v, i) => {
2232
- if (!Number.isFinite(v)) return
2233
- const xV = xOf(v)
2234
- bars.push({
2235
- x: Math.min(xV, xBase),
2236
- y: round(bandTop(i) + barH * bi),
2237
- w: round(Math.max(1, Math.abs(xV - xBase))),
2238
- h: round(Math.max(1, barH - 1)),
2239
- color: s.color,
2240
- label: spec.categories[i] ?? String(i),
2241
- series: s.label,
2242
- value: v,
2243
- })
2244
- })
2245
- })
2246
- }
2247
-
2248
- const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
2249
- label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat, spec),
2250
- x: xOf(value),
2251
- }))
2252
- const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
2253
- value: i,
2254
- y: round(bandTop(i) + inner / 2),
2255
- label,
2256
- }))
2257
- const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
2258
- x: xOf(ref.value),
2259
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
2260
- color: ref.color ?? '#ef4444',
2261
- dashed: ref.dashed !== false,
2262
- }))
2263
-
2264
- return {
2265
- ...empty,
2266
- plot,
2267
- bars,
2268
- orientation: 'horizontal',
2269
- valueTicks,
2270
- catTicks,
2271
- referenceLinesV,
2272
- xLabelRotated: false,
2273
- }
2274
- }
2275
-
2276
- // ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
2277
- const leftSeries = series.filter((s) => s.axis === 'left')
2278
- const rightSeries = series.filter((s) => s.axis === 'right')
2279
- const hasRightAxis = rightSeries.length > 0
2280
-
2281
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
2282
- const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
2283
- // Grouped (nested) category axis: valid only when the spans cover every leaf.
2284
- const validGroups =
2285
- spec.categoryGroups &&
2286
- spec.categoryGroups.length > 0 &&
2287
- spec.xType !== 'time' &&
2288
- spec.orientation !== 'horizontal' &&
2289
- spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
2290
- ? spec.categoryGroups
2291
- : null
2292
- const groupTierH = validGroups ? 18 : 0
2293
- const padL = 48 + (spec.yAxisTitle ? 16 : 0)
2294
- const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
2295
- const padT = 10
2296
- const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH
2297
- const plotW = Math.max(1, width - padL - padR)
2298
- const plotH = Math.max(1, height - padT - padB)
2299
- const plot = { x: padL, y: padT, w: plotW, h: plotH }
2300
-
2301
- const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
2302
- const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
2303
- const leftLog = spec.yScale === 'log'
2304
- const rightLog = spec.y2Scale === 'log'
2305
- const leftDom = spec.stacked100
2306
- ? niceScale(0, 100)
2307
- : axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog)
2308
- const rightDom = hasRightAxis
2309
- ? spec.stacked100
2310
- ? niceScale(0, 100)
2311
- : axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
2312
- : null
2313
-
2314
- /** Map a data value to a y pixel. Returns NaN for non-positive values on
2315
- * a log axis so callers can drop the point (line gap / missing bar). */
2316
- const yOf = (dom: NiceScale, v: number, isLog = false) => {
2317
- const t = project(v, dom.min, dom.max, isLog)
2318
- if (t === null) return NaN
2319
- return round(padT + plotH - t * plotH)
2320
- }
2321
- const yLeft = (v: number) => yOf(leftDom, v, leftLog)
2322
- const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog)
2323
- const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
2324
- const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
2325
-
2326
- const n = spec.categories.length
2327
- const slot = plotW / Math.max(1, n)
2328
-
2329
- // X positions. A time axis spaces points by actual time (irregular gaps);
2330
- // a category axis is uniform. (Bars stay uniform either way.)
2331
- // Two date modes, and the difference is only where the marks go: `'time'`
2332
- // positions by the timestamp, `'ordinal-time'` positions by the index and
2333
- // uses the dates for labels alone. Both parse; only one scales.
2334
- const isDateAxis = spec.xType === 'time' || spec.xType === 'ordinal-time'
2335
- const timeVals = isDateAxis ? spec.categories.map((c) => Date.parse(c)) : null
2336
- const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
2337
- const timeScaled = timeOk && spec.xType === 'time'
2338
- const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
2339
- const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
2340
- const xCenter = (i: number) =>
2341
- timeScaled && Number.isFinite(timeVals![i])
2342
- ? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
2343
- : round(padL + slot * i + slot / 2)
2344
- const xTicks: ChartCategoryTick[] = timeScaled
2345
- ? dateTicks(tMin, tMin + tSpan).map((t) => ({
2346
- label: fmtDate(t, tSpan),
2347
- x: round(padL + ((t - tMin) / tSpan) * plotW),
2348
- }))
2349
- : timeOk
2350
- ? // Ordinal: ticks land on points that exist, labelled from their dates.
2351
- ordinalDateTicks(timeVals!).map((i) => ({
2352
- label: fmtDate(timeVals![i]!, tSpan),
2353
- x: xCenter(i),
2354
- }))
2355
- : thinCategoryTicks(spec.categories, xCenter, slot, xLabelRotated)
2356
-
2357
- // Parent-tier ticks for a grouped category axis: each spans its leaves.
2358
- const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
2359
- if (validGroups && !timeOk) {
2360
- let start = 0
2361
- for (const g of validGroups) {
2362
- const x0 = round(padL + slot * start)
2363
- const x1 = round(padL + slot * (start + g.span))
2364
- categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
2365
- start += g.span
2366
- }
2367
- }
2368
-
2369
- const barSeries = series.filter((s) => s.kind === 'bar')
2370
- const bars: ChartBar[] = []
2371
- if (barSeries.length) {
2372
- const groupPad = slot * 0.2
2373
- if (stacked) {
2374
- const inner = slot - groupPad
2375
- const x0 = (i: number) => padL + slot * i + groupPad / 2
2376
- // Stack independently per axis so dual-axis stacks line up to their own scale.
2377
- for (const axis of ['left', 'right'] as const) {
2378
- const axisBars = barSeries.filter((s) => s.axis === axis)
2379
- if (!axisBars.length) continue
2380
- const yA = axis === 'right' ? yRight : yLeft
2381
- // 100% mode normalizes each category to its absolute total.
2382
- const totals = spec.stacked100
2383
- ? spec.categories.map(
2384
- (_, i) =>
2385
- axisBars.reduce(
2386
- (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
2387
- 0,
2388
- ) || 1,
2389
- )
2390
- : null
2391
- const pos = new Array(n).fill(0)
2392
- const neg = new Array(n).fill(0)
2393
- for (const s of axisBars) {
2394
- s.values.forEach((v, i) => {
2395
- if (!Number.isFinite(v)) return
2396
- const vp = totals ? (v / totals[i]!) * 100 : v
2397
- let yTop: number
2398
- let yBot: number
2399
- if (vp >= 0) {
2400
- yTop = yA(pos[i] + vp)
2401
- yBot = yA(pos[i])
2402
- pos[i] += vp
2403
- } else {
2404
- yTop = yA(neg[i])
2405
- yBot = yA(neg[i] + vp)
2406
- neg[i] += vp
2407
- }
2408
- bars.push({
2409
- x: round(x0(i)),
2410
- y: Math.min(yTop, yBot),
2411
- w: round(Math.max(1, inner)),
2412
- h: round(Math.abs(yBot - yTop)),
2413
- color: s.color,
2414
- label: spec.categories[i] ?? String(i),
2415
- series: s.label,
2416
- value: v,
2417
- })
2418
- })
2419
- }
2420
- }
2421
- } else {
2422
- const inner = slot - groupPad
2423
- const barW = inner / barSeries.length
2424
- barSeries.forEach((s, bi) => {
2425
- const dom = domOf(s)
2426
- const log = isLogOf(s)
2427
- // Log axis: bars grow from the axis floor (dom.min) up to v rather
2428
- // than from 0, since 0 is invalid in log space.
2429
- const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log)
2430
- s.values.forEach((v, i) => {
2431
- if (!Number.isFinite(v)) return
2432
- if (log && v <= 0) return
2433
- const x = padL + slot * i + groupPad / 2 + barW * bi
2434
- const yV = yOf(dom, v, log)
2435
- bars.push({
2436
- x: round(x),
2437
- y: Math.min(yV, base),
2438
- w: round(Math.max(1, barW - 1)),
2439
- h: round(Math.max(1, Math.abs(yV - base))),
2440
- color: s.color,
2441
- label: spec.categories[i] ?? String(i),
2442
- series: s.label,
2443
- value: v,
2444
- })
2445
- })
2446
- })
2447
- }
2448
- }
2449
-
2450
- // Candlesticks / OHLC bars. Laid out once; the two marks differ only in how
2451
- // the renderer paints them, so there is no second geometry pass.
2452
- const candleSeries = series.filter((s) => s.kind === 'candle')
2453
- const candles: ChartCandle[] = []
2454
- if (candleSeries.length) {
2455
- const upColor = spec.candleColors?.up ?? '#16a34a'
2456
- const downColor = spec.candleColors?.down ?? '#ef4444'
2457
- // Share the slot when two instruments are charted together, the same way
2458
- // grouped bars do.
2459
- const bodyW = Math.max(1, (slot * 0.7) / candleSeries.length)
2460
- candleSeries.forEach((s, si) => {
2461
- const dom = domOf(s)
2462
- const log = isLogOf(s)
2463
- ;(s.ohlc ?? []).forEach((k, i) => {
2464
- if (!k) return
2465
- if (![k.o, k.h, k.l, k.c].every(Number.isFinite)) return
2466
- // A log price axis is genuinely used for long histories, and a
2467
- // non-positive price has no place on one.
2468
- if (log && (k.o <= 0 || k.h <= 0 || k.l <= 0 || k.c <= 0)) return
2469
- const centre = padL + slot * i + slot / 2
2470
- const x = centre - (bodyW * candleSeries.length) / 2 + bodyW * si
2471
- const yOpen = yOf(dom, k.o, log)
2472
- const yClose = yOf(dom, k.c, log)
2473
- const up = k.c >= k.o
2474
- candles.push({
2475
- x: round(x),
2476
- w: round(bodyW),
2477
- xCenter: round(x + bodyW / 2),
2478
- yOpen,
2479
- yClose,
2480
- yHigh: yOf(dom, k.h, log),
2481
- yLow: yOf(dom, k.l, log),
2482
- bodyY: Math.min(yOpen, yClose),
2483
- // A doji closes where it opened; keep it visible as a 1px line
2484
- // rather than a zero-height rect that paints nothing.
2485
- bodyH: Math.max(1, Math.abs(yClose - yOpen)),
2486
- up,
2487
- color: up ? upColor : downColor,
2488
- label: spec.categories[i] ?? String(i),
2489
- series: s.label,
2490
- o: k.o,
2491
- h: k.h,
2492
- l: k.l,
2493
- c: k.c,
2494
- })
2495
- })
2496
- })
2497
- }
2498
-
2499
- // Box plots. Same slot-sharing as grouped bars and candles, so several
2500
- // samples can sit side by side under one category.
2501
- const boxSeries = series.filter((s) => s.kind === 'box')
2502
- const boxes: ChartBox[] = []
2503
- if (boxSeries.length) {
2504
- const boxW = Math.max(1, (slot * 0.6) / boxSeries.length)
2505
- boxSeries.forEach((s, si) => {
2506
- const dom = domOf(s)
2507
- const log = isLogOf(s)
2508
- ;(s.boxes ?? []).forEach((b, i) => {
2509
- if (!b) return
2510
- if (![b.min, b.q1, b.median, b.q3, b.max].every(Number.isFinite)) return
2511
- if (log && b.min <= 0) return
2512
- const centre = padL + slot * i + slot / 2
2513
- const x = centre - (boxW * boxSeries.length) / 2 + boxW * si
2514
- const yQ1 = yOf(dom, b.q1, log)
2515
- const yQ3 = yOf(dom, b.q3, log)
2516
- boxes.push({
2517
- x: round(x),
2518
- w: round(boxW),
2519
- xCenter: round(x + boxW / 2),
2520
- yMin: yOf(dom, b.min, log),
2521
- yQ1,
2522
- yMedian: yOf(dom, b.median, log),
2523
- yQ3,
2524
- yMax: yOf(dom, b.max, log),
2525
- boxY: Math.min(yQ1, yQ3),
2526
- // A sample with no spread would otherwise paint nothing at all.
2527
- boxH: Math.max(1, Math.abs(yQ1 - yQ3)),
2528
- outliers: (b.outliers ?? [])
2529
- .filter((o) => Number.isFinite(o) && (!log || o > 0))
2530
- .map((o) => ({ y: yOf(dom, o, log), value: o })),
2531
- color: s.color,
2532
- label: spec.categories[i] ?? String(i),
2533
- series: s.label,
2534
- min: b.min,
2535
- q1: b.q1,
2536
- median: b.median,
2537
- q3: b.q3,
2538
- max: b.max,
2539
- })
2540
- })
2541
- })
2542
- }
2543
-
2544
- // Error bars. Not a mark of their own: they annotate whatever the series
2545
- // already draws, so this runs over every series carrying `errors` regardless
2546
- // of kind, and the geometry sits in its own array so no existing loop changes.
2547
- const errorBars: ChartErrorBar[] = []
2548
- for (const s of series) {
2549
- if (!s.errors) continue
2550
- const dom = domOf(s)
2551
- const log = isLogOf(s)
2552
- s.errors.forEach((e, i) => {
2553
- const v = s.values[i]
2554
- if (!Number.isFinite(v)) return
2555
- const span = errorSpan(e, v!)
2556
- if (!span) return
2557
- if (log && span.lo <= 0) return
2558
- errorBars.push({
2559
- xCenter: round(padL + slot * i + slot / 2),
2560
- yLo: yOf(dom, span.lo, log),
2561
- yHi: yOf(dom, span.hi, log),
2562
- cap: round(Math.min(6, slot * 0.15)),
2563
- color: s.color,
2564
- label: spec.categories[i] ?? String(i),
2565
- series: s.label,
2566
- lo: span.lo,
2567
- hi: span.hi,
2568
- })
2569
- })
2570
- }
2571
-
2572
- // Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
2573
- const lines: ChartLine[] = []
2574
- const areaCum: Record<'left' | 'right', number[]> = {
2575
- left: new Array(n).fill(0),
2576
- right: new Array(n).fill(0),
2577
- }
2578
- // 100% mode: per-axis per-category totals to normalize stacked areas to 100.
2579
- const areaTotals: Record<'left' | 'right', number[] | null> = { left: null, right: null }
2580
- if (spec.stacked100) {
2581
- for (const axis of ['left', 'right'] as const) {
2582
- const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis)
2583
- if (areaSeries.length) {
2584
- areaTotals[axis] = spec.categories.map(
2585
- (_, i) =>
2586
- areaSeries.reduce(
2587
- (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
2588
- 0,
2589
- ) || 1,
2590
- )
2591
- }
2592
- }
2593
- }
2594
- for (const s of series) {
2595
- // Bars, candles and boxes draw their own marks. Candles and boxes
2596
- // especially: `values` holds their closes / medians so that tooltips, CSV
2597
- // and overlays work, and without this guard that same array was ALSO drawn
2598
- // as a line, laying a dotted close-line straight over every candle. Boxes
2599
- // would do exactly the same thing through the median.
2600
- if (s.kind === 'bar' || s.kind === 'candle' || s.kind === 'box') continue
2601
- const dom = domOf(s)
2602
- const log = isLogOf(s)
2603
- const yA = (v: number) => yOf(dom, v, log)
2604
- const isStackedArea = stacked && s.kind === 'area'
2605
- const px = (i: number) => xCenter(i)
2606
- let pts: ChartLinePoint[]
2607
- let baselinePts: Array<{ x: number; y: number }> | null = null
2608
- if (isStackedArea) {
2609
- // Stacked areas treat a gap as 0 so the stack stays continuous.
2610
- const cum = areaCum[s.axis]
2611
- const prev = cum.slice()
2612
- const totals = areaTotals[s.axis]
2613
- pts = s.values.map((v, i) => {
2614
- const vv = Number.isFinite(v) ? v : 0
2615
- // 100% mode positions by share of the category total; value stays original.
2616
- const norm = totals ? (vv / totals[i]!) * 100 : vv
2617
- const c = (cum[i] ?? 0) + norm
2618
- cum[i] = c
2619
- return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) }
2620
- })
2621
- baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
2622
- } else {
2623
- pts = s.values.map((v, i) => {
2624
- const ok = Number.isFinite(v)
2625
- return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
2626
- })
2627
- }
2628
- // Build the line - smoothed via monotone cubic when requested, else
2629
- // straight polylines. Either way, gaps break the path cleanly.
2630
- const smooth = !!s.smooth
2631
- const path = buildLinePath(pts, smooth)
2632
-
2633
- let areaPath = ''
2634
- if (s.kind === 'area' && pts.length) {
2635
- if (baselinePts) {
2636
- const top = smooth
2637
- ? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
2638
- : pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2639
- const back = smooth
2640
- ? `L${baselinePts[baselinePts.length - 1]!.x},${baselinePts[baselinePts.length - 1]!.y} ` +
2641
- monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
2642
- : baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
2643
- areaPath = `${top} ${back} Z`
2644
- } else {
2645
- // One filled polygon per contiguous run of defined points.
2646
- const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)))
2647
- const runs: ChartLinePoint[][] = []
2648
- let cur: ChartLinePoint[] = []
2649
- for (const p of pts) {
2650
- if (p.defined) cur.push(p)
2651
- else if (cur.length) {
2652
- runs.push(cur)
2653
- cur = []
2654
- }
2655
- }
2656
- if (cur.length) runs.push(cur)
2657
- areaPath = runs
2658
- .map((run) => {
2659
- const top = smooth
2660
- ? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
2661
- : run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2662
- return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
2663
- })
2664
- .join(' ')
2665
- }
2666
- }
2667
-
2668
- // Confidence band: shaded envelope between upperValues / lowerValues.
2669
- // Both arrays must be present and aligned to the value array.
2670
- let bandPath = ''
2671
- if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
2672
- const upperPts: Array<{ x: number; y: number }> = []
2673
- const lowerPts: Array<{ x: number; y: number }> = []
2674
- for (let i = 0; i < s.values.length; i += 1) {
2675
- const u = s.upperValues[i]!
2676
- const lo = s.lowerValues[i]!
2677
- if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
2678
- if (log && (u <= 0 || lo <= 0)) continue
2679
- upperPts.push({ x: px(i), y: yA(u) })
2680
- lowerPts.push({ x: px(i), y: yA(lo) })
2681
- }
2682
- if (upperPts.length >= 2) {
2683
- const top = smooth
2684
- ? monotoneCubicPath(upperPts)
2685
- : upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
2686
- const back = smooth
2687
- ? `L${lowerPts[lowerPts.length - 1]!.x},${lowerPts[lowerPts.length - 1]!.y} ` +
2688
- monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
2689
- : lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
2690
- bandPath = `${top} ${back} Z`
2691
- }
2692
- }
2693
-
2694
- lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath })
2695
- }
2696
-
2697
- // Under `stacked100` the axis is a share of the total, not the measure, so
2698
- // it is labelled as a percentage whatever `valueFormat` says. Formatting it
2699
- // as currency gives an axis reading "$0 .. $100" for what are percentages -
2700
- // which is what it did, unnoticed, while `stacked100` was reachable only
2701
- // from config.
2702
- const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
2703
- dom.ticks.map((value) => ({
2704
- value,
2705
- y: yOf(dom, value, log),
2706
- label: spec.stacked100 ? `${round(value)}%` : formatChartValue(value, spec.valueFormat, spec),
2707
- }))
2708
-
2709
- const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
2710
- const onRight = ref.axis === 'right'
2711
- const dom = onRight ? (rightDom ?? leftDom) : leftDom
2712
- const log = onRight ? rightLog : leftLog
2713
- return {
2714
- y: yOf(dom, ref.value, log),
2715
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
2716
- color: ref.color ?? '#ef4444',
2717
- dashed: ref.dashed !== false,
2718
- }
2719
- })
2720
-
2721
- // ---- Overlays: trendline / moving average ------------------------
2722
- // For every series with an `overlay`, compute the smoothed values and
2723
- // render as a dashed line in the source series' color (or overlayColor).
2724
- const overlays: ChartLine[] = []
2725
- for (const s of series) {
2726
- if (!s.overlay) continue
2727
- const dom = domOf(s)
2728
- const log = isLogOf(s)
2729
- const overlayVals = computeOverlay(s.values, s.overlay)
2730
- const color = s.overlayColor ?? s.color
2731
- const pts: ChartLinePoint[] = overlayVals.map((v, i) => {
2732
- const ok = Number.isFinite(v) && (!log || v > 0)
2733
- return {
2734
- x: xCenter(i),
2735
- y: ok ? yOf(dom, v, log) : NaN,
2736
- label: spec.categories[i] ?? String(i),
2737
- value: v,
2738
- defined: ok,
2739
- }
2740
- })
2741
- const path = buildLinePath(pts, !!s.smooth)
2742
- overlays.push({
2743
- path,
2744
- areaPath: '',
2745
- color,
2746
- label: `${s.label} (${s.overlay})`,
2747
- points: pts,
2748
- })
2749
- }
2750
-
2751
- // ---- Annotations: resolve data-space anchors to pixel coords ------
2752
- const annotations: ChartGeometry['annotations'] = []
2753
- for (const a of (spec.annotations ?? [])) {
2754
- let ax: number | null = null
2755
- let ay: number | null = null
2756
- if ('category' in a.at) {
2757
- const ci = spec.categories.indexOf(a.at.category)
2758
- if (ci < 0) continue
2759
- ax = xCenter(ci)
2760
- // Anchor to the named series' value at that category, else just
2761
- // mid-plot. Picks the first matching series if `series` is set.
2762
- const seriesName = a.at.series
2763
- const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
2764
- if (s) {
2765
- const v = s.values[ci]
2766
- if (Number.isFinite(v)) ay = yOf(domOf(s), v as number, isLogOf(s))
2767
- }
2768
- if (ay == null) ay = padT + plotH / 2
2769
- } else {
2770
- // Raw x/y in data space (x ignored for category x-axis; takes the
2771
- // mid-plot in that case). y projects through the left axis.
2772
- ax = padL + plotW / 2
2773
- if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog)
2774
- else ay = padT + plotH / 2
2775
- }
2776
- if (ax != null && ay != null && Number.isFinite(ay)) {
2777
- annotations.push({
2778
- x: ax,
2779
- y: ay,
2780
- label: a.label,
2781
- color: a.color ?? '#0f172a',
2782
- placement: a.placement ?? 'top',
2783
- })
2784
- }
2785
- }
2786
-
2787
- return {
2788
- ...empty,
2789
- plot,
2790
- // What a caller needs to put its own marks in this chart's coordinates.
2791
- // Reported rather than recomputed, so a custom mark lands on exactly the
2792
- // scale the built-in ones did - including the nice-scale rounding and the
2793
- // "always include zero" rule, which are impossible to guess from outside.
2794
- axes: {
2795
- y: { min: leftDom.min, max: leftDom.max, log: leftLog },
2796
- y2: rightDom ? { min: rightDom.min, max: rightDom.max, log: rightLog } : null,
2797
- slot,
2798
- count: spec.categories.length,
2799
- },
2800
- bars,
2801
- candles,
2802
- boxes,
2803
- errorBars,
2804
- lines,
2805
- yTicks: tickFor(leftDom, leftLog),
2806
- y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
2807
- hasRightAxis,
2808
- xTicks,
2809
- categoryGroupTicks,
2810
- xLabelRotated: timeOk ? false : xLabelRotated,
2811
- referenceLines,
2812
- overlays,
2813
- annotations,
2814
- }
263
+ spec.orientation === 'horizontal' && spec.type !== 'histogram' && series.length > 0 && series.every((s) => s.kind === 'bar')
264
+ if (horizontal) return layoutHorizontalBars(ctx)
265
+ return layoutCartesian(ctx)
2815
266
  }
2816
267
 
2817
268
  /**
@@ -2833,32 +284,18 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
2833
284
  export function sliceChartWindow(spec: ChartSpec, lo: number, hi: number): ChartSpec {
2834
285
  const from = Math.max(0, lo)
2835
286
  const to = Math.min(spec.categories.length - 1, hi)
2836
- const cut = <T,>(arr: T[] | undefined): T[] | undefined =>
2837
- arr ? arr.slice(from, to + 1) : undefined
2838
- return {
2839
- ...spec,
2840
- categories: spec.categories.slice(from, to + 1),
2841
- // EVERY per-category array on a series has to be cut here, not just the
2842
- // ones that existed when this function was written. A missed one does not
2843
- // throw: the geometry keeps indexing the full-length array against the
2844
- // sliced categories, so marks land at the wrong x or off the plot entirely.
2845
- // `upperValues` / `lowerValues` were missed once and silently dropped the
2846
- // confidence band on zoom; `ohlc` was missed the same way and drew a
2847
- // zoomed candlestick chart against the wrong categories.
2848
- series: spec.series.map((s) => ({
2849
- ...s,
2850
- values: s.values.slice(from, to + 1),
2851
- rowIds: cut(s.rowIds),
2852
- upperValues: cut(s.upperValues),
2853
- lowerValues: cut(s.lowerValues),
2854
- ohlc: cut(s.ohlc),
2855
- boxes: cut(s.boxes),
2856
- errors: cut(s.errors),
2857
- })),
2858
- // Per-category, so it has to travel with the window or the waterfall's
2859
- // running total resets on the wrong bars.
2860
- waterfallTotals: cut(spec.waterfallTotals),
2861
- }
287
+ if (to < from) return { ...spec, categories: [], series: spec.series.map((s) => ({ ...s, values: [] })) }
288
+ // EVERY per-category array on a series has to be cut here, not just the
289
+ // ones that existed when this function was written. A missed one does not
290
+ // throw: the geometry keeps indexing the full-length array against the
291
+ // sliced categories, so marks land at the wrong x or off the plot entirely.
292
+ // `upperValues` / `lowerValues` were missed once and silently dropped the
293
+ // confidence band on zoom; `ohlc` was missed the same way. The list of those
294
+ // arrays now lives in one constant (PER_CATEGORY_SERIES_KEYS) shared with
295
+ // decimation, and `pickCategories` is the one function that applies it.
296
+ const indices: number[] = new Array(to - from + 1)
297
+ for (let i = from; i <= to; i += 1) indices[i - from] = i
298
+ return pickCategories(spec, indices)
2862
299
  }
2863
300
 
2864
301
  /**
@@ -2877,14 +314,25 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
2877
314
  value: (keyof T & string) | Array<keyof T & string>
2878
315
  /** Pivot dimension: one series per distinct value of this field. */
2879
316
  series?: keyof T & string
2880
- reduce?: 'sum' | 'avg' | 'count'
317
+ /** How each group collapses to one number. Default `'sum'`. See
318
+ * {@link ChartReducer} for the full list (min, max, median, p90, ...). */
319
+ reduce?: ChartReducer
320
+ /**
321
+ * Group a date category by calendar unit instead of by exact value:
322
+ * `'month'` files every row of March under `2026-03-01`. Categories come
323
+ * out as ISO dates in chronological order and the spec's x axis is set to
324
+ * `'ordinal-time'`, so the ticks read as dates. Rows whose category does
325
+ * not parse as a date are dropped.
326
+ */
327
+ bucket?: ChartTimeBucket
2881
328
  seriesLabel?: string
2882
329
  width?: number
2883
330
  height?: number
2884
331
  stacked?: boolean
2885
332
  stacked100?: boolean
2886
333
  palette?: string[]
2887
- /** Order categories. Defaults to insertion order (or value-desc when topN). */
334
+ /** Order categories. Defaults to insertion order (or value-desc when topN,
335
+ * or chronological when bucketed). */
2888
336
  sort?: 'value-desc' | 'value-asc' | 'category' | 'none'
2889
337
  /** Keep only the top N categories; bucket the rest into "Other". */
2890
338
  topN?: number
@@ -2896,10 +344,15 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
2896
344
  idField?: keyof T & string
2897
345
  },
2898
346
  ): ChartSpec {
2899
- const reduce = opts.reduce ?? 'sum'
347
+ const reduce: ChartReducer = opts.reduce ?? 'sum'
348
+ const wantSamples = reducerNeedsSamples(reduce)
2900
349
  const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value]
2901
- const reduceCell = (sum: number, count: number) =>
2902
- reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
350
+ // The three original reducers run on a sum and a count; the rest keep every
351
+ // observation, which is what a median or a percentile needs.
352
+ const reduceCell = (cell: Cell) =>
353
+ wantSamples
354
+ ? reduceValues(cell.samples, reduce)
355
+ : reduce === 'count' ? cell.count : reduce === 'avg' ? (cell.count ? cell.sum / cell.count : 0) : cell.sum
2903
356
 
2904
357
  const categories: string[] = []
2905
358
  const catIndex = new Map<string, number>()
@@ -2914,7 +367,7 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
2914
367
  }
2915
368
 
2916
369
  // Series keyed by name -> per-category {sum,count,rowIds}.
2917
- type Cell = { sum: number; count: number; rowIds: Array<string | number> }
370
+ type Cell = { sum: number; count: number; rowIds: Array<string | number>; samples: number[] }
2918
371
  const seriesMap = new Map<string, Cell[]>()
2919
372
  const ensureSeries = (name: string) => {
2920
373
  let arr = seriesMap.get(name)
@@ -2924,42 +377,40 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
2924
377
  }
2925
378
  return arr
2926
379
  }
380
+ const note = (cell: Cell, num: number, rowId: string | number | undefined) => {
381
+ cell.sum += num
382
+ cell.count += 1
383
+ if (wantSamples) cell.samples.push(num)
384
+ if (rowId !== undefined) cell.rowIds.push(rowId)
385
+ }
386
+ const fresh = (): Cell => ({ sum: 0, count: 0, rowIds: [], samples: [] })
2927
387
 
2928
388
  const trackIds = opts.idField !== undefined
2929
389
  for (const row of rows) {
2930
- const cat = String(row[opts.category] ?? '')
390
+ const raw = row[opts.category]
391
+ const cat = opts.bucket ? bucketStart(raw as string | number | Date, opts.bucket) : String(raw ?? '')
392
+ if (opts.bucket && !cat) continue
2931
393
  const ci = ensureCat(cat)
2932
394
  const rowId = trackIds ? (row[opts.idField as keyof T] as string | number) : undefined
2933
395
  if (opts.series) {
2934
396
  const sName = String(row[opts.series] ?? '')
2935
397
  const arr = ensureSeries(sName)
2936
398
  const num = Number(row[valueFields[0]!])
2937
- const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
2938
- if (Number.isFinite(num)) {
2939
- cell.sum += num
2940
- cell.count += 1
2941
- if (rowId !== undefined) cell.rowIds.push(rowId)
2942
- }
399
+ const cell = (arr[ci] ??= fresh())
400
+ if (Number.isFinite(num)) note(cell, num, rowId)
2943
401
  } else {
2944
402
  for (const vf of valueFields) {
2945
403
  const arr = ensureSeries(vf)
2946
404
  const num = Number(row[vf])
2947
- const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
2948
- if (Number.isFinite(num)) {
2949
- cell.sum += num
2950
- cell.count += 1
2951
- if (rowId !== undefined) cell.rowIds.push(rowId)
2952
- }
405
+ const cell = (arr[ci] ??= fresh())
406
+ if (Number.isFinite(num)) note(cell, num, rowId)
2953
407
  }
2954
408
  }
2955
409
  }
2956
410
 
2957
411
  const entries = [...seriesMap.entries()].map(([name, arr]) => ({
2958
412
  label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
2959
- values: categories.map((_, i) => {
2960
- const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] as Array<string | number> }
2961
- return reduceCell(cell.sum, cell.count)
2962
- }),
413
+ values: categories.map((_, i) => reduceCell(arr[i] ?? fresh())),
2963
414
  rowIds: trackIds
2964
415
  ? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
2965
416
  : undefined,
@@ -2969,7 +420,7 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
2969
420
  const totals = categories.map((_, i) =>
2970
421
  entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0),
2971
422
  )
2972
- const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none')
423
+ const sort = opts.sort ?? (opts.topN ? 'value-desc' : opts.bucket ? 'category' : 'none')
2973
424
  const order = categories.map((_, i) => i)
2974
425
  if (sort === 'value-desc') order.sort((a, b) => totals[b]! - totals[a]!)
2975
426
  else if (sort === 'value-asc') order.sort((a, b) => totals[a]! - totals[b]!)
@@ -2978,9 +429,16 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
2978
429
  let finalCategories: string[]
2979
430
  let finalSeries: ChartSeries[]
2980
431
  if (opts.topN && order.length > opts.topN) {
2981
- const keep = order.slice(0, opts.topN)
2982
- const rest = order.slice(opts.topN)
2983
- finalCategories = keep.map((i) => categories[i]!).concat(opts.otherLabel ?? 'Other')
432
+ const other = opts.otherLabel ?? 'Other'
433
+ // A real category may already be called "Other" (a survey's own bucket,
434
+ // a browser share's long tail). Two categories with one name is a
435
+ // duplicate key the renderer cannot draw, so the tail folds INTO that
436
+ // category rather than beside it: it is kept whether or not it made the
437
+ // top N, and the rest is added to it.
438
+ const own = order.find((i) => categories[i] === other)
439
+ const keep = order.slice(0, opts.topN).filter((i) => i !== own)
440
+ const rest = order.filter((i) => !keep.includes(i))
441
+ finalCategories = keep.map((i) => categories[i]!).concat(other)
2984
442
  finalSeries = entries.map((e) => ({
2985
443
  label: e.label,
2986
444
  values: keep
@@ -3008,6 +466,146 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
3008
466
  stacked: opts.stacked,
3009
467
  stacked100: opts.stacked100,
3010
468
  palette: opts.palette,
469
+ ...(opts.bucket ? { xType: 'ordinal-time' as const } : {}),
470
+ }
471
+ }
472
+
473
+ /**
474
+ * The bar-plus-line spec a pareto chart draws: categories sorted by value,
475
+ * bars for the values, and a cumulative-share line on the right axis pinned to
476
+ * 0..100 with a reference line at `threshold` (default 80, the "vital few").
477
+ * `buildChart` calls this for `type: 'pareto'`; call it yourself to get at the
478
+ * spec, or pass a ready spec whose first series is the values.
479
+ */
480
+ export function paretoSpec(spec: ChartSpec, threshold = 80): ChartSpec {
481
+ const src = spec.series[0]
482
+ if (!src) return { ...spec, type: 'bar' }
483
+ const order = spec.categories.map((_, i) => i).sort((a, b) => (src.values[b] ?? 0) - (src.values[a] ?? 0))
484
+ const sorted = pickCategories(spec, order)
485
+ const values = sorted.series[0]!.values.map((v) => (Number.isFinite(v) ? Math.max(0, v) : 0))
486
+ const total = values.reduce((a, b) => a + b, 0) || 1
487
+ let cum = 0
488
+ const share = values.map((v) => {
489
+ cum += v
490
+ return Math.round((cum / total) * 1000) / 10
491
+ })
492
+ return {
493
+ ...sorted,
494
+ type: 'bar',
495
+ series: [
496
+ { ...sorted.series[0]!, type: 'bar' },
497
+ { label: 'Cumulative %', values: share, type: 'line', axis: 'right', marker: 'circle', color: sorted.series[0]!.overlayColor ?? '#ef4444' },
498
+ ],
499
+ y2Axis: { min: 0, max: 100, tickInterval: 25, formatter: (v) => `${v}%`, ...(spec.y2Axis ?? {}) },
500
+ referenceLines: [
501
+ ...(spec.referenceLines ?? []),
502
+ ...(threshold > 0 ? [{ value: threshold, axis: 'right' as const, label: `${threshold}%`, dashed: true }] : []),
503
+ ],
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Bin one numeric field of the rows into a histogram spec. The categories are
509
+ * the bin midpoints on a numeric axis and `binEdges` labels the edges, so the
510
+ * bars touch and the axis reads `0, 10, 20` rather than `5, 15, 25`.
511
+ * `series` splits the sample into one histogram per distinct value.
512
+ */
513
+ export function rowsToHistogramSpec<T extends Record<string, unknown>>(
514
+ rows: ReadonlyArray<T>,
515
+ opts: {
516
+ value: keyof T & string
517
+ series?: keyof T & string
518
+ seriesLabel?: string
519
+ bins?: number
520
+ binWidth?: number
521
+ method?: 'sturges' | 'fd' | 'sqrt'
522
+ min?: number
523
+ max?: number
524
+ palette?: string[]
525
+ width?: number
526
+ height?: number
527
+ },
528
+ ): ChartSpec {
529
+ const all = rows.map((r) => Number(r[opts.value])).filter(Number.isFinite)
530
+ // Bin the whole sample once so every split shares the same edges.
531
+ const bins = binValues(all, { bins: opts.bins, binWidth: opts.binWidth, method: opts.method, min: opts.min, max: opts.max })
532
+ const groups: Array<{ label: string; rows: ReadonlyArray<T> }> = opts.series
533
+ ? [...new Set(rows.map((r) => String(r[opts.series!] ?? '')))].map((g) => ({ label: g, rows: rows.filter((r) => String(r[opts.series!] ?? '') === g) }))
534
+ : [{ label: opts.seriesLabel ?? String(opts.value), rows }]
535
+ const series: ChartSeries[] = groups.map((g) => {
536
+ const counts = new Array<number>(bins.counts.length).fill(0)
537
+ const lo = bins.edges[0] ?? 0
538
+ for (const r of g.rows) {
539
+ const n = Number(r[opts.value])
540
+ if (!Number.isFinite(n) || !bins.width) continue
541
+ let i = Math.floor((n - lo) / bins.width)
542
+ if (i >= counts.length) i = counts.length - 1
543
+ if (i < 0) i = 0
544
+ counts[i] = (counts[i] ?? 0) + 1
545
+ }
546
+ return { label: g.label, values: counts }
547
+ })
548
+ return {
549
+ type: 'histogram',
550
+ categories: bins.categories,
551
+ series,
552
+ binEdges: bins.edges,
553
+ xType: 'number',
554
+ ...(opts.palette ? { palette: opts.palette } : {}),
555
+ ...(opts.width ? { width: opts.width } : {}),
556
+ ...(opts.height ? { height: opts.height } : {}),
557
+ }
558
+ }
559
+
560
+ /**
561
+ * A range (floating) bar or area spec from rows: one row per category with a
562
+ * low field and a high field, e.g. a day's low and high temperature, or a
563
+ * salary band. `series` splits into one range per distinct value.
564
+ */
565
+ export function rowsToRangeSpec<T extends Record<string, unknown>>(
566
+ rows: ReadonlyArray<T>,
567
+ opts: {
568
+ type?: 'range-bar' | 'range-area'
569
+ category: keyof T & string
570
+ low: keyof T & string
571
+ high: keyof T & string
572
+ series?: keyof T & string
573
+ seriesLabel?: string
574
+ palette?: string[]
575
+ width?: number
576
+ height?: number
577
+ },
578
+ ): ChartSpec {
579
+ const cats: string[] = []
580
+ const seen = new Set<string>()
581
+ for (const r of rows) {
582
+ const c = String(r[opts.category] ?? '')
583
+ if (!seen.has(c)) { seen.add(c); cats.push(c) }
584
+ }
585
+ const names = opts.series
586
+ ? [...new Set(rows.map((r) => String(r[opts.series!] ?? '')))]
587
+ : [opts.seriesLabel ?? `${String(opts.low)} to ${String(opts.high)}`]
588
+ const series: ChartSeries[] = names.map((name) => {
589
+ const lows = new Array<number>(cats.length).fill(Number.NaN)
590
+ const highs = new Array<number>(cats.length).fill(Number.NaN)
591
+ for (const r of rows) {
592
+ if (opts.series && String(r[opts.series] ?? '') !== name) continue
593
+ const i = cats.indexOf(String(r[opts.category] ?? ''))
594
+ const lo = Number(r[opts.low])
595
+ const hi = Number(r[opts.high])
596
+ if (i < 0 || !Number.isFinite(lo) || !Number.isFinite(hi)) continue
597
+ lows[i] = Math.min(lo, hi)
598
+ highs[i] = Math.max(lo, hi)
599
+ }
600
+ return { label: name, values: highs, lowValues: lows }
601
+ })
602
+ return {
603
+ type: opts.type ?? 'range-bar',
604
+ categories: cats,
605
+ series,
606
+ ...(opts.palette ? { palette: opts.palette } : {}),
607
+ ...(opts.width ? { width: opts.width } : {}),
608
+ ...(opts.height ? { height: opts.height } : {}),
3011
609
  }
3012
610
  }
3013
611
 
@@ -3107,6 +705,125 @@ export function specToSankey(spec: ChartSpec): {
3107
705
  }
3108
706
  }
3109
707
 
708
+
709
+ // ---- Panel format state -----------------------------------------------------
710
+
711
+ /**
712
+ * What the grid panel's chart builder lets a reader change about the LOOK of
713
+ * a chart, as plain data so it can be saved with the view and applied to the
714
+ * spec the panel derives on every data change. Every field is optional and
715
+ * additive; an absent field leaves the derived spec alone. Series are keyed
716
+ * by label, so a per-series colour survives a re-sort of the categories.
717
+ */
718
+ export type ChartFormatState = {
719
+ title?: string
720
+ subtitle?: string
721
+ caption?: string
722
+ /** Where the legend goes; `false` hides it. The panel reads this for the
723
+ * component prop, it is not a spec field. */
724
+ legend?: 'top' | 'bottom' | 'left' | 'right' | false
725
+ xAxis?: { title?: string; gridLines?: boolean; labelRotation?: number | 'auto' }
726
+ yAxis?: { min?: number | null; max?: number | null; title?: string; gridLines?: boolean; format?: ChartValueFormat }
727
+ y2Axis?: { min?: number | null; max?: number | null; title?: string; gridLines?: boolean; format?: ChartValueFormat }
728
+ /** Per-series overrides, keyed by series label. */
729
+ series?: Record<string, {
730
+ color?: string
731
+ type?: 'bar' | 'line' | 'area'
732
+ axis?: 'left' | 'right'
733
+ marker?: ChartMarkerShape
734
+ strokeWidth?: number
735
+ dash?: string
736
+ smooth?: boolean
737
+ /** The stack group a bar or area joins; an empty string leaves the
738
+ * group. See {@link ChartSeries.stack}. */
739
+ stack?: string
740
+ }>
741
+ /** Where data labels sit; `null` hides them. */
742
+ dataLabels?: 'inside' | 'outside' | 'top' | 'center' | null
743
+ palette?: string[] | null
744
+ /** Series names at the end of each line (`spec.seriesLabels`). */
745
+ seriesLabels?: boolean
746
+ /** The axis value pills on the crosshair. A component prop, like
747
+ * `legend`: the panel reads it, the engine does not. */
748
+ crosshairLabels?: boolean
749
+ /** Below this rendered width in px the chart goes compact: no series or
750
+ * data labels, vertical category labels, no legend. Appended to the
751
+ * spec's own `responsive` rules as {@link CHART_RESPONSIVE_PRESETS}.compact. */
752
+ compactBelow?: number | null
753
+ /** Font, size and colours for the whole chart (`spec.style`), merged
754
+ * over what the spec carries. */
755
+ style?: ChartStyle
756
+ }
757
+
758
+ /**
759
+ * Responsive rules the builder offers by name. `compact(maxWidth)` is the
760
+ * one the Format tab writes through `compactBelow`: the labels that crowd a
761
+ * narrow chart go, the category labels turn vertical, and the legend hides.
762
+ */
763
+ export const CHART_RESPONSIVE_PRESETS = {
764
+ compact: (maxWidth: number): ChartResponsiveRule => ({
765
+ maxWidth,
766
+ spec: { seriesLabels: false, dataLabels: { show: false }, xAxis: { labelRotation: 90 } },
767
+ legend: false,
768
+ }),
769
+ } as const
770
+
771
+ /**
772
+ * Apply a {@link ChartFormatState} to a spec: a new spec with the titles,
773
+ * axis settings, palette and per-series styling merged in. Pure, so the
774
+ * panel can run it on every derived spec and a saved format keeps applying
775
+ * to fresh data. Unknown series labels are ignored.
776
+ */
777
+ export function applyChartFormat(spec: ChartSpec, format: ChartFormatState | null | undefined): ChartSpec {
778
+ if (!format) return spec
779
+ const out: ChartSpec = { ...spec }
780
+ if (format.title !== undefined) out.title = format.title || undefined
781
+ if (format.subtitle !== undefined) out.subtitle = format.subtitle || undefined
782
+ if (format.caption !== undefined) out.caption = format.caption || undefined
783
+ if (format.palette) out.palette = format.palette
784
+ if (format.seriesLabels !== undefined) out.seriesLabels = format.seriesLabels
785
+ if (format.style) out.style = { ...spec.style, ...format.style }
786
+ if (format.compactBelow != null && format.compactBelow > 0) {
787
+ out.responsive = [...(spec.responsive ?? []), CHART_RESPONSIVE_PRESETS.compact(format.compactBelow)]
788
+ }
789
+ const axis = (base: ChartAxisConfig | undefined, f: NonNullable<ChartFormatState['yAxis']> | undefined): ChartAxisConfig | undefined => {
790
+ if (!f) return base
791
+ const next: ChartAxisConfig = { ...base }
792
+ if (f.min !== undefined) { if (f.min === null) delete next.min; else next.min = f.min }
793
+ if (f.max !== undefined) { if (f.max === null) delete next.max; else next.max = f.max }
794
+ if (f.title !== undefined) next.title = f.title || undefined
795
+ if (f.gridLines !== undefined) next.gridLines = f.gridLines
796
+ if (f.format !== undefined) next.format = f.format
797
+ return next
798
+ }
799
+ if (format.xAxis) {
800
+ const next: ChartAxisConfig = { ...out.xAxis }
801
+ if (format.xAxis.title !== undefined) next.title = format.xAxis.title || undefined
802
+ if (format.xAxis.gridLines !== undefined) next.gridLines = format.xAxis.gridLines
803
+ if (format.xAxis.labelRotation !== undefined) next.labelRotation = format.xAxis.labelRotation
804
+ out.xAxis = next
805
+ }
806
+ if (format.yAxis) out.yAxis = axis(out.yAxis, format.yAxis)
807
+ if (format.y2Axis) out.y2Axis = axis(out.y2Axis, format.y2Axis)
808
+ if (format.series) {
809
+ out.series = spec.series.map((s) => {
810
+ const f = format.series![s.label]
811
+ if (!f) return s
812
+ const next: ChartSeries = { ...s }
813
+ if (f.color) next.color = f.color
814
+ if (f.type) next.type = f.type
815
+ if (f.axis) next.axis = f.axis
816
+ if (f.marker) next.marker = f.marker
817
+ if (f.strokeWidth !== undefined) next.strokeWidth = f.strokeWidth
818
+ if (f.dash !== undefined) next.dash = f.dash || undefined
819
+ if (f.smooth !== undefined) next.smooth = f.smooth
820
+ if (f.stack !== undefined) next.stack = f.stack || undefined
821
+ return next
822
+ })
823
+ }
824
+ return out
825
+ }
826
+
3110
827
  /**
3111
828
  * The chart types that read ROWS directly instead of a grouped grid, behind one
3112
829
  * call. Returns `null` for every other type, which then goes through
@@ -3128,13 +845,32 @@ export function rowsToDirectSpec<T extends Record<string, unknown>>(
3128
845
  /** Scatter's Y measure. */
3129
846
  value2?: string
3130
847
  series?: string
3131
- reduce?: 'sum' | 'avg' | 'count'
848
+ reduce?: ChartReducer
3132
849
  palette?: string[]
850
+ /** Histogram: how many bins. Default: Sturges' rule. */
851
+ bins?: number
852
+ /** Candlestick / OHLC: the price columns, an optional volume, a calendar
853
+ * bucket to roll the bars up to, and the candle style. `category` is
854
+ * the date column. */
855
+ ohlc?: { open: string; high: string; low: string; close: string; volume?: string; bucket?: ChartTimeBucket; candleStyle?: ChartSpec['candleStyle']; lastPriceLine?: boolean }
3133
856
  },
3134
857
  ): ChartSpec | null {
3135
858
  const cat = opts.category as (keyof T & string) | undefined
3136
859
  const val = opts.value as (keyof T & string) | undefined
3137
860
  const ser = opts.series as (keyof T & string) | undefined
861
+ if (type === 'candlestick' || type === 'ohlc') {
862
+ const o = opts.ohlc
863
+ if (!cat || !o) return null
864
+ const k = (f: string) => f as keyof T & string
865
+ return rowsToOhlcSpec(rows, {
866
+ date: cat, open: k(o.open), high: k(o.high), low: k(o.low), close: k(o.close),
867
+ ...(o.volume ? { volume: k(o.volume) } : {}),
868
+ ...(o.bucket ? { bucket: o.bucket } : {}),
869
+ ...(o.candleStyle ? { candleStyle: o.candleStyle } : {}),
870
+ ...(o.lastPriceLine ? { lastPriceLine: true } : {}),
871
+ type,
872
+ })
873
+ }
3138
874
  if (type === 'scatter') {
3139
875
  const y = opts.value2 as (keyof T & string) | undefined
3140
876
  if (!val || !y) return null
@@ -3159,6 +895,46 @@ export function rowsToDirectSpec<T extends Record<string, unknown>>(
3159
895
  if (opts.palette) spec.palette = opts.palette
3160
896
  return spec
3161
897
  }
898
+ if (type === 'histogram') {
899
+ if (!val) return null
900
+ return rowsToHistogramSpec(rows, {
901
+ value: val,
902
+ ...(ser ? { series: ser } : {}),
903
+ ...(opts.bins ? { bins: opts.bins } : {}),
904
+ ...(opts.palette ? { palette: opts.palette } : {}),
905
+ })
906
+ }
907
+ if (type === 'range-bar' || type === 'range-area' || type === 'dumbbell') {
908
+ // Two measures: the first is the low end, the second the high end, each
909
+ // reduced per category the way a bar would be.
910
+ const hi = opts.value2 as (keyof T & string) | undefined
911
+ if (!cat || !val || !hi) return null
912
+ const lows = rowsToChartSpec(rows, { type: 'bar', category: cat, value: val, ...(ser ? { series: ser } : {}), reduce: opts.reduce ?? 'sum' })
913
+ const highs = rowsToChartSpec(rows, { type: 'bar', category: cat, value: hi, ...(ser ? { series: ser } : {}), reduce: opts.reduce ?? 'sum' })
914
+ return {
915
+ type,
916
+ categories: highs.categories,
917
+ series: highs.series.map((s, i) => ({
918
+ ...s,
919
+ label: ser ? s.label : `${String(val)} to ${String(hi)}`,
920
+ lowValues: lows.series[i]?.values.map((v, k) => Math.min(v, s.values[k] ?? v)) ?? [],
921
+ values: s.values.map((v, k) => Math.max(v, lows.series[i]?.values[k] ?? v)),
922
+ })),
923
+ ...(opts.palette ? { palette: opts.palette } : {}),
924
+ }
925
+ }
926
+ if (type === 'bullet') {
927
+ if (!cat || !val) return null
928
+ const measure = rowsToChartSpec(rows, { type: 'bar', category: cat, value: val, reduce: opts.reduce ?? 'sum' })
929
+ const t = opts.value2 as (keyof T & string) | undefined
930
+ const target = t ? rowsToChartSpec(rows, { type: 'bar', category: cat, value: t, reduce: opts.reduce ?? 'sum' }) : null
931
+ return {
932
+ type: 'bullet',
933
+ categories: measure.categories,
934
+ series: [{ ...measure.series[0]!, ...(target ? { targets: target.series[0]!.values } : {}) }],
935
+ ...(opts.palette ? { palette: opts.palette } : {}),
936
+ }
937
+ }
3162
938
  return null
3163
939
  }
3164
940
 
@@ -3306,7 +1082,7 @@ export function rowsToGaugeSpec<T extends Record<string, unknown>>(
3306
1082
  rows: ReadonlyArray<T>,
3307
1083
  opts: {
3308
1084
  value: keyof T & string
3309
- reduce?: 'sum' | 'avg' | 'count'
1085
+ reduce?: ChartReducer
3310
1086
  min?: number
3311
1087
  max?: number
3312
1088
  unit?: string
@@ -3315,16 +1091,13 @@ export function rowsToGaugeSpec<T extends Record<string, unknown>>(
3315
1091
  height?: number
3316
1092
  },
3317
1093
  ): ChartSpec {
3318
- const reduce = opts.reduce ?? 'sum'
3319
- let sum = 0
3320
- let count = 0
1094
+ const samples: number[] = []
3321
1095
  for (const row of rows) {
3322
1096
  const v = Number(row[opts.value])
3323
- if (!Number.isFinite(v)) continue
3324
- sum += v
3325
- count += 1
1097
+ if (Number.isFinite(v)) samples.push(v)
3326
1098
  }
3327
- const value = reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
1099
+ const reduced = reduceValues(samples, opts.reduce ?? 'sum')
1100
+ const value = Number.isFinite(reduced) ? reduced : 0
3328
1101
  const min = opts.min ?? Math.min(0, value)
3329
1102
  const max = opts.max ?? (value > min ? niceScale(min, value).max : min + 1)
3330
1103
  return {