@svgrid/grid 3.0.0 → 3.0.2

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 (304) hide show
  1. package/CHANGELOG.md +116 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +190 -181
  5. package/dist/GridFooter.svelte.d.ts +8 -0
  6. package/dist/GridMenus.svelte +18 -10
  7. package/dist/GridMenus.svelte.d.ts +2 -1
  8. package/dist/SvAutoComplete.svelte +169 -169
  9. package/dist/SvAvatar.svelte +75 -75
  10. package/dist/SvCalendar.svelte +503 -503
  11. package/dist/SvCarousel.svelte +141 -141
  12. package/dist/SvCheckBox.svelte +102 -102
  13. package/dist/SvCircularProgress.svelte +109 -109
  14. package/dist/SvColorInput.svelte +181 -181
  15. package/dist/SvComboBox.svelte +279 -279
  16. package/dist/SvContextMenu.svelte +116 -116
  17. package/dist/SvCountryInput.svelte +163 -163
  18. package/dist/SvDrawer.svelte +254 -254
  19. package/dist/SvDropDownList.svelte +378 -378
  20. package/dist/SvDurationInput.svelte +126 -126
  21. package/dist/SvField.svelte +293 -293
  22. package/dist/SvForm.svelte +437 -437
  23. package/dist/SvGrid.controller.svelte.d.ts +17 -0
  24. package/dist/SvGrid.controller.svelte.js +128 -14
  25. package/dist/SvGrid.css +79 -10
  26. package/dist/SvGrid.svelte +191 -186
  27. package/dist/SvGrid.types.d.ts +42 -1
  28. package/dist/SvGridCellEditor.svelte +748 -744
  29. package/dist/SvGridChart.svelte +2246 -1716
  30. package/dist/SvGridChart.svelte.d.ts +63 -1
  31. package/dist/SvGridChartPanel.svelte +633 -485
  32. package/dist/SvGridChartView.svelte +79 -70
  33. package/dist/SvGridChartView.svelte.d.ts +7 -0
  34. package/dist/SvGridDropdown.svelte +728 -728
  35. package/dist/SvGridSelect.svelte +270 -270
  36. package/dist/SvGroupCell.svelte +141 -116
  37. package/dist/SvGroupCell.svelte.d.ts +8 -0
  38. package/dist/SvListBox.svelte +334 -334
  39. package/dist/SvMaskedInput.svelte +122 -122
  40. package/dist/SvMenu.svelte +124 -124
  41. package/dist/SvMenuList.svelte +146 -146
  42. package/dist/SvMultiSelect.svelte +293 -293
  43. package/dist/SvNumberInput.svelte +172 -172
  44. package/dist/SvOtpInput.svelte +158 -158
  45. package/dist/SvPasswordInput.svelte +151 -151
  46. package/dist/SvPhoneInput.svelte +133 -133
  47. package/dist/SvPopover.svelte +197 -197
  48. package/dist/SvProgress.svelte +116 -116
  49. package/dist/SvRadioGroup.svelte +107 -107
  50. package/dist/SvRating.svelte +112 -112
  51. package/dist/SvResult.svelte +73 -73
  52. package/dist/SvRichText.svelte +211 -211
  53. package/dist/SvRowGroupPanel.svelte +186 -170
  54. package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
  55. package/dist/SvScrollArea.svelte +61 -61
  56. package/dist/SvSlider.svelte +203 -203
  57. package/dist/SvSwitchButton.svelte +108 -108
  58. package/dist/SvTagsInput.svelte +115 -115
  59. package/dist/SvTextInput.svelte +147 -147
  60. package/dist/SvTimePicker.svelte +245 -245
  61. package/dist/SvToaster.svelte +159 -159
  62. package/dist/SvToggleButton.svelte +85 -85
  63. package/dist/SvTooltip.svelte +161 -161
  64. package/dist/SvTour.svelte +208 -208
  65. package/dist/SvTree.svelte +444 -444
  66. package/dist/SvTreeSelect.svelte +239 -239
  67. package/dist/ai.d.ts +11 -2
  68. package/dist/ai.js +51 -7
  69. package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
  70. package/dist/cdn/GridMenus-M2XP9t0_.js +603 -0
  71. package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
  72. package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
  73. package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
  74. package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
  75. package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
  76. package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
  77. package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
  78. package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
  79. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
  80. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
  81. package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-CxfakxR0.js} +5 -3
  82. package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-Dm_YEx9L.js} +5 -3
  83. package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
  84. package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
  85. package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
  86. package/dist/cdn/chart-CKJRCPOk.js +1652 -0
  87. package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
  88. package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
  89. package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
  90. package/dist/cdn/{src-B_YS5AOc.js → src-BJ2opd0j.js} +3642 -3450
  91. package/dist/cdn/{src-DYXXpuSk.js → src-DNbz94Ct.js} +8649 -8457
  92. package/dist/cdn/svgrid.js +16 -15
  93. package/dist/cdn/svgrid.svelte-external.js +16 -15
  94. package/dist/cell-formatting.d.ts +7 -0
  95. package/dist/cell-formatting.js +7 -1
  96. package/dist/chart-export.d.ts +23 -2
  97. package/dist/chart-export.js +88 -14
  98. package/dist/chart.d.ts +410 -18
  99. package/dist/chart.js +824 -24
  100. package/dist/editing.js +22 -13
  101. package/dist/grid-icons.d.ts +49 -0
  102. package/dist/grid-icons.js +86 -0
  103. package/dist/index.d.ts +3 -2
  104. package/dist/index.js +3 -2
  105. package/package.json +1 -1
  106. package/src/FlexRender.svelte +96 -96
  107. package/src/GridFooter.svelte +190 -181
  108. package/src/GridMenus.svelte +18 -10
  109. package/src/SvAutoComplete.svelte +169 -169
  110. package/src/SvAvatar.svelte +75 -75
  111. package/src/SvCalendar.svelte +503 -503
  112. package/src/SvCalendar.test.ts +226 -226
  113. package/src/SvCarousel.svelte +141 -141
  114. package/src/SvCheckBox.svelte +102 -102
  115. package/src/SvCircularProgress.svelte +109 -109
  116. package/src/SvColorInput.svelte +181 -181
  117. package/src/SvComboBox.svelte +279 -279
  118. package/src/SvContextMenu.svelte +116 -116
  119. package/src/SvCountryInput.svelte +163 -163
  120. package/src/SvDrawer.svelte +254 -254
  121. package/src/SvDropDownList.svelte +378 -378
  122. package/src/SvDurationInput.svelte +126 -126
  123. package/src/SvField.svelte +293 -293
  124. package/src/SvForm.svelte +437 -437
  125. package/src/SvForm.test.ts +411 -411
  126. package/src/SvGrid.controller.svelte.ts +146 -13
  127. package/src/SvGrid.css +79 -10
  128. package/src/SvGrid.svelte +191 -186
  129. package/src/SvGrid.types.ts +2133 -2092
  130. package/src/SvGridCellEditor.svelte +748 -744
  131. package/src/SvGridChart.svelte +2246 -1716
  132. package/src/SvGridChart.test.ts +243 -1
  133. package/src/SvGridChartPanel.svelte +633 -485
  134. package/src/SvGridChartView.svelte +79 -70
  135. package/src/SvGridDropdown.svelte +728 -728
  136. package/src/SvGridSelect.svelte +270 -270
  137. package/src/SvGroupCell.svelte +141 -116
  138. package/src/SvListBox.svelte +334 -334
  139. package/src/SvMaskedInput.svelte +122 -122
  140. package/src/SvMenu.svelte +124 -124
  141. package/src/SvMenu.test.ts +97 -97
  142. package/src/SvMenuList.svelte +146 -146
  143. package/src/SvMultiSelect.svelte +293 -293
  144. package/src/SvNumberInput.svelte +172 -172
  145. package/src/SvOtpInput.svelte +158 -158
  146. package/src/SvPasswordInput.svelte +151 -151
  147. package/src/SvPhoneInput.svelte +133 -133
  148. package/src/SvPopover.svelte +197 -197
  149. package/src/SvProgress.svelte +116 -116
  150. package/src/SvRadioGroup.svelte +107 -107
  151. package/src/SvRating.svelte +112 -112
  152. package/src/SvResult.svelte +73 -73
  153. package/src/SvRichText.svelte +211 -211
  154. package/src/SvRowGroupPanel.svelte +186 -170
  155. package/src/SvScrollArea.svelte +61 -61
  156. package/src/SvSlider.svelte +203 -203
  157. package/src/SvSwitchButton.svelte +108 -108
  158. package/src/SvTagsInput.svelte +115 -115
  159. package/src/SvTextInput.svelte +147 -147
  160. package/src/SvTimePicker.svelte +245 -245
  161. package/src/SvToaster.svelte +159 -159
  162. package/src/SvToaster.test.ts +95 -95
  163. package/src/SvToggleButton.svelte +85 -85
  164. package/src/SvTooltip.svelte +161 -161
  165. package/src/SvTour.svelte +208 -208
  166. package/src/SvTree.svelte +444 -444
  167. package/src/SvTreeSelect.svelte +239 -239
  168. package/src/a11y/dismissable.test.ts +119 -119
  169. package/src/a11y/dismissable.ts +114 -114
  170. package/src/a11y.contract.test.ts +49 -49
  171. package/src/a11y.test.ts +59 -59
  172. package/src/a11y.ts +61 -61
  173. package/src/ai.test.ts +561 -502
  174. package/src/ai.ts +1472 -1419
  175. package/src/build-api.coverage.test.ts +633 -633
  176. package/src/build-api.ts +846 -846
  177. package/src/builtin-editors.grid.test.ts +83 -83
  178. package/src/cell-formatting.ts +177 -171
  179. package/src/cell-render.test.ts +513 -513
  180. package/src/cell-render.ts +496 -496
  181. package/src/cell-values.ts +148 -148
  182. package/src/chart-export.test.ts +112 -0
  183. package/src/chart-export.ts +270 -202
  184. package/src/chart-view.svelte.ts +36 -36
  185. package/src/chart.test.ts +830 -1
  186. package/src/chart.ts +3342 -2321
  187. package/src/collaboration.test.ts +104 -104
  188. package/src/collaboration.ts +167 -167
  189. package/src/column-groups.ts +78 -78
  190. package/src/core.performance.test.ts +30 -30
  191. package/src/core.ts +1865 -1865
  192. package/src/createAutocomplete.svelte.ts +132 -132
  193. package/src/createCombobox.svelte.ts +191 -191
  194. package/src/createCountryInput.svelte.ts +157 -157
  195. package/src/createDropdownList.svelte.ts +168 -168
  196. package/src/createForm.svelte.ts +386 -386
  197. package/src/createGrid.svelte.ts +42 -42
  198. package/src/createGrid.test.ts +10 -10
  199. package/src/createGridState.svelte.ts +17 -17
  200. package/src/createListbox.svelte.ts +250 -250
  201. package/src/createMenu.svelte.ts +224 -224
  202. package/src/createPopoverSelect.svelte.ts +213 -213
  203. package/src/createSlider.svelte.ts +191 -191
  204. package/src/createTooltip.svelte.ts +144 -144
  205. package/src/createTree.svelte.ts +322 -322
  206. package/src/datetime/date-core.ts +206 -206
  207. package/src/datetime/date-restrict.ts +61 -61
  208. package/src/datetime/timezone.ts +135 -135
  209. package/src/dock-manager-model.ts +596 -596
  210. package/src/dock-model.ts +374 -374
  211. package/src/editing.test.ts +974 -974
  212. package/src/editing.ts +618 -609
  213. package/src/editor-contract.ts +171 -171
  214. package/src/editor-registry.grid.test.ts +144 -144
  215. package/src/editor-registry.ts +122 -122
  216. package/src/export-data-api.test.ts +126 -126
  217. package/src/export-format.test.ts +107 -107
  218. package/src/export-format.ts +601 -601
  219. package/src/filter-operators.ts +160 -160
  220. package/src/filtering/excel-filters.ts +325 -325
  221. package/src/flex-render.ts +3 -3
  222. package/src/form-field.ts +127 -127
  223. package/src/grid-icons.test.ts +46 -0
  224. package/src/grid-icons.ts +184 -0
  225. package/src/group-display.test.ts +167 -167
  226. package/src/group-display.ts +200 -200
  227. package/src/headless.ts +87 -87
  228. package/src/icon-overrides.grid.test.ts +188 -0
  229. package/src/icon-seam.test.ts +100 -0
  230. package/src/index.ts +19 -0
  231. package/src/js-scroller.svelte.ts +173 -173
  232. package/src/keyboard-handlers.ts +270 -270
  233. package/src/keyboard.test.ts +59 -59
  234. package/src/keyboard.ts +97 -97
  235. package/src/list-nav.test.ts +49 -49
  236. package/src/list-nav.ts +29 -29
  237. package/src/list-option.test.ts +56 -56
  238. package/src/list-option.ts +179 -179
  239. package/src/menus.ts +597 -597
  240. package/src/merge-objects.ts +48 -48
  241. package/src/overlays.test.ts +90 -90
  242. package/src/positioning.ts +268 -268
  243. package/src/render-component.ts +28 -28
  244. package/src/row-drag.test.ts +401 -401
  245. package/src/row-drag.ts +419 -419
  246. package/src/row-resize.test.ts +524 -524
  247. package/src/row-resize.ts +228 -228
  248. package/src/scheduler-ical.ts +181 -181
  249. package/src/scheduler-model.test.ts +562 -562
  250. package/src/scheduler-model.ts +873 -873
  251. package/src/selection.test.ts +885 -885
  252. package/src/server-data-source.test.ts +383 -383
  253. package/src/server-data-source.ts +469 -469
  254. package/src/sparkline.test.ts +68 -68
  255. package/src/sparkline.ts +169 -169
  256. package/src/spreadsheet.test.ts +488 -488
  257. package/src/spreadsheet.ts +312 -312
  258. package/src/static-functions.ts +11 -11
  259. package/src/subscribe.ts +38 -38
  260. package/src/summaries.ts +113 -113
  261. package/src/svgrid-wrapper.types.ts +563 -563
  262. package/src/svgrid.async-editor-options.test.ts +273 -273
  263. package/src/svgrid.auto-row-height.test.ts +204 -204
  264. package/src/svgrid.behavior.test.ts +910 -910
  265. package/src/svgrid.charting.test.ts +654 -534
  266. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  267. package/src/svgrid.context-menu.test.ts +147 -147
  268. package/src/svgrid.features.test.ts +157 -157
  269. package/src/svgrid.filter-depth.test.ts +163 -163
  270. package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
  271. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  272. package/src/svgrid.grand-total.test.ts +188 -188
  273. package/src/svgrid.group-display-mode.test.ts +171 -171
  274. package/src/svgrid.group-footers.test.ts +121 -121
  275. package/src/svgrid.group-pagination.test.ts +153 -153
  276. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  277. package/src/svgrid.tree-data.test.ts +186 -186
  278. package/src/svgrid.wrapper.test.ts +63 -63
  279. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  280. package/src/test-setup.ts +62 -62
  281. package/src/themes/index.ts +288 -288
  282. package/src/toast-store.svelte.ts +250 -250
  283. package/src/toast-store.test.ts +147 -147
  284. package/src/tree-row-model.test.ts +168 -168
  285. package/src/ui-buttons.test.ts +144 -144
  286. package/src/ui-inputs.test.ts +118 -118
  287. package/src/ui-localization.test.ts +113 -113
  288. package/src/ui-range.test.ts +70 -70
  289. package/src/ui-selection.test.ts +155 -155
  290. package/src/ui-tier1.test.ts +142 -142
  291. package/src/virtual.test.ts +88 -88
  292. package/src/virtualization/column-virtualizer.test.ts +27 -27
  293. package/src/virtualization/column-virtualizer.ts +30 -30
  294. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  295. package/src/virtualization/types.ts +30 -30
  296. package/src/virtualization/virtualizer.test.ts +47 -47
  297. package/src/virtualization/virtualizer.ts +322 -322
  298. package/dist/cdn/GridMenus-DMihUtma.js +0 -600
  299. package/dist/cdn/GridMenus-nmNDj1a3.js +0 -601
  300. package/dist/cdn/SvGridChart-Bs2GIR2Q.js +0 -1202
  301. package/dist/cdn/SvGridChart-CwhFz7GV.js +0 -1203
  302. package/dist/cdn/SvGridChartPanel-1WXuStzM.js +0 -561
  303. package/dist/cdn/SvGridChartPanel-e5_nqNP9.js +0 -585
  304. package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
package/dist/chart.js CHANGED
@@ -1,3 +1,97 @@
1
+ /**
2
+ * Integrated chart geometry. Pure functions that turn categories + numeric
3
+ * series into SVG primitives - the "chart from a grid range" enterprise
4
+ * feature without bundling a charting library. The `<SvGridChart>` component
5
+ * paints the result; this module has no DOM so it is unit-testable.
6
+ *
7
+ * Supports: grouped + stacked bars, line, area, pie/donut, combo charts
8
+ * (per-series type), a secondary (right) Y axis, signed Y domains (negative
9
+ * values drop below a zero baseline), and nice auto-scaled ticks.
10
+ */
11
+ // 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
+ }
1
95
  /** Series colours used when a {@link ChartSeries} sets none, in order. */
2
96
  export const DEFAULT_PALETTE = [
3
97
  '#2563eb',
@@ -112,6 +206,48 @@ function project(value, min, max, isLog) {
112
206
  }
113
207
  return (value - min) / (max - min);
114
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, w: pw, 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
+ }
115
251
  // ---- Overlay math: trendline + moving averages -----------------------
116
252
  /** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
117
253
  * monotone cubic interpolation (preserves local extrema - no overshoots).
@@ -314,13 +450,38 @@ function fmtTick(n) {
314
450
  }
315
451
  /**
316
452
  * Format a numeric value for display, honouring an optional `valueFormat`.
317
- * Builds on the compact `1.2k` / `1.2M` base: currency prefixes `$` (sign
318
- * outside), percent multiplies by 100 and suffixes `%`. Unset / `'number'` /
319
- * `'compact'` = the plain compact form.
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.
320
464
  */
321
- export function formatChartValue(n, format) {
465
+ export function formatChartValue(n, format, opts) {
322
466
  if (!Number.isFinite(n))
323
467
  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
+ }
324
485
  if (format === 'currency')
325
486
  return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`;
326
487
  if (format === 'percent') {
@@ -341,6 +502,56 @@ function dateTicks(tMin, tMax) {
341
502
  ticks.push(tMin, tMax);
342
503
  return ticks;
343
504
  }
505
+ /**
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.
515
+ */
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
+ }
344
555
  function fmtDate(t, span) {
345
556
  const d = new Date(t);
346
557
  if (span <= 70 * DAY)
@@ -349,6 +560,24 @@ function fmtDate(t, span) {
349
560
  return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' });
350
561
  return String(d.getFullYear());
351
562
  }
563
+ /**
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.
570
+ */
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
+ }
352
581
  /** Data domain for one axis, honoring stacking of its bar/area series.
353
582
  * When `isLog` is true, non-positive values are discarded (log undefined)
354
583
  * and the domain is rounded to decade boundaries instead of nice steps. */
@@ -392,6 +621,50 @@ function axisDomain(list, categories, stacked, extra = [], isLog = false) {
392
621
  for (const s of lines)
393
622
  for (const v of s.values)
394
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')
645
+ 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);
653
+ }
654
+ }
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
+ }
395
668
  if (dMin === Infinity) {
396
669
  dMin = isLog ? 1 : 0;
397
670
  dMax = isLog ? 10 : 1;
@@ -417,7 +690,7 @@ export function buildChart(spec, theme = 'light') {
417
690
  const series = spec.series.map((s, i) => ({
418
691
  ...s,
419
692
  color: s.color ?? palette[i % palette.length],
420
- kind: (s.type ?? (spec.type === 'pie' || spec.type === 'scatter' ? 'bar' : spec.type)),
693
+ kind: kindOf(s, spec.type),
421
694
  axis: s.axis ?? 'left',
422
695
  }));
423
696
  const legend = series.map((s) => ({ label: s.label, color: s.color }));
@@ -426,7 +699,11 @@ export function buildChart(spec, theme = 'light') {
426
699
  width,
427
700
  height,
428
701
  plot: { x: 0, y: 0, w: width, h: height },
702
+ axes: null,
429
703
  bars: [],
704
+ candles: [],
705
+ boxes: [],
706
+ errorBars: [],
430
707
  lines: [],
431
708
  slices: [],
432
709
  yTicks: [],
@@ -548,7 +825,7 @@ export function buildChart(spec, theme = 'light') {
548
825
  x: round(padL + slotW * i + slotW / 2),
549
826
  }));
550
827
  const yTicks = dom.ticks.map((value) => ({
551
- value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat),
828
+ value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
552
829
  }));
553
830
  return {
554
831
  ...empty,
@@ -744,7 +1021,7 @@ export function buildChart(spec, theme = 'light') {
744
1021
  const legend = Array.from({ length: 5 }, (_, i) => {
745
1022
  const t = i / 4;
746
1023
  const value = vMin + (vMax - vMin) * t;
747
- return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) };
1024
+ return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) };
748
1025
  });
749
1026
  return {
750
1027
  ...empty,
@@ -779,8 +1056,13 @@ export function buildChart(spec, theme = 'light') {
779
1056
  };
780
1057
  const trackPath = arc(A0, A1, r);
781
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.
782
1064
  const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
783
- path: arc(angleAt(band.from), angleAt(band.to), r - 9),
1065
+ path: arc(angleAt(band.from), angleAt(band.to), r - 16),
784
1066
  color: band.color, from: band.from, to: band.to,
785
1067
  }));
786
1068
  let targetPx = null;
@@ -810,23 +1092,46 @@ export function buildChart(spec, theme = 'light') {
810
1092
  }
811
1093
  // Pointer needle: a kite (long tip toward the value, short counterweight
812
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.
813
1103
  const aV = angleAt(value);
814
- const tipR = r - 16, tailR = 18, baseR = 6;
1104
+ const tipR = r - 14, tailR = 5, baseR = 9;
815
1105
  const aPerp = aV + Math.PI / 2;
816
1106
  const pt = (rad, ang) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`;
817
1107
  const needlePath = `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`;
818
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.
819
1114
  let valueColor = null;
820
- for (const band of spec.gaugeRanges ?? []) {
821
- if (value >= band.from && value <= band.to)
1115
+ const bands = spec.gaugeRanges ?? [];
1116
+ for (const band of bands) {
1117
+ if (value >= band.from && value < band.to) {
822
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;
823
1128
  }
824
1129
  return {
825
1130
  ...empty,
826
1131
  plot: { x: 0, y: 0, w: width, h: height },
827
1132
  gauge: {
828
1133
  cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
829
- ticks, needle: { path: needlePath, hubR: 7 }, valueColor,
1134
+ ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
830
1135
  minLabel: { x: cx - r, y: cy + 20 },
831
1136
  maxLabel: { x: cx + r, y: cy + 20 },
832
1137
  value, min, max, unit: spec.gaugeUnit ?? '',
@@ -1131,7 +1436,7 @@ export function buildChart(spec, theme = 'light') {
1131
1436
  const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
1132
1437
  const t = i / 4;
1133
1438
  const value = vMin + (vMax - vMin) * t;
1134
- return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) };
1439
+ return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) };
1135
1440
  });
1136
1441
  return {
1137
1442
  ...empty,
@@ -1261,7 +1566,7 @@ export function buildChart(spec, theme = 'light') {
1261
1566
  }
1262
1567
  const referenceLines = (spec.referenceLines ?? []).map((ref) => ({
1263
1568
  y: yOf(ref.value),
1264
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
1569
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
1265
1570
  color: ref.color ?? '#ef4444',
1266
1571
  dashed: ref.dashed !== false,
1267
1572
  }));
@@ -1270,7 +1575,7 @@ export function buildChart(spec, theme = 'light') {
1270
1575
  plot,
1271
1576
  scatterPoints,
1272
1577
  referenceLines,
1273
- yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat) })),
1578
+ yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat, spec) })),
1274
1579
  xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
1275
1580
  };
1276
1581
  }
@@ -1353,7 +1658,7 @@ export function buildChart(spec, theme = 'light') {
1353
1658
  });
1354
1659
  }
1355
1660
  const valueTicks = dom.ticks.map((value) => ({
1356
- label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat),
1661
+ label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat, spec),
1357
1662
  x: xOf(value),
1358
1663
  }));
1359
1664
  const catTicks = spec.categories.map((label, i) => ({
@@ -1363,7 +1668,7 @@ export function buildChart(spec, theme = 'light') {
1363
1668
  }));
1364
1669
  const referenceLinesV = (spec.referenceLines ?? []).map((ref) => ({
1365
1670
  x: xOf(ref.value),
1366
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
1671
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
1367
1672
  color: ref.color ?? '#ef4444',
1368
1673
  dashed: ref.dashed !== false,
1369
1674
  }));
@@ -1428,19 +1733,30 @@ export function buildChart(spec, theme = 'light') {
1428
1733
  const slot = plotW / Math.max(1, n);
1429
1734
  // X positions. A time axis spaces points by actual time (irregular gaps);
1430
1735
  // a category axis is uniform. (Bars stay uniform either way.)
1431
- const timeVals = spec.xType === 'time' ? spec.categories.map((c) => Date.parse(c)) : null;
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;
1432
1741
  const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t));
1742
+ const timeScaled = timeOk && spec.xType === 'time';
1433
1743
  const tMin = timeOk ? Math.min(...timeVals.filter(Number.isFinite)) : 0;
1434
1744
  const tSpan = timeOk ? Math.max(...timeVals.filter(Number.isFinite)) - tMin || 1 : 1;
1435
- const xCenter = (i) => timeOk && Number.isFinite(timeVals[i])
1745
+ const xCenter = (i) => timeScaled && Number.isFinite(timeVals[i])
1436
1746
  ? round(padL + ((timeVals[i] - tMin) / tSpan) * plotW)
1437
1747
  : round(padL + slot * i + slot / 2);
1438
- const xTicks = timeOk
1748
+ const xTicks = timeScaled
1439
1749
  ? dateTicks(tMin, tMin + tSpan).map((t) => ({
1440
1750
  label: fmtDate(t, tSpan),
1441
1751
  x: round(padL + ((t - tMin) / tSpan) * plotW),
1442
1752
  }))
1443
- : spec.categories.map((label, i) => ({ label, x: xCenter(i) }));
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);
1444
1760
  // Parent-tier ticks for a grouped category axis: each spans its leaves.
1445
1761
  const categoryGroupTicks = [];
1446
1762
  if (validGroups && !timeOk) {
@@ -1532,6 +1848,135 @@ export function buildChart(spec, theme = 'light') {
1532
1848
  });
1533
1849
  }
1534
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
+ }
1535
1980
  // Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
1536
1981
  const lines = [];
1537
1982
  const areaCum = {
@@ -1549,7 +1994,12 @@ export function buildChart(spec, theme = 'light') {
1549
1994
  }
1550
1995
  }
1551
1996
  for (const s of series) {
1552
- if (s.kind === 'bar')
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')
1553
2003
  continue;
1554
2004
  const dom = domOf(s);
1555
2005
  const log = isLogOf(s);
@@ -1649,14 +2099,23 @@ export function buildChart(spec, theme = 'light') {
1649
2099
  }
1650
2100
  lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath });
1651
2101
  }
1652
- const tickFor = (dom, log) => dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: formatChartValue(value, spec.valueFormat) }));
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
+ }));
1653
2112
  const referenceLines = (spec.referenceLines ?? []).map((ref) => {
1654
2113
  const onRight = ref.axis === 'right';
1655
2114
  const dom = onRight ? (rightDom ?? leftDom) : leftDom;
1656
2115
  const log = onRight ? rightLog : leftLog;
1657
2116
  return {
1658
2117
  y: yOf(dom, ref.value, log),
1659
- label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
2118
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
1660
2119
  color: ref.color ?? '#ef4444',
1661
2120
  dashed: ref.dashed !== false,
1662
2121
  };
@@ -1735,7 +2194,20 @@ export function buildChart(spec, theme = 'light') {
1735
2194
  return {
1736
2195
  ...empty,
1737
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
+ },
1738
2207
  bars,
2208
+ candles,
2209
+ boxes,
2210
+ errorBars,
1739
2211
  lines,
1740
2212
  yTicks: tickFor(leftDom, leftLog),
1741
2213
  y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
@@ -1748,6 +2220,51 @@ export function buildChart(spec, theme = 'light') {
1748
2220
  annotations,
1749
2221
  };
1750
2222
  }
2223
+ /**
2224
+ * Narrow a spec to the category window `[lo, hi]`, keeping every
2225
+ * category-parallel array in step.
2226
+ *
2227
+ * This is the zoom / brush slice. It lives here rather than in the renderer
2228
+ * because getting it wrong is a MODEL bug, not a paint bug, and it was wrong:
2229
+ * the component used to slice `categories`, `values` and `rowIds` by hand and
2230
+ * spread the rest of the series through untouched. `upperValues` and
2231
+ * `lowerValues` therefore kept their full length, the equality guard on the
2232
+ * confidence band (see `buildChart`) stopped matching, and the band silently
2233
+ * disappeared the moment anyone zoomed.
2234
+ *
2235
+ * The lesson generalises: every array here is indexed by category, so each one
2236
+ * added in future has to be sliced too. Keeping them in one function is what
2237
+ * makes that a single place to remember rather than a scattered convention.
2238
+ */
2239
+ export function sliceChartWindow(spec, lo, hi) {
2240
+ const from = Math.max(0, lo);
2241
+ 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
+ };
2267
+ }
1751
2268
  /**
1752
2269
  * Aggregate flat rows into a chart spec. Group by a category field, reduce a
1753
2270
  * value field per group. Three multi-series shapes:
@@ -1866,3 +2383,286 @@ export function rowsToChartSpec(rows, opts) {
1866
2383
  palette: opts.palette,
1867
2384
  };
1868
2385
  }
2386
+ // ---------------------------------------------------------------------------
2387
+ // Shape adapters.
2388
+ //
2389
+ // Nine of the thirteen chart types were unreachable from the chart panel
2390
+ // because they do not read a `categories x series` grid: a treemap wants a
2391
+ // hierarchy, a sankey wants an edge list, a gauge wants one number. Rather
2392
+ // than give each its own aggregation path, these take the spec
2393
+ // `rowsToChartSpec` already produced and reshape it, so grouping, `reduce`,
2394
+ // `sort`, `topN` and the "Other" bucket keep working for all of them.
2395
+ // ---------------------------------------------------------------------------
2396
+ /**
2397
+ * Reshape an aggregated spec into a tree-map hierarchy.
2398
+ *
2399
+ * One series gives a flat set of leaves. Several (a split-by) give two levels,
2400
+ * category above series, which is the shape people expect from "sales by
2401
+ * region, split by channel".
2402
+ */
2403
+ export function specToTreemap(spec, rootName = 'Total') {
2404
+ const positive = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
2405
+ if (spec.series.length <= 1) {
2406
+ const s = spec.series[0];
2407
+ return {
2408
+ name: rootName,
2409
+ children: spec.categories
2410
+ .map((name, i) => ({ name, value: positive(s?.values[i]) }))
2411
+ .filter((n) => n.value > 0),
2412
+ };
2413
+ }
2414
+ return {
2415
+ name: rootName,
2416
+ children: spec.categories
2417
+ .map((name, i) => ({
2418
+ name,
2419
+ children: spec.series
2420
+ .map((s) => ({ name: s.label, value: positive(s.values[i]) }))
2421
+ .filter((n) => n.value > 0),
2422
+ }))
2423
+ .filter((n) => n.children.length > 0),
2424
+ };
2425
+ }
2426
+ /**
2427
+ * Reshape an aggregated spec into calendar samples.
2428
+ *
2429
+ * Categories that do not parse as a date are dropped rather than rendered at
2430
+ * epoch zero, which would put a stray cell in 1970 and rescale the whole year.
2431
+ */
2432
+ export function specToCalendar(spec) {
2433
+ const s = spec.series[0];
2434
+ const out = [];
2435
+ spec.categories.forEach((c, i) => {
2436
+ const t = Date.parse(c);
2437
+ if (!Number.isFinite(t))
2438
+ return;
2439
+ const v = s?.values[i];
2440
+ if (typeof v !== 'number' || !Number.isFinite(v))
2441
+ return;
2442
+ out.push({ date: new Date(t).toISOString().slice(0, 10), value: v });
2443
+ });
2444
+ return out;
2445
+ }
2446
+ /**
2447
+ * Reshape a pivoted spec into sankey nodes and links.
2448
+ *
2449
+ * The pivot `rowsToChartSpec` already performs is exactly an edge list read
2450
+ * sideways: categories are sources, series are targets, and each cell is the
2451
+ * flow between them. Zero cells and self-edges are dropped, the first because
2452
+ * a zero-width ribbon is not a flow and the second because the layout has no
2453
+ * meaningful place to put one.
2454
+ */
2455
+ export function specToSankey(spec) {
2456
+ const links = [];
2457
+ const ids = new Set();
2458
+ spec.categories.forEach((from, i) => {
2459
+ for (const s of spec.series) {
2460
+ const v = s.values[i];
2461
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0)
2462
+ continue;
2463
+ // Sources and targets share a namespace, so a value appearing on both
2464
+ // sides would otherwise become one node with a cycle through it.
2465
+ const source = `from:${from}`;
2466
+ const target = `to:${s.label}`;
2467
+ if (source === target)
2468
+ continue;
2469
+ ids.add(source);
2470
+ ids.add(target);
2471
+ links.push({ source, target, value: v });
2472
+ }
2473
+ });
2474
+ return {
2475
+ nodes: [...ids].map((id) => ({ id, label: id.slice(id.indexOf(':') + 1) })),
2476
+ links,
2477
+ };
2478
+ }
2479
+ /**
2480
+ * The chart types that read ROWS directly instead of a grouped grid, behind one
2481
+ * call. Returns `null` for every other type, which then goes through
2482
+ * `rowsToChartSpec` and its reduce / sort / topN / "Other" pipeline.
2483
+ *
2484
+ * One entry point rather than a branch per type in the caller, because the
2485
+ * caller is the grid controller and the controller is in the BASE bundle: every
2486
+ * type named there is bytes paid by grids that never chart. Here it is in the
2487
+ * lazy chart chunk, next to the builders it dispatches to, and adding a fourth
2488
+ * direct type costs a grid nothing.
2489
+ */
2490
+ export function rowsToDirectSpec(type, rows, opts) {
2491
+ const cat = opts.category;
2492
+ const val = opts.value;
2493
+ const ser = opts.series;
2494
+ if (type === 'scatter') {
2495
+ const y = opts.value2;
2496
+ if (!val || !y)
2497
+ return null;
2498
+ return rowsToScatterSpec(rows, {
2499
+ x: val,
2500
+ y,
2501
+ ...(ser ? { series: ser } : {}),
2502
+ ...(opts.palette ? { palette: opts.palette } : {}),
2503
+ });
2504
+ }
2505
+ if (type === 'gauge') {
2506
+ if (!val)
2507
+ return null;
2508
+ return rowsToGaugeSpec(rows, { value: val, ...(opts.reduce ? { reduce: opts.reduce } : {}) });
2509
+ }
2510
+ if (type === 'boxplot') {
2511
+ if (!cat || !val)
2512
+ return null;
2513
+ const spec = rowsToBoxSpec(rows, {
2514
+ category: cat,
2515
+ value: val,
2516
+ ...(ser ? { series: ser } : {}),
2517
+ });
2518
+ if (opts.palette)
2519
+ spec.palette = opts.palette;
2520
+ return spec;
2521
+ }
2522
+ return null;
2523
+ }
2524
+ /**
2525
+ * Build a box plot spec straight from rows: group by a category, then reduce
2526
+ * each group to a five-number summary.
2527
+ *
2528
+ * This is the one aggregate the panel's `sum | avg | count` cannot express, and
2529
+ * that is the point of it. Every other chart answers "how much"; a box plot
2530
+ * answers "how spread out", which needs the whole sample per group rather than
2531
+ * one number, so it cannot go through `rowsToChartSpec`.
2532
+ *
2533
+ * `values` comes out as the medians, so tooltips, CSV and overlays work with no
2534
+ * box-specific code - the same contract `ohlc` follows.
2535
+ */
2536
+ export function rowsToBoxSpec(rows, opts) {
2537
+ const cats = [];
2538
+ const seen = new Set();
2539
+ for (const r of rows) {
2540
+ const c = String(r[opts.category] ?? '');
2541
+ if (!seen.has(c)) {
2542
+ seen.add(c);
2543
+ cats.push(c);
2544
+ }
2545
+ }
2546
+ const groupNames = [];
2547
+ const groupSeen = new Set();
2548
+ if (opts.series) {
2549
+ for (const r of rows) {
2550
+ const g = String(r[opts.series] ?? '');
2551
+ if (!groupSeen.has(g)) {
2552
+ groupSeen.add(g);
2553
+ groupNames.push(g);
2554
+ }
2555
+ }
2556
+ }
2557
+ else {
2558
+ groupNames.push(opts.seriesLabel ?? String(opts.value));
2559
+ }
2560
+ const series = groupNames.map((g) => {
2561
+ const boxes = cats.map((c) => {
2562
+ const sample = [];
2563
+ for (const r of rows) {
2564
+ if (String(r[opts.category] ?? '') !== c)
2565
+ continue;
2566
+ if (opts.series && String(r[opts.series] ?? '') !== g)
2567
+ continue;
2568
+ // `Number(null)` and `Number('')` are both 0, so coercing first would
2569
+ // fold every empty cell into the sample as a zero and drag the whole
2570
+ // box down. An absent observation is absent, not zero.
2571
+ const raw = r[opts.value];
2572
+ if (raw == null || raw === '')
2573
+ continue;
2574
+ const n = Number(raw);
2575
+ if (Number.isFinite(n))
2576
+ sample.push(n);
2577
+ }
2578
+ return boxStats(sample, opts.whisker);
2579
+ });
2580
+ return {
2581
+ label: g,
2582
+ // Medians, so a gap stays a gap rather than plotting as zero.
2583
+ values: boxes.map((b) => (b ? b.median : Number.NaN)),
2584
+ boxes,
2585
+ };
2586
+ });
2587
+ return {
2588
+ type: 'boxplot',
2589
+ categories: cats,
2590
+ series,
2591
+ ...(opts.width ? { width: opts.width } : {}),
2592
+ ...(opts.height ? { height: opts.height } : {}),
2593
+ };
2594
+ }
2595
+ /**
2596
+ * Build a scatter / bubble spec straight from rows.
2597
+ *
2598
+ * Unlike the adapters above this cannot reuse `rowsToChartSpec`: a scatter
2599
+ * point is one row, not one group, so there is nothing to reduce. `series`
2600
+ * colours the points by a categorical field.
2601
+ */
2602
+ export function rowsToScatterSpec(rows, opts) {
2603
+ const bySeries = new Map();
2604
+ for (const row of rows) {
2605
+ const x = Number(row[opts.x]);
2606
+ const y = Number(row[opts.y]);
2607
+ if (!Number.isFinite(x) || !Number.isFinite(y))
2608
+ continue;
2609
+ const key = opts.series ? String(row[opts.series] ?? '') : opts.y;
2610
+ const pt = { x, y };
2611
+ if (opts.r) {
2612
+ const r = Number(row[opts.r]);
2613
+ if (Number.isFinite(r))
2614
+ pt.r = r;
2615
+ }
2616
+ if (opts.label)
2617
+ pt.label = String(row[opts.label] ?? '');
2618
+ const list = bySeries.get(key);
2619
+ if (list)
2620
+ list.push(pt);
2621
+ else
2622
+ bySeries.set(key, [pt]);
2623
+ }
2624
+ return {
2625
+ type: 'scatter',
2626
+ categories: [],
2627
+ series: [...bySeries].map(([label, points]) => ({ label, values: [], points })),
2628
+ palette: opts.palette,
2629
+ width: opts.width,
2630
+ height: opts.height,
2631
+ xAxisTitle: opts.x,
2632
+ yAxisTitle: opts.y,
2633
+ };
2634
+ }
2635
+ /**
2636
+ * Reduce rows to the single number a gauge shows.
2637
+ *
2638
+ * There is no category axis here, which is why this cannot go through
2639
+ * `rowsToChartSpec`. The dial ends on a nice round number rather than exactly
2640
+ * the value, so the needle never sits pinned at the far end of the arc.
2641
+ */
2642
+ export function rowsToGaugeSpec(rows, opts) {
2643
+ const reduce = opts.reduce ?? 'sum';
2644
+ let sum = 0;
2645
+ let count = 0;
2646
+ for (const row of rows) {
2647
+ const v = Number(row[opts.value]);
2648
+ if (!Number.isFinite(v))
2649
+ continue;
2650
+ sum += v;
2651
+ count += 1;
2652
+ }
2653
+ const value = reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum;
2654
+ const min = opts.min ?? Math.min(0, value);
2655
+ const max = opts.max ?? (value > min ? niceScale(min, value).max : min + 1);
2656
+ return {
2657
+ type: 'gauge',
2658
+ categories: [],
2659
+ series: [],
2660
+ gaugeValue: value,
2661
+ gaugeMin: min,
2662
+ gaugeMax: max,
2663
+ gaugeTarget: opts.target,
2664
+ gaugeUnit: opts.unit,
2665
+ width: opts.width,
2666
+ height: opts.height,
2667
+ };
2668
+ }