@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,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { CHART_TYPES, KNOWN_SERIES_KEYS, KNOWN_SPEC_KEYS, nearestKey, validateChartSpec } from './chart-validate'
3
+ import { sampleChartSpec, sampleChartThumb } from './chart-samples'
4
+ import { PER_CATEGORY_SERIES_KEYS } from './chart-decimate'
5
+ import type { ChartSpec } from './chart-types'
6
+
7
+ const base = (): ChartSpec => ({
8
+ type: 'bar',
9
+ categories: ['a', 'b', 'c'],
10
+ series: [{ label: 's', values: [1, 2, 3] }],
11
+ })
12
+ const codes = (spec: unknown) => validateChartSpec(spec).map((d) => d.code)
13
+
14
+ describe('validateChartSpec', () => {
15
+ it('passes every sample spec of every type, full size and thumbnail', () => {
16
+ for (const type of CHART_TYPES) {
17
+ expect(validateChartSpec(sampleChartSpec(type)), type).toEqual([])
18
+ expect(validateChartSpec(sampleChartThumb(type)), `${type} thumb`).toEqual([])
19
+ }
20
+ expect(validateChartSpec(base())).toEqual([])
21
+ })
22
+
23
+ it('names an unknown top-level key with the nearest known one', () => {
24
+ const d = validateChartSpec({ ...base(), serie: [] } as unknown)
25
+ expect(d).toHaveLength(1)
26
+ expect(d[0]).toMatchObject({ code: 'chart/unknown-key', path: 'serie', severity: 'warning' })
27
+ expect(d[0]!.message).toContain('"series"')
28
+ expect(validateChartSpec({ ...base(), zzzz: 1 } as unknown)[0]!.message).toContain('is ignored')
29
+ expect(nearestKey('valeus', KNOWN_SERIES_KEYS)).toBe('values')
30
+ expect(nearestKey('completely-different', KNOWN_SPEC_KEYS)).toBeNull()
31
+ })
32
+
33
+ it('rejects an unknown chart type and suggests the closest', () => {
34
+ const d = validateChartSpec({ ...base(), type: 'lines' })
35
+ expect(d[0]).toMatchObject({ code: 'chart/unknown-type', path: 'type', severity: 'error' })
36
+ expect(d[0]!.message).toContain('"line"')
37
+ expect(codes({ categories: [], series: [] })).toContain('chart/unknown-type')
38
+ expect(codes(null)).toEqual(['chart/not-an-object'])
39
+ expect(codes('bar')).toEqual(['chart/not-an-object'])
40
+ })
41
+
42
+ it('checks series length against the categories, and every parallel array', () => {
43
+ expect(codes({ ...base(), series: [{ label: 's', values: [1, 2] }] })).toEqual(['chart/series-length'])
44
+ for (const key of PER_CATEGORY_SERIES_KEYS) {
45
+ if (key === 'values') continue
46
+ const spec = { ...base(), series: [{ label: 's', values: [1, 2, 3], [key]: [null] }] }
47
+ expect(codes(spec), key).toContain('chart/series-length')
48
+ }
49
+ // Families that do not index values by category are left alone.
50
+ expect(codes({ type: 'scatter', categories: [], series: [{ label: 'p', values: [], points: [{ x: 1, y: 2 }] }] })).toEqual([])
51
+ expect(codes({ type: 'gauge', categories: [], series: [], gaugeValue: 3 })).toEqual([])
52
+ })
53
+
54
+ it('checks series keys, type, axis, stack, overlay and the point shapes', () => {
55
+ expect(validateChartSpec({ ...base(), series: [{ label: 's', valeus: [1, 2, 3], values: [1, 2, 3] }] })[0]).toMatchObject({ code: 'chart/unknown-series-key', path: 'series[0].valeus' })
56
+ expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], type: 'column' }] })).toEqual(['chart/series-type'])
57
+ expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], axis: 'top' }] })).toEqual(['chart/series-axis'])
58
+ expect(codes({ ...base(), type: 'line', series: [{ label: 's', values: [1, 2, 3], stack: 'x' }] })).toEqual(['chart/stack-kind'])
59
+ expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], stack: 'x' }] })).toEqual([])
60
+ expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], overlay: 'sma20' }] })).toEqual(['chart/overlay'])
61
+ for (const ok of ['linear', 'sma:20', 'ema:7', 'wma:5', 'bb:20:2', 'bb:20:2.5', 'vwap', 'poly:3', 'exp', 'log', 'power']) {
62
+ expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], overlay: ok }] }), ok).toEqual([])
63
+ }
64
+ expect(codes({ ...base(), type: 'candlestick', series: [{ label: 's', values: [1, 2, 3], ohlc: [{ o: 1, h: 2, l: 3, c: 2 }, null, { o: 1 }] }] })).toEqual(['chart/ohlc-range', 'chart/ohlc-shape'])
65
+ expect(codes({ ...base(), type: 'boxplot', series: [{ label: 's', values: [1, 2, 3], boxes: [{ min: 0, q1: 5, median: 2, q3: 6, max: 9 }, null, null] }] })).toEqual(['chart/box-order'])
66
+ expect(codes({ type: 'scatter', categories: [], series: [{ label: 'p', values: [], points: [{ x: 'a', y: 2 }] }] })).toEqual(['chart/point-shape'])
67
+ })
68
+
69
+ it('checks the axis config', () => {
70
+ expect(codes({ ...base(), yAxis: { min: 10, max: 5 } })).toEqual(['chart/axis-range'])
71
+ expect(codes({ ...base(), yAxis: { scale: 'log', min: 0 } })).toEqual(['chart/axis-log-min'])
72
+ expect(codes({ ...base(), xAxis: { type: 'category', scale: 'log' } })).toEqual(['chart/axis-log-x'])
73
+ expect(codes({ ...base(), xAxis: { type: 'number', scale: 'log' } })).toEqual([])
74
+ expect(codes({ ...base(), y2Axis: { tickCount: 0 } })).toEqual(['chart/axis-ticks'])
75
+ expect(codes({ ...base(), xAxis: { labelRotation: 'tilted' } })).toEqual(['chart/axis-rotation'])
76
+ expect(codes({ ...base(), yAxis: { scale: 'sqrt' } })).toEqual(['chart/axis-scale'])
77
+ expect(codes({ ...base(), yAxis: 'log' })).toEqual(['chart/axis-shape'])
78
+ })
79
+
80
+ it('checks the spec-level shapes: stacked100, innerRadius, groups, bins, waterfall flags, responsive rules', () => {
81
+ expect(codes({ ...base(), type: 'pie', stacked100: true })).toEqual(['chart/stacked100-kind'])
82
+ expect(codes({ ...base(), innerRadius: 1.5 })).toEqual(['chart/inner-radius'])
83
+ expect(codes({ ...base(), categoryGroups: [{ label: 'g', span: 2 }] })).toEqual(['chart/category-groups'])
84
+ expect(codes({ ...base(), type: 'histogram', binEdges: [0, 1] })).toEqual(['chart/bin-edges'])
85
+ expect(codes({ ...base(), type: 'waterfall', waterfallTotals: [true] })).toEqual(['chart/waterfall-totals'])
86
+ expect(codes({ ...base(), responsive: [{ spec: { title: 'x' } }] })).toEqual(['chart/responsive-size'])
87
+ expect(codes({ ...base(), responsive: [{ maxWidth: 400, spec: { width: 10 } }] })).toEqual(['chart/responsive-size-key'])
88
+ expect(codes({ ...base(), responsive: ['narrow'] })).toEqual(['chart/responsive-shape'])
89
+ })
90
+
91
+ it('warns about x anchors that name no category, and not on a time or number axis', () => {
92
+ expect(codes({ ...base(), referenceLines: [{ axis: 'x', value: 'd' }] })).toEqual(['chart/unknown-category'])
93
+ expect(codes({ ...base(), annotations: [{ at: { category: 'zz' }, label: 'n' }] })).toEqual(['chart/unknown-category'])
94
+ expect(codes({ ...base(), drawings: [{ id: 'd1', kind: 'trend', points: [{ x: 'a', y: 1 }, { x: 'q', y: 2 }] }] })).toEqual(['chart/unknown-category'])
95
+ expect(codes({ ...base(), categories: ['1', '2', '3'], xType: 'number', referenceLines: [{ axis: 'x', value: '2.5' }] })).toEqual([])
96
+ // The drawing tools write a timestamp for every pointer position on a time axis.
97
+ expect(codes({ ...base(), categories: ['2026-01-01', '2026-02-01', '2026-03-01'], xType: 'time', drawings: [{ id: 'd1', kind: 'trend', points: [{ x: '2026-01-12T10:00:00.000Z', y: 1 }, { x: '2026-02-20T08:30:00.000Z', y: 2 }] }] })).toEqual([])
98
+ expect(codes({ ...base(), annotations: [{ at: { category: 'b' }, label: 'n' }] })).toEqual([])
99
+ })
100
+ })
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Chart spec diagnostics. A `ChartSpec` is plain data, and plain data gets
3
+ * typos: a `serie` key, a `values` array one short of the categories, a log
4
+ * axis pinned at zero. The engine draws what it can and stays quiet, which
5
+ * is right for production and wrong for the developer who wonders why a bar
6
+ * is missing. `validateChartSpec` says what is off, with the path of the
7
+ * field and a severity: `error` for what will not draw as intended,
8
+ * `warning` for what is ignored or looks like a slip.
9
+ *
10
+ * Pure and dependency-free (types only), so the same rules run in `SvChart`
11
+ * in development, in the MCP server's code check and in a test. The key and
12
+ * type lists are exported for the JSON schema's drift guard.
13
+ */
14
+ import type { ChartSeries, ChartSpec, ChartType } from './chart-types'
15
+
16
+ /** One finding of {@link validateChartSpec}. */
17
+ export type ChartDiagnostic = {
18
+ /** A stable rule id, `chart/<rule>`. */
19
+ code: string
20
+ /** The field's path in the spec, like `series[1].values` or `yAxis.min`. */
21
+ path: string
22
+ message: string
23
+ /** `error`: will not draw as intended. `warning`: ignored, or a likely slip. */
24
+ severity: 'error' | 'warning'
25
+ }
26
+
27
+ /** Every `ChartType` the engine draws. */
28
+ export const CHART_TYPES: ReadonlyArray<ChartType> = [
29
+ 'bar', 'line', 'area', 'pie', 'scatter', 'heatmap', 'waterfall', 'funnel', 'radar', 'calendar', 'gauge',
30
+ 'treemap', 'sankey', 'candlestick', 'ohlc', 'boxplot', 'histogram', 'range-bar', 'range-area', 'lollipop',
31
+ 'dumbbell', 'pareto', 'stream', 'sunburst', 'radial-bar', 'radial-column', 'nightingale', 'chord', 'bullet',
32
+ ]
33
+
34
+ /** Every top-level key a `ChartSpec` may carry. */
35
+ export const KNOWN_SPEC_KEYS: ReadonlyArray<keyof ChartSpec> = [
36
+ 'type', 'categories', 'series', 'width', 'height', 'palette', 'categoryColors', 'valueFormat', 'locale',
37
+ 'currency', 'categoryGroups', 'stacked', 'stacked100', 'orientation', 'innerRadius', 'referenceLines',
38
+ 'xType', 'yAxisTitle', 'y2AxisTitle', 'xAxisTitle', 'yScale', 'y2Scale', 'xAxis', 'yAxis', 'y2Axis',
39
+ 'title', 'subtitle', 'caption', 'referenceBands', 'nullAs', 'decimate', 'dataLabels', 'annotations',
40
+ 'seriesLabels', 'responsive', 'style', 'drawings', 'lastPriceLine', 'patternFallback', 'calendarValues',
41
+ 'calendarStart', 'calendarEnd', 'gaugeValue', 'gaugeMin', 'gaugeMax', 'gaugeTarget', 'gaugeRanges',
42
+ 'gaugeUnit', 'treemap', 'sankeyNodes', 'sankeyLinks', 'waterfallTotals', 'waterfallColors', 'candleColors',
43
+ 'candleStyle', 'funnelShape', 'stackOffset', 'binEdges', 'tree', 'bulletRanges', 'colorScale',
44
+ ]
45
+
46
+ /** Every key a `ChartSeries` may carry. */
47
+ export const KNOWN_SERIES_KEYS: ReadonlyArray<keyof ChartSeries> = [
48
+ 'label', 'values', 'color', 'type', 'ohlc', 'volumes', 'boxes', 'errors', 'axis', 'points', 'rowIds',
49
+ 'overlay', 'overlayColor', 'pattern', 'smooth', 'upperValues', 'lowerValues', 'marker', 'markers', 'colors',
50
+ 'stack', 'strokeWidth', 'dash', 'opacity', 'visible', 'gradient', 'step', 'connectNulls', 'nullAs',
51
+ 'lowValues', 'targets',
52
+ ]
53
+
54
+ const SERIES_TYPES = new Set(['bar', 'line', 'area', 'scatter', 'candlestick', 'ohlc', 'boxplot', 'range-bar', 'range-area', 'lollipop', 'dumbbell'])
55
+ /** The series arrays that must run parallel to `categories`. */
56
+ const PER_CATEGORY = ['values', 'rowIds', 'upperValues', 'lowerValues', 'ohlc', 'boxes', 'errors', 'markers', 'colors', 'lowValues', 'targets', 'volumes'] as const
57
+ /** Families whose `series[].values` are not indexed by `categories`. */
58
+ const FREE_LENGTH_TYPES = new Set<string>(['scatter', 'gauge', 'treemap', 'sankey', 'calendar', 'sunburst', 'chord', 'histogram'])
59
+ const OVERLAY_RE = /^(linear|exp|log|power|vwap|sma:\d+|ema:\d+|wma:\d+|bb:\d+:\d+(?:\.\d+)?|poly:\d+)$/
60
+
61
+ /** Levenshtein distance, for a "did you mean" on a misspelt key. */
62
+ function distance(a: string, b: string): number {
63
+ const dp: number[] = Array.from({ length: b.length + 1 }, (_, j) => j)
64
+ for (let i = 1; i <= a.length; i += 1) {
65
+ let prev = dp[0]!
66
+ dp[0] = i
67
+ for (let j = 1; j <= b.length; j += 1) {
68
+ const tmp = dp[j]!
69
+ dp[j] = Math.min(dp[j]! + 1, dp[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
70
+ prev = tmp
71
+ }
72
+ }
73
+ return dp[b.length]!
74
+ }
75
+
76
+ /** The known key closest to `key`, when it is close enough to be a typo. */
77
+ export function nearestKey(key: string, known: ReadonlyArray<string>): string | null {
78
+ let best: string | null = null
79
+ let bestD = Infinity
80
+ for (const k of known) {
81
+ const d = distance(key.toLowerCase(), k.toLowerCase())
82
+ if (d < bestD) { bestD = d; best = k }
83
+ }
84
+ return best !== null && bestD <= Math.max(1, Math.floor(key.length / 3)) ? best : null
85
+ }
86
+
87
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
88
+ const isNum = (v: unknown): v is number => typeof v === 'number'
89
+
90
+ const warnedSpecs = new WeakSet<object>()
91
+
92
+ /**
93
+ * Log every finding of a spec once per spec object, the way `SvChart` does in
94
+ * development, and return them. A spec already reported (by any chart) is
95
+ * skipped, so a spec shared by ten charts warns once.
96
+ */
97
+ export function warnChartSpec(spec: unknown, log: (line: string) => void = (line) => console.warn(line)): ChartDiagnostic[] {
98
+ if (typeof spec !== 'object' || spec === null || warnedSpecs.has(spec)) return []
99
+ warnedSpecs.add(spec)
100
+ const out = validateChartSpec(spec)
101
+ for (const d of out) log(`[SvChart] ${d.path ? d.path + ': ' : ''}${d.message}`)
102
+ return out
103
+ }
104
+
105
+ /**
106
+ * Check a spec and return every finding, an empty array for a clean one. Never
107
+ * throws: a spec that is not even an object comes back as one error.
108
+ */
109
+ export function validateChartSpec(spec: unknown): ChartDiagnostic[] {
110
+ const out: ChartDiagnostic[] = []
111
+ const err = (code: string, path: string, message: string) => out.push({ code: `chart/${code}`, path, message, severity: 'error' })
112
+ const warn = (code: string, path: string, message: string) => out.push({ code: `chart/${code}`, path, message, severity: 'warning' })
113
+ if (!isObj(spec)) {
114
+ err('not-an-object', '', 'a ChartSpec is an object with type, categories and series')
115
+ return out
116
+ }
117
+ const s = spec as Record<string, unknown>
118
+
119
+ // ---- Keys and type -------------------------------------------------------
120
+ for (const k of Object.keys(s)) {
121
+ if ((KNOWN_SPEC_KEYS as ReadonlyArray<string>).includes(k)) continue
122
+ const near = nearestKey(k, KNOWN_SPEC_KEYS)
123
+ warn('unknown-key', k, near ? `unknown field "${k}"; did you mean "${near}"?` : `unknown field "${k}" is ignored`)
124
+ }
125
+ const type = s.type
126
+ if (typeof type !== 'string' || !(CHART_TYPES as ReadonlyArray<string>).includes(type)) {
127
+ const near = typeof type === 'string' ? nearestKey(type, CHART_TYPES) : null
128
+ err('unknown-type', 'type', near ? `unknown chart type "${String(type)}"; did you mean "${near}"?` : `unknown chart type "${String(type)}"`)
129
+ }
130
+ const categories = Array.isArray(s.categories) ? (s.categories as unknown[]) : null
131
+ if (!categories) err('categories', 'categories', 'categories must be an array of labels')
132
+ const n = categories?.length ?? 0
133
+ const series = Array.isArray(s.series) ? (s.series as unknown[]) : null
134
+ if (!series) err('series', 'series', 'series must be an array')
135
+
136
+ // ---- Series ----------------------------------------------------------------
137
+ const fixedLength = typeof type === 'string' && !FREE_LENGTH_TYPES.has(type)
138
+ series?.forEach((sr, i) => {
139
+ const p = `series[${i}]`
140
+ if (!isObj(sr)) { err('series-shape', p, 'a series is an object with label and values'); return }
141
+ for (const k of Object.keys(sr)) {
142
+ if ((KNOWN_SERIES_KEYS as ReadonlyArray<string>).includes(k)) continue
143
+ const near = nearestKey(k, KNOWN_SERIES_KEYS)
144
+ warn('unknown-series-key', `${p}.${k}`, near ? `unknown series field "${k}"; did you mean "${near}"?` : `unknown series field "${k}" is ignored`)
145
+ }
146
+ if (typeof sr.label !== 'string') warn('series-label', `${p}.label`, 'a series wants a string label; the legend and tooltip name it')
147
+ if (!Array.isArray(sr.values)) { err('series-values', `${p}.values`, 'values must be an array of numbers'); return }
148
+ const values = sr.values as unknown[]
149
+ if (fixedLength && categories && values.length !== n && !(sr.points && values.length === 0)) {
150
+ err('series-length', `${p}.values`, `${values.length} values for ${n} categories; they run in parallel`)
151
+ }
152
+ for (const key of PER_CATEGORY) {
153
+ if (key === 'values') continue
154
+ const arr = sr[key]
155
+ if (arr === undefined) continue
156
+ if (!Array.isArray(arr)) { err('series-array', `${p}.${key}`, `${key} must be an array parallel to values`); continue }
157
+ if (fixedLength && arr.length !== values.length) err('series-length', `${p}.${key}`, `${key} has ${arr.length} entries for ${values.length} values; they run in parallel`)
158
+ }
159
+ if (sr.type !== undefined && (typeof sr.type !== 'string' || !SERIES_TYPES.has(sr.type))) err('series-type', `${p}.type`, `unknown series type "${String(sr.type)}"`)
160
+ if (sr.axis !== undefined && sr.axis !== 'left' && sr.axis !== 'right') err('series-axis', `${p}.axis`, `axis is 'left' or 'right', not "${String(sr.axis)}"`)
161
+ if (sr.stack !== undefined) {
162
+ const kind = (sr.type as string | undefined) ?? type
163
+ if (kind !== 'bar' && kind !== 'area') warn('stack-kind', `${p}.stack`, 'stack groups apply to bar and area series; ignored here')
164
+ }
165
+ if (sr.overlay !== undefined && (typeof sr.overlay !== 'string' || !OVERLAY_RE.test(sr.overlay))) err('overlay', `${p}.overlay`, `unknown overlay "${String(sr.overlay)}"; expected linear, poly:N, exp, log, power, sma:N, ema:N, wma:N, bb:N:K or vwap`)
166
+ if (Array.isArray(sr.ohlc)) {
167
+ ;(sr.ohlc as unknown[]).forEach((b, j) => {
168
+ if (b === null || b === undefined) return
169
+ if (!isObj(b) || !isNum(b.o) || !isNum(b.h) || !isNum(b.l) || !isNum(b.c)) err('ohlc-shape', `${p}.ohlc[${j}]`, 'an OHLC bar is { o, h, l, c } or null')
170
+ else if (b.l > b.h) err('ohlc-range', `${p}.ohlc[${j}]`, `low ${b.l} is above high ${b.h}`)
171
+ })
172
+ }
173
+ if (Array.isArray(sr.boxes)) {
174
+ ;(sr.boxes as unknown[]).forEach((b, j) => {
175
+ if (b === null || b === undefined) return
176
+ if (!isObj(b) || !isNum(b.min) || !isNum(b.q1) || !isNum(b.median) || !isNum(b.q3) || !isNum(b.max)) err('box-shape', `${p}.boxes[${j}]`, 'a box is { min, q1, median, q3, max } or null')
177
+ else if (b.q1 > b.median || b.median > b.q3) err('box-order', `${p}.boxes[${j}]`, 'a box wants q1 <= median <= q3')
178
+ })
179
+ }
180
+ if (Array.isArray(sr.points)) {
181
+ ;(sr.points as unknown[]).forEach((pt, j) => {
182
+ if (!isObj(pt) || !isNum(pt.x) || !isNum(pt.y)) err('point-shape', `${p}.points[${j}]`, 'a scatter point is { x, y, r?, label? }')
183
+ })
184
+ }
185
+ })
186
+
187
+ // ---- Axes ----------------------------------------------------------------
188
+ for (const key of ['xAxis', 'yAxis', 'y2Axis'] as const) {
189
+ const ax = s[key]
190
+ if (ax === undefined) continue
191
+ if (!isObj(ax)) { err('axis-shape', key, `${key} must be an object`); continue }
192
+ if (isNum(ax.min) && isNum(ax.max) && ax.min >= ax.max) err('axis-range', `${key}.min`, `min ${ax.min} is not below max ${ax.max}`)
193
+ if (ax.scale === 'log' && isNum(ax.min) && ax.min <= 0) err('axis-log-min', `${key}.min`, 'a log axis cannot start at or below zero')
194
+ if (ax.scale === 'log' && key === 'xAxis' && ax.type !== undefined && ax.type !== 'number') warn('axis-log-x', `${key}.scale`, "a log x axis needs type: 'number'")
195
+ if (ax.tickCount !== undefined && (!isNum(ax.tickCount) || ax.tickCount <= 0)) err('axis-ticks', `${key}.tickCount`, 'tickCount must be a positive number')
196
+ if (ax.labelRotation !== undefined && ax.labelRotation !== 'auto' && !isNum(ax.labelRotation)) err('axis-rotation', `${key}.labelRotation`, "labelRotation is a number of degrees or 'auto'")
197
+ if (ax.scale !== undefined && ax.scale !== 'linear' && ax.scale !== 'log') err('axis-scale', `${key}.scale`, `scale is 'linear' or 'log', not "${String(ax.scale)}"`)
198
+ }
199
+
200
+ // ---- Spec-level shapes -----------------------------------------------------
201
+ if (s.stacked100 && typeof type === 'string' && type !== 'bar' && type !== 'area') warn('stacked100-kind', 'stacked100', 'stacked100 applies to bar and area charts; ignored here')
202
+ if (s.innerRadius !== undefined && (!isNum(s.innerRadius) || s.innerRadius < 0 || s.innerRadius > 1)) err('inner-radius', 'innerRadius', 'innerRadius is a fraction 0..1 of the pie radius')
203
+ if (Array.isArray(s.categoryGroups) && categories) {
204
+ const span = (s.categoryGroups as Array<{ span?: unknown }>).reduce((a, g) => a + (isObj(g) && isNum(g.span) ? g.span : 0), 0)
205
+ if (span !== n) err('category-groups', 'categoryGroups', `the group spans add up to ${span}, not the ${n} categories`)
206
+ }
207
+ if (Array.isArray(s.binEdges) && categories && (s.binEdges as unknown[]).length !== n + 1) err('bin-edges', 'binEdges', `binEdges wants ${n + 1} edges for ${n} bins`)
208
+ if (Array.isArray(s.waterfallTotals) && categories && (s.waterfallTotals as unknown[]).length !== n) err('waterfall-totals', 'waterfallTotals', `waterfallTotals has ${(s.waterfallTotals as unknown[]).length} flags for ${n} categories`)
209
+ if (Array.isArray(s.responsive)) {
210
+ ;(s.responsive as unknown[]).forEach((r, i) => {
211
+ const p = `responsive[${i}]`
212
+ if (!isObj(r)) { err('responsive-shape', p, 'a rule is { maxWidth?, minWidth?, maxHeight?, spec?, legend? }'); return }
213
+ if (!isNum(r.maxWidth) && !isNum(r.minWidth) && !isNum(r.maxHeight)) warn('responsive-size', p, 'a rule with no maxWidth, minWidth or maxHeight always applies')
214
+ if (isObj(r.spec)) {
215
+ for (const k of ['width', 'height', 'responsive']) if (k in r.spec) warn('responsive-size-key', `${p}.spec.${k}`, `${k} in a responsive rule is ignored; a rule cannot change the size`)
216
+ }
217
+ })
218
+ }
219
+ // Anchors on the category axis that name no category.
220
+ if (categories && typeof type === 'string' && !FREE_LENGTH_TYPES.has(type)) {
221
+ const known = new Set(categories.map(String))
222
+ const isTimeOrNumber = (s.xType ?? (isObj(s.xAxis) ? s.xAxis.type : undefined)) as string | undefined
223
+ const anchored = isTimeOrNumber === 'time' || isTimeOrNumber === 'number'
224
+ const check = (list: unknown, path: string, valueOf: (item: Record<string, unknown>) => unknown) => {
225
+ if (!Array.isArray(list) || anchored) return
226
+ list.forEach((item, i) => {
227
+ if (!isObj(item)) return
228
+ const v = valueOf(item)
229
+ if (typeof v === 'string' && !known.has(v)) warn('unknown-category', `${path}[${i}]`, `"${v}" is not one of the categories, so it has no place on the axis`)
230
+ })
231
+ }
232
+ check(s.annotations, 'annotations', (a) => (isObj(a.at) ? a.at.category : undefined))
233
+ check(Array.isArray(s.referenceLines) ? (s.referenceLines as unknown[]).filter((r) => isObj(r) && r.axis === 'x') : [], 'referenceLines', (r) => r.value)
234
+ // On a time or number axis a drawing's x is a timestamp or a number the
235
+ // axis places itself; the tools write one for every pointer position, so
236
+ // matching those against the category list warned on every drawing.
237
+ if (Array.isArray(s.drawings) && !anchored) {
238
+ ;(s.drawings as unknown[]).forEach((d, i) => {
239
+ if (!isObj(d) || !Array.isArray(d.points)) return
240
+ ;(d.points as unknown[]).forEach((pt, j) => {
241
+ if (isObj(pt) && typeof pt.x === 'string' && !known.has(pt.x)) warn('unknown-category', `drawings[${i}].points[${j}].x`, `"${pt.x}" is not one of the categories`)
242
+ })
243
+ })
244
+ }
245
+ }
246
+ return out
247
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { nearestIndexByTime, panWindow, pinchWindow, presetWindow, wheelWindow } from './chart-zoom'
3
+ import { drillTree, pathTo } from './chart-hierarchy'
4
+ import { interpolateGeometry } from './chart-motion'
5
+ import { buildChart, type ChartSpec } from './chart'
6
+
7
+ describe('wheelWindow', () => {
8
+ it('zooms in around the anchor and keeps its relative position', () => {
9
+ const n = 100
10
+ const w = wheelWindow(null, n, 50, 0.5)!
11
+ expect(w.i1 - w.i0 + 1).toBe(50)
12
+ // The anchor was at the middle of the full axis, so it stays at the middle.
13
+ expect((50 - w.i0) / (w.i1 - w.i0)).toBeCloseTo(0.5, 1)
14
+ const edge = wheelWindow(null, n, 0, 0.5)!
15
+ expect(edge.i0).toBe(0)
16
+ expect(edge.i1).toBe(49)
17
+ })
18
+ it('zooms out until the whole axis, which is null', () => {
19
+ const w = wheelWindow({ i0: 40, i1: 59 }, 100, 50, 2)!
20
+ expect(w.i1 - w.i0 + 1).toBe(40)
21
+ expect(wheelWindow({ i0: 10, i1: 89 }, 100, 50, 2)).toBeNull()
22
+ })
23
+ it('never goes below two categories', () => {
24
+ expect(wheelWindow({ i0: 10, i1: 11 }, 100, 10, 0.5)).toEqual({ i0: 10, i1: 11 })
25
+ })
26
+ })
27
+
28
+ describe('panWindow', () => {
29
+ it('slides the window and clamps at both ends', () => {
30
+ expect(panWindow({ i0: 10, i1: 19 }, 100, 5)).toEqual({ i0: 15, i1: 24 })
31
+ expect(panWindow({ i0: 10, i1: 19 }, 100, -50)).toEqual({ i0: 0, i1: 9 })
32
+ expect(panWindow({ i0: 85, i1: 94 }, 100, 50)).toEqual({ i0: 90, i1: 99 })
33
+ expect(panWindow(null, 100, 5)).toBeNull()
34
+ })
35
+ })
36
+
37
+ describe('pinchWindow', () => {
38
+ it('spreading the fingers zooms in', () => {
39
+ const w = pinchWindow(null, 100, 50, 2)!
40
+ expect(w.i1 - w.i0 + 1).toBe(50)
41
+ })
42
+ })
43
+
44
+ describe('presetWindow', () => {
45
+ const days = Array.from({ length: 730 }, (_, i) => new Date(Date.UTC(2025, 0, 1) + i * 86_400_000).toISOString().slice(0, 10))
46
+ it('picks the last month, three months and year to date up to the latest category', () => {
47
+ const m = presetWindow(days, '1M')!
48
+ expect(m.i1).toBe(729)
49
+ expect(m.i1 - m.i0).toBeGreaterThanOrEqual(28)
50
+ expect(m.i1 - m.i0).toBeLessThanOrEqual(31)
51
+ const q = presetWindow(days, '3M')!
52
+ expect(q.i1 - q.i0).toBeGreaterThanOrEqual(89)
53
+ const ytd = presetWindow(days, 'YTD')!
54
+ expect(days[ytd.i0]).toBe('2026-01-01')
55
+ expect(ytd.i1).toBe(729)
56
+ expect(presetWindow(days, '1W')!.i1 - presetWindow(days, '1W')!.i0).toBe(7)
57
+ })
58
+ it('All and unparseable categories give the whole axis', () => {
59
+ expect(presetWindow(days, 'All')).toBeNull()
60
+ expect(presetWindow(['a', 'b', 'c'], '1M')).toBeNull()
61
+ })
62
+ it('takes explicit from / to and day counts', () => {
63
+ const w = presetWindow(days, { label: 'Q1', from: '2026-01-01', to: '2026-03-31' })!
64
+ expect(days[w.i0]).toBe('2026-01-01')
65
+ expect(days[w.i1]).toBe('2026-03-31')
66
+ const d = presetWindow(days, { label: '10d', days: 10 })!
67
+ expect(d.i1 - d.i0).toBe(10)
68
+ })
69
+ it('honours an explicit now', () => {
70
+ const w = presetWindow(days, '1M', Date.UTC(2025, 5, 30))!
71
+ expect(days[w.i1]).toBe('2025-06-30')
72
+ })
73
+ })
74
+
75
+ describe('nearestIndexByTime', () => {
76
+ it('finds the closest date and skips non-dates', () => {
77
+ expect(nearestIndexByTime(['2026-01-01', 'x', '2026-01-10', '2026-01-20'], Date.parse('2026-01-12'))).toBe(2)
78
+ expect(nearestIndexByTime(['a', 'b'], 0)).toBe(-1)
79
+ })
80
+ })
81
+
82
+ describe('drillTree / pathTo', () => {
83
+ const tree = { name: 'root', children: [{ name: 'A', children: [{ name: 'A1', value: 1 }] }, { name: 'B', value: 2 }] }
84
+ it('descends by names and reports the path to a node', () => {
85
+ expect(drillTree(tree, [])).toBe(tree)
86
+ expect(drillTree(tree, ['A'])!.name).toBe('A')
87
+ expect(drillTree(tree, ['A', 'A1'])!.value).toBe(1)
88
+ expect(drillTree(tree, ['Z'])).toBeNull()
89
+ expect(pathTo(tree, 'A1')).toEqual(['A', 'A1'])
90
+ expect(pathTo(tree, 'nope')).toBeNull()
91
+ })
92
+ })
93
+
94
+ describe('interpolateGeometry', () => {
95
+ const spec = (values: number[], categories = ['a', 'b', 'c']): ChartSpec => ({
96
+ type: 'bar', categories, series: [{ label: 's', values }, { label: 'l', values: values.map((v) => v / 2), type: 'line' }], width: 400, height: 200,
97
+ })
98
+ it('is the source at 0 and the target at 1, and halfway between in between', () => {
99
+ const a = buildChart(spec([10, 20, 30]))
100
+ const b = buildChart(spec([30, 20, 10]))
101
+ expect(interpolateGeometry(a, b, 0)).toBe(a)
102
+ expect(interpolateGeometry(a, b, 1)).toBe(b)
103
+ const mid = interpolateGeometry(a, b, 0.5)
104
+ expect(mid.bars[0]!.h).toBeCloseTo((a.bars[0]!.h + b.bars[0]!.h) / 2, 1)
105
+ expect(mid.lines[0]!.points[0]!.y).toBeCloseTo((a.lines[0]!.points[0]!.y + b.lines[0]!.points[0]!.y) / 2, 1)
106
+ expect(mid.lines[0]!.path).not.toBe(a.lines[0]!.path)
107
+ })
108
+ it('grows a new bar from the baseline and shrinks a removed one', () => {
109
+ const a = buildChart(spec([10, 20], ['a', 'b']))
110
+ const b = buildChart(spec([10, 20, 30]))
111
+ const mid = interpolateGeometry(a, b, 0.5)
112
+ const c = mid.bars.find((x) => x.label === 'c')!
113
+ expect(c.h).toBeCloseTo(b.bars[2]!.h / 2, 1)
114
+ const back = interpolateGeometry(b, a, 0.5)
115
+ const gone = back.bars.find((x) => x.label === 'c')!
116
+ expect(gone.h).toBeCloseTo(b.bars[2]!.h / 2, 1)
117
+ expect(interpolateGeometry(b, a, 1).bars.find((x) => x.label === 'c')).toBeUndefined()
118
+ })
119
+ it('tweens pie slices and arcs by angle', () => {
120
+ const pie = (v: number[]): ChartSpec => ({ type: 'pie', categories: ['x', 'y'], series: [{ label: 's', values: v }], width: 300, height: 200 })
121
+ const a = buildChart(pie([1, 1]))
122
+ const b = buildChart(pie([3, 1]))
123
+ const mid = interpolateGeometry(a, b, 0.5)
124
+ expect(mid.slices[0]!.arc!.a1).toBeCloseTo((a.slices[0]!.arc!.a1 + b.slices[0]!.arc!.a1) / 2, 6)
125
+ expect(mid.slices[0]!.path).not.toBe(a.slices[0]!.path)
126
+ const rb = (v: number[]): ChartSpec => ({ type: 'radial-bar', categories: ['x', 'y'], series: [{ label: 's', values: v }], width: 300, height: 200 })
127
+ const ra = buildChart(rb([50, 100]))
128
+ const rbz = buildChart(rb([100, 100]))
129
+ const rm = interpolateGeometry(ra, rbz, 0.5)
130
+ expect(rm.arcs[0]!.a1).toBeCloseTo((ra.arcs[0]!.a1 + rbz.arcs[0]!.a1) / 2, 6)
131
+ })
132
+ it('snaps when the chart type changes', () => {
133
+ const a = buildChart(spec([1, 2, 3]))
134
+ const b = buildChart({ ...spec([1, 2, 3]), type: 'pie' })
135
+ expect(interpolateGeometry(a, b, 0.5)).toBe(b)
136
+ })
137
+ })
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Zoom-window arithmetic for the chart: the category window a wheel, a pinch,
3
+ * a pan or a range preset produces. Pure functions over indices and dates, so
4
+ * the renderer's gestures are thin and the maths is unit-tested.
5
+ */
6
+ import type { ChartZoomWindow } from './chart-types'
7
+
8
+ /** Clamp a window to [0, n-1] with at least one category in it. */
9
+ function clampWindow(i0: number, i1: number, n: number): ChartZoomWindow | null {
10
+ if (n <= 0) return null
11
+ let a = Math.max(0, Math.min(n - 1, Math.round(i0)))
12
+ let b = Math.max(0, Math.min(n - 1, Math.round(i1)))
13
+ if (b < a) [a, b] = [b, a]
14
+ if (a === 0 && b === n - 1) return null
15
+ return { i0: a, i1: b }
16
+ }
17
+
18
+ /**
19
+ * Zoom the window in or out around an anchor category, keeping the anchor at
20
+ * the same relative position. `factor < 1` zooms in, `> 1` out; a null window
21
+ * means the whole axis. Never narrower than 2 categories.
22
+ */
23
+ export function wheelWindow(win: ChartZoomWindow | null, n: number, anchor: number, factor: number): ChartZoomWindow | null {
24
+ if (n <= 2) return null
25
+ const i0 = win ? win.i0 : 0
26
+ const i1 = win ? win.i1 : n - 1
27
+ const span = i1 - i0 + 1
28
+ const next = Math.max(2, Math.min(n, Math.round(span * factor)))
29
+ if (next === span) return win
30
+ // Where the anchor sits inside the window, 0..1, so it stays put.
31
+ const rel = span > 1 ? (Math.max(i0, Math.min(i1, anchor)) - i0) / (span - 1) : 0.5
32
+ let a = Math.round(anchor - rel * (next - 1))
33
+ a = Math.max(0, Math.min(n - next, a))
34
+ return clampWindow(a, a + next - 1, n)
35
+ }
36
+
37
+ /** Slide the window by `delta` categories, clamped to the axis. */
38
+ export function panWindow(win: ChartZoomWindow | null, n: number, delta: number): ChartZoomWindow | null {
39
+ if (!win || !delta) return win
40
+ const span = win.i1 - win.i0
41
+ let a = win.i0 + Math.round(delta)
42
+ a = Math.max(0, Math.min(n - 1 - span, a))
43
+ return clampWindow(a, a + span, n)
44
+ }
45
+
46
+ /**
47
+ * Resize the window by a pinch: `scale > 1` spreads the fingers (zoom in),
48
+ * around the category under the pinch midpoint.
49
+ */
50
+ export function pinchWindow(win: ChartZoomWindow | null, n: number, midpoint: number, scale: number): ChartZoomWindow | null {
51
+ if (!Number.isFinite(scale) || scale <= 0) return win
52
+ return wheelWindow(win, n, midpoint, 1 / scale)
53
+ }
54
+
55
+ /** The range presets a time axis offers. */
56
+ export type ChartRangePreset = '1W' | '1M' | '3M' | '6M' | 'YTD' | '1Y' | 'All' | { label: string; days?: number; from?: string; to?: string }
57
+
58
+ /**
59
+ * The window a preset selects on a time / ordinal-time axis whose categories
60
+ * parse as dates: the last N days up to the latest category, the year to
61
+ * date, or an explicit from / to. `'All'` (and a preset nothing matches)
62
+ * returns null, the whole axis. `now` defaults to the last category, which is
63
+ * what "1M" means on a chart that stops last quarter.
64
+ */
65
+ export function presetWindow(categories: ReadonlyArray<string>, preset: ChartRangePreset, now?: number): ChartZoomWindow | null {
66
+ const n = categories.length
67
+ if (!n) return null
68
+ const times = categories.map((c) => Date.parse(c))
69
+ const last = [...times].reverse().find(Number.isFinite)
70
+ if (last == null) return null
71
+ const end = now ?? last
72
+ const DAY = 86_400_000
73
+ let from: number
74
+ let to = end
75
+ if (typeof preset === 'object') {
76
+ if (preset.from) from = Date.parse(preset.from)
77
+ else if (preset.days) from = end - preset.days * DAY
78
+ else return null
79
+ if (preset.to) to = Date.parse(preset.to)
80
+ } else {
81
+ switch (preset) {
82
+ case '1W': from = end - 7 * DAY; break
83
+ case '1M': from = shiftMonths(end, -1); break
84
+ case '3M': from = shiftMonths(end, -3); break
85
+ case '6M': from = shiftMonths(end, -6); break
86
+ case '1Y': from = shiftMonths(end, -12); break
87
+ case 'YTD': { const d = new Date(end); from = Date.UTC(d.getUTCFullYear(), 0, 1); break }
88
+ default: return null
89
+ }
90
+ }
91
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return null
92
+ let i0 = -1
93
+ let i1 = -1
94
+ for (let i = 0; i < n; i += 1) {
95
+ const t = times[i]!
96
+ if (!Number.isFinite(t)) continue
97
+ if (t >= from && i0 < 0) i0 = i
98
+ if (t <= to) i1 = i
99
+ }
100
+ if (i0 < 0 || i1 < 0 || i1 < i0) return null
101
+ return clampWindow(i0, i1, n)
102
+ }
103
+
104
+ function shiftMonths(t: number, months: number): number {
105
+ const d = new Date(t)
106
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes())
107
+ }
108
+
109
+ /**
110
+ * The category whose date is nearest to `t`, for syncing a hover between two
111
+ * charts whose categories differ. Categories that do not parse are skipped;
112
+ * returns -1 when none do.
113
+ */
114
+ export function nearestIndexByTime(categories: ReadonlyArray<string>, t: number): number {
115
+ let best = -1
116
+ let bestD = Infinity
117
+ for (let i = 0; i < categories.length; i += 1) {
118
+ const ct = Date.parse(categories[i]!)
119
+ if (!Number.isFinite(ct)) continue
120
+ const d = Math.abs(ct - t)
121
+ if (d < bestD) { bestD = d; best = i }
122
+ }
123
+ return best
124
+ }