@svgrid/grid 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { chartSummary } from './chart-summary'
3
+ import { CHART_TYPES } from './chart-validate'
4
+ import { sampleChartSpec } from './chart-samples'
5
+ import { pearson } from './chart-stats'
6
+ import type { ChartSpec } from './chart-types'
7
+
8
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
9
+
10
+ describe('chartSummary', () => {
11
+ it('reads a rising line with its ends, change, peak and trough', () => {
12
+ const s = chartSummary({ type: 'line', categories: months, series: [{ label: 'Revenue', values: [100, 180, 90, 140, 160, 150] }] })
13
+ expect(s).toBe('Revenue rises 50% from 100 at Jan to 150 at Jun, peaking at 180 at Feb, lowest at 90 at Mar.')
14
+ })
15
+
16
+ it('reads a falling series, a flat one, and caps the series it lists', () => {
17
+ const spec: ChartSpec = {
18
+ type: 'bar',
19
+ categories: months,
20
+ series: [
21
+ { label: 'Cost', values: [200, 190, 170, 160, 150, 120] },
22
+ { label: 'Flat', values: [50, 50.2, 49.9, 50.1, 50, 50] },
23
+ { label: 'A', values: [1, 2, 3, 4, 5, 6] },
24
+ { label: 'B', values: [1, 2, 3, 4, 5, 6] },
25
+ { label: 'C', values: [1, 2, 3, 4, 5, 6] },
26
+ ],
27
+ }
28
+ const s = chartSummary(spec)
29
+ expect(s).toContain('Cost falls 40% from 200 at Jan to 120 at Jun.')
30
+ expect(s).toContain('Flat holds at about 50 from Jan to Jun.')
31
+ expect(s).toContain('And 1 more series.')
32
+ expect(chartSummary(spec, { maxSeries: 2 })).toContain('And 3 more series.')
33
+ // Hidden series are left out; the formatter is honoured.
34
+ const hidden = chartSummary({ ...spec, series: [{ ...spec.series[0]!, visible: false }, spec.series[1]!] }, { formatValue: (v) => `$${v}` })
35
+ expect(hidden).toBe('Flat holds at about $50 from Jan to Jun.')
36
+ })
37
+
38
+ it('shares the top three slices of a pie and a tree map', () => {
39
+ expect(chartSummary({ type: 'pie', categories: ['A', 'B', 'C', 'D', 'E'], series: [{ label: 'v', values: [50, 25, 15, 6, 4] }] })).toBe(
40
+ '5 slices totalling 100: A 50%, B 25%, C 15%, 2 more share the rest.',
41
+ )
42
+ expect(chartSummary({ type: 'treemap', categories: [], series: [], treemap: { name: 'r', children: [{ name: 'X', value: 3 }, { name: 'Y', children: [{ name: 'y1', value: 1 }] }] } })).toBe(
43
+ '2 nodes totalling 4: X 75%, Y 25%.',
44
+ )
45
+ })
46
+
47
+ it('reports the correlation of a scatter, the last close of candles, and the gauge against its target', () => {
48
+ const pts = Array.from({ length: 20 }, (_, i) => ({ x: i, y: 2 * i + (i % 2) }))
49
+ expect(chartSummary({ type: 'scatter', categories: [], series: [{ label: 'p', values: [], points: pts }] })).toMatch(/^20 points in 1 series with a strong positive correlation \(r (?:0\.9\d|1\.00)\)/)
50
+ const anti = pts.map((p) => ({ x: p.x, y: -p.y }))
51
+ expect(chartSummary({ type: 'scatter', categories: [], series: [{ label: 'p', values: [], points: anti }] })).toContain('strong negative')
52
+ expect(pearson([1, 2, 3], [1, 1, 1])).toBe(0)
53
+ const candles = chartSummary({
54
+ type: 'candlestick',
55
+ categories: ['d1', 'd2', 'd3'],
56
+ series: [{ label: 'ACME', values: [10, 11, 12], ohlc: [{ o: 10, h: 12, l: 9, c: 10 }, { o: 10, h: 13, l: 10, c: 11 }, { o: 11, h: 14, l: 11, c: 12.1 }] }],
57
+ })
58
+ expect(candles).toBe('ACME: last close 12.1, up 10% on the session, ranging 9 to 14 over 3 bars.')
59
+ expect(chartSummary({ type: 'gauge', categories: [], series: [], gaugeValue: 72, gaugeMin: 0, gaugeMax: 100, gaugeTarget: 80, gaugeUnit: '%' })).toBe(
60
+ '72 %, 72% of the way from 0 to 100, below the target of 80.',
61
+ )
62
+ })
63
+
64
+ it('reads a heat map, a calendar, a funnel, a box plot and a sankey', () => {
65
+ expect(chartSummary({ type: 'heatmap', categories: ['c1', 'c2'], series: [{ label: 'r1', values: [1, 9] }, { label: 'r2', values: [4, 2] }] })).toBe(
66
+ '2 rows by 2 columns; the highest cell is 9 at r1 and c2.',
67
+ )
68
+ expect(chartSummary({ type: 'calendar', categories: [], series: [], calendarValues: [{ date: '2026-01-01', value: 2 }, { date: '2026-01-02', value: 7 }] })).toBe(
69
+ '2 days totalling 9; the busiest is 2026-01-02 at 7.',
70
+ )
71
+ expect(chartSummary({ type: 'funnel', categories: ['Visit', 'Sign up', 'Pay'], series: [{ label: 'n', values: [1000, 300, 150] }] })).toBe(
72
+ '15% of 1k at Visit reach Pay; the biggest drop is into Sign up (70% lost).',
73
+ )
74
+ expect(chartSummary({ type: 'boxplot', categories: ['a'], series: [{ label: 'X', values: [5], boxes: [{ min: 1, q1: 4, median: 5, q3: 6, max: 9 }] }, { label: 'Y', values: [5], boxes: [{ min: 0, q1: 2, median: 5, q3: 9, max: 12 }] }] })).toBe(
75
+ '2 distributions over 1 categories; Y has the widest interquartile range at 7.',
76
+ )
77
+ expect(chartSummary({ type: 'sankey', categories: [], series: [], sankeyNodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], sankeyLinks: [{ source: 'a', target: 'b', value: 3 }, { source: 'a', target: 'c', value: 5 }] })).toBe(
78
+ '2 flows totalling 8; the largest is a to c at 5.',
79
+ )
80
+ })
81
+
82
+ it('never throws on an empty spec of any type, and describes every sample spec', () => {
83
+ for (const type of CHART_TYPES) {
84
+ expect(chartSummary({ type, categories: [], series: [] }), type).toBe('No data.')
85
+ const s = chartSummary(sampleChartSpec(type))
86
+ expect(typeof s, type).toBe('string')
87
+ expect(s.length, type).toBeGreaterThan(8)
88
+ }
89
+ })
90
+ })
@@ -0,0 +1,197 @@
1
+ /**
2
+ * A chart in a sentence or two. `chartSummary` reads a spec the way a
3
+ * colleague would read the picture: which way each series goes and by how
4
+ * much, where it peaks, what the biggest slices are, whether two measures
5
+ * move together, how the last session closed. `SvChart` puts the result in
6
+ * the SVG's `aria-description`, the context menu copies it, and the AI
7
+ * "explain this chart" prompt is grounded on it, so the words a screen
8
+ * reader hears and the words a model reasons from are the same words.
9
+ *
10
+ * Pure and dependency-light (two small leaves: the number formatter and the
11
+ * trend primitives, kept apart from the engine's modules so the chunk a
12
+ * bundler shares between this lazy module and the base stays tiny), so it
13
+ * runs on the server and in a test, and the renderer loads it lazily.
14
+ */
15
+ import type { ChartSpec, ChartSeries } from './chart-types'
16
+ import { formatChartValue } from './chart-format'
17
+ import { linearTrend, pearson } from './chart-trend'
18
+
19
+ /** What {@link chartSummary} takes. */
20
+ export type ChartSummaryOptions = {
21
+ /** How numbers are written. Default: the spec's `valueFormat` / `locale` / `currency`. */
22
+ formatValue?: (v: number) => string
23
+ /** How many series a cartesian chart describes before it says "and N more". Default 4. */
24
+ maxSeries?: number
25
+ }
26
+
27
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
28
+
29
+ /** First and last finite value of a series with their indices, or null. */
30
+ function ends(values: ReadonlyArray<number>): { i0: number; v0: number; i1: number; v1: number } | null {
31
+ let i0 = -1
32
+ let i1 = -1
33
+ for (let i = 0; i < values.length; i += 1) if (finite(values[i])) { if (i0 < 0) i0 = i; i1 = i }
34
+ if (i0 < 0) return null
35
+ return { i0, v0: values[i0]!, i1, v1: values[i1]! }
36
+ }
37
+
38
+ function extremes(values: ReadonlyArray<number>): { max: number; iMax: number; min: number; iMin: number } | null {
39
+ let max = -Infinity, min = Infinity, iMax = -1, iMin = -1
40
+ values.forEach((v, i) => {
41
+ if (!finite(v)) return
42
+ if (v > max) { max = v; iMax = i }
43
+ if (v < min) { min = v; iMin = i }
44
+ })
45
+ return iMax < 0 ? null : { max, iMax, min, iMin }
46
+ }
47
+
48
+ const pct = (from: number, to: number): string => {
49
+ if (from === 0) return ''
50
+ const p = Math.abs(((to - from) / Math.abs(from)) * 100)
51
+ return `${p >= 10 ? Math.round(p) : Math.round(p * 10) / 10}%`
52
+ }
53
+
54
+ function describeSeries(s: ChartSeries, categories: ReadonlyArray<string>, fmt: (v: number) => string): string {
55
+ const values = s.ohlc ? s.ohlc.map((b) => (b ? b.c : Number.NaN)) : s.values
56
+ const e = ends(values)
57
+ if (!e) return `${s.label} has no values.`
58
+ const at = (i: number) => categories[i] ?? `#${i + 1}`
59
+ if (e.i0 === e.i1) return `${s.label} is ${fmt(e.v0)} at ${at(e.i0)}.`
60
+ const slope = linearTrend(values)
61
+ const rise = slope[slope.length - 1]! - slope[0]!
62
+ const span = Math.abs(e.v1 - e.v0)
63
+ const scale = Math.max(Math.abs(e.v0), Math.abs(e.v1), 1e-9)
64
+ const flat = span / scale < 0.02 && Math.abs(rise) / scale < 0.05
65
+ const dir = flat ? 'holds at about' : e.v1 > e.v0 ? 'rises' : 'falls'
66
+ const change = pct(e.v0, e.v1)
67
+ let out = flat
68
+ ? `${s.label} holds at about ${fmt(e.v1)} from ${at(e.i0)} to ${at(e.i1)}`
69
+ : `${s.label} ${dir}${change ? ` ${change}` : ''} from ${fmt(e.v0)} at ${at(e.i0)} to ${fmt(e.v1)} at ${at(e.i1)}`
70
+ const x = extremes(values)
71
+ if (x && !flat) {
72
+ const peakInside = x.iMax !== e.i0 && x.iMax !== e.i1
73
+ const troughInside = x.iMin !== e.i0 && x.iMin !== e.i1
74
+ if (peakInside) out += `, peaking at ${fmt(x.max)} at ${at(x.iMax)}`
75
+ if (troughInside) out += `, lowest at ${fmt(x.min)} at ${at(x.iMin)}`
76
+ }
77
+ return out + '.'
78
+ }
79
+
80
+ function shares(labels: ReadonlyArray<string>, values: ReadonlyArray<number>, fmt: (v: number) => string, noun: string): string {
81
+ const total = values.reduce((a, v) => a + (finite(v) ? Math.abs(v) : 0), 0)
82
+ if (!total) return `No ${noun} has a value.`
83
+ const ranked = labels.map((l, i) => ({ l, v: finite(values[i]) ? Math.abs(values[i]!) : 0 })).sort((a, b) => b.v - a.v)
84
+ const top = ranked.slice(0, 3).map((r) => `${r.l} ${Math.round((r.v / total) * 100)}%`)
85
+ const rest = ranked.length > 3 ? `, ${ranked.length - 3} more share the rest` : ''
86
+ return `${labels.length} ${noun}s totalling ${fmt(total)}: ${top.join(', ')}${rest}.`
87
+ }
88
+
89
+ /**
90
+ * Describe a spec in plain words. Never throws: an empty spec of any type
91
+ * comes back as "No data." so a live region always has something to say.
92
+ */
93
+ export function chartSummary(spec: ChartSpec, opts: ChartSummaryOptions = {}): string {
94
+ const fmt = opts.formatValue ?? ((v: number) => formatChartValue(v, spec.valueFormat, spec))
95
+ const max = opts.maxSeries ?? 4
96
+ const cats = spec.categories ?? []
97
+ const series = (spec.series ?? []).filter((s) => s.visible !== false)
98
+ const type = spec.type
99
+ try {
100
+ if (type === 'pie' || type === 'radial-bar' || type === 'nightingale') {
101
+ const s = series[0]
102
+ if (!s || !s.values.some(finite)) return 'No data.'
103
+ return shares(cats, s.values, fmt, 'slice')
104
+ }
105
+ if (type === 'treemap' || type === 'sunburst') {
106
+ const root = spec.treemap ?? spec.tree
107
+ const kids = root?.children ?? []
108
+ if (!kids.length) return 'No data.'
109
+ const total = (n: { value?: number; children?: Array<{ value?: number; children?: unknown[] }> }): number =>
110
+ n.value ?? (n.children ?? []).reduce((a, c) => a + total(c as never), 0)
111
+ return shares(kids.map((k) => k.name), kids.map((k) => total(k)), fmt, 'node')
112
+ }
113
+ if (type === 'gauge') {
114
+ if (!finite(spec.gaugeValue)) return 'No data.'
115
+ const lo = spec.gaugeMin ?? 0
116
+ const hi = spec.gaugeMax ?? 100
117
+ const share = hi > lo ? Math.round(((spec.gaugeValue - lo) / (hi - lo)) * 100) : 0
118
+ const target = finite(spec.gaugeTarget) ? `, ${spec.gaugeValue >= spec.gaugeTarget ? 'above' : 'below'} the target of ${fmt(spec.gaugeTarget)}` : ''
119
+ return `${fmt(spec.gaugeValue)}${spec.gaugeUnit ? ' ' + spec.gaugeUnit : ''}, ${share}% of the way from ${fmt(lo)} to ${fmt(hi)}${target}.`
120
+ }
121
+ if (type === 'scatter') {
122
+ const pts = series.flatMap((s) => s.points ?? []).filter((p) => finite(p.x) && finite(p.y))
123
+ if (!pts.length) return 'No data.'
124
+ const r = pearson(pts.map((p) => p.x), pts.map((p) => p.y))
125
+ const strength = Math.abs(r) >= 0.7 ? 'strong' : Math.abs(r) >= 0.4 ? 'moderate' : Math.abs(r) >= 0.2 ? 'weak' : 'no'
126
+ const sign = r > 0 ? 'positive' : 'negative'
127
+ const rel = strength === 'no' ? 'no clear correlation' : `a ${strength} ${sign} correlation (r ${r.toFixed(2)})`
128
+ return `${pts.length} points in ${series.length} series with ${rel} between x and y.`
129
+ }
130
+ if (type === 'candlestick' || type === 'ohlc') {
131
+ const s = series.find((x) => x.ohlc) ?? series[0]
132
+ const bars = (s?.ohlc ?? []).filter((b): b is NonNullable<typeof b> => !!b)
133
+ if (!s || !bars.length) return 'No data.'
134
+ const last = bars[bars.length - 1]!
135
+ const prev = bars.length > 1 ? bars[bars.length - 2]! : null
136
+ const hi = Math.max(...bars.map((b) => b.h))
137
+ const lo = Math.min(...bars.map((b) => b.l))
138
+ const move = prev ? `, ${last.c >= prev.c ? 'up' : 'down'} ${pct(prev.c, last.c)} on the session` : ''
139
+ return `${s.label}: last close ${fmt(last.c)}${move}, ranging ${fmt(lo)} to ${fmt(hi)} over ${bars.length} bars.`
140
+ }
141
+ if (type === 'heatmap') {
142
+ let best: { v: number; row: string; col: string } | null = null
143
+ for (const s of series) s.values.forEach((v, i) => { if (finite(v) && (!best || v > best.v)) best = { v, row: s.label, col: cats[i] ?? String(i) } })
144
+ if (!best) return 'No data.'
145
+ const b = best as { v: number; row: string; col: string }
146
+ return `${series.length} rows by ${cats.length} columns; the highest cell is ${fmt(b.v)} at ${b.row} and ${b.col}.`
147
+ }
148
+ if (type === 'calendar') {
149
+ const days = (spec.calendarValues ?? []).filter((d) => finite(d.value))
150
+ if (!days.length) return 'No data.'
151
+ const top = days.reduce((a, d) => (d.value > a.value ? d : a), days[0]!)
152
+ const total = days.reduce((a, d) => a + d.value, 0)
153
+ return `${days.length} days totalling ${fmt(total)}; the busiest is ${top.date} at ${fmt(top.value)}.`
154
+ }
155
+ if (type === 'funnel') {
156
+ const s = series[0]
157
+ const vals = (s?.values ?? []).filter(finite)
158
+ if (!s || vals.length < 2) return s && vals.length === 1 ? `${cats[0] ?? s.label}: ${fmt(vals[0]!)}.` : 'No data.'
159
+ const first = vals[0]!
160
+ const last = vals[vals.length - 1]!
161
+ const conv = first ? Math.round((last / first) * 100) : 0
162
+ let worstDrop = 0
163
+ let worstAt = ''
164
+ for (let i = 1; i < vals.length; i += 1) {
165
+ const drop = vals[i - 1]! ? 1 - vals[i]! / vals[i - 1]! : 0
166
+ if (drop > worstDrop) { worstDrop = drop; worstAt = cats[i] ?? `step ${i + 1}` }
167
+ }
168
+ return `${conv}% of ${fmt(first)} at ${cats[0] ?? 'the top'} reach ${cats[vals.length - 1] ?? 'the bottom'}${worstAt ? `; the biggest drop is into ${worstAt} (${Math.round(worstDrop * 100)}% lost)` : ''}.`
169
+ }
170
+ if (type === 'boxplot') {
171
+ let widest: { label: string; spread: number } | null = null
172
+ for (const s of series) for (const b of s.boxes ?? []) {
173
+ if (!b) continue
174
+ const spread = b.q3 - b.q1
175
+ if (!widest || spread > widest.spread) widest = { label: s.label, spread }
176
+ }
177
+ if (!widest) return 'No data.'
178
+ return `${series.length} distributions over ${cats.length} categories; ${widest.label} has the widest interquartile range at ${fmt(widest.spread)}.`
179
+ }
180
+ if (type === 'sankey' || type === 'chord') {
181
+ const links = spec.sankeyLinks ?? []
182
+ if (!links.length) return 'No data.'
183
+ const total = links.reduce((a, l) => a + (finite(l.value) ? l.value : 0), 0)
184
+ const top = [...links].sort((a, b) => b.value - a.value)[0]!
185
+ return `${links.length} flows totalling ${fmt(total)}; the largest is ${top.source} to ${top.target} at ${fmt(top.value)}.`
186
+ }
187
+ // Cartesian families: one sentence per series, up to the cap.
188
+ const usable = series.filter((s) => s.values.some(finite) || s.ohlc)
189
+ if (!usable.length) return 'No data.'
190
+ const shown = usable.slice(0, max)
191
+ const parts = shown.map((s) => describeSeries(s, cats, fmt))
192
+ const more = usable.length > shown.length ? ` And ${usable.length - shown.length} more series.` : ''
193
+ return `${parts.join(' ')}${more}`
194
+ } catch {
195
+ return 'No data.'
196
+ }
197
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared hover and zoom between charts in the same `syncGroup`.
3
+ *
4
+ * A registry of groups whose entries are `$state`, so every chart that reads
5
+ * its group's entry re-renders when another chart in the group writes to it. Each write
6
+ * carries the writer's token so a chart can ignore its own echoes.
7
+ */
8
+ import type { ChartZoomWindow } from './chart-types'
9
+
10
+ /** What one chart publishes for the others: the hovered category (its label,
11
+ * index and, when the category parses as a date, its time) and the window. */
12
+ export type ChartSyncState = {
13
+ hover: { label: string; index: number; time: number | null; from: symbol } | null
14
+ zoom: { window: ChartZoomWindow | null; count: number; from: symbol; labels: [string, string] | null } | null
15
+ }
16
+
17
+ // A plain map of reactive entries: looking a group up (which a chart does
18
+ // inside a $derived) must not itself be a state mutation, so the map is not
19
+ // reactive and each entry is.
20
+ const groups = new Map<string, ChartSyncState>()
21
+
22
+ /** The live entry for a group, created on first use. */
23
+ export function syncGroupState(name: string): ChartSyncState {
24
+ const existing = groups.get(name)
25
+ if (existing) return existing
26
+ const fresh = $state<ChartSyncState>({ hover: null, zoom: null })
27
+ groups.set(name, fresh)
28
+ return fresh
29
+ }
30
+
31
+ /** Publish a hover (or clear it with null). */
32
+ export function publishSyncHover(name: string, hover: ChartSyncState['hover']): void {
33
+ syncGroupState(name).hover = hover
34
+ }
35
+
36
+ /** Publish a zoom window (null = the whole axis) with the axis length it
37
+ * was made against and the category labels at its two ends, so a chart with
38
+ * a different axis can map it by date. */
39
+ export function publishSyncZoom(name: string, window: ChartZoomWindow | null, count: number, from: symbol, labels: [string, string] | null = null): void {
40
+ syncGroupState(name).zoom = { window, count, from, labels }
41
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { chartSpecToTable } from './chart-table'
3
+ import type { ChartSpec } from './chart-types'
4
+
5
+ describe('chartSpecToTable', () => {
6
+ it('lays a category chart out as one row per category and a column per series, in the chart format', () => {
7
+ const t = chartSpecToTable({
8
+ type: 'bar',
9
+ categories: ['Q1', 'Q2', 'Q1', 'Q2'],
10
+ categoryGroups: [{ label: '2025', span: 2 }, { label: '2026', span: 2 }],
11
+ series: [{ label: 'Americas', values: [1, 2, 3, Number.NaN] }, { label: 'EMEA', values: [4, 5, 6, 7] }],
12
+ valueFormat: 'currency',
13
+ currency: 'EUR',
14
+ })
15
+ expect(t.columns.map((c) => c.header)).toEqual(['Group', 'Category', 'Americas', 'EMEA'])
16
+ expect(t.columns[2]).toMatchObject({ cellDataType: 'number', format: { type: 'currency', currency: 'EUR' } })
17
+ expect(t.rows).toEqual([
18
+ { group: '2025', category: 'Q1', s0: 1, s1: 4 },
19
+ { group: '2025', category: 'Q2', s0: 2, s1: 5 },
20
+ { group: '2026', category: 'Q1', s0: 3, s1: 6 },
21
+ { group: '2026', category: 'Q2', s0: null, s1: 7 },
22
+ ])
23
+ })
24
+
25
+ it('reads a date axis as a date column named after the axis, and a right-axis series in that axis format', () => {
26
+ const t = chartSpecToTable({
27
+ type: 'bar',
28
+ xType: 'time',
29
+ categories: ['2026-01-01', '2026-02-01'],
30
+ series: [{ label: 'Revenue', values: [2.1, 2.3] }, { label: 'Margin', values: [0.29, 0.31], type: 'line', axis: 'right' }],
31
+ valueFormat: 'currency',
32
+ y2Axis: { format: 'percent' },
33
+ xAxisTitle: 'Month',
34
+ })
35
+ expect(t.columns[0]).toEqual({ field: 'category', header: 'Month', cellDataType: 'date' })
36
+ expect(t.columns[1]!.format).toEqual({ type: 'currency', currency: 'USD' })
37
+ expect(t.columns[2]!.format).toEqual({ type: 'percent' })
38
+ expect(chartSpecToTable({ type: 'gauge', categories: [], series: [], gaugeValue: 1, gaugeUnit: 'ms' }).columns[1]!.header).toBe('Value (ms)')
39
+ })
40
+
41
+ it('expands the numbers a series carries: ranges, targets, error bars, candles, boxes, bins', () => {
42
+ const range = chartSpecToTable({ type: 'range-bar', categories: ['a'], series: [{ label: 'Band', values: [9], lowValues: [4] }] })
43
+ expect(range.columns.map((c) => c.header)).toEqual(['Category', 'Band Low', 'Band High'])
44
+ expect(range.rows[0]).toEqual({ category: 'a', s0lo: 4, s0hi: 9 })
45
+ const bullet = chartSpecToTable({ type: 'bullet', categories: ['a'], series: [{ label: 'Actual', values: [82], targets: [90] }] })
46
+ expect(bullet.rows[0]).toEqual({ category: 'a', s0: 82, s0t: 90 })
47
+ const err = chartSpecToTable({ type: 'bar', categories: ['a', 'b'], series: [{ label: 'Mean', values: [10, 20], errors: [2, { lo: 15, hi: 30 }] }] })
48
+ expect(err.rows).toEqual([{ category: 'a', s0: 10, s0lo: 8, s0hi: 12 }, { category: 'b', s0: 20, s0lo: 15, s0hi: 30 }])
49
+ const candles = chartSpecToTable({ type: 'candlestick', categories: ['d1'], series: [{ label: 'ACME', values: [3], ohlc: [{ o: 1, h: 4, l: 0.5, c: 3 }], volumes: [100] }] })
50
+ expect(candles.columns.map((c) => c.header)).toEqual(['Category', 'ACME Open', 'ACME High', 'ACME Low', 'ACME Close', 'ACME Volume'])
51
+ expect(candles.rows[0]).toEqual({ category: 'd1', s0o: 1, s0h: 4, s0l: 0.5, s0c: 3, s0v: 100 })
52
+ const boxes = chartSpecToTable({ type: 'boxplot', categories: ['a'], series: [{ label: 'ms', values: [5], boxes: [{ min: 1, q1: 3, median: 5, q3: 7, max: 9, outliers: [20, 30] }] }] })
53
+ expect(boxes.rows[0]).toEqual({ category: 'a', s0min: 1, s0q1: 3, s0med: 5, s0q3: 7, s0max: 9, s0out: '20 30' })
54
+ const bins = chartSpecToTable({ type: 'histogram', categories: ['5', '15'], series: [{ label: 'n', values: [2, 3] }], binEdges: [0, 10, 20] })
55
+ expect(bins.columns.map((c) => c.header)).toEqual(['From', 'To', 'n'])
56
+ expect(bins.rows).toEqual([{ from: 0, to: 10, s0: 2 }, { from: 10, to: 20, s0: 3 }])
57
+ })
58
+
59
+ it('tabulates the shapes the category grid cannot: points, links, leaves, days, a gauge', () => {
60
+ const scatter = chartSpecToTable({ type: 'scatter', categories: [], series: [{ label: 'S', values: [], points: [{ x: 1, y: 2, r: 3, label: 'p' }] }] })
61
+ expect(scatter.columns.map((c) => c.header)).toEqual(['Series', 'X', 'Y', 'Size', 'Label'])
62
+ expect(scatter.rows).toEqual([{ series: 'S', x: 1, y: 2, size: 3, label: 'p' }])
63
+ const flow = chartSpecToTable({ type: 'sankey', categories: [], series: [], sankeyNodes: [{ id: 'a', label: 'Wind' }, { id: 'b' }], sankeyLinks: [{ source: 'a', target: 'b', value: 27 }] })
64
+ expect(flow.rows).toEqual([{ source: 'Wind', target: 'b', value: 27 }])
65
+ const tree: ChartSpec = { type: 'sunburst', categories: [], series: [], tree: { name: 'All', children: [{ name: 'Blink', children: [{ name: 'Chrome', value: 6 }] }, { name: 'Gecko', value: 3 }] } }
66
+ const leaves = chartSpecToTable(tree)
67
+ expect(leaves.columns.map((c) => c.header)).toEqual(['Level 1', 'Level 2', 'Value'])
68
+ expect(leaves.rows).toEqual([{ level1: 'Blink', level2: 'Chrome', value: 6 }, { level1: 'Gecko', value: 3 }])
69
+ const days = chartSpecToTable({ type: 'calendar', categories: [], series: [], calendarValues: [{ date: '2026-01-05', value: 4 }] })
70
+ expect(days.columns[0]).toMatchObject({ header: 'Date', cellDataType: 'date' })
71
+ expect(days.rows).toEqual([{ date: '2026-01-05', value: 4 }])
72
+ const gauge = chartSpecToTable({ type: 'gauge', categories: [], series: [], title: 'Uptime', gaugeValue: 99.2, gaugeMin: 99, gaugeMax: 100, gaugeTarget: 99.9 })
73
+ expect(gauge.rows).toEqual([{ metric: 'Uptime', value: 99.2, min: 99, max: 100, target: 99.9 }])
74
+ expect(chartSpecToTable({ type: 'bar', categories: [], series: [] })).toEqual({ columns: [], rows: [] })
75
+ })
76
+ })
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Chart spec -> grid table.
3
+ *
4
+ * The rows and columns a `ChartSpec` amounts to, in the shape `SvGrid` takes:
5
+ * one row per category with a column per series for the category types, one
6
+ * row per point for a scatter, per link for a sankey or chord, per leaf for
7
+ * a tree map or sunburst, per day for a calendar, and one row for a gauge.
8
+ * Number columns carry `cellDataType: 'number'` and the chart's value format,
9
+ * so a grid of the chart reads its numbers the way the chart does.
10
+ *
11
+ * This is the grid-shaped sibling of `chartSpecToCsv`: the CSV keeps its
12
+ * header contract byte for byte, this one adds types and formats and covers
13
+ * the shapes the CSV leaves out. A "Chart | Grid" switch on a chart is this
14
+ * function behind an `SvGrid`.
15
+ */
16
+ import type { CellFormatConfig } from './core'
17
+ import type { ChartSpec, TreeNode } from './chart-types'
18
+
19
+ /** One column of a chart's table: what `SvGrid` needs to draw it. */
20
+ export type ChartTableColumn = {
21
+ field: string
22
+ header: string
23
+ cellDataType?: 'number' | 'text' | 'date'
24
+ format?: CellFormatConfig
25
+ width?: number
26
+ }
27
+
28
+ /** A chart's data as a table: grid columns and plain row objects. */
29
+ export type ChartTable = {
30
+ columns: ChartTableColumn[]
31
+ rows: Array<Record<string, unknown>>
32
+ }
33
+
34
+ /** A finite number as itself, anything else as an empty cell. */
35
+ const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
36
+
37
+ /** The cell format a chart value format maps to, for the number columns. */
38
+ function formatOf(format: ChartSpec['valueFormat'], spec: ChartSpec): CellFormatConfig | undefined {
39
+ const locales: string | string[] | undefined = typeof spec.locale === 'string' ? spec.locale : spec.locale ? [...spec.locale] : undefined
40
+ if (format === 'currency') return { type: 'currency', currency: spec.currency ?? 'USD', ...(locales ? { locales } : {}) }
41
+ if (format === 'percent') return { type: 'percent', ...(locales ? { locales } : {}) }
42
+ if (format === 'number' || format === 'compact') return { type: 'number', ...(locales ? { locales } : {}) }
43
+ return undefined
44
+ }
45
+
46
+ /**
47
+ * The rows and columns a chart spec amounts to. Never throws: a spec with
48
+ * nothing to tabulate comes back with no rows and no columns.
49
+ */
50
+ export function chartSpecToTable(spec: ChartSpec): ChartTable {
51
+ const fmt = formatOf(spec.valueFormat, spec)
52
+ // A series on the right axis reads in that axis's format: a margin of 0.29
53
+ // beside a revenue in dollars is "29%", not "$0.29".
54
+ const fmt2 = formatOf(spec.y2Axis?.format, spec)
55
+ const numCol = (field: string, header: string, right = false): ChartTableColumn => {
56
+ const f = right ? fmt2 : fmt
57
+ return { field, header, cellDataType: 'number', ...(f ? { format: f } : {}) }
58
+ }
59
+ const textCol = (field: string, header: string): ChartTableColumn => ({ field, header })
60
+ const series = spec.series ?? []
61
+
62
+ // Scatter and bubble: one row per point.
63
+ if (series.some((s) => s.points?.length)) {
64
+ const rows: Array<Record<string, unknown>> = []
65
+ for (const s of series) for (const p of s.points ?? []) rows.push({ series: s.label, x: num(p.x), y: num(p.y), size: num(p.r), label: p.label ?? '' })
66
+ return { columns: [textCol('series', 'Series'), numCol('x', 'X'), numCol('y', 'Y'), numCol('size', 'Size'), textCol('label', 'Label')], rows }
67
+ }
68
+ // Sankey and chord: one row per link.
69
+ if (spec.sankeyLinks?.length) {
70
+ const names = new Map((spec.sankeyNodes ?? []).map((n) => [n.id, n.label ?? n.id]))
71
+ const rows = spec.sankeyLinks.map((l) => ({ source: names.get(l.source) ?? l.source, target: names.get(l.target) ?? l.target, value: num(l.value) }))
72
+ return { columns: [textCol('source', 'Source'), textCol('target', 'Target'), numCol('value', 'Value')], rows }
73
+ }
74
+ // Tree map and sunburst: one row per leaf, a column per level.
75
+ const tree = spec.tree ?? spec.treemap
76
+ if (tree && (spec.type === 'treemap' || spec.type === 'sunburst' || !spec.categories?.length)) {
77
+ const rows: Array<Record<string, unknown>> = []
78
+ let depth = 0
79
+ const walk = (n: TreeNode, path: string[]) => {
80
+ if (n.children?.length) {
81
+ for (const c of n.children) walk(c, [...path, c.name])
82
+ return
83
+ }
84
+ depth = Math.max(depth, path.length)
85
+ const row: Record<string, unknown> = { value: num(n.value) }
86
+ path.forEach((seg, i) => (row[`level${i + 1}`] = seg))
87
+ rows.push(row)
88
+ }
89
+ walk(tree, [])
90
+ const columns: ChartTableColumn[] = []
91
+ for (let i = 1; i <= depth; i += 1) columns.push(textCol(`level${i}`, depth === 1 ? 'Name' : `Level ${i}`))
92
+ columns.push(numCol('value', 'Value'))
93
+ return { columns, rows }
94
+ }
95
+ // Calendar: one row per day.
96
+ if (spec.calendarValues?.length) {
97
+ return {
98
+ columns: [{ field: 'date', header: 'Date', cellDataType: 'date' }, numCol('value', 'Value')],
99
+ rows: spec.calendarValues.map((d) => ({ date: d.date, value: num(d.value) })),
100
+ }
101
+ }
102
+ // Gauge: the one reading and its scale.
103
+ if (spec.type === 'gauge') {
104
+ return {
105
+ columns: [textCol('metric', 'Metric'), numCol('value', spec.gaugeUnit ? `Value (${spec.gaugeUnit})` : 'Value'), numCol('min', 'Min'), numCol('max', 'Max'), numCol('target', 'Target')],
106
+ rows: [{ metric: spec.title ?? spec.series[0]?.label ?? 'Value', value: num(spec.gaugeValue), min: num(spec.gaugeMin ?? 0), max: num(spec.gaugeMax ?? 100), target: num(spec.gaugeTarget) }],
107
+ }
108
+ }
109
+
110
+ // Everything else reads categories x series: one row per category, one
111
+ // column per number a series carries.
112
+ const cats = spec.categories ?? []
113
+ if (!cats.length || !series.length) return { columns: [], rows: [] }
114
+ const columns: ChartTableColumn[] = []
115
+ const groups = spec.categoryGroups
116
+ if (groups?.length) columns.push(textCol('group', 'Group'))
117
+ const edges = spec.binEdges
118
+ const xType = spec.xAxis?.type ?? spec.xType
119
+ const dated = xType === 'time' || xType === 'ordinal-time'
120
+ const catHeader = spec.xAxis?.title ?? spec.xAxisTitle ?? (dated ? 'Date' : 'Category')
121
+ if (edges && edges.length === cats.length + 1) columns.push(numCol('from', 'From'), numCol('to', 'To'))
122
+ else columns.push(dated ? { field: 'category', header: catHeader, cellDataType: 'date' } : textCol('category', catHeader))
123
+ const fields: Array<{ field: string; pick: (i: number) => unknown }> = []
124
+ series.forEach((s, si) => {
125
+ const key = (suffix: string) => `s${si}${suffix}`
126
+ const add = (suffix: string, header: string, pick: (i: number) => unknown) => {
127
+ columns.push(numCol(key(suffix), header, s.axis === 'right'))
128
+ fields.push({ field: key(suffix), pick })
129
+ }
130
+ if (s.ohlc) {
131
+ add('o', `${s.label} Open`, (i) => num(s.ohlc![i]?.o))
132
+ add('h', `${s.label} High`, (i) => num(s.ohlc![i]?.h))
133
+ add('l', `${s.label} Low`, (i) => num(s.ohlc![i]?.l))
134
+ add('c', `${s.label} Close`, (i) => num(s.ohlc![i]?.c))
135
+ if (s.volumes) add('v', `${s.label} Volume`, (i) => num(s.volumes![i]))
136
+ } else if (s.boxes) {
137
+ add('min', `${s.label} Min`, (i) => num(s.boxes![i]?.min))
138
+ add('q1', `${s.label} Q1`, (i) => num(s.boxes![i]?.q1))
139
+ add('med', `${s.label} Median`, (i) => num(s.boxes![i]?.median))
140
+ add('q3', `${s.label} Q3`, (i) => num(s.boxes![i]?.q3))
141
+ add('max', `${s.label} Max`, (i) => num(s.boxes![i]?.max))
142
+ columns.push(textCol(key('out'), `${s.label} Outliers`))
143
+ fields.push({ field: key('out'), pick: (i) => s.boxes![i]?.outliers?.join(' ') ?? '' })
144
+ } else if (s.errors) {
145
+ add('', s.label, (i) => num(s.values[i]))
146
+ add('lo', `${s.label} Low`, (i) => {
147
+ const v = s.values[i], e = s.errors![i]
148
+ return e == null || !Number.isFinite(v) ? null : typeof e === 'number' ? (v as number) - Math.abs(e) : Math.min(e.lo, e.hi)
149
+ })
150
+ add('hi', `${s.label} High`, (i) => {
151
+ const v = s.values[i], e = s.errors![i]
152
+ return e == null || !Number.isFinite(v) ? null : typeof e === 'number' ? (v as number) + Math.abs(e) : Math.max(e.lo, e.hi)
153
+ })
154
+ } else if (s.lowValues) {
155
+ add('lo', `${s.label} Low`, (i) => num(s.lowValues![i]))
156
+ add('hi', `${s.label} High`, (i) => num(s.values[i]))
157
+ } else if (s.targets) {
158
+ add('', s.label, (i) => num(s.values[i]))
159
+ add('t', `${s.label} Target`, (i) => num(s.targets![i]))
160
+ } else {
161
+ add('', s.label, (i) => num(s.values[i]))
162
+ }
163
+ })
164
+ // A category's group, from the spans.
165
+ const groupOf: string[] = []
166
+ if (groups?.length) for (const g of groups) for (let k = 0; k < g.span; k += 1) groupOf.push(g.label)
167
+ const rows = cats.map((cat, i) => {
168
+ const row: Record<string, unknown> = {}
169
+ if (groups?.length) row.group = groupOf[i] ?? ''
170
+ if (edges && edges.length === cats.length + 1) {
171
+ row.from = num(edges[i])
172
+ row.to = num(edges[i + 1])
173
+ } else row.category = cat
174
+ for (const f of fields) row[f.field] = f.pick(i)
175
+ return row
176
+ })
177
+ return { columns, rows }
178
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The two trend primitives a chart is read with: the least-squares line
3
+ * through a series and Pearson's correlation of two samples.
4
+ *
5
+ * A leaf of its own, and deliberately small, for the same reason as
6
+ * chart-format.ts: the engine and the lazily loaded summary
7
+ * (chart-summary.ts) share these two functions, a bundler hoists what the
8
+ * base chunk and a lazy chunk share into a third chunk, and hoisting all of
9
+ * chart-stats.ts for two functions cost the base bundle gzip locality for no
10
+ * new code. chart-stats.ts re-exports both.
11
+ */
12
+
13
+ /** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
14
+ * Returns the fitted value at each x index, or NaN where the source value
15
+ * was non-finite. */
16
+ export function linearTrend(values: number[]): number[] {
17
+ let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
18
+ for (let i = 0; i < values.length; i += 1) {
19
+ const y = values[i]!
20
+ if (!Number.isFinite(y)) continue
21
+ n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
22
+ }
23
+ if (n < 2) return values.map(() => NaN)
24
+ const denom = n * sumXX - sumX * sumX
25
+ if (denom === 0) return values.map(() => sumY / n)
26
+ const slope = (n * sumXY - sumX * sumY) / denom
27
+ const intercept = (sumY - slope * sumX) / n
28
+ return values.map((_, i) => slope * i + intercept)
29
+ }
30
+
31
+ /** Pearson's correlation coefficient of two equal-length samples, skipping
32
+ * pairs where either side is non-finite; 0 when fewer than two pairs remain
33
+ * or either side has no variance. */
34
+ export function pearson(xs: ReadonlyArray<number>, ys: ReadonlyArray<number>): number {
35
+ let n = 0, sx = 0, sy = 0
36
+ const len = Math.min(xs.length, ys.length)
37
+ for (let i = 0; i < len; i += 1) if (Number.isFinite(xs[i]) && Number.isFinite(ys[i])) { n += 1; sx += xs[i]!; sy += ys[i]! }
38
+ if (n < 2) return 0
39
+ const mx = sx / n, my = sy / n
40
+ let sxy = 0, sxx = 0, syy = 0
41
+ for (let i = 0; i < len; i += 1) {
42
+ if (!Number.isFinite(xs[i]) || !Number.isFinite(ys[i])) continue
43
+ const dx = xs[i]! - mx, dy = ys[i]! - my
44
+ sxy += dx * dy; sxx += dx * dx; syy += dy * dy
45
+ }
46
+ return sxx === 0 || syy === 0 ? 0 : sxy / Math.sqrt(sxx * syy)
47
+ }