@svgrid/grid 3.0.3 → 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-D6XvA1Ij.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-Bugf5XlI.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-CHOMnW-c.js +0 -602
  273. package/dist/cdn/GridMenus-DhdPF700.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
  276. package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
  277. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
  279. package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
  280. package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
  281. package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
package/dist/chart.js CHANGED
@@ -1,698 +1,109 @@
1
+ import { DEFAULT_PALETTE, niceScale } from './chart-scale.js';
2
+ import { binValues, boxStats, bucketStart, reducerNeedsSamples, reduceValues } from './chart-stats.js';
3
+ import { chartFrame, kindOf, resolveAxes } from './chart-axes.js';
4
+ import { pickCategories } from './chart-decimate.js';
5
+ import { layoutBullet, layoutCartesian, layoutHorizontalBars, layoutScatter } from './chart-cartesian.js';
6
+ import { layoutChord, layoutGauge, layoutPie, layoutRadar, layoutRadialBar, layoutRadialColumn, layoutSunburst } from './chart-polar.js';
7
+ import { layoutFunnel, layoutSankey, layoutWaterfall } from './chart-flow.js';
8
+ import { layoutCalendar, layoutHeatmap } from './chart-grid.js';
9
+ import { layoutTreemap } from './chart-hierarchy.js';
10
+ export { DEFAULT_PALETTE, formatChartValue, niceLogScale, niceScale, ordinalDateTicks, pickContrastText, sampleGradient, } from './chart-scale.js';
11
+ export { chartScales } from './chart-axes.js';
12
+ export { decimateSpec, lttb, minMaxIndices, pickCategories, PER_CATEGORY_SERIES_KEYS, PER_CATEGORY_SPEC_KEYS } from './chart-decimate.js';
13
+ export { buildLinePath, layoutDataLabels, markerPath, streamBaseline } from './chart-cartesian.js';
14
+ export { arcPath } from './chart-polar.js';
15
+ export { pivotResultToChartSpec, pivotChartType, pivotFilterColumn, bucketsToChartSpec } from './chart-pivot.js';
16
+ export { heikinAshi, resampleOhlc, rowsToOhlcSpec, guessOhlcColumns, splitPanelIndicators, ohlcDirectOptions } from './chart-financial.js';
17
+ import { rowsToOhlcSpec } from './chart-financial.js';
18
+ export { binValues, boxStats, bucketStart, percentile, reduceValues, exponentialMovingAverage, linearTrend, linearFit, polynomialFit, exponentialFit, logarithmicFit, powerFit, rSquared, pearson, simpleMovingAverage, } from './chart-stats.js';
19
+ export { computeOverlay, computeOverlayFit, regressionFit, overlayName, bollingerBands, rsi, macd, vwap, atr, stochastic, wma, obv, indicatorPane, } from './chart-indicators.js';
1
20
  /**
2
- * Integrated chart geometry. Pure functions that turn categories + numeric
3
- * series into SVG primitives - the "chart from a grid range" enterprise
4
- * feature without bundling a charting library. The `<SvGridChart>` component
5
- * paints the result; this module has no DOM so it is unit-testable.
6
- *
7
- * Supports: grouped + stacked bars, line, area, pie/donut, combo charts
8
- * (per-series type), a secondary (right) Y axis, signed Y domains (negative
9
- * values drop below a zero baseline), and nice auto-scaled ticks.
10
- */
11
- // 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.js';
16
- /**
17
- * Five-number summary of a raw sample, with the 1.5 IQR whisker rule.
18
- *
19
- * Whiskers stop at the last observation INSIDE the fence rather than at the
20
- * fence itself, which is what makes them read as real data; anything past them
21
- * comes back in `outliers`. Quartiles use linear interpolation between the two
22
- * neighbouring order statistics.
23
- *
24
- * Returns `null` for an empty sample, so a category with no observations is a
25
- * gap rather than a box drawn at zero.
26
- */
27
- export function boxStats(sample, whisker = 1.5) {
28
- const v = sample.filter((n) => Number.isFinite(n)).slice().sort((a, b) => a - b);
29
- if (!v.length)
30
- return null;
31
- const q = (p) => {
32
- const pos = (v.length - 1) * p;
33
- const lo = Math.floor(pos);
34
- const hi = Math.ceil(pos);
35
- return lo === hi ? v[lo] : v[lo] + (v[hi] - v[lo]) * (pos - lo);
36
- };
37
- const q1 = q(0.25);
38
- const median = q(0.5);
39
- const q3 = q(0.75);
40
- const fenceLo = q1 - whisker * (q3 - q1);
41
- const fenceHi = q3 + whisker * (q3 - q1);
42
- const inside = v.filter((n) => n >= fenceLo && n <= fenceHi);
43
- const outliers = v.filter((n) => n < fenceLo || n > fenceHi);
44
- return {
45
- // `inside` can only be empty if every point is an outlier, which the fence
46
- // rule makes impossible (q1 and q3 are always within it) - but a degenerate
47
- // sample should still produce a box rather than `undefined` coordinates.
48
- min: inside.length ? inside[0] : v[0],
49
- q1,
50
- median,
51
- q3,
52
- max: inside.length ? inside[inside.length - 1] : v[v.length - 1],
53
- ...(outliers.length ? { outliers } : {}),
54
- };
55
- }
56
- /**
57
- * Category-axis labels, thinned so they do not overlap.
58
- *
59
- * A category axis used to emit one tick per category however many there were.
60
- * At 5000 categories that is 5000 `<text>` nodes stacked into an unreadable
61
- * grey band - the labels were the single biggest thing the renderer had to put
62
- * in the DOM, and none of them could be read. The time and ordinal-time axes
63
- * already thinned themselves (`dateTicks` / `ordinalDateTicks`); this brings
64
- * the plain category axis in line.
65
- *
66
- * The first category always gets a label, so a thinned axis still starts where
67
- * the data does.
68
- */
69
- function thinCategoryTicks(categories, xCenter, slot, rotated) {
70
- // Rotated labels run diagonally and pack far tighter than upright ones. 18px
71
- // is deliberately just under the spacing a 40-category chart at 800px already
72
- // had, so charts that read fine before are untouched and only genuinely
73
- // overlapping axes get thinned.
74
- const minPx = rotated ? 18 : 60;
75
- const step = Math.max(1, Math.ceil(minPx / Math.max(slot, 0.001)));
76
- const out = [];
77
- for (let i = 0; i < categories.length; i += step)
78
- out.push({ label: categories[i], x: xCenter(i) });
79
- return out;
80
- }
81
- /** Normalize one `errors` entry to an absolute low/high pair around `value`. */
82
- function errorSpan(e, value) {
83
- if (e == null)
84
- return null;
85
- if (typeof e === 'number') {
86
- if (!Number.isFinite(e))
87
- return null;
88
- const m = Math.abs(e);
89
- return { lo: value - m, hi: value + m };
90
- }
91
- if (!Number.isFinite(e.lo) || !Number.isFinite(e.hi))
92
- return null;
93
- return { lo: Math.min(e.lo, e.hi), hi: Math.max(e.lo, e.hi) };
94
- }
95
- /** Series colours used when a {@link ChartSeries} sets none, in order. */
96
- export const DEFAULT_PALETTE = [
97
- '#2563eb',
98
- '#16a34a',
99
- '#f59e0b',
100
- '#ef4444',
101
- '#8b5cf6',
102
- '#0ea5e9',
103
- '#ec4899',
104
- '#14b8a6',
105
- ];
106
- function round(n) {
107
- return Math.round(n * 100) / 100;
108
- }
109
- function niceNum(range, roundIt) {
110
- if (range <= 0)
111
- return 1;
112
- const exp = Math.floor(Math.log10(range));
113
- const f = range / Math.pow(10, exp);
114
- let nf;
115
- if (roundIt)
116
- nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10;
117
- else
118
- nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10;
119
- return nf * Math.pow(10, exp);
120
- }
121
- // ---- Color helpers for heatmap / pattern fills ----------------------
122
- /** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
123
- const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a'];
124
- /** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
125
- const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8'];
126
- /** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
127
- * above the dark grid surface instead of near-white, so empty / low cells read
128
- * as "cold" rather than as glaring white rectangles. */
129
- const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd'];
130
- const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa'];
131
- function resolveColorScale(scale, vMin, vMax, theme = 'light') {
132
- if (Array.isArray(scale) && scale.length >= 2)
133
- return scale;
134
- const dark = theme === 'dark';
135
- if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
136
- return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS;
137
- }
138
- return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS;
139
- }
140
- /** Sample a hex color from an array of hex stops at fractional position t.
141
- * Linearly interpolates between the two nearest stops in RGB space. */
142
- export function sampleGradient(stops, t) {
143
- if (!stops.length)
144
- return '#888';
145
- const clamped = Math.max(0, Math.min(1, t));
146
- if (stops.length === 1)
147
- return stops[0];
148
- const pos = clamped * (stops.length - 1);
149
- const i = Math.floor(pos);
150
- const frac = pos - i;
151
- const a = hexToRgb(stops[i]);
152
- const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]);
153
- if (!a || !b)
154
- return stops[i] ?? '#888';
155
- const lerp = (x, y) => Math.round(x + (y - x) * frac);
156
- const toHex = (n) => n.toString(16).padStart(2, '0');
157
- return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b));
158
- }
159
- function hexToRgb(hex) {
160
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
161
- if (!m)
162
- return null;
163
- const n = parseInt(m[1], 16);
164
- return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
165
- }
166
- /** Pick a black or white text color that has the better contrast against
167
- * the given background. Uses the WCAG relative-luminance heuristic. */
168
- export function pickContrastText(bgHex) {
169
- const rgb = hexToRgb(bgHex);
170
- if (!rgb)
171
- return '#0f172a';
172
- const lin = (c) => {
173
- const s = c / 255;
174
- return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
175
- };
176
- const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
177
- return L > 0.5 ? '#0f172a' : '#ffffff';
178
- }
179
- /** Pick the largest power of 10 that fits at the bottom of [min,max], and
180
- * the smallest that covers the top, then enumerate decade boundaries. Used
181
- * by log-scale axes (yScale: 'log'). */
182
- export function niceLogScale(min, max) {
183
- // Only positive values are plottable on a log scale; callers should
184
- // strip non-positive values before passing them in.
185
- if (!Number.isFinite(min) || min <= 0)
186
- min = 1;
187
- if (!Number.isFinite(max) || max <= min)
188
- max = min * 10;
189
- const lo = Math.floor(Math.log10(min));
190
- const hi = Math.ceil(Math.log10(max));
191
- const ticks = [];
192
- for (let p = lo; p <= hi; p += 1)
193
- ticks.push(Math.pow(10, p));
194
- return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks };
195
- }
196
- /** Map a value to a fractional position [0..1] across the axis domain.
197
- * Pass the appropriate fn into projection code so linear / log share the
198
- * same plumbing. Returns null for non-positive values on log. */
199
- function project(value, min, max, isLog) {
200
- if (!Number.isFinite(value))
201
- return null;
202
- if (isLog) {
203
- if (value <= 0 || min <= 0)
204
- return null;
205
- return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min));
206
- }
207
- return (value - min) / (max - min);
208
- }
209
- /**
210
- * Turn a laid-out chart's axes into functions, so a caller can draw its own
211
- * marks in the same coordinates the built-in ones use.
212
- *
213
- * This is the custom-series seam. Rather than a registry of mark types, the
214
- * chart hands over its geometry and its scales and lets the caller render
215
- * whatever SVG it likes into the plot - which is the Svelte-shaped answer, and
216
- * means a custom mark is ordinary markup rather than a plugin.
217
- *
218
- * Deriving the scale from `geo.axes` matters: the domain a chart drew against
219
- * is the NICE-ROUNDED one, stretched to include zero for bar charts and any
220
- * reference lines. Recomputing it from the data outside would land custom marks
221
- * a few pixels off the built-in ones, in a way that looks like a rendering bug.
222
- *
223
- * Returns `null` for a chart with no cartesian axes (pie, gauge, treemap,
224
- * sankey, calendar, radar, funnel), where plot coordinates mean nothing.
225
- */
226
- export function chartScales(geo) {
227
- const a = geo.axes;
228
- if (!a)
229
- return null;
230
- const { x: px, y: py, h: ph } = geo.plot;
231
- const yFor = (dom) => (value) => {
232
- const t = project(value, dom.min, dom.max, dom.log);
233
- return t === null ? Number.NaN : py + ph - t * ph;
234
- };
235
- const left = yFor(a.y);
236
- const right = a.y2 ? yFor(a.y2) : left;
237
- const invFor = (dom) => (y) => {
238
- const t = ph === 0 ? 0 : (py + ph - y) / ph;
239
- if (!dom.log)
240
- return dom.min + t * (dom.max - dom.min);
241
- const lo = Math.log10(dom.min);
242
- return 10 ** (lo + t * (Math.log10(dom.max) - lo));
243
- };
244
- return {
245
- xOf: (i) => px + a.slot * i + a.slot / 2,
246
- yOf: (value, axis) => (axis === 'right' ? right(value) : left(value)),
247
- xInvert: (x) => Math.max(0, Math.min(a.count - 1, Math.floor((x - px) / (a.slot || 1)))),
248
- yInvert: (y, axis) => (axis === 'right' && a.y2 ? invFor(a.y2)(y) : invFor(a.y)(y)),
249
- };
250
- }
251
- // ---- Overlay math: trendline + moving averages -----------------------
252
- /** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
253
- * monotone cubic interpolation (preserves local extrema - no overshoots).
254
- * Breaks the path at `defined === false` gaps. */
255
- export function buildLinePath(pts, smooth) {
256
- if (!smooth) {
257
- let path = '';
258
- let pen = false;
259
- for (const p of pts) {
260
- if (!p.defined) {
261
- pen = false;
262
- continue;
263
- }
264
- path += `${pen ? 'L' : 'M'}${p.x},${p.y} `;
265
- pen = true;
266
- }
267
- return path.trim();
268
- }
269
- // Group defined-only runs; each run is smoothed independently.
270
- const runs = [];
271
- let cur = [];
272
- for (const p of pts) {
273
- if (p.defined)
274
- cur.push({ x: p.x, y: p.y });
275
- else if (cur.length) {
276
- runs.push(cur);
277
- cur = [];
278
- }
279
- }
280
- if (cur.length)
281
- runs.push(cur);
282
- return runs.map(monotoneCubicPath).filter(Boolean).join(' ');
283
- }
284
- /** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
285
- * Slope at each point chosen so the curve passes through every (xi, yi)
286
- * AND stays monotonic between them; control points sit 1/3 of the way
287
- * to the neighbours along that tangent. */
288
- function monotoneCubicPath(pts) {
289
- const n = pts.length;
290
- if (n === 0)
291
- return '';
292
- if (n === 1)
293
- return `M${pts[0].x},${pts[0].y}`;
294
- if (n === 2)
295
- return `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y}`;
296
- // Secant slopes between adjacent points.
297
- const dx = new Array(n - 1);
298
- const m = new Array(n - 1);
299
- for (let i = 0; i < n - 1; i += 1) {
300
- const d = pts[i + 1].x - pts[i].x;
301
- dx[i] = d;
302
- m[i] = d === 0 ? 0 : (pts[i + 1].y - pts[i].y) / d;
303
- }
304
- // Tangent at each point: average of neighbouring slopes, with sign
305
- // checks that flatten the tangent when slopes change sign.
306
- const tan = new Array(n);
307
- tan[0] = m[0];
308
- tan[n - 1] = m[n - 2];
309
- for (let i = 1; i < n - 1; i += 1) {
310
- if (m[i - 1] * m[i] <= 0)
311
- tan[i] = 0;
312
- else
313
- tan[i] = (m[i - 1] + m[i]) / 2;
314
- }
315
- // Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
316
- for (let i = 0; i < n - 1; i += 1) {
317
- if (m[i] === 0) {
318
- tan[i] = 0;
319
- tan[i + 1] = 0;
320
- continue;
321
- }
322
- const a = tan[i] / m[i];
323
- const b = tan[i + 1] / m[i];
324
- const h = Math.hypot(a, b);
325
- if (h > 3) {
326
- tan[i] = (3 / h) * a * m[i];
327
- tan[i + 1] = (3 / h) * b * m[i];
328
- }
329
- }
330
- // Build the Bezier path. Each segment: control points at 1/3 of dx.
331
- let path = `M${pts[0].x},${pts[0].y}`;
332
- for (let i = 0; i < n - 1; i += 1) {
333
- const h = dx[i];
334
- const c1x = pts[i].x + h / 3;
335
- const c1y = pts[i].y + (tan[i] * h) / 3;
336
- const c2x = pts[i + 1].x - h / 3;
337
- const c2y = pts[i + 1].y - (tan[i + 1] * h) / 3;
338
- path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1].x},${pts[i + 1].y}`;
339
- }
340
- return path;
341
- }
342
- /** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
343
- * Returns the fitted value at each x index, or NaN where the source value
344
- * was non-finite. */
345
- export function linearTrend(values) {
346
- let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0;
347
- for (let i = 0; i < values.length; i += 1) {
348
- const y = values[i];
349
- if (!Number.isFinite(y))
350
- continue;
351
- n += 1;
352
- sumX += i;
353
- sumY += y;
354
- sumXX += i * i;
355
- sumXY += i * y;
356
- }
357
- if (n < 2)
358
- return values.map(() => NaN);
359
- const denom = n * sumXX - sumX * sumX;
360
- if (denom === 0)
361
- return values.map(() => sumY / n);
362
- const slope = (n * sumXY - sumX * sumY) / denom;
363
- const intercept = (sumY - slope * sumX) / n;
364
- return values.map((_, i) => slope * i + intercept);
365
- }
366
- /** Simple moving average over a window of `period` values. Window centres
367
- * trail to the right (typical for time-series). NaN for points before the
368
- * window is full. */
369
- export function simpleMovingAverage(values, period) {
370
- if (period < 1)
371
- return values.slice();
372
- const out = new Array(values.length).fill(NaN);
373
- let sum = 0, count = 0;
374
- for (let i = 0; i < values.length; i += 1) {
375
- const v = values[i];
376
- if (Number.isFinite(v)) {
377
- sum += v;
378
- count += 1;
379
- }
380
- if (i >= period) {
381
- const drop = values[i - period];
382
- if (Number.isFinite(drop)) {
383
- sum -= drop;
384
- count -= 1;
385
- }
386
- }
387
- if (i >= period - 1 && count > 0)
388
- out[i] = sum / count;
389
- }
390
- return out;
391
- }
392
- /** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
393
- export function exponentialMovingAverage(values, period) {
394
- const alpha = 2 / (Math.max(1, period) + 1);
395
- const out = new Array(values.length).fill(NaN);
396
- let prev = null;
397
- for (let i = 0; i < values.length; i += 1) {
398
- const v = values[i];
399
- if (!Number.isFinite(v)) {
400
- out[i] = prev ?? NaN;
401
- continue;
402
- }
403
- prev = prev == null ? v : alpha * v + (1 - alpha) * prev;
404
- out[i] = prev;
405
- }
406
- return out;
407
- }
408
- /** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
409
- export function computeOverlay(values, spec) {
410
- if (spec === 'linear')
411
- return linearTrend(values);
412
- const m = /^(sma|ema):(\d+)$/.exec(spec);
413
- if (!m)
414
- return values.map(() => NaN);
415
- const period = Number(m[2]);
416
- return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period);
417
- }
418
- /** Round a [min,max] domain out to nice tick boundaries. */
419
- export function niceScale(min, max, tickCount = 4) {
420
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
421
- min = 0;
422
- max = 1;
423
- }
424
- if (min === max) {
425
- if (min === 0)
426
- max = 1;
427
- else {
428
- min = Math.min(0, min);
429
- max = Math.max(0, max);
430
- }
431
- if (min === max)
432
- max = min + 1;
433
- }
434
- const range = niceNum(max - min, false);
435
- const step = niceNum(range / Math.max(1, tickCount), true);
436
- const nMin = Math.floor(min / step) * step;
437
- const nMax = Math.ceil(max / step) * step;
438
- const ticks = [];
439
- for (let v = nMin; v <= nMax + step * 0.5; v += step)
440
- ticks.push(round(v));
441
- return { min: nMin, max: nMax, step, ticks };
442
- }
443
- function fmtTick(n) {
444
- const abs = Math.abs(n);
445
- if (abs >= 1000000)
446
- return `${(n / 1000000).toFixed(abs % 1000000 ? 1 : 0)}M`;
447
- if (abs >= 1000)
448
- return `${(n / 1000).toFixed(abs % 1000 ? 1 : 0)}k`;
449
- return String(Math.round(n * 100) / 100);
450
- }
451
- /**
452
- * Format a numeric value for display, honouring an optional `valueFormat`.
453
- *
454
- * Two modes, on purpose. With no `locale` and no `currency` this is the original
455
- * locale-free output: the compact `1.2k` / `1.2M` base, currency prefixed with
456
- * `$` (sign outside), percent multiplied by 100 and suffixed `%`. Set either one
457
- * and it switches to `Intl.NumberFormat`, so separators, the decimal mark and the
458
- * compact suffixes all follow the locale.
459
- *
460
- * Keeping the old path as the default is deliberate rather than lazy. `Intl`'s
461
- * compact notation is not the same string even for `en-US` (`1.2K`, capital),
462
- * so formatting everything through it would silently restyle every axis in every
463
- * existing chart. Opting in is the only version of this that is not a surprise.
21
+ * The CSS custom properties `spec.style` sets on the chart's host, as one
22
+ * inline style string (empty when nothing is set). `background` becomes the
23
+ * chart's `--sg-bg` (exports read it as the page colour), `textColor` the
24
+ * text tokens, `gridColor` the border token, `fontSize` a scale every label
25
+ * multiplies (12px is 1), and `fontFamily` the host's font.
464
26
  */
465
- export function formatChartValue(n, format, opts) {
466
- if (!Number.isFinite(n))
27
+ export function chartStyleVars(style) {
28
+ if (!style)
467
29
  return '';
468
- const localized = opts && (opts.locale || opts.currency);
469
- if (localized) {
470
- // Compact notation because these are axis ticks and data labels, where a
471
- // full-precision number is what makes an axis unreadable.
472
- const style = format === 'currency' ? 'currency' : format === 'percent' ? 'percent' : 'decimal';
473
- const o = { notation: 'compact', maximumFractionDigits: 1 };
474
- if (style === 'currency') {
475
- o.style = 'currency';
476
- // `style: 'currency'` throws without a code, so fall back to the symbol
477
- // this used to hard-code rather than refusing to draw the chart.
478
- o.currency = opts.currency || 'USD';
479
- }
480
- else if (style === 'percent') {
481
- o.style = 'percent';
482
- }
483
- return getNumberFormatter(opts.locale, o).format(n);
484
- }
485
- if (format === 'currency')
486
- return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`;
487
- if (format === 'percent') {
488
- const p = n * 100;
489
- return `${Math.round(p * 10) / 10}%`;
490
- }
491
- return fmtTick(n);
492
- }
493
- const DAY = 86400000;
494
- /** Nice date-tick timestamps across [min, max]. */
495
- function dateTicks(tMin, tMax) {
496
- const span = tMax - tMin;
497
- const step = span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY;
498
- const ticks = [];
499
- for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step)
500
- ticks.push(t);
501
- if (!ticks.length)
502
- ticks.push(tMin, tMax);
503
- return ticks;
30
+ const out = [];
31
+ if (style.background)
32
+ out.push(`--sg-chart-bg:${style.background}`, `--sg-bg:${style.background}`);
33
+ if (style.textColor)
34
+ out.push(`--sg-fg:${style.textColor}`, `--sg-muted:${style.textColor}`);
35
+ if (style.gridColor)
36
+ out.push(`--sg-border:${style.gridColor}`);
37
+ if (style.fontSize && Number.isFinite(style.fontSize) && style.fontSize > 0)
38
+ out.push(`--sg-chart-font-scale:${Math.round((style.fontSize / 12) * 1000) / 1000}`);
39
+ if (style.fontFamily)
40
+ out.push(`font-family:${style.fontFamily}`);
41
+ return out.join(';');
504
42
  }
505
43
  /**
506
- * Tick positions for an ordinal (evenly spaced) date axis, as INDICES into
507
- * `times`.
508
- *
509
- * A time axis can put a tick anywhere, because x is a function of the
510
- * timestamp. An ordinal axis cannot: x is a function of the index, so a tick
511
- * has to land on a point that exists. This picks the first point of each
512
- * calendar unit - day, week, month, year, whichever gets closest to `target`
513
- * ticks without going over - so labels sit on real sessions and a weekend or a
514
- * holiday never stretches the spacing.
44
+ * The responsive rules that apply at a rendered size, in spec order. Pure:
45
+ * `SvChart` uses it for the legend field, {@link resolveResponsive} for the
46
+ * spec patches.
515
47
  */
516
- export function ordinalDateTicks(times, target = 6) {
517
- if (times.length <= 1)
518
- return times.length ? [0] : [];
519
- const keyOf = {
520
- day: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`,
521
- week: (d) => Math.floor((Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / DAY + 4) / 7),
522
- month: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}`,
523
- year: (d) => d.getUTCFullYear(),
524
- };
525
- let chosen = null;
526
- for (const unit of ['day', 'week', 'month', 'year']) {
527
- const at = [];
528
- let prev = null;
529
- for (let i = 0; i < times.length; i += 1) {
530
- const t = times[i];
531
- if (!Number.isFinite(t))
532
- continue;
533
- const k = keyOf[unit](new Date(t));
534
- if (k !== prev)
535
- at.push(i);
536
- prev = k;
537
- }
538
- chosen = at;
539
- if (at.length <= target * 2)
540
- break;
541
- }
542
- let out = chosen ?? [];
543
- // Even the coarsest unit can be too dense (a decade of yearly points), and a
544
- // single trading day yields one boundary. Thin, or fall back to plain strides.
545
- if (out.length > target) {
546
- const stride = Math.ceil(out.length / target);
547
- out = out.filter((_, i) => i % stride === 0);
548
- }
549
- if (out.length < 2) {
550
- const stride = Math.max(1, Math.ceil(times.length / target));
551
- out = times.map((_, i) => i).filter((i) => i % stride === 0);
552
- }
553
- return out;
554
- }
555
- function fmtDate(t, span) {
556
- const d = new Date(t);
557
- if (span <= 70 * DAY)
558
- return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
559
- if (span <= 800 * DAY)
560
- return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' });
561
- return String(d.getFullYear());
48
+ export function matchResponsiveRules(rules, width, height) {
49
+ if (!rules?.length)
50
+ return [];
51
+ return rules.filter((r) => (r.maxWidth == null || width <= r.maxWidth) &&
52
+ (r.minWidth == null || width >= r.minWidth) &&
53
+ (r.maxHeight == null || height <= r.maxHeight));
562
54
  }
563
55
  /**
564
- * Which mark a series draws.
565
- *
566
- * A series counts as a candle when it says so OR when it carries `ohlc` data,
567
- * so a spec typed `'candlestick'` can still hold a plain volume bar series
568
- * beside the prices. Boxes work the same way. Anything unrecognised falls back
569
- * to `'bar'`, which is what pie and scatter have always relied on.
56
+ * Apply `spec.responsive` for a rendered size: every matching rule's `spec`
57
+ * is merged over the input in order (later wins), the three axis objects one
58
+ * level deep. Returns the input itself when nothing matches, so callers can
59
+ * compare by identity. `width`, `height` and `responsive` in a rule are
60
+ * ignored; the size is the caller's.
570
61
  */
571
- function kindOf(s, specType) {
572
- const t = s.type ?? specType;
573
- if (t === 'candlestick' || t === 'ohlc' || s.ohlc)
574
- return 'candle';
575
- if (t === 'boxplot' || s.boxes)
576
- return 'box';
577
- if (t === 'line' || t === 'area')
578
- return t;
579
- return 'bar';
580
- }
581
- /** Data domain for one axis, honoring stacking of its bar/area series.
582
- * When `isLog` is true, non-positive values are discarded (log undefined)
583
- * and the domain is rounded to decade boundaries instead of nice steps. */
584
- function axisDomain(list, categories, stacked, extra = [], isLog = false) {
585
- let dMin = Infinity;
586
- let dMax = -Infinity;
587
- const note = (v) => {
588
- if (!Number.isFinite(v))
589
- return;
590
- if (isLog && v <= 0)
591
- return;
592
- if (v < dMin)
593
- dMin = v;
594
- if (v > dMax)
595
- dMax = v;
596
- };
597
- for (const v of extra)
598
- note(v);
599
- const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area');
600
- const lines = list.filter((s) => s.kind === 'line');
601
- if (stacked && stackable.length) {
602
- for (let i = 0; i < categories.length; i += 1) {
603
- let pos = 0;
604
- let neg = 0;
605
- for (const s of stackable) {
606
- const v = s.values[i] ?? 0;
607
- if (v >= 0)
608
- pos += v;
609
- else
610
- neg += v;
611
- }
612
- note(pos);
613
- note(neg);
614
- }
615
- }
616
- else {
617
- for (const s of stackable)
618
- for (const v of s.values)
619
- note(v);
620
- }
621
- for (const s of lines)
622
- for (const v of s.values)
623
- note(v);
624
- // Candles: note the HIGH and the LOW, not `values` (the closes), or every
625
- // wick clips at the body. Note also that candles are deliberately absent
626
- // from `stackable`, so the zero-baseline rule below does not fire for them:
627
- // a price series running 180 to 195 keeps a readable domain instead of being
628
- // flattened against zero.
629
- for (const s of list) {
630
- if (s.kind !== 'candle')
631
- continue;
632
- for (const k of s.ohlc ?? []) {
633
- if (!k)
634
- continue;
635
- note(k.h);
636
- note(k.l);
637
- }
638
- }
639
- // Boxes: the whisker ends and any outlier, for the same reason - `values`
640
- // holds the medians, so a domain built from those alone would clip half of
641
- // every box. Boxes are also absent from `stackable`, so a sample that never
642
- // goes near zero keeps a readable domain.
643
- for (const s of list) {
644
- if (s.kind !== 'box')
62
+ export function resolveResponsive(spec, width, height) {
63
+ const hits = matchResponsiveRules(spec.responsive, width, height);
64
+ if (!hits.length)
65
+ return spec;
66
+ let out = spec;
67
+ for (const rule of hits) {
68
+ if (!rule.spec)
645
69
  continue;
646
- for (const b of s.boxes ?? []) {
647
- if (!b)
648
- continue;
649
- note(b.min);
650
- note(b.max);
651
- for (const o of b.outliers ?? [])
652
- note(o);
70
+ const { width: _w, height: _h, responsive: _r, ...patch } = rule.spec;
71
+ const merged = { ...out, ...patch };
72
+ for (const k of ['xAxis', 'yAxis', 'y2Axis']) {
73
+ if (patch[k])
74
+ merged[k] = { ...(out[k] ?? {}), ...patch[k] };
653
75
  }
76
+ out = merged;
654
77
  }
655
- // Error bars extend past their own mark, so a whisker that leaves the plot is
656
- // the same defect as a clipped candle wick.
657
- for (const s of list) {
658
- if (!s.errors)
659
- continue;
660
- s.errors.forEach((e, i) => {
661
- const span = errorSpan(e, s.values[i] ?? 0);
662
- if (!span)
663
- return;
664
- note(span.lo);
665
- note(span.hi);
666
- });
667
- }
668
- if (dMin === Infinity) {
669
- dMin = isLog ? 1 : 0;
670
- dMax = isLog ? 10 : 1;
671
- }
672
- // Bar / area charts read against a zero baseline, so always include 0
673
- // - but only on linear axes (0 is invalid in log).
674
- if (stackable.length && !isLog) {
675
- dMin = Math.min(dMin, 0);
676
- dMax = Math.max(dMax, 0);
677
- }
678
- return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax);
78
+ return out;
679
79
  }
680
80
  /**
681
81
  * Lay out a {@link ChartSpec} into renderable {@link ChartGeometry} - scales,
682
82
  * ticks, and the position of every bar, line, slice and dot. Pure: no DOM, so
683
83
  * it runs during SSR and can be unit-tested directly.
684
84
  */
685
- export function buildChart(spec, theme = 'light') {
686
- const width = spec.width ?? 520;
687
- const height = spec.height ?? 300;
85
+ export function buildChart(input, theme = 'light') {
86
+ const width = input.width ?? 520;
87
+ const height = input.height ?? 300;
88
+ // Size rules first, so every family sees the patched spec.
89
+ const spec = resolveResponsive(input, width, height);
90
+ // A pareto is a bar chart with a cumulative-share line on the right axis,
91
+ // sorted by value. Build that spec and lay it out as the combo it is.
92
+ if (spec.type === 'pareto')
93
+ return buildChart(paretoSpec(spec), theme);
688
94
  const palette = spec.palette ?? DEFAULT_PALETTE;
689
95
  const stacked = !!(spec.stacked || spec.stacked100);
690
96
  const series = spec.series.map((s, i) => ({
691
97
  ...s,
98
+ // A null is a gap unless the series (or the spec) says it is a zero. Done
99
+ // here, once, so every family and every overlay sees the same numbers.
100
+ values: (s.nullAs ?? spec.nullAs) === 'zero' ? s.values.map((v) => (Number.isFinite(v) ? v : 0)) : s.values,
692
101
  color: s.color ?? palette[i % palette.length],
693
102
  kind: kindOf(s, spec.type),
694
103
  axis: s.axis ?? 'left',
695
104
  }));
105
+ const frame = chartFrame(spec, width, height);
106
+ const axes = resolveAxes(spec);
696
107
  const legend = series.map((s) => ({ label: s.label, color: s.color }));
697
108
  const empty = {
698
109
  type: spec.type,
@@ -700,10 +111,21 @@ export function buildChart(spec, theme = 'light') {
700
111
  height,
701
112
  plot: { x: 0, y: 0, w: width, h: height },
702
113
  axes: null,
114
+ frame,
115
+ grid: { x: false, y: false },
116
+ referenceBands: [],
703
117
  bars: [],
704
118
  candles: [],
705
119
  boxes: [],
706
120
  errorBars: [],
121
+ stems: [],
122
+ arcs: [],
123
+ chordRibbons: [],
124
+ bullets: [],
125
+ drawings: [],
126
+ seriesLabels: [],
127
+ polarAxes: [],
128
+ polarRings: [],
707
129
  lines: [],
708
130
  slices: [],
709
131
  yTicks: [],
@@ -712,6 +134,7 @@ export function buildChart(spec, theme = 'light') {
712
134
  xTicks: [],
713
135
  categoryGroupTicks: [],
714
136
  xLabelRotated: false,
137
+ xLabelAngle: 0,
715
138
  legend,
716
139
  donut: null,
717
140
  referenceLines: [],
@@ -739,1486 +162,43 @@ export function buildChart(spec, theme = 'light') {
739
162
  sankeyNodes: [],
740
163
  sankeyLinks: [],
741
164
  };
742
- // ---- Waterfall ----------------------------------------------------
743
- // First series provides the values. Each non-total bar starts at the
744
- // running cumulative sum; total bars (waterfallTotals[i]) reset and
745
- // span from 0 to that sum. Color is derived from sign + total flag, with
746
- // optional palette overrides via spec.waterfallColors.
747
- if (spec.type === 'waterfall') {
748
- const src = series[0];
749
- if (!src)
750
- return { ...empty };
751
- const colors = spec.waterfallColors ?? {};
752
- const positive = colors.positive ?? '#16a34a';
753
- const negative = colors.negative ?? '#ef4444';
754
- const total = colors.total ?? '#475569';
755
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0);
756
- const xLabelRotated = spec.categories.length > 8 || maxLabel > 9;
757
- const padL = 48 + (spec.yAxisTitle ? 16 : 0);
758
- const padR = 12;
759
- const padT = 10;
760
- const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0);
761
- const plotW = Math.max(1, width - padL - padR);
762
- const plotH = Math.max(1, height - padT - padB);
763
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
764
- // Compute the running cumulative + per-bar (from, to) pairs.
765
- const totals = spec.waterfallTotals ?? [];
766
- const pairs = [];
767
- let cum = 0;
768
- spec.categories.forEach((_, i) => {
769
- const v = src.values[i] ?? 0;
770
- const isTotal = !!totals[i];
771
- if (isTotal) {
772
- pairs.push({ from: 0, to: cum, value: cum, isTotal: true });
773
- }
774
- else {
775
- pairs.push({ from: cum, to: cum + v, value: v, isTotal: false });
776
- cum += v;
777
- }
778
- });
779
- // Y-axis domain spans every visited level (including 0).
780
- let dMin = 0, dMax = 0;
781
- for (const p of pairs) {
782
- if (p.from < dMin)
783
- dMin = p.from;
784
- if (p.to < dMin)
785
- dMin = p.to;
786
- if (p.from > dMax)
787
- dMax = p.from;
788
- if (p.to > dMax)
789
- dMax = p.to;
790
- }
791
- const dom = niceScale(dMin, dMax);
792
- const yOfW = (v) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH);
793
- const slotW = plotW / Math.max(1, spec.categories.length);
794
- const barPad = slotW * 0.2;
795
- const barW = Math.max(1, slotW - barPad);
796
- const bars = pairs.map((p, i) => {
797
- const yTop = yOfW(Math.max(p.from, p.to));
798
- const yBot = yOfW(Math.min(p.from, p.to));
799
- const x = padL + slotW * i + barPad / 2;
800
- const color = p.isTotal ? total : p.value >= 0 ? positive : negative;
801
- return {
802
- x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
803
- color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
804
- };
805
- });
806
- // Thin connector lines between bar tops -> running total reads cleanly.
807
- const connectors = [{
808
- path: pairs
809
- .map((p, i) => {
810
- const x0 = padL + slotW * i + barPad / 2 + barW;
811
- const y = yOfW(p.to);
812
- const x1 = padL + slotW * (i + 1) + barPad / 2;
813
- // Skip the final connector beyond the last bar.
814
- return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : '';
815
- })
816
- .filter(Boolean)
817
- .join(' '),
818
- areaPath: '',
819
- color: 'var(--sg-muted, #94a3b8)',
820
- label: '',
821
- points: [],
822
- }];
823
- const xTicks = spec.categories.map((label, i) => ({
824
- label,
825
- x: round(padL + slotW * i + slotW / 2),
826
- }));
827
- const yTicks = dom.ticks.map((value) => ({
828
- value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
829
- }));
830
- return {
831
- ...empty,
832
- plot,
833
- bars,
834
- lines: connectors,
835
- yTicks,
836
- xTicks,
837
- xLabelRotated,
838
- };
839
- }
840
- // ---- Funnel -------------------------------------------------------
841
- // One series of strictly-decreasing values gets rendered as a stack
842
- // of horizontal trapezoids: each level's width is proportional to its
843
- // value relative to the largest, slope automatically links level N+1
844
- // narrower than level N. Labels show value, conversion vs. top, and
845
- // step drop-off.
846
- if (spec.type === 'funnel') {
847
- const src = series[0];
848
- if (!src || !src.values.length)
849
- return { ...empty };
850
- const padL = 20, padR = 20, padT = 16, padB = 16;
851
- const plotW = Math.max(1, width - padL - padR);
852
- const plotH = Math.max(1, height - padT - padB);
853
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
854
- const n = src.values.length;
855
- const stepH = plotH / n;
856
- const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)));
857
- const top = src.values[0] ?? 0;
858
- const widthAt = (v) => (valMax > 0 ? (v / valMax) * plotW : 0);
859
- const cx = padL + plotW / 2;
860
- const palette = spec.palette ?? DEFAULT_PALETTE;
861
- const segments = src.values.map((v, i) => {
862
- const next = src.values[i + 1] ?? v * 0.8; // taper to a point on the last level
863
- const w0 = widthAt(v);
864
- const w1 = widthAt(next);
865
- const y0 = padT + stepH * i;
866
- const y1 = y0 + stepH;
867
- const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`;
868
- const color = src.color ?? palette[i % palette.length];
869
- return {
870
- path, color,
871
- label: spec.categories[i] ?? src.label,
872
- value: v,
873
- conversion: top > 0 ? v / top : 0,
874
- dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
875
- cx,
876
- cy: (y0 + y1) / 2,
877
- textColor: pickContrastText(color),
878
- };
879
- });
880
- return {
881
- ...empty,
882
- plot,
883
- funnelSegments: segments,
884
- };
885
- }
886
- // ---- Radar --------------------------------------------------------
887
- // Polar coordinates: each `category` is a spoke (axis); each `series`
888
- // contributes a polygon connecting its values across the spokes. All
889
- // series share the same scale (max across every value). Concentric
890
- // ring count derived from data, capped at 5 for legibility.
891
- if (spec.type === 'radar') {
892
- if (!series.length || !spec.categories.length)
893
- return { ...empty };
894
- const padL = 30, padR = 30, padT = 24, padB = 24;
895
- const plotW = Math.max(1, width - padL - padR);
896
- const plotH = Math.max(1, height - padT - padB);
897
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
898
- const cx = padL + plotW / 2;
899
- const cy = padT + plotH / 2;
900
- const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20);
901
- const axes = spec.categories;
902
- const k = axes.length;
903
- let vMax = 0;
904
- for (const s of series)
905
- for (const v of s.values) {
906
- if (Number.isFinite(v) && v > vMax)
907
- vMax = v;
908
- }
909
- if (vMax === 0)
910
- vMax = 1;
911
- const ringCount = 5;
912
- const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax);
913
- /** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
914
- * proceed clockwise so axes lay out left-to-right when k <= 4. */
915
- const angleAt = (i) => -Math.PI / 2 + (i / k) * Math.PI * 2;
916
- const pointAt = (i, v) => {
917
- const t = v / vMax;
918
- const a = angleAt(i);
919
- return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) };
920
- };
921
- const radarAxes = axes.map((label, i) => {
922
- const p = pointAt(i, vMax);
923
- return { label, x: p.x, y: p.y };
924
- });
925
- const radarSeriesGeo = series.map((s, si) => {
926
- const pts = s.values.map((v, i) => {
927
- const safe = Number.isFinite(v) ? v : 0;
928
- const p = pointAt(i, safe);
929
- return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' };
930
- });
931
- const path = pts.length
932
- ? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
933
- : '';
934
- const palette = spec.palette ?? DEFAULT_PALETTE;
935
- return { label: s.label, color: s.color ?? palette[si % palette.length], path, points: pts };
936
- });
937
- return {
938
- ...empty,
939
- plot,
940
- radarRings: ringValues,
941
- radarAxes,
942
- radarSeries: radarSeriesGeo,
943
- radarCenter: { cx, cy, r },
944
- };
945
- }
946
- // ---- Calendar heatmap --------------------------------------------
947
- // GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
948
- // cell is a small square shaded by `calendarValues[i].value` via the
949
- // sequential color scale. Days with no value render blank (border only)
950
- // so missing data is visually obvious.
951
- if (spec.type === 'calendar') {
952
- const values = spec.calendarValues ?? [];
953
- if (!values.length && !spec.calendarStart)
954
- return { ...empty };
955
- // Build a value lookup + figure out the date range.
956
- const valueByDate = new Map();
957
- let vMin = Infinity, vMax = -Infinity;
958
- for (const v of values) {
959
- valueByDate.set(v.date, v.value);
960
- if (Number.isFinite(v.value)) {
961
- if (v.value < vMin)
962
- vMin = v.value;
963
- if (v.value > vMax)
964
- vMax = v.value;
965
- }
966
- }
967
- if (vMin === Infinity) {
968
- vMin = 0;
969
- vMax = 1;
970
- }
971
- if (vMin === vMax)
972
- vMax = vMin + 1;
973
- const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme);
974
- const colorAt = (v) => sampleGradient(stops, (v - vMin) / (vMax - vMin));
975
- // Determine date range. If calendarStart/End set, use them, otherwise
976
- // span the data + round to whole weeks (Sun..Sat).
977
- const sorted = values.map((v) => v.date).sort();
978
- const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01';
979
- const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr;
980
- const start = new Date(startStr + 'T00:00:00Z');
981
- const end = new Date(endStr + 'T00:00:00Z');
982
- // Roll start back to its Sunday, end forward to its Saturday.
983
- start.setUTCDate(start.getUTCDate() - start.getUTCDay());
984
- end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
985
- const totalDays = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
986
- const weeks = Math.ceil(totalDays / 7);
987
- const padL = 36, padR = 80, padT = 26, padB = 16;
988
- const plotW = Math.max(1, width - padL - padR);
989
- const plotH = Math.max(1, height - padT - padB);
990
- // Cell size: fit weeks across width, 7 rows down height.
991
- const cellW = Math.floor(plotW / weeks);
992
- const cellH = Math.floor(plotH / 7);
993
- const cellSize = Math.max(6, Math.min(cellW, cellH));
994
- const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 };
995
- const cells = [];
996
- let lastMonth = -1;
997
- const monthTicks = [];
998
- for (let i = 0; i < totalDays; i += 1) {
999
- const day = new Date(start.getTime() + i * 86400000);
1000
- const col = Math.floor(i / 7);
1001
- const row = i % 7;
1002
- const date = day.toISOString().slice(0, 10);
1003
- const has = valueByDate.has(date);
1004
- const v = valueByDate.get(date) ?? 0;
1005
- cells.push({
1006
- x: padL + col * cellSize,
1007
- y: padT + row * cellSize,
1008
- size: cellSize,
1009
- date, value: v,
1010
- defined: has,
1011
- color: has ? colorAt(v) : 'transparent',
1012
- });
1013
- if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
1014
- lastMonth = day.getUTCMonth();
1015
- monthTicks.push({
1016
- label: day.toLocaleDateString(undefined, { month: 'short' }),
1017
- x: padL + col * cellSize,
1018
- });
1019
- }
1020
- }
1021
- const legend = Array.from({ length: 5 }, (_, i) => {
1022
- const t = i / 4;
1023
- const value = vMin + (vMax - vMin) * t;
1024
- return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) };
1025
- });
1026
- return {
1027
- ...empty,
1028
- plot,
1029
- calendarCells: cells,
1030
- calendarMonthTicks: monthTicks,
1031
- calendarLegend: legend,
1032
- };
1033
- }
1034
- // ---- Gauge --------------------------------------------------------
1035
- // Semicircle dial: track arc + value arc + optional colored range bands
1036
- // + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
1037
- if (spec.type === 'gauge') {
1038
- const min = spec.gaugeMin ?? 0;
1039
- const max = spec.gaugeMax ?? 100;
1040
- const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0));
1041
- const target = spec.gaugeTarget;
1042
- const cx = width / 2;
1043
- const cy = height * 0.78;
1044
- const r = Math.min(width * 0.42, height * 0.65);
1045
- // Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
1046
- const A0 = Math.PI;
1047
- const A1 = 2 * Math.PI;
1048
- const angleAt = (v) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0);
1049
- const arc = (a0, a1, radius) => {
1050
- const x1 = cx + radius * Math.cos(a0);
1051
- const y1 = cy + radius * Math.sin(a0);
1052
- const x2 = cx + radius * Math.cos(a1);
1053
- const y2 = cy + radius * Math.sin(a1);
1054
- const large = a1 - a0 > Math.PI ? 1 : 0;
1055
- return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`;
1056
- };
1057
- const trackPath = arc(A0, A1, r);
1058
- const valuePath = arc(A0, angleAt(value), r);
1059
- // Bands sit on their own inner ring, well clear of the value arc's 16px
1060
- // stroke at `r`. They are context, not the reading: a band covering most
1061
- // of the scale (an error-rate dial where anything above 0.45 is red) used
1062
- // to out-shout the value arc completely, so the dial looked pegged at
1063
- // maximum when the actual value was 9 percent.
1064
- const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
1065
- path: arc(angleAt(band.from), angleAt(band.to), r - 16),
1066
- color: band.color, from: band.from, to: band.to,
1067
- }));
1068
- let targetPx = null;
1069
- if (target != null && Number.isFinite(target)) {
1070
- const a = angleAt(Math.max(min, Math.min(max, target)));
1071
- const inner = r - 12;
1072
- const outer = r + 4;
1073
- targetPx = {
1074
- x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1075
- x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1076
- };
1077
- }
1078
- // Tick marks just outside the track: a major tick every 1/4 of the scale,
1079
- // with 4 minor ticks between each. Gives the dial a measured, instrument feel.
1080
- const ticks = [];
1081
- const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR;
1082
- for (let i = 0; i <= TICK_TOTAL; i++) {
1083
- const a = A0 + (i / TICK_TOTAL) * (A1 - A0);
1084
- const major = i % TICK_MINOR === 0;
1085
- const inner = r + 9;
1086
- const outer = r + (major ? 17 : 13);
1087
- ticks.push({
1088
- x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1089
- x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1090
- major,
1091
- });
1092
- }
1093
- // Pointer needle: a kite (long tip toward the value, short counterweight
1094
- // tail) pivoting on a center hub.
1095
- //
1096
- // The proportions matter more than they look, and the counterweight was the
1097
- // whole problem. A tail sticking out past the hub is a short, solid triangle;
1098
- // the pointer is a 130px taper that thins to nothing. The compact shape wins
1099
- // the eye, so on a near-full dial the needle read as an arrow pointing at the
1100
- // MINIMUM. Keeping the tail inside the hub radius removes the competing
1101
- // point without giving up the pivot, and the shoulders sit outside the hub so
1102
- // the needle actually has a visible base to taper from.
1103
- const aV = angleAt(value);
1104
- const tipR = r - 14, tailR = 5, baseR = 9;
1105
- const aPerp = aV + Math.PI / 2;
1106
- const pt = (rad, ang) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`;
1107
- const needlePath = `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`;
1108
- // Color the value arc by the band the value currently sits in.
1109
- // First match wins, and bands are half-open [from, to). Bands normally
1110
- // share endpoints - green 0..0.3, amber 0.3..0.45, red 0.45..5 - and with
1111
- // an inclusive `to` plus last-match-wins, a value sitting exactly ON a
1112
- // boundary took the colour of the band ABOVE it. An error rate of 0.45
1113
- // against a 0.45 amber ceiling read as red.
1114
- let valueColor = null;
1115
- const bands = spec.gaugeRanges ?? [];
1116
- for (const band of bands) {
1117
- if (value >= band.from && value < band.to) {
1118
- valueColor = band.color;
1119
- break;
1120
- }
1121
- }
1122
- // The very top of the scale belongs to the last band that reaches it,
1123
- // which the half-open test above would otherwise exclude.
1124
- if (valueColor == null) {
1125
- for (const band of bands)
1126
- if (value >= band.from && value <= band.to)
1127
- valueColor = band.color;
1128
- }
1129
- return {
1130
- ...empty,
1131
- plot: { x: 0, y: 0, w: width, h: height },
1132
- gauge: {
1133
- cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
1134
- ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
1135
- minLabel: { x: cx - r, y: cy + 20 },
1136
- maxLabel: { x: cx + r, y: cy + 20 },
1137
- value, min, max, unit: spec.gaugeUnit ?? '',
1138
- },
1139
- };
1140
- }
1141
- // ---- Tree-map -----------------------------------------------------
1142
- // Squarified tree-map (Bruls et al. 2000): each level recursively
1143
- // partitions its rectangle in proportion to its children, picking the
1144
- // split orientation that keeps aspect ratios closest to 1.
1145
- if (spec.type === 'treemap') {
1146
- const root = spec.treemap;
1147
- if (!root)
1148
- return { ...empty };
1149
- const padL = 4, padR = 4, padT = 4, padB = 4;
1150
- const plotW = Math.max(1, width - padL - padR);
1151
- const plotH = Math.max(1, height - padT - padB);
1152
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
1153
- const palette = spec.palette ?? DEFAULT_PALETTE;
1154
- const cells = [];
1155
- function totalOf(n) {
1156
- if (n.children?.length)
1157
- return n.children.reduce((s, c) => s + totalOf(c), 0);
1158
- return Math.max(0, n.value ?? 0);
1159
- }
1160
- function squarify(items, x, y, w, h, depth) {
1161
- if (!items.length || w <= 0 || h <= 0)
1162
- return;
1163
- const totals = items.map(totalOf);
1164
- const sum = totals.reduce((a, b) => a + b, 0);
1165
- if (sum <= 0)
1166
- return;
1167
- // Process largest-first so big items dominate the first row.
1168
- const ordered = items
1169
- .map((n, i) => ({ node: n, value: totals[i] }))
1170
- .sort((a, b) => b.value - a.value);
1171
- let cx = x, cy = y, cw = w, ch = h, remaining = sum;
1172
- let row = [];
1173
- const worstRatio = (vals, shortSide, rowSum, scale) => {
1174
- if (rowSum <= 0)
1175
- return Infinity;
1176
- const rowArea = rowSum * scale;
1177
- const rowSide = rowArea / shortSide;
1178
- let worst = 0;
1179
- for (const v of vals) {
1180
- const cell = v * scale;
1181
- const long = cell / rowSide;
1182
- const r = Math.max(shortSide / long, long / shortSide);
1183
- if (r > worst)
1184
- worst = r;
1185
- }
1186
- return worst;
1187
- };
1188
- function flushRow() {
1189
- if (!row.length)
1190
- return;
1191
- const rowSum = row.reduce((a, b) => a + b.value, 0);
1192
- const scale = (cw * ch) / remaining;
1193
- const horizontal = cw >= ch;
1194
- const shortSide = horizontal ? ch : cw;
1195
- const rowSide = (rowSum * scale) / shortSide;
1196
- let offset = 0;
1197
- for (const it of row) {
1198
- const cellSize = (it.value * scale) / rowSide;
1199
- const cx2 = horizontal ? cx : cx + offset;
1200
- const cy2 = horizontal ? cy + offset : cy;
1201
- const ww = horizontal ? rowSide : cellSize;
1202
- const hh = horizontal ? cellSize : rowSide;
1203
- const color = it.node.color ?? palette[(depth + cells.length) % palette.length];
1204
- // Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
1205
- if (it.node.children?.length) {
1206
- cells.push({
1207
- x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1208
- color, textColor: pickContrastText(color),
1209
- name: it.node.name, value: it.value, depth,
1210
- });
1211
- const labelH = Math.min(18, hh * 0.25);
1212
- squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1);
1213
- }
1214
- else {
1215
- cells.push({
1216
- x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1217
- color, textColor: pickContrastText(color),
1218
- name: it.node.name, value: it.value, depth,
1219
- });
1220
- }
1221
- offset += cellSize;
1222
- }
1223
- // Shrink the remaining strip.
1224
- if (horizontal) {
1225
- cx += rowSide;
1226
- cw -= rowSide;
1227
- }
1228
- else {
1229
- cy += rowSide;
1230
- ch -= rowSide;
1231
- }
1232
- remaining -= rowSum;
1233
- row = [];
1234
- }
1235
- for (const it of ordered) {
1236
- const scale = (cw * ch) / remaining;
1237
- const shortSide = Math.min(cw, ch);
1238
- const rowSum = row.reduce((a, b) => a + b.value, 0);
1239
- const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale);
1240
- const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale);
1241
- if (row.length && nextWorst > currWorst) {
1242
- flushRow();
1243
- }
1244
- row.push(it);
1245
- }
1246
- flushRow();
1247
- }
1248
- const seedItems = root.children ?? [root];
1249
- squarify(seedItems, padL, padT, plotW, plotH, 0);
1250
- return { ...empty, plot, treemapCells: cells };
1251
- }
1252
- // ---- Sankey -------------------------------------------------------
1253
- // Multi-column flow layout. Each node assigned to a column by longest
1254
- // path from any source. Within a column, nodes are stacked vertically;
1255
- // height proportional to max(totalIn, totalOut). Links render as
1256
- // bezier ribbons whose width is the link value (in pixels).
1257
- if (spec.type === 'sankey') {
1258
- const nodes = spec.sankeyNodes ?? [];
1259
- const links = spec.sankeyLinks ?? [];
1260
- if (!nodes.length || !links.length)
1261
- return { ...empty };
1262
- const padL = 10, padR = 10, padT = 14, padB = 14;
1263
- const plotW = Math.max(1, width - padL - padR);
1264
- const plotH = Math.max(1, height - padT - padB);
1265
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
1266
- const palette = spec.palette ?? DEFAULT_PALETTE;
1267
- const nodeById = new Map(nodes.map((n) => [n.id, n]));
1268
- // Column = longest path from any node with no incoming edges.
1269
- const targets = new Set(links.map((l) => l.target));
1270
- const sources = nodes.filter((n) => !targets.has(n.id));
1271
- const column = new Map();
1272
- function visit(id, depth, seen) {
1273
- if (seen.has(id))
1274
- return;
1275
- seen.add(id);
1276
- const cur = column.get(id) ?? 0;
1277
- if (depth > cur || !column.has(id))
1278
- column.set(id, depth);
1279
- for (const l of links)
1280
- if (l.source === id)
1281
- visit(l.target, depth + 1, seen);
1282
- seen.delete(id);
1283
- }
1284
- for (const s of sources)
1285
- visit(s.id, 0, new Set());
1286
- // Cover any nodes with no path from a source (orphan rings).
1287
- for (const n of nodes)
1288
- if (!column.has(n.id))
1289
- column.set(n.id, 0);
1290
- const maxCol = Math.max(...column.values());
1291
- const cols = maxCol + 1;
1292
- const nodeW = 14;
1293
- const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0;
1294
- // Totals per node.
1295
- const totalIn = new Map();
1296
- const totalOut = new Map();
1297
- for (const l of links) {
1298
- totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value);
1299
- totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value);
1300
- }
1301
- // Per-column groups + max total in that column.
1302
- const byCol = new Map();
1303
- for (const n of nodes) {
1304
- const c = column.get(n.id) ?? 0;
1305
- const arr = byCol.get(c) ?? [];
1306
- arr.push(n.id);
1307
- byCol.set(c, arr);
1308
- }
1309
- // Per-column total height + node height scale.
1310
- let maxColTotal = 0;
1311
- for (const ids of byCol.values()) {
1312
- const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0);
1313
- if (t > maxColTotal)
1314
- maxColTotal = t;
1315
- }
1316
- if (maxColTotal === 0)
1317
- return { ...empty, plot };
1318
- const nodeGapPx = 8;
1319
- const heightScale = (plotH - nodeGapPx * 8) / maxColTotal; // leave gap room
1320
- const placed = [];
1321
- for (const [c, ids] of byCol) {
1322
- const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale));
1323
- const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1);
1324
- let yCursor = padT + (plotH - totalH) / 2;
1325
- const xCol = padL + c * (nodeW + gapBetweenColumns);
1326
- ids.forEach((id, idx) => {
1327
- const node = nodeById.get(id);
1328
- const h = heights[idx];
1329
- placed.push({
1330
- id,
1331
- label: node.label ?? id,
1332
- color: node.color ?? palette[(placed.length) % palette.length],
1333
- x: xCol, y: yCursor, w: nodeW, h,
1334
- column: c,
1335
- totalIn: totalIn.get(id) ?? 0,
1336
- totalOut: totalOut.get(id) ?? 0,
1337
- });
1338
- yCursor += h + nodeGapPx;
1339
- });
1340
- }
1341
- const placedById = new Map(placed.map((n) => [n.id, n]));
1342
- // Per-node sub-cursor so multiple links from one node stack vertically.
1343
- const inCursor = new Map();
1344
- const outCursor = new Map();
1345
- const builtLinks = [];
1346
- // Sort links so wider ribbons render first (so thin ribbons stack on top).
1347
- const sortedLinks = links.slice().sort((a, b) => b.value - a.value);
1348
- for (const link of sortedLinks) {
1349
- const a = placedById.get(link.source);
1350
- const b = placedById.get(link.target);
1351
- if (!a || !b)
1352
- continue;
1353
- const linkH = Math.max(1, link.value * heightScale);
1354
- const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2;
1355
- const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2;
1356
- outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH);
1357
- inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH);
1358
- const x0 = a.x + a.w;
1359
- const x1 = b.x;
1360
- const mid = (x0 + x1) / 2;
1361
- const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`;
1362
- builtLinks.push({
1363
- path, color: link.color ?? a.color, width: linkH,
1364
- source: link.source, target: link.target, value: link.value,
1365
- });
1366
- }
1367
- return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks };
1368
- }
1369
- // ---- Heatmap ------------------------------------------------------
1370
- // Each series is one row, series.values are the cells across categories.
1371
- // Color comes from a sequential/diverging/custom palette mapped to the
1372
- // global value range. Cell text contrasts black/white against the cell.
1373
- if (spec.type === 'heatmap') {
1374
- if (!series.length || !spec.categories.length)
1375
- return { ...empty, plot: { x: 0, y: 0, w: width, h: height } };
1376
- // Layout: left gutter for row labels, bottom for column labels.
1377
- const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0);
1378
- const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7));
1379
- const padR = 64; // room for the right-side legend bar
1380
- const padT = 12;
1381
- const padB = 32;
1382
- const plotW = Math.max(1, width - padL - padR);
1383
- const plotH = Math.max(1, height - padT - padB);
1384
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
1385
- const cellW = plotW / spec.categories.length;
1386
- const cellH = plotH / series.length;
1387
- // Resolve value range across the whole matrix.
1388
- let vMin = Infinity, vMax = -Infinity;
1389
- for (const s of series)
1390
- for (const v of s.values) {
1391
- if (!Number.isFinite(v))
1392
- continue;
1393
- if (v < vMin)
1394
- vMin = v;
1395
- if (v > vMax)
1396
- vMax = v;
1397
- }
1398
- if (vMin === Infinity) {
1399
- vMin = 0;
1400
- vMax = 1;
1401
- }
1402
- if (vMin === vMax)
1403
- vMax = vMin + 1;
1404
- // Pick the palette stops.
1405
- const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme);
1406
- const colorAt = (v) => sampleGradient(stops, (v - vMin) / (vMax - vMin));
1407
- const heatmapCells = [];
1408
- series.forEach((s, ri) => {
1409
- s.values.forEach((v, ci) => {
1410
- if (!Number.isFinite(v))
1411
- return;
1412
- const color = colorAt(v);
1413
- heatmapCells.push({
1414
- x: round(padL + cellW * ci),
1415
- y: round(padT + cellH * ri),
1416
- w: round(cellW),
1417
- h: round(cellH),
1418
- color,
1419
- textColor: pickContrastText(color),
1420
- value: v,
1421
- rowLabel: s.label,
1422
- colLabel: spec.categories[ci] ?? '',
1423
- });
1424
- });
1425
- });
1426
- const heatmapRowTicks = series.map((s, i) => ({
1427
- value: i,
1428
- y: round(padT + cellH * i + cellH / 2),
1429
- label: s.label,
1430
- }));
1431
- const heatmapColTicks = spec.categories.map((label, i) => ({
1432
- label,
1433
- x: round(padL + cellW * i + cellW / 2),
1434
- }));
1435
- // Legend: sample 5 stops across the range.
1436
- const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
1437
- const t = i / 4;
1438
- const value = vMin + (vMax - vMin) * t;
1439
- return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) };
1440
- });
1441
- return {
1442
- ...empty,
1443
- plot,
1444
- heatmapCells,
1445
- heatmapRowTicks,
1446
- heatmapColTicks,
1447
- heatmapLegend,
1448
- };
1449
- }
1450
- if (spec.type === 'pie') {
1451
- const s = series[0];
1452
- if (!s)
1453
- return empty;
1454
- const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1;
1455
- const cx = width / 2;
1456
- const cy = height / 2;
1457
- const r = Math.min(width, height) / 2 - 10;
1458
- const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0));
1459
- const ir = r * innerFrac;
1460
- let angle = -Math.PI / 2;
1461
- const slices = s.values.map((v, i) => {
1462
- const frac = Math.max(0, v) / total;
1463
- const a0 = angle;
1464
- const a1 = angle + frac * Math.PI * 2;
1465
- angle = a1;
1466
- const large = a1 - a0 > Math.PI ? 1 : 0;
1467
- const mid = (a0 + a1) / 2;
1468
- const labelR = (r + ir) / 2 || r * 0.6;
1469
- const ox0 = cx + r * Math.cos(a0);
1470
- const oy0 = cy + r * Math.sin(a0);
1471
- const ox1 = cx + r * Math.cos(a1);
1472
- const oy1 = cy + r * Math.sin(a1);
1473
- let path;
1474
- if (frac >= 0.999) {
1475
- path = ir
1476
- ? `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` +
1477
- `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`
1478
- : `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`;
1479
- }
1480
- else if (ir > 0) {
1481
- const ix0 = cx + ir * Math.cos(a0);
1482
- const iy0 = cy + ir * Math.sin(a0);
1483
- const ix1 = cx + ir * Math.cos(a1);
1484
- const iy1 = cy + ir * Math.sin(a1);
1485
- path =
1486
- `M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
1487
- `L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`;
1488
- }
1489
- else {
1490
- path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`;
1491
- }
1492
- const catLabel = spec.categories[i] ?? String(i);
1493
- return {
1494
- path,
1495
- color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length],
1496
- label: catLabel,
1497
- value: v,
1498
- percent: frac * 100,
1499
- cx: round(cx + labelR * Math.cos(mid)),
1500
- cy: round(cy + labelR * Math.sin(mid)),
1501
- };
1502
- });
1503
- return {
1504
- ...empty,
1505
- slices,
1506
- legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length] })),
1507
- 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,
1508
- };
1509
- }
1510
- if (spec.type === 'scatter') {
1511
- const padL = 48 + (spec.yAxisTitle ? 16 : 0);
1512
- const padR = 12;
1513
- const padT = 10;
1514
- const padB = 28 + (spec.xAxisTitle ? 16 : 0);
1515
- const plotW = Math.max(1, width - padL - padR);
1516
- const plotH = Math.max(1, height - padT - padB);
1517
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
1518
- let xMin = Infinity;
1519
- let xMax = -Infinity;
1520
- let yMin = Infinity;
1521
- let yMax = -Infinity;
1522
- let rMin = Infinity;
1523
- let rMax = -Infinity;
1524
- for (const s of series) {
1525
- for (const pt of s.points ?? []) {
1526
- if (Number.isFinite(pt.x)) {
1527
- xMin = Math.min(xMin, pt.x);
1528
- xMax = Math.max(xMax, pt.x);
1529
- }
1530
- if (Number.isFinite(pt.y)) {
1531
- yMin = Math.min(yMin, pt.y);
1532
- yMax = Math.max(yMax, pt.y);
1533
- }
1534
- if (pt.r != null && Number.isFinite(pt.r)) {
1535
- rMin = Math.min(rMin, pt.r);
1536
- rMax = Math.max(rMax, pt.r);
1537
- }
1538
- }
1539
- }
1540
- if (xMin === Infinity)
1541
- return { ...empty, plot };
1542
- const xDom = niceScale(xMin, xMax);
1543
- const yDom = niceScale(yMin, yMax);
1544
- const hasR = rMax > rMin;
1545
- const xOf = (v) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW);
1546
- const yOf = (v) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH);
1547
- const rOf = (r) => hasR && r != null && Number.isFinite(r)
1548
- ? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
1549
- : 5;
1550
- const scatterPoints = [];
1551
- for (const s of series) {
1552
- for (const pt of s.points ?? []) {
1553
- if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y))
1554
- continue;
1555
- scatterPoints.push({
1556
- cx: xOf(pt.x),
1557
- cy: yOf(pt.y),
1558
- r: rOf(pt.r),
1559
- color: s.color,
1560
- label: pt.label ?? '',
1561
- series: s.label,
1562
- x: pt.x,
1563
- y: pt.y,
1564
- });
1565
- }
1566
- }
1567
- const referenceLines = (spec.referenceLines ?? []).map((ref) => ({
1568
- y: yOf(ref.value),
1569
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
1570
- color: ref.color ?? '#ef4444',
1571
- dashed: ref.dashed !== false,
1572
- }));
1573
- return {
1574
- ...empty,
1575
- plot,
1576
- scatterPoints,
1577
- referenceLines,
1578
- yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat, spec) })),
1579
- xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
1580
- };
1581
- }
165
+ const ctx = { spec, theme, width, height, palette, stacked, series, legend, empty, frame, axes };
166
+ if (spec.type === 'waterfall')
167
+ return layoutWaterfall(ctx);
168
+ if (spec.type === 'funnel')
169
+ return layoutFunnel(ctx);
170
+ if (spec.type === 'radar')
171
+ return layoutRadar(ctx);
172
+ if (spec.type === 'calendar')
173
+ return layoutCalendar(ctx);
174
+ if (spec.type === 'gauge')
175
+ return layoutGauge(ctx);
176
+ if (spec.type === 'treemap')
177
+ return layoutTreemap(ctx);
178
+ if (spec.type === 'sankey')
179
+ return layoutSankey(ctx);
180
+ if (spec.type === 'heatmap')
181
+ return layoutHeatmap(ctx);
182
+ if (spec.type === 'pie')
183
+ return layoutPie(ctx);
184
+ if (spec.type === 'scatter')
185
+ return layoutScatter(ctx);
186
+ if (spec.type === 'sunburst')
187
+ return layoutSunburst(ctx);
188
+ if (spec.type === 'radial-bar')
189
+ return layoutRadialBar(ctx);
190
+ if (spec.type === 'radial-column' || spec.type === 'nightingale')
191
+ return layoutRadialColumn(ctx);
192
+ if (spec.type === 'chord')
193
+ return layoutChord(ctx);
194
+ if (spec.type === 'bullet')
195
+ return layoutBullet(ctx);
1582
196
  // ---- Horizontal bars ----------------------------------------------------
1583
197
  // Categories run down the left, bars grow rightward. Bars-only (no combo).
1584
- const horizontal = spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar');
1585
- if (horizontal) {
1586
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0);
1587
- const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0);
1588
- const padR = 16;
1589
- const padT = 8;
1590
- const padB = 26 + (spec.xAxisTitle ? 16 : 0);
1591
- const plotW = Math.max(1, width - padL - padR);
1592
- const plotH = Math.max(1, height - padT - padB);
1593
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
1594
- const refs = (spec.referenceLines ?? []).map((r) => r.value);
1595
- const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs);
1596
- const xOf = (v) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW);
1597
- const n = spec.categories.length;
1598
- const slot = plotH / Math.max(1, n);
1599
- const groupPad = slot * 0.2;
1600
- const inner = slot - groupPad;
1601
- const bandTop = (i) => padT + slot * i + groupPad / 2;
1602
- const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max));
1603
- const bars = [];
1604
- if (stacked) {
1605
- const totals = spec.stacked100
1606
- ? spec.categories.map((_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]) ? s.values[i] : 0), 0) || 1)
1607
- : null;
1608
- const pos = new Array(n).fill(0);
1609
- const neg = new Array(n).fill(0);
1610
- for (const s of series) {
1611
- s.values.forEach((v, i) => {
1612
- if (!Number.isFinite(v))
1613
- return;
1614
- const vp = totals ? (v / totals[i]) * 100 : v;
1615
- let xL;
1616
- let xR;
1617
- if (vp >= 0) {
1618
- xL = xOf(pos[i]);
1619
- xR = xOf(pos[i] + vp);
1620
- pos[i] += vp;
1621
- }
1622
- else {
1623
- xL = xOf(neg[i] + vp);
1624
- xR = xOf(neg[i]);
1625
- neg[i] += vp;
1626
- }
1627
- bars.push({
1628
- x: Math.min(xL, xR),
1629
- y: round(bandTop(i)),
1630
- w: round(Math.abs(xR - xL)),
1631
- h: round(Math.max(1, inner)),
1632
- color: s.color,
1633
- label: spec.categories[i] ?? String(i),
1634
- series: s.label,
1635
- value: v,
1636
- });
1637
- });
1638
- }
1639
- }
1640
- else {
1641
- const barH = inner / series.length;
1642
- series.forEach((s, bi) => {
1643
- s.values.forEach((v, i) => {
1644
- if (!Number.isFinite(v))
1645
- return;
1646
- const xV = xOf(v);
1647
- bars.push({
1648
- x: Math.min(xV, xBase),
1649
- y: round(bandTop(i) + barH * bi),
1650
- w: round(Math.max(1, Math.abs(xV - xBase))),
1651
- h: round(Math.max(1, barH - 1)),
1652
- color: s.color,
1653
- label: spec.categories[i] ?? String(i),
1654
- series: s.label,
1655
- value: v,
1656
- });
1657
- });
1658
- });
1659
- }
1660
- const valueTicks = dom.ticks.map((value) => ({
1661
- label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat, spec),
1662
- x: xOf(value),
1663
- }));
1664
- const catTicks = spec.categories.map((label, i) => ({
1665
- value: i,
1666
- y: round(bandTop(i) + inner / 2),
1667
- label,
1668
- }));
1669
- const referenceLinesV = (spec.referenceLines ?? []).map((ref) => ({
1670
- x: xOf(ref.value),
1671
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
1672
- color: ref.color ?? '#ef4444',
1673
- dashed: ref.dashed !== false,
1674
- }));
1675
- return {
1676
- ...empty,
1677
- plot,
1678
- bars,
1679
- orientation: 'horizontal',
1680
- valueTicks,
1681
- catTicks,
1682
- referenceLinesV,
1683
- xLabelRotated: false,
1684
- };
1685
- }
1686
- // ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
1687
- const leftSeries = series.filter((s) => s.axis === 'left');
1688
- const rightSeries = series.filter((s) => s.axis === 'right');
1689
- const hasRightAxis = rightSeries.length > 0;
1690
- const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0);
1691
- const xLabelRotated = spec.categories.length > 8 || maxLabel > 9;
1692
- // Grouped (nested) category axis: valid only when the spans cover every leaf.
1693
- const validGroups = spec.categoryGroups &&
1694
- spec.categoryGroups.length > 0 &&
1695
- spec.xType !== 'time' &&
1696
- spec.orientation !== 'horizontal' &&
1697
- spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
1698
- ? spec.categoryGroups
1699
- : null;
1700
- const groupTierH = validGroups ? 18 : 0;
1701
- const padL = 48 + (spec.yAxisTitle ? 16 : 0);
1702
- const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0);
1703
- const padT = 10;
1704
- const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH;
1705
- const plotW = Math.max(1, width - padL - padR);
1706
- const plotH = Math.max(1, height - padT - padB);
1707
- const plot = { x: padL, y: padT, w: plotW, h: plotH };
1708
- const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value);
1709
- const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value);
1710
- const leftLog = spec.yScale === 'log';
1711
- const rightLog = spec.y2Scale === 'log';
1712
- const leftDom = spec.stacked100
1713
- ? niceScale(0, 100)
1714
- : axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog);
1715
- const rightDom = hasRightAxis
1716
- ? spec.stacked100
1717
- ? niceScale(0, 100)
1718
- : axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
1719
- : null;
1720
- /** Map a data value to a y pixel. Returns NaN for non-positive values on
1721
- * a log axis so callers can drop the point (line gap / missing bar). */
1722
- const yOf = (dom, v, isLog = false) => {
1723
- const t = project(v, dom.min, dom.max, isLog);
1724
- if (t === null)
1725
- return NaN;
1726
- return round(padT + plotH - t * plotH);
1727
- };
1728
- const yLeft = (v) => yOf(leftDom, v, leftLog);
1729
- const yRight = (v) => yOf(rightDom ?? leftDom, v, rightLog);
1730
- const domOf = (s) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom);
1731
- const isLogOf = (s) => (s.axis === 'right' ? rightLog : leftLog);
1732
- const n = spec.categories.length;
1733
- const slot = plotW / Math.max(1, n);
1734
- // X positions. A time axis spaces points by actual time (irregular gaps);
1735
- // a category axis is uniform. (Bars stay uniform either way.)
1736
- // Two date modes, and the difference is only where the marks go: `'time'`
1737
- // positions by the timestamp, `'ordinal-time'` positions by the index and
1738
- // uses the dates for labels alone. Both parse; only one scales.
1739
- const isDateAxis = spec.xType === 'time' || spec.xType === 'ordinal-time';
1740
- const timeVals = isDateAxis ? spec.categories.map((c) => Date.parse(c)) : null;
1741
- const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t));
1742
- const timeScaled = timeOk && spec.xType === 'time';
1743
- const tMin = timeOk ? Math.min(...timeVals.filter(Number.isFinite)) : 0;
1744
- const tSpan = timeOk ? Math.max(...timeVals.filter(Number.isFinite)) - tMin || 1 : 1;
1745
- const xCenter = (i) => timeScaled && Number.isFinite(timeVals[i])
1746
- ? round(padL + ((timeVals[i] - tMin) / tSpan) * plotW)
1747
- : round(padL + slot * i + slot / 2);
1748
- const xTicks = timeScaled
1749
- ? dateTicks(tMin, tMin + tSpan).map((t) => ({
1750
- label: fmtDate(t, tSpan),
1751
- x: round(padL + ((t - tMin) / tSpan) * plotW),
1752
- }))
1753
- : timeOk
1754
- ? // Ordinal: ticks land on points that exist, labelled from their dates.
1755
- ordinalDateTicks(timeVals).map((i) => ({
1756
- label: fmtDate(timeVals[i], tSpan),
1757
- x: xCenter(i),
1758
- }))
1759
- : thinCategoryTicks(spec.categories, xCenter, slot, xLabelRotated);
1760
- // Parent-tier ticks for a grouped category axis: each spans its leaves.
1761
- const categoryGroupTicks = [];
1762
- if (validGroups && !timeOk) {
1763
- let start = 0;
1764
- for (const g of validGroups) {
1765
- const x0 = round(padL + slot * start);
1766
- const x1 = round(padL + slot * (start + g.span));
1767
- categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) });
1768
- start += g.span;
1769
- }
1770
- }
1771
- const barSeries = series.filter((s) => s.kind === 'bar');
1772
- const bars = [];
1773
- if (barSeries.length) {
1774
- const groupPad = slot * 0.2;
1775
- if (stacked) {
1776
- const inner = slot - groupPad;
1777
- const x0 = (i) => padL + slot * i + groupPad / 2;
1778
- // Stack independently per axis so dual-axis stacks line up to their own scale.
1779
- for (const axis of ['left', 'right']) {
1780
- const axisBars = barSeries.filter((s) => s.axis === axis);
1781
- if (!axisBars.length)
1782
- continue;
1783
- const yA = axis === 'right' ? yRight : yLeft;
1784
- // 100% mode normalizes each category to its absolute total.
1785
- const totals = spec.stacked100
1786
- ? spec.categories.map((_, i) => axisBars.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]) ? s.values[i] : 0), 0) || 1)
1787
- : null;
1788
- const pos = new Array(n).fill(0);
1789
- const neg = new Array(n).fill(0);
1790
- for (const s of axisBars) {
1791
- s.values.forEach((v, i) => {
1792
- if (!Number.isFinite(v))
1793
- return;
1794
- const vp = totals ? (v / totals[i]) * 100 : v;
1795
- let yTop;
1796
- let yBot;
1797
- if (vp >= 0) {
1798
- yTop = yA(pos[i] + vp);
1799
- yBot = yA(pos[i]);
1800
- pos[i] += vp;
1801
- }
1802
- else {
1803
- yTop = yA(neg[i]);
1804
- yBot = yA(neg[i] + vp);
1805
- neg[i] += vp;
1806
- }
1807
- bars.push({
1808
- x: round(x0(i)),
1809
- y: Math.min(yTop, yBot),
1810
- w: round(Math.max(1, inner)),
1811
- h: round(Math.abs(yBot - yTop)),
1812
- color: s.color,
1813
- label: spec.categories[i] ?? String(i),
1814
- series: s.label,
1815
- value: v,
1816
- });
1817
- });
1818
- }
1819
- }
1820
- }
1821
- else {
1822
- const inner = slot - groupPad;
1823
- const barW = inner / barSeries.length;
1824
- barSeries.forEach((s, bi) => {
1825
- const dom = domOf(s);
1826
- const log = isLogOf(s);
1827
- // Log axis: bars grow from the axis floor (dom.min) up to v rather
1828
- // than from 0, since 0 is invalid in log space.
1829
- const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log);
1830
- s.values.forEach((v, i) => {
1831
- if (!Number.isFinite(v))
1832
- return;
1833
- if (log && v <= 0)
1834
- return;
1835
- const x = padL + slot * i + groupPad / 2 + barW * bi;
1836
- const yV = yOf(dom, v, log);
1837
- bars.push({
1838
- x: round(x),
1839
- y: Math.min(yV, base),
1840
- w: round(Math.max(1, barW - 1)),
1841
- h: round(Math.max(1, Math.abs(yV - base))),
1842
- color: s.color,
1843
- label: spec.categories[i] ?? String(i),
1844
- series: s.label,
1845
- value: v,
1846
- });
1847
- });
1848
- });
1849
- }
1850
- }
1851
- // Candlesticks / OHLC bars. Laid out once; the two marks differ only in how
1852
- // the renderer paints them, so there is no second geometry pass.
1853
- const candleSeries = series.filter((s) => s.kind === 'candle');
1854
- const candles = [];
1855
- if (candleSeries.length) {
1856
- const upColor = spec.candleColors?.up ?? '#16a34a';
1857
- const downColor = spec.candleColors?.down ?? '#ef4444';
1858
- // Share the slot when two instruments are charted together, the same way
1859
- // grouped bars do.
1860
- const bodyW = Math.max(1, (slot * 0.7) / candleSeries.length);
1861
- candleSeries.forEach((s, si) => {
1862
- const dom = domOf(s);
1863
- const log = isLogOf(s);
1864
- (s.ohlc ?? []).forEach((k, i) => {
1865
- if (!k)
1866
- return;
1867
- if (![k.o, k.h, k.l, k.c].every(Number.isFinite))
1868
- return;
1869
- // A log price axis is genuinely used for long histories, and a
1870
- // non-positive price has no place on one.
1871
- if (log && (k.o <= 0 || k.h <= 0 || k.l <= 0 || k.c <= 0))
1872
- return;
1873
- const centre = padL + slot * i + slot / 2;
1874
- const x = centre - (bodyW * candleSeries.length) / 2 + bodyW * si;
1875
- const yOpen = yOf(dom, k.o, log);
1876
- const yClose = yOf(dom, k.c, log);
1877
- const up = k.c >= k.o;
1878
- candles.push({
1879
- x: round(x),
1880
- w: round(bodyW),
1881
- xCenter: round(x + bodyW / 2),
1882
- yOpen,
1883
- yClose,
1884
- yHigh: yOf(dom, k.h, log),
1885
- yLow: yOf(dom, k.l, log),
1886
- bodyY: Math.min(yOpen, yClose),
1887
- // A doji closes where it opened; keep it visible as a 1px line
1888
- // rather than a zero-height rect that paints nothing.
1889
- bodyH: Math.max(1, Math.abs(yClose - yOpen)),
1890
- up,
1891
- color: up ? upColor : downColor,
1892
- label: spec.categories[i] ?? String(i),
1893
- series: s.label,
1894
- o: k.o,
1895
- h: k.h,
1896
- l: k.l,
1897
- c: k.c,
1898
- });
1899
- });
1900
- });
1901
- }
1902
- // Box plots. Same slot-sharing as grouped bars and candles, so several
1903
- // samples can sit side by side under one category.
1904
- const boxSeries = series.filter((s) => s.kind === 'box');
1905
- const boxes = [];
1906
- if (boxSeries.length) {
1907
- const boxW = Math.max(1, (slot * 0.6) / boxSeries.length);
1908
- boxSeries.forEach((s, si) => {
1909
- const dom = domOf(s);
1910
- const log = isLogOf(s);
1911
- (s.boxes ?? []).forEach((b, i) => {
1912
- if (!b)
1913
- return;
1914
- if (![b.min, b.q1, b.median, b.q3, b.max].every(Number.isFinite))
1915
- return;
1916
- if (log && b.min <= 0)
1917
- return;
1918
- const centre = padL + slot * i + slot / 2;
1919
- const x = centre - (boxW * boxSeries.length) / 2 + boxW * si;
1920
- const yQ1 = yOf(dom, b.q1, log);
1921
- const yQ3 = yOf(dom, b.q3, log);
1922
- boxes.push({
1923
- x: round(x),
1924
- w: round(boxW),
1925
- xCenter: round(x + boxW / 2),
1926
- yMin: yOf(dom, b.min, log),
1927
- yQ1,
1928
- yMedian: yOf(dom, b.median, log),
1929
- yQ3,
1930
- yMax: yOf(dom, b.max, log),
1931
- boxY: Math.min(yQ1, yQ3),
1932
- // A sample with no spread would otherwise paint nothing at all.
1933
- boxH: Math.max(1, Math.abs(yQ1 - yQ3)),
1934
- outliers: (b.outliers ?? [])
1935
- .filter((o) => Number.isFinite(o) && (!log || o > 0))
1936
- .map((o) => ({ y: yOf(dom, o, log), value: o })),
1937
- color: s.color,
1938
- label: spec.categories[i] ?? String(i),
1939
- series: s.label,
1940
- min: b.min,
1941
- q1: b.q1,
1942
- median: b.median,
1943
- q3: b.q3,
1944
- max: b.max,
1945
- });
1946
- });
1947
- });
1948
- }
1949
- // Error bars. Not a mark of their own: they annotate whatever the series
1950
- // already draws, so this runs over every series carrying `errors` regardless
1951
- // of kind, and the geometry sits in its own array so no existing loop changes.
1952
- const errorBars = [];
1953
- for (const s of series) {
1954
- if (!s.errors)
1955
- continue;
1956
- const dom = domOf(s);
1957
- const log = isLogOf(s);
1958
- s.errors.forEach((e, i) => {
1959
- const v = s.values[i];
1960
- if (!Number.isFinite(v))
1961
- return;
1962
- const span = errorSpan(e, v);
1963
- if (!span)
1964
- return;
1965
- if (log && span.lo <= 0)
1966
- return;
1967
- errorBars.push({
1968
- xCenter: round(padL + slot * i + slot / 2),
1969
- yLo: yOf(dom, span.lo, log),
1970
- yHi: yOf(dom, span.hi, log),
1971
- cap: round(Math.min(6, slot * 0.15)),
1972
- color: s.color,
1973
- label: spec.categories[i] ?? String(i),
1974
- series: s.label,
1975
- lo: span.lo,
1976
- hi: span.hi,
1977
- });
1978
- });
1979
- }
1980
- // Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
1981
- const lines = [];
1982
- const areaCum = {
1983
- left: new Array(n).fill(0),
1984
- right: new Array(n).fill(0),
1985
- };
1986
- // 100% mode: per-axis per-category totals to normalize stacked areas to 100.
1987
- const areaTotals = { left: null, right: null };
1988
- if (spec.stacked100) {
1989
- for (const axis of ['left', 'right']) {
1990
- const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis);
1991
- if (areaSeries.length) {
1992
- areaTotals[axis] = spec.categories.map((_, i) => areaSeries.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]) ? s.values[i] : 0), 0) || 1);
1993
- }
1994
- }
1995
- }
1996
- for (const s of series) {
1997
- // Bars, candles and boxes draw their own marks. Candles and boxes
1998
- // especially: `values` holds their closes / medians so that tooltips, CSV
1999
- // and overlays work, and without this guard that same array was ALSO drawn
2000
- // as a line, laying a dotted close-line straight over every candle. Boxes
2001
- // would do exactly the same thing through the median.
2002
- if (s.kind === 'bar' || s.kind === 'candle' || s.kind === 'box')
2003
- continue;
2004
- const dom = domOf(s);
2005
- const log = isLogOf(s);
2006
- const yA = (v) => yOf(dom, v, log);
2007
- const isStackedArea = stacked && s.kind === 'area';
2008
- const px = (i) => xCenter(i);
2009
- let pts;
2010
- let baselinePts = null;
2011
- if (isStackedArea) {
2012
- // Stacked areas treat a gap as 0 so the stack stays continuous.
2013
- const cum = areaCum[s.axis];
2014
- const prev = cum.slice();
2015
- const totals = areaTotals[s.axis];
2016
- pts = s.values.map((v, i) => {
2017
- const vv = Number.isFinite(v) ? v : 0;
2018
- // 100% mode positions by share of the category total; value stays original.
2019
- const norm = totals ? (vv / totals[i]) * 100 : vv;
2020
- const c = (cum[i] ?? 0) + norm;
2021
- cum[i] = c;
2022
- return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) };
2023
- });
2024
- baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }));
2025
- }
2026
- else {
2027
- pts = s.values.map((v, i) => {
2028
- const ok = Number.isFinite(v);
2029
- return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok };
2030
- });
2031
- }
2032
- // Build the line - smoothed via monotone cubic when requested, else
2033
- // straight polylines. Either way, gaps break the path cleanly.
2034
- const smooth = !!s.smooth;
2035
- const path = buildLinePath(pts, smooth);
2036
- let areaPath = '';
2037
- if (s.kind === 'area' && pts.length) {
2038
- if (baselinePts) {
2039
- const top = smooth
2040
- ? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
2041
- : pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
2042
- const back = smooth
2043
- ? `L${baselinePts[baselinePts.length - 1].x},${baselinePts[baselinePts.length - 1].y} ` +
2044
- monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
2045
- : baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ');
2046
- areaPath = `${top} ${back} Z`;
2047
- }
2048
- else {
2049
- // One filled polygon per contiguous run of defined points.
2050
- const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)));
2051
- const runs = [];
2052
- let cur = [];
2053
- for (const p of pts) {
2054
- if (p.defined)
2055
- cur.push(p);
2056
- else if (cur.length) {
2057
- runs.push(cur);
2058
- cur = [];
2059
- }
2060
- }
2061
- if (cur.length)
2062
- runs.push(cur);
2063
- areaPath = runs
2064
- .map((run) => {
2065
- const top = smooth
2066
- ? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
2067
- : run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
2068
- return `${top} L${run[run.length - 1].x},${baseY} L${run[0].x},${baseY} Z`;
2069
- })
2070
- .join(' ');
2071
- }
2072
- }
2073
- // Confidence band: shaded envelope between upperValues / lowerValues.
2074
- // Both arrays must be present and aligned to the value array.
2075
- let bandPath = '';
2076
- if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
2077
- const upperPts = [];
2078
- const lowerPts = [];
2079
- for (let i = 0; i < s.values.length; i += 1) {
2080
- const u = s.upperValues[i];
2081
- const lo = s.lowerValues[i];
2082
- if (!Number.isFinite(u) || !Number.isFinite(lo))
2083
- continue;
2084
- if (log && (u <= 0 || lo <= 0))
2085
- continue;
2086
- upperPts.push({ x: px(i), y: yA(u) });
2087
- lowerPts.push({ x: px(i), y: yA(lo) });
2088
- }
2089
- if (upperPts.length >= 2) {
2090
- const top = smooth
2091
- ? monotoneCubicPath(upperPts)
2092
- : upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
2093
- const back = smooth
2094
- ? `L${lowerPts[lowerPts.length - 1].x},${lowerPts[lowerPts.length - 1].y} ` +
2095
- monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
2096
- : lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ');
2097
- bandPath = `${top} ${back} Z`;
2098
- }
2099
- }
2100
- lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath });
2101
- }
2102
- // Under `stacked100` the axis is a share of the total, not the measure, so
2103
- // it is labelled as a percentage whatever `valueFormat` says. Formatting it
2104
- // as currency gives an axis reading "$0 .. $100" for what are percentages -
2105
- // which is what it did, unnoticed, while `stacked100` was reachable only
2106
- // from config.
2107
- const tickFor = (dom, log) => dom.ticks.map((value) => ({
2108
- value,
2109
- y: yOf(dom, value, log),
2110
- label: spec.stacked100 ? `${round(value)}%` : formatChartValue(value, spec.valueFormat, spec),
2111
- }));
2112
- const referenceLines = (spec.referenceLines ?? []).map((ref) => {
2113
- const onRight = ref.axis === 'right';
2114
- const dom = onRight ? (rightDom ?? leftDom) : leftDom;
2115
- const log = onRight ? rightLog : leftLog;
2116
- return {
2117
- y: yOf(dom, ref.value, log),
2118
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
2119
- color: ref.color ?? '#ef4444',
2120
- dashed: ref.dashed !== false,
2121
- };
2122
- });
2123
- // ---- Overlays: trendline / moving average ------------------------
2124
- // For every series with an `overlay`, compute the smoothed values and
2125
- // render as a dashed line in the source series' color (or overlayColor).
2126
- const overlays = [];
2127
- for (const s of series) {
2128
- if (!s.overlay)
2129
- continue;
2130
- const dom = domOf(s);
2131
- const log = isLogOf(s);
2132
- const overlayVals = computeOverlay(s.values, s.overlay);
2133
- const color = s.overlayColor ?? s.color;
2134
- const pts = overlayVals.map((v, i) => {
2135
- const ok = Number.isFinite(v) && (!log || v > 0);
2136
- return {
2137
- x: xCenter(i),
2138
- y: ok ? yOf(dom, v, log) : NaN,
2139
- label: spec.categories[i] ?? String(i),
2140
- value: v,
2141
- defined: ok,
2142
- };
2143
- });
2144
- const path = buildLinePath(pts, !!s.smooth);
2145
- overlays.push({
2146
- path,
2147
- areaPath: '',
2148
- color,
2149
- label: `${s.label} (${s.overlay})`,
2150
- points: pts,
2151
- });
2152
- }
2153
- // ---- Annotations: resolve data-space anchors to pixel coords ------
2154
- const annotations = [];
2155
- for (const a of (spec.annotations ?? [])) {
2156
- let ax = null;
2157
- let ay = null;
2158
- if ('category' in a.at) {
2159
- const ci = spec.categories.indexOf(a.at.category);
2160
- if (ci < 0)
2161
- continue;
2162
- ax = xCenter(ci);
2163
- // Anchor to the named series' value at that category, else just
2164
- // mid-plot. Picks the first matching series if `series` is set.
2165
- const seriesName = a.at.series;
2166
- const s = seriesName ? series.find((x) => x.label === seriesName) : series[0];
2167
- if (s) {
2168
- const v = s.values[ci];
2169
- if (Number.isFinite(v))
2170
- ay = yOf(domOf(s), v, isLogOf(s));
2171
- }
2172
- if (ay == null)
2173
- ay = padT + plotH / 2;
2174
- }
2175
- else {
2176
- // Raw x/y in data space (x ignored for category x-axis; takes the
2177
- // mid-plot in that case). y projects through the left axis.
2178
- ax = padL + plotW / 2;
2179
- if (Number.isFinite(a.at.y))
2180
- ay = yOf(leftDom, a.at.y, leftLog);
2181
- else
2182
- ay = padT + plotH / 2;
2183
- }
2184
- if (ax != null && ay != null && Number.isFinite(ay)) {
2185
- annotations.push({
2186
- x: ax,
2187
- y: ay,
2188
- label: a.label,
2189
- color: a.color ?? '#0f172a',
2190
- placement: a.placement ?? 'top',
2191
- });
2192
- }
2193
- }
2194
- return {
2195
- ...empty,
2196
- plot,
2197
- // What a caller needs to put its own marks in this chart's coordinates.
2198
- // Reported rather than recomputed, so a custom mark lands on exactly the
2199
- // scale the built-in ones did - including the nice-scale rounding and the
2200
- // "always include zero" rule, which are impossible to guess from outside.
2201
- axes: {
2202
- y: { min: leftDom.min, max: leftDom.max, log: leftLog },
2203
- y2: rightDom ? { min: rightDom.min, max: rightDom.max, log: rightLog } : null,
2204
- slot,
2205
- count: spec.categories.length,
2206
- },
2207
- bars,
2208
- candles,
2209
- boxes,
2210
- errorBars,
2211
- lines,
2212
- yTicks: tickFor(leftDom, leftLog),
2213
- y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
2214
- hasRightAxis,
2215
- xTicks,
2216
- categoryGroupTicks,
2217
- xLabelRotated: timeOk ? false : xLabelRotated,
2218
- referenceLines,
2219
- overlays,
2220
- annotations,
2221
- };
198
+ const horizontal = spec.orientation === 'horizontal' && spec.type !== 'histogram' && series.length > 0 && series.every((s) => s.kind === 'bar');
199
+ if (horizontal)
200
+ return layoutHorizontalBars(ctx);
201
+ return layoutCartesian(ctx);
2222
202
  }
2223
203
  /**
2224
204
  * Narrow a spec to the category window `[lo, hi]`, keeping every
@@ -2239,31 +219,20 @@ export function buildChart(spec, theme = 'light') {
2239
219
  export function sliceChartWindow(spec, lo, hi) {
2240
220
  const from = Math.max(0, lo);
2241
221
  const to = Math.min(spec.categories.length - 1, hi);
2242
- const cut = (arr) => arr ? arr.slice(from, to + 1) : undefined;
2243
- return {
2244
- ...spec,
2245
- categories: spec.categories.slice(from, to + 1),
2246
- // EVERY per-category array on a series has to be cut here, not just the
2247
- // ones that existed when this function was written. A missed one does not
2248
- // throw: the geometry keeps indexing the full-length array against the
2249
- // sliced categories, so marks land at the wrong x or off the plot entirely.
2250
- // `upperValues` / `lowerValues` were missed once and silently dropped the
2251
- // confidence band on zoom; `ohlc` was missed the same way and drew a
2252
- // zoomed candlestick chart against the wrong categories.
2253
- series: spec.series.map((s) => ({
2254
- ...s,
2255
- values: s.values.slice(from, to + 1),
2256
- rowIds: cut(s.rowIds),
2257
- upperValues: cut(s.upperValues),
2258
- lowerValues: cut(s.lowerValues),
2259
- ohlc: cut(s.ohlc),
2260
- boxes: cut(s.boxes),
2261
- errors: cut(s.errors),
2262
- })),
2263
- // Per-category, so it has to travel with the window or the waterfall's
2264
- // running total resets on the wrong bars.
2265
- waterfallTotals: cut(spec.waterfallTotals),
2266
- };
222
+ if (to < from)
223
+ return { ...spec, categories: [], series: spec.series.map((s) => ({ ...s, values: [] })) };
224
+ // EVERY per-category array on a series has to be cut here, not just the
225
+ // ones that existed when this function was written. A missed one does not
226
+ // throw: the geometry keeps indexing the full-length array against the
227
+ // sliced categories, so marks land at the wrong x or off the plot entirely.
228
+ // `upperValues` / `lowerValues` were missed once and silently dropped the
229
+ // confidence band on zoom; `ohlc` was missed the same way. The list of those
230
+ // arrays now lives in one constant (PER_CATEGORY_SERIES_KEYS) shared with
231
+ // decimation, and `pickCategories` is the one function that applies it.
232
+ const indices = new Array(to - from + 1);
233
+ for (let i = from; i <= to; i += 1)
234
+ indices[i - from] = i;
235
+ return pickCategories(spec, indices);
2267
236
  }
2268
237
  /**
2269
238
  * Aggregate flat rows into a chart spec. Group by a category field, reduce a
@@ -2275,8 +244,13 @@ export function sliceChartWindow(spec, lo, hi) {
2275
244
  */
2276
245
  export function rowsToChartSpec(rows, opts) {
2277
246
  const reduce = opts.reduce ?? 'sum';
247
+ const wantSamples = reducerNeedsSamples(reduce);
2278
248
  const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value];
2279
- const reduceCell = (sum, count) => reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum;
249
+ // The three original reducers run on a sum and a count; the rest keep every
250
+ // observation, which is what a median or a percentile needs.
251
+ const reduceCell = (cell) => wantSamples
252
+ ? reduceValues(cell.samples, reduce)
253
+ : reduce === 'count' ? cell.count : reduce === 'avg' ? (cell.count ? cell.sum / cell.count : 0) : cell.sum;
2280
254
  const categories = [];
2281
255
  const catIndex = new Map();
2282
256
  const ensureCat = (key) => {
@@ -2297,50 +271,51 @@ export function rowsToChartSpec(rows, opts) {
2297
271
  }
2298
272
  return arr;
2299
273
  };
274
+ const note = (cell, num, rowId) => {
275
+ cell.sum += num;
276
+ cell.count += 1;
277
+ if (wantSamples)
278
+ cell.samples.push(num);
279
+ if (rowId !== undefined)
280
+ cell.rowIds.push(rowId);
281
+ };
282
+ const fresh = () => ({ sum: 0, count: 0, rowIds: [], samples: [] });
2300
283
  const trackIds = opts.idField !== undefined;
2301
284
  for (const row of rows) {
2302
- const cat = String(row[opts.category] ?? '');
285
+ const raw = row[opts.category];
286
+ const cat = opts.bucket ? bucketStart(raw, opts.bucket) : String(raw ?? '');
287
+ if (opts.bucket && !cat)
288
+ continue;
2303
289
  const ci = ensureCat(cat);
2304
290
  const rowId = trackIds ? row[opts.idField] : undefined;
2305
291
  if (opts.series) {
2306
292
  const sName = String(row[opts.series] ?? '');
2307
293
  const arr = ensureSeries(sName);
2308
294
  const num = Number(row[valueFields[0]]);
2309
- const cell = (arr[ci] ?? (arr[ci] = { sum: 0, count: 0, rowIds: [] }));
2310
- if (Number.isFinite(num)) {
2311
- cell.sum += num;
2312
- cell.count += 1;
2313
- if (rowId !== undefined)
2314
- cell.rowIds.push(rowId);
2315
- }
295
+ const cell = (arr[ci] ?? (arr[ci] = fresh()));
296
+ if (Number.isFinite(num))
297
+ note(cell, num, rowId);
2316
298
  }
2317
299
  else {
2318
300
  for (const vf of valueFields) {
2319
301
  const arr = ensureSeries(vf);
2320
302
  const num = Number(row[vf]);
2321
- const cell = (arr[ci] ?? (arr[ci] = { sum: 0, count: 0, rowIds: [] }));
2322
- if (Number.isFinite(num)) {
2323
- cell.sum += num;
2324
- cell.count += 1;
2325
- if (rowId !== undefined)
2326
- cell.rowIds.push(rowId);
2327
- }
303
+ const cell = (arr[ci] ?? (arr[ci] = fresh()));
304
+ if (Number.isFinite(num))
305
+ note(cell, num, rowId);
2328
306
  }
2329
307
  }
2330
308
  }
2331
309
  const entries = [...seriesMap.entries()].map(([name, arr]) => ({
2332
310
  label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
2333
- values: categories.map((_, i) => {
2334
- const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] };
2335
- return reduceCell(cell.sum, cell.count);
2336
- }),
311
+ values: categories.map((_, i) => reduceCell(arr[i] ?? fresh())),
2337
312
  rowIds: trackIds
2338
313
  ? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
2339
314
  : undefined,
2340
315
  }));
2341
316
  // ---- Sort + top-N -----------------------------------------------------
2342
317
  const totals = categories.map((_, i) => entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]) ? e.values[i] : 0), 0));
2343
- const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none');
318
+ const sort = opts.sort ?? (opts.topN ? 'value-desc' : opts.bucket ? 'category' : 'none');
2344
319
  const order = categories.map((_, i) => i);
2345
320
  if (sort === 'value-desc')
2346
321
  order.sort((a, b) => totals[b] - totals[a]);
@@ -2351,9 +326,16 @@ export function rowsToChartSpec(rows, opts) {
2351
326
  let finalCategories;
2352
327
  let finalSeries;
2353
328
  if (opts.topN && order.length > opts.topN) {
2354
- const keep = order.slice(0, opts.topN);
2355
- const rest = order.slice(opts.topN);
2356
- finalCategories = keep.map((i) => categories[i]).concat(opts.otherLabel ?? 'Other');
329
+ const other = opts.otherLabel ?? 'Other';
330
+ // A real category may already be called "Other" (a survey's own bucket,
331
+ // a browser share's long tail). Two categories with one name is a
332
+ // duplicate key the renderer cannot draw, so the tail folds INTO that
333
+ // category rather than beside it: it is kept whether or not it made the
334
+ // top N, and the rest is added to it.
335
+ const own = order.find((i) => categories[i] === other);
336
+ const keep = order.slice(0, opts.topN).filter((i) => i !== own);
337
+ const rest = order.filter((i) => !keep.includes(i));
338
+ finalCategories = keep.map((i) => categories[i]).concat(other);
2357
339
  finalSeries = entries.map((e) => ({
2358
340
  label: e.label,
2359
341
  values: keep
@@ -2381,6 +363,124 @@ export function rowsToChartSpec(rows, opts) {
2381
363
  stacked: opts.stacked,
2382
364
  stacked100: opts.stacked100,
2383
365
  palette: opts.palette,
366
+ ...(opts.bucket ? { xType: 'ordinal-time' } : {}),
367
+ };
368
+ }
369
+ /**
370
+ * The bar-plus-line spec a pareto chart draws: categories sorted by value,
371
+ * bars for the values, and a cumulative-share line on the right axis pinned to
372
+ * 0..100 with a reference line at `threshold` (default 80, the "vital few").
373
+ * `buildChart` calls this for `type: 'pareto'`; call it yourself to get at the
374
+ * spec, or pass a ready spec whose first series is the values.
375
+ */
376
+ export function paretoSpec(spec, threshold = 80) {
377
+ const src = spec.series[0];
378
+ if (!src)
379
+ return { ...spec, type: 'bar' };
380
+ const order = spec.categories.map((_, i) => i).sort((a, b) => (src.values[b] ?? 0) - (src.values[a] ?? 0));
381
+ const sorted = pickCategories(spec, order);
382
+ const values = sorted.series[0].values.map((v) => (Number.isFinite(v) ? Math.max(0, v) : 0));
383
+ const total = values.reduce((a, b) => a + b, 0) || 1;
384
+ let cum = 0;
385
+ const share = values.map((v) => {
386
+ cum += v;
387
+ return Math.round((cum / total) * 1000) / 10;
388
+ });
389
+ return {
390
+ ...sorted,
391
+ type: 'bar',
392
+ series: [
393
+ { ...sorted.series[0], type: 'bar' },
394
+ { label: 'Cumulative %', values: share, type: 'line', axis: 'right', marker: 'circle', color: sorted.series[0].overlayColor ?? '#ef4444' },
395
+ ],
396
+ y2Axis: { min: 0, max: 100, tickInterval: 25, formatter: (v) => `${v}%`, ...(spec.y2Axis ?? {}) },
397
+ referenceLines: [
398
+ ...(spec.referenceLines ?? []),
399
+ ...(threshold > 0 ? [{ value: threshold, axis: 'right', label: `${threshold}%`, dashed: true }] : []),
400
+ ],
401
+ };
402
+ }
403
+ /**
404
+ * Bin one numeric field of the rows into a histogram spec. The categories are
405
+ * the bin midpoints on a numeric axis and `binEdges` labels the edges, so the
406
+ * bars touch and the axis reads `0, 10, 20` rather than `5, 15, 25`.
407
+ * `series` splits the sample into one histogram per distinct value.
408
+ */
409
+ export function rowsToHistogramSpec(rows, opts) {
410
+ const all = rows.map((r) => Number(r[opts.value])).filter(Number.isFinite);
411
+ // Bin the whole sample once so every split shares the same edges.
412
+ const bins = binValues(all, { bins: opts.bins, binWidth: opts.binWidth, method: opts.method, min: opts.min, max: opts.max });
413
+ const groups = opts.series
414
+ ? [...new Set(rows.map((r) => String(r[opts.series] ?? '')))].map((g) => ({ label: g, rows: rows.filter((r) => String(r[opts.series] ?? '') === g) }))
415
+ : [{ label: opts.seriesLabel ?? String(opts.value), rows }];
416
+ const series = groups.map((g) => {
417
+ const counts = new Array(bins.counts.length).fill(0);
418
+ const lo = bins.edges[0] ?? 0;
419
+ for (const r of g.rows) {
420
+ const n = Number(r[opts.value]);
421
+ if (!Number.isFinite(n) || !bins.width)
422
+ continue;
423
+ let i = Math.floor((n - lo) / bins.width);
424
+ if (i >= counts.length)
425
+ i = counts.length - 1;
426
+ if (i < 0)
427
+ i = 0;
428
+ counts[i] = (counts[i] ?? 0) + 1;
429
+ }
430
+ return { label: g.label, values: counts };
431
+ });
432
+ return {
433
+ type: 'histogram',
434
+ categories: bins.categories,
435
+ series,
436
+ binEdges: bins.edges,
437
+ xType: 'number',
438
+ ...(opts.palette ? { palette: opts.palette } : {}),
439
+ ...(opts.width ? { width: opts.width } : {}),
440
+ ...(opts.height ? { height: opts.height } : {}),
441
+ };
442
+ }
443
+ /**
444
+ * A range (floating) bar or area spec from rows: one row per category with a
445
+ * low field and a high field, e.g. a day's low and high temperature, or a
446
+ * salary band. `series` splits into one range per distinct value.
447
+ */
448
+ export function rowsToRangeSpec(rows, opts) {
449
+ const cats = [];
450
+ const seen = new Set();
451
+ for (const r of rows) {
452
+ const c = String(r[opts.category] ?? '');
453
+ if (!seen.has(c)) {
454
+ seen.add(c);
455
+ cats.push(c);
456
+ }
457
+ }
458
+ const names = opts.series
459
+ ? [...new Set(rows.map((r) => String(r[opts.series] ?? '')))]
460
+ : [opts.seriesLabel ?? `${String(opts.low)} to ${String(opts.high)}`];
461
+ const series = names.map((name) => {
462
+ const lows = new Array(cats.length).fill(Number.NaN);
463
+ const highs = new Array(cats.length).fill(Number.NaN);
464
+ for (const r of rows) {
465
+ if (opts.series && String(r[opts.series] ?? '') !== name)
466
+ continue;
467
+ const i = cats.indexOf(String(r[opts.category] ?? ''));
468
+ const lo = Number(r[opts.low]);
469
+ const hi = Number(r[opts.high]);
470
+ if (i < 0 || !Number.isFinite(lo) || !Number.isFinite(hi))
471
+ continue;
472
+ lows[i] = Math.min(lo, hi);
473
+ highs[i] = Math.max(lo, hi);
474
+ }
475
+ return { label: name, values: highs, lowValues: lows };
476
+ });
477
+ return {
478
+ type: opts.type ?? 'range-bar',
479
+ categories: cats,
480
+ series,
481
+ ...(opts.palette ? { palette: opts.palette } : {}),
482
+ ...(opts.width ? { width: opts.width } : {}),
483
+ ...(opts.height ? { height: opts.height } : {}),
2384
484
  };
2385
485
  }
2386
486
  // ---------------------------------------------------------------------------
@@ -2476,6 +576,108 @@ export function specToSankey(spec) {
2476
576
  links,
2477
577
  };
2478
578
  }
579
+ /**
580
+ * Responsive rules the builder offers by name. `compact(maxWidth)` is the
581
+ * one the Format tab writes through `compactBelow`: the labels that crowd a
582
+ * narrow chart go, the category labels turn vertical, and the legend hides.
583
+ */
584
+ export const CHART_RESPONSIVE_PRESETS = {
585
+ compact: (maxWidth) => ({
586
+ maxWidth,
587
+ spec: { seriesLabels: false, dataLabels: { show: false }, xAxis: { labelRotation: 90 } },
588
+ legend: false,
589
+ }),
590
+ };
591
+ /**
592
+ * Apply a {@link ChartFormatState} to a spec: a new spec with the titles,
593
+ * axis settings, palette and per-series styling merged in. Pure, so the
594
+ * panel can run it on every derived spec and a saved format keeps applying
595
+ * to fresh data. Unknown series labels are ignored.
596
+ */
597
+ export function applyChartFormat(spec, format) {
598
+ if (!format)
599
+ return spec;
600
+ const out = { ...spec };
601
+ if (format.title !== undefined)
602
+ out.title = format.title || undefined;
603
+ if (format.subtitle !== undefined)
604
+ out.subtitle = format.subtitle || undefined;
605
+ if (format.caption !== undefined)
606
+ out.caption = format.caption || undefined;
607
+ if (format.palette)
608
+ out.palette = format.palette;
609
+ if (format.seriesLabels !== undefined)
610
+ out.seriesLabels = format.seriesLabels;
611
+ if (format.style)
612
+ out.style = { ...spec.style, ...format.style };
613
+ if (format.compactBelow != null && format.compactBelow > 0) {
614
+ out.responsive = [...(spec.responsive ?? []), CHART_RESPONSIVE_PRESETS.compact(format.compactBelow)];
615
+ }
616
+ const axis = (base, f) => {
617
+ if (!f)
618
+ return base;
619
+ const next = { ...base };
620
+ if (f.min !== undefined) {
621
+ if (f.min === null)
622
+ delete next.min;
623
+ else
624
+ next.min = f.min;
625
+ }
626
+ if (f.max !== undefined) {
627
+ if (f.max === null)
628
+ delete next.max;
629
+ else
630
+ next.max = f.max;
631
+ }
632
+ if (f.title !== undefined)
633
+ next.title = f.title || undefined;
634
+ if (f.gridLines !== undefined)
635
+ next.gridLines = f.gridLines;
636
+ if (f.format !== undefined)
637
+ next.format = f.format;
638
+ return next;
639
+ };
640
+ if (format.xAxis) {
641
+ const next = { ...out.xAxis };
642
+ if (format.xAxis.title !== undefined)
643
+ next.title = format.xAxis.title || undefined;
644
+ if (format.xAxis.gridLines !== undefined)
645
+ next.gridLines = format.xAxis.gridLines;
646
+ if (format.xAxis.labelRotation !== undefined)
647
+ next.labelRotation = format.xAxis.labelRotation;
648
+ out.xAxis = next;
649
+ }
650
+ if (format.yAxis)
651
+ out.yAxis = axis(out.yAxis, format.yAxis);
652
+ if (format.y2Axis)
653
+ out.y2Axis = axis(out.y2Axis, format.y2Axis);
654
+ if (format.series) {
655
+ out.series = spec.series.map((s) => {
656
+ const f = format.series[s.label];
657
+ if (!f)
658
+ return s;
659
+ const next = { ...s };
660
+ if (f.color)
661
+ next.color = f.color;
662
+ if (f.type)
663
+ next.type = f.type;
664
+ if (f.axis)
665
+ next.axis = f.axis;
666
+ if (f.marker)
667
+ next.marker = f.marker;
668
+ if (f.strokeWidth !== undefined)
669
+ next.strokeWidth = f.strokeWidth;
670
+ if (f.dash !== undefined)
671
+ next.dash = f.dash || undefined;
672
+ if (f.smooth !== undefined)
673
+ next.smooth = f.smooth;
674
+ if (f.stack !== undefined)
675
+ next.stack = f.stack || undefined;
676
+ return next;
677
+ });
678
+ }
679
+ return out;
680
+ }
2479
681
  /**
2480
682
  * The chart types that read ROWS directly instead of a grouped grid, behind one
2481
683
  * call. Returns `null` for every other type, which then goes through
@@ -2491,6 +693,20 @@ export function rowsToDirectSpec(type, rows, opts) {
2491
693
  const cat = opts.category;
2492
694
  const val = opts.value;
2493
695
  const ser = opts.series;
696
+ if (type === 'candlestick' || type === 'ohlc') {
697
+ const o = opts.ohlc;
698
+ if (!cat || !o)
699
+ return null;
700
+ const k = (f) => f;
701
+ return rowsToOhlcSpec(rows, {
702
+ date: cat, open: k(o.open), high: k(o.high), low: k(o.low), close: k(o.close),
703
+ ...(o.volume ? { volume: k(o.volume) } : {}),
704
+ ...(o.bucket ? { bucket: o.bucket } : {}),
705
+ ...(o.candleStyle ? { candleStyle: o.candleStyle } : {}),
706
+ ...(o.lastPriceLine ? { lastPriceLine: true } : {}),
707
+ type,
708
+ });
709
+ }
2494
710
  if (type === 'scatter') {
2495
711
  const y = opts.value2;
2496
712
  if (!val || !y)
@@ -2519,6 +735,49 @@ export function rowsToDirectSpec(type, rows, opts) {
2519
735
  spec.palette = opts.palette;
2520
736
  return spec;
2521
737
  }
738
+ if (type === 'histogram') {
739
+ if (!val)
740
+ return null;
741
+ return rowsToHistogramSpec(rows, {
742
+ value: val,
743
+ ...(ser ? { series: ser } : {}),
744
+ ...(opts.bins ? { bins: opts.bins } : {}),
745
+ ...(opts.palette ? { palette: opts.palette } : {}),
746
+ });
747
+ }
748
+ if (type === 'range-bar' || type === 'range-area' || type === 'dumbbell') {
749
+ // Two measures: the first is the low end, the second the high end, each
750
+ // reduced per category the way a bar would be.
751
+ const hi = opts.value2;
752
+ if (!cat || !val || !hi)
753
+ return null;
754
+ const lows = rowsToChartSpec(rows, { type: 'bar', category: cat, value: val, ...(ser ? { series: ser } : {}), reduce: opts.reduce ?? 'sum' });
755
+ const highs = rowsToChartSpec(rows, { type: 'bar', category: cat, value: hi, ...(ser ? { series: ser } : {}), reduce: opts.reduce ?? 'sum' });
756
+ return {
757
+ type,
758
+ categories: highs.categories,
759
+ series: highs.series.map((s, i) => ({
760
+ ...s,
761
+ label: ser ? s.label : `${String(val)} to ${String(hi)}`,
762
+ lowValues: lows.series[i]?.values.map((v, k) => Math.min(v, s.values[k] ?? v)) ?? [],
763
+ values: s.values.map((v, k) => Math.max(v, lows.series[i]?.values[k] ?? v)),
764
+ })),
765
+ ...(opts.palette ? { palette: opts.palette } : {}),
766
+ };
767
+ }
768
+ if (type === 'bullet') {
769
+ if (!cat || !val)
770
+ return null;
771
+ const measure = rowsToChartSpec(rows, { type: 'bar', category: cat, value: val, reduce: opts.reduce ?? 'sum' });
772
+ const t = opts.value2;
773
+ const target = t ? rowsToChartSpec(rows, { type: 'bar', category: cat, value: t, reduce: opts.reduce ?? 'sum' }) : null;
774
+ return {
775
+ type: 'bullet',
776
+ categories: measure.categories,
777
+ series: [{ ...measure.series[0], ...(target ? { targets: target.series[0].values } : {}) }],
778
+ ...(opts.palette ? { palette: opts.palette } : {}),
779
+ };
780
+ }
2522
781
  return null;
2523
782
  }
2524
783
  /**
@@ -2640,17 +899,14 @@ export function rowsToScatterSpec(rows, opts) {
2640
899
  * the value, so the needle never sits pinned at the far end of the arc.
2641
900
  */
2642
901
  export function rowsToGaugeSpec(rows, opts) {
2643
- const reduce = opts.reduce ?? 'sum';
2644
- let sum = 0;
2645
- let count = 0;
902
+ const samples = [];
2646
903
  for (const row of rows) {
2647
904
  const v = Number(row[opts.value]);
2648
- if (!Number.isFinite(v))
2649
- continue;
2650
- sum += v;
2651
- count += 1;
905
+ if (Number.isFinite(v))
906
+ samples.push(v);
2652
907
  }
2653
- const value = reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum;
908
+ const reduced = reduceValues(samples, opts.reduce ?? 'sum');
909
+ const value = Number.isFinite(reduced) ? reduced : 0;
2654
910
  const min = opts.min ?? Math.min(0, value);
2655
911
  const max = opts.max ?? (value > min ? niceScale(min, value).max : min + 1);
2656
912
  return {