@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,249 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildChart, rowsToChartSpec, sliceChartWindow, type ChartSeries, type ChartSpec } from './chart'
3
+ import { decimateSpec, lttb, minMaxIndices, pickCategories, PER_CATEGORY_SERIES_KEYS, PER_CATEGORY_SPEC_KEYS } from './chart-decimate'
4
+ import { bucketStart, percentile, reduceValues } from './chart-stats'
5
+
6
+ describe('pickCategories', () => {
7
+ it('cuts EVERY per-category array on a series, not just the ones that existed when it was written', () => {
8
+ // A series carrying every key in the list, each of a distinct length-4
9
+ // shape, so a key the picker forgets shows up as a wrong length.
10
+ const full: Required<Pick<ChartSeries, (typeof PER_CATEGORY_SERIES_KEYS)[number]>> = {
11
+ values: [1, 2, 3, 4],
12
+ rowIds: [[1], [2], [3], [4]],
13
+ upperValues: [2, 3, 4, 5],
14
+ lowerValues: [0, 1, 2, 3],
15
+ ohlc: [{ o: 1, h: 2, l: 0, c: 1 }, { o: 2, h: 3, l: 1, c: 2 }, { o: 3, h: 4, l: 2, c: 3 }, { o: 4, h: 5, l: 3, c: 4 }],
16
+ boxes: [null, null, { min: 1, q1: 2, median: 3, q3: 4, max: 5 }, null],
17
+ errors: [1, 2, 3, 4],
18
+ markers: [null, { shape: 'square' }, null, { shape: 'none' }],
19
+ colors: ['a', 'b', 'c', 'd'],
20
+ lowValues: [0, 1, 2, 3],
21
+ targets: [5, 6, 7, 8],
22
+ volumes: [100, 200, 300, 400],
23
+ }
24
+ const spec: ChartSpec = {
25
+ type: 'line',
26
+ categories: ['a', 'b', 'c', 'd'],
27
+ series: [{ label: 's', ...full }],
28
+ waterfallTotals: [false, true, false, true],
29
+ }
30
+ const out = pickCategories(spec, [1, 3])
31
+ expect(out.categories).toEqual(['b', 'd'])
32
+ for (const key of PER_CATEGORY_SERIES_KEYS) {
33
+ expect((out.series[0] as unknown as Record<string, unknown[]>)[key], key).toHaveLength(2)
34
+ }
35
+ for (const key of PER_CATEGORY_SPEC_KEYS) {
36
+ expect((out as unknown as Record<string, unknown[]>)[key], key).toHaveLength(2)
37
+ }
38
+ expect(out.series[0]!.values).toEqual([2, 4])
39
+ expect(out.series[0]!.rowIds).toEqual([[2], [4]])
40
+ expect(out.series[0]!.colors).toEqual(['b', 'd'])
41
+ expect(out.waterfallTotals).toEqual([true, true])
42
+ })
43
+
44
+ it('is what sliceChartWindow uses, with identical output for a contiguous range', () => {
45
+ const spec: ChartSpec = {
46
+ type: 'line',
47
+ categories: ['a', 'b', 'c', 'd', 'e'],
48
+ series: [{ label: 's', values: [1, 2, 3, 4, 5], upperValues: [2, 3, 4, 5, 6], lowerValues: [0, 1, 2, 3, 4], rowIds: [[1], [2], [3], [4], [5]] }],
49
+ }
50
+ expect(sliceChartWindow(spec, 1, 3)).toEqual(pickCategories(spec, [1, 2, 3]))
51
+ expect(sliceChartWindow(spec, 1, 3).series[0]!.upperValues).toEqual([3, 4, 5])
52
+ expect(sliceChartWindow(spec, -5, 99).categories).toEqual(spec.categories)
53
+ expect(sliceChartWindow(spec, 4, 2).categories).toEqual([])
54
+ })
55
+
56
+ it('does not invent arrays a series never had', () => {
57
+ const out = pickCategories({ type: 'bar', categories: ['a', 'b'], series: [{ label: 's', values: [1, 2] }] }, [0])
58
+ expect('rowIds' in out.series[0]!).toBe(false)
59
+ expect('waterfallTotals' in out).toBe(false)
60
+ })
61
+ })
62
+
63
+ describe('lttb', () => {
64
+ const n = 1000
65
+ const wave = Array.from({ length: n }, (_, i) => Math.sin(i / 20) * 100)
66
+ it('keeps the first and last points and returns ascending indices of the target length', () => {
67
+ const idx = lttb(wave, 100)
68
+ expect(idx).toHaveLength(100)
69
+ expect(idx[0]).toBe(0)
70
+ expect(idx[idx.length - 1]).toBe(n - 1)
71
+ for (let i = 1; i < idx.length; i += 1) expect(idx[i]).toBeGreaterThan(idx[i - 1]!)
72
+ })
73
+ it('preserves a spike that would vanish under naive striding', () => {
74
+ const flat = new Array(n).fill(10)
75
+ flat[537] = 1000
76
+ const idx = lttb(flat, 50)
77
+ expect(idx).toContain(537)
78
+ })
79
+ it('returns everything when there is nothing to thin', () => {
80
+ expect(lttb([1, 2, 3], 10)).toEqual([0, 1, 2])
81
+ expect(lttb([1, 2, 3, 4, 5], 2)).toEqual([0, 4])
82
+ })
83
+ it('never picks a gap as a representative point', () => {
84
+ const gappy = wave.map((v, i) => (i % 7 === 3 ? Number.NaN : v))
85
+ const idx = lttb(gappy, 120)
86
+ expect(idx.slice(1, -1).every((i) => Number.isFinite(gappy[i]))).toBe(true)
87
+ })
88
+ })
89
+
90
+ describe('minMaxIndices', () => {
91
+ it('keeps the extreme of every bucket', () => {
92
+ const v = Array.from({ length: 400 }, (_, i) => (i % 50 === 25 ? 500 : i % 50 === 10 ? -500 : 0))
93
+ const idx = minMaxIndices(v, 8)
94
+ for (let b = 0; b < 8; b += 1) {
95
+ expect(idx).toContain(b * 50 + 25)
96
+ expect(idx).toContain(b * 50 + 10)
97
+ }
98
+ expect(idx[0]).toBe(0)
99
+ expect(idx[idx.length - 1]).toBe(399)
100
+ })
101
+ })
102
+
103
+ describe('decimateSpec', () => {
104
+ const long = (type: ChartSpec['type'] = 'line', extra: Partial<ChartSpec> = {}): ChartSpec => ({
105
+ type,
106
+ categories: Array.from({ length: 5000 }, (_, i) => `c${i}`),
107
+ series: [
108
+ { label: 'a', values: Array.from({ length: 5000 }, (_, i) => Math.sin(i / 90) * 50), rowIds: Array.from({ length: 5000 }, (_, i) => [i]) },
109
+ { label: 'b', values: Array.from({ length: 5000 }, (_, i) => (i === 4321 ? 900 : Math.cos(i / 70) * 30)) },
110
+ ],
111
+ ...extra,
112
+ })
113
+ it('thins a long line to about the plot width and keeps rowIds aligned', () => {
114
+ const out = decimateSpec(long(), 400)
115
+ expect(out.categories.length).toBeLessThan(5000)
116
+ expect(out.categories.length).toBeGreaterThanOrEqual(400)
117
+ out.categories.forEach((c, k) => {
118
+ const i = Number(c.slice(1))
119
+ expect(out.series[0]!.rowIds![k]).toEqual([i])
120
+ expect(out.series[0]!.values[k]).toBe(Math.sin(i / 90) * 50)
121
+ })
122
+ })
123
+ it('keeps a spike that only one series has', () => {
124
+ const out = decimateSpec(long(), 300)
125
+ expect(out.categories).toContain('c4321')
126
+ })
127
+ it('does nothing below the threshold, when off, or when bars are involved', () => {
128
+ const short = { ...long(), categories: long().categories.slice(0, 100), series: long().series.map((s) => ({ ...s, values: s.values.slice(0, 100), rowIds: s.rowIds?.slice(0, 100) })) }
129
+ expect(decimateSpec(short, 400)).toBe(short)
130
+ const off = long('line', { decimate: false })
131
+ expect(decimateSpec(off, 400)).toBe(off)
132
+ const bars = long('bar')
133
+ expect(decimateSpec(bars, 400)).toBe(bars)
134
+ const combo = long('line', { series: [{ label: 'v', values: long().series[0]!.values, type: 'bar' }, long().series[1]!] })
135
+ expect(decimateSpec(combo, 400)).toBe(combo)
136
+ })
137
+ it('honours an explicit target and method', () => {
138
+ const a = decimateSpec(long(), 400, { target: 100 })
139
+ expect(a.categories.length).toBeLessThan(220)
140
+ const b = decimateSpec(long(), 400, { method: 'minmax', target: 100 })
141
+ expect(b.categories).toContain('c4321')
142
+ })
143
+ it('lays out through buildChart with the decimated categories', () => {
144
+ const geo = buildChart({ ...decimateSpec(long(), 500), width: 600, height: 300 })
145
+ expect(geo.lines[0]!.points.length).toBeLessThan(5000)
146
+ expect(geo.lines[0]!.points.length).toBeGreaterThan(100)
147
+ })
148
+ })
149
+
150
+ describe('reducers', () => {
151
+ const s = [5, 1, 4, 2, 3, 3]
152
+ it('computes every reducer', () => {
153
+ expect(reduceValues(s, 'sum')).toBe(18)
154
+ expect(reduceValues(s, 'avg')).toBe(3)
155
+ expect(reduceValues(s, 'count')).toBe(6)
156
+ expect(reduceValues(s, 'min')).toBe(1)
157
+ expect(reduceValues(s, 'max')).toBe(5)
158
+ expect(reduceValues(s, 'first')).toBe(5)
159
+ expect(reduceValues(s, 'last')).toBe(3)
160
+ expect(reduceValues(s, 'countDistinct')).toBe(5)
161
+ expect(reduceValues(s, 'median')).toBe(3)
162
+ expect(reduceValues(s, 'p90')).toBeCloseTo(4.5, 6)
163
+ expect(reduceValues(s, 'p100')).toBe(5)
164
+ })
165
+ it('an empty group is 0 for sum / count and a gap for the rest', () => {
166
+ expect(reduceValues([], 'sum')).toBe(0)
167
+ expect(reduceValues([], 'count')).toBe(0)
168
+ expect(reduceValues([], 'avg')).toBe(0)
169
+ expect(Number.isNaN(reduceValues([], 'median'))).toBe(true)
170
+ expect(Number.isNaN(reduceValues([], 'min'))).toBe(true)
171
+ })
172
+ it('percentile interpolates between order statistics', () => {
173
+ expect(percentile([10, 20, 30, 40], 50)).toBe(25)
174
+ expect(percentile([10, 20, 30, 40], 0)).toBe(10)
175
+ expect(percentile([10, 20, 30, 40], 100)).toBe(40)
176
+ })
177
+ it('rowsToChartSpec takes the new reducers', () => {
178
+ const rows = [
179
+ { region: 'N', v: 10 }, { region: 'N', v: 30 }, { region: 'N', v: 20 },
180
+ { region: 'S', v: 7 }, { region: 'S', v: 7 },
181
+ ]
182
+ expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'median' }).series[0]!.values).toEqual([20, 7])
183
+ expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'max' }).series[0]!.values).toEqual([30, 7])
184
+ expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'countDistinct' }).series[0]!.values).toEqual([3, 1])
185
+ expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'last' }).series[0]!.values).toEqual([20, 7])
186
+ // and the original three are untouched
187
+ expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'avg' }).series[0]!.values).toEqual([20, 7])
188
+ })
189
+ })
190
+
191
+ describe('time bucketing', () => {
192
+ it('bucketStart lands on the calendar boundary, in UTC', () => {
193
+ expect(bucketStart('2026-03-18T15:00:00Z', 'day')).toBe('2026-03-18')
194
+ expect(bucketStart('2026-03-18', 'week')).toBe('2026-03-16') // a Wednesday -> its Monday
195
+ expect(bucketStart('2026-03-22', 'week')).toBe('2026-03-16') // Sunday belongs to the week before
196
+ expect(bucketStart('2026-03-18', 'month')).toBe('2026-03-01')
197
+ expect(bucketStart('2026-05-18', 'quarter')).toBe('2026-04-01')
198
+ expect(bucketStart('2026-12-31', 'quarter')).toBe('2026-10-01')
199
+ expect(bucketStart('2026-03-18', 'year')).toBe('2026-01-01')
200
+ expect(bucketStart(Date.UTC(2026, 0, 1), 'year')).toBe('2026-01-01')
201
+ expect(bucketStart('not a date', 'month')).toBe('')
202
+ })
203
+ it('rowsToChartSpec buckets a date category and sorts it chronologically', () => {
204
+ const rows = [
205
+ { day: '2026-03-20', v: 1 },
206
+ { day: '2026-01-05', v: 2 },
207
+ { day: '2026-03-02', v: 3 },
208
+ { day: '2026-01-30', v: 4 },
209
+ { day: 'garbage', v: 99 },
210
+ ]
211
+ const spec = rowsToChartSpec(rows, { type: 'line', category: 'day', value: 'v', bucket: 'month' })
212
+ expect(spec.categories).toEqual(['2026-01-01', '2026-03-01'])
213
+ expect(spec.series[0]!.values).toEqual([6, 4])
214
+ expect(spec.xType).toBe('ordinal-time')
215
+ const weeks = rowsToChartSpec(rows, { type: 'line', category: 'day', value: 'v', bucket: 'week', reduce: 'count' })
216
+ expect(weeks.categories).toEqual(['2026-01-05', '2026-01-26', '2026-03-02', '2026-03-16'])
217
+ expect(weeks.series[0]!.values).toEqual([1, 1, 1, 1])
218
+ })
219
+ })
220
+
221
+ describe('lttb with explicit x positions', () => {
222
+ it('measures the triangles in the given positions rather than by index', () => {
223
+ // Points crowd together in x except the last two, which are far apart. A
224
+ // spike at index 3 is small in area when x is the index, but with the
225
+ // real spacing the wide final segment matters more than the spike.
226
+ const values = [0, 0, 0, 5, 0, 0, 0, 10]
227
+ const xs = [0, 1, 2, 3, 4, 5, 6, 1000]
228
+ const byIndex = lttb(values, 4)
229
+ const byX = lttb(values, 4, xs)
230
+ expect(byIndex[0]).toBe(0)
231
+ expect(byIndex[byIndex.length - 1]).toBe(7)
232
+ expect(byX[0]).toBe(0)
233
+ expect(byX[byX.length - 1]).toBe(7)
234
+ expect(byX).toHaveLength(4)
235
+ // Both keep the first and last point and stay strictly increasing.
236
+ for (let k = 1; k < byX.length; k += 1) expect(byX[k]!).toBeGreaterThan(byX[k - 1]!)
237
+ })
238
+
239
+ it('decimateSpec passes log10 positions for a log number axis', () => {
240
+ const n = 2000
241
+ const categories = Array.from({ length: n }, (_, i) => String(Math.pow(10, (i / (n - 1)) * 4)))
242
+ const values = categories.map((c) => Math.log10(Number(c)) * 10 + (Number(c) > 5000 && Number(c) < 5100 ? 50 : 0))
243
+ const spec: ChartSpec = { type: 'line', categories, series: [{ label: 'v', values }], xAxis: { type: 'number', scale: 'log' } }
244
+ const thin = decimateSpec(spec, 300)
245
+ expect(thin.categories.length).toBeLessThan(n)
246
+ // The spike (a band of points 50 above the trend) survives.
247
+ expect(Math.max(...thin.series[0]!.values)).toBeGreaterThan(80)
248
+ })
249
+ })
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Thinning long series before layout, and the one place that knows which
3
+ * arrays on a spec run parallel to `categories`.
4
+ *
5
+ * `buildChart` scales fine (100k line points is ~140 ms of maths); what does
6
+ * not scale is asking the DOM to hold a mark per point, and past one point
7
+ * per pixel the extra marks carry no information anyway. So a long line or
8
+ * area series is reduced to about one point per pixel with an algorithm that
9
+ * keeps its visual shape (Largest-Triangle-Three-Buckets) or its extremes
10
+ * (min / max per bucket), and every category-parallel array is picked with the
11
+ * same indices so tooltips, drill-through and error bars stay aligned.
12
+ */
13
+ import type { ChartDecimateConfig, ChartSeries, ChartSpec } from './chart-types'
14
+
15
+ /**
16
+ * Every array on a {@link ChartSeries} indexed by category. Anything that
17
+ * narrows a spec to a subset of categories (the zoom window, decimation) picks
18
+ * each of these with the same indices; a key missing here is a mark drawn
19
+ * against the wrong category, which is why the list is one constant rather
20
+ * than a convention. `values` is included for completeness.
21
+ */
22
+ export const PER_CATEGORY_SERIES_KEYS = [
23
+ 'values',
24
+ 'rowIds',
25
+ 'upperValues',
26
+ 'lowerValues',
27
+ 'ohlc',
28
+ 'boxes',
29
+ 'errors',
30
+ 'markers',
31
+ 'colors',
32
+ 'lowValues',
33
+ 'targets',
34
+ 'volumes',
35
+ ] as const satisfies ReadonlyArray<keyof ChartSeries>
36
+
37
+ /** Spec-level arrays indexed by category (see {@link PER_CATEGORY_SERIES_KEYS}). */
38
+ export const PER_CATEGORY_SPEC_KEYS = ['waterfallTotals'] as const satisfies ReadonlyArray<keyof ChartSpec>
39
+
40
+ /**
41
+ * Narrow a spec to the categories at `indices` (ascending), keeping every
42
+ * category-parallel array in step. The zoom window and decimation both go
43
+ * through here, so there is exactly one list of arrays to keep in step.
44
+ */
45
+ export function pickCategories(spec: ChartSpec, indices: ReadonlyArray<number>): ChartSpec {
46
+ const pick = <T,>(arr: T[] | undefined): T[] | undefined => {
47
+ if (!arr) return undefined
48
+ const out: T[] = new Array(indices.length)
49
+ for (let k = 0; k < indices.length; k += 1) out[k] = arr[indices[k]!]!
50
+ return out
51
+ }
52
+ const series = spec.series.map((s) => {
53
+ const next: ChartSeries = { ...s, values: pick(s.values) ?? [] }
54
+ for (const key of PER_CATEGORY_SERIES_KEYS) {
55
+ if (key === 'values') continue
56
+ const arr = s[key] as unknown[] | undefined
57
+ if (arr) (next as unknown as Record<string, unknown>)[key] = pick(arr)
58
+ }
59
+ return next
60
+ })
61
+ const out: ChartSpec = { ...spec, categories: pick(spec.categories) ?? [], series }
62
+ for (const key of PER_CATEGORY_SPEC_KEYS) {
63
+ const arr = spec[key] as unknown[] | undefined
64
+ if (arr) (out as unknown as Record<string, unknown>)[key] = pick(arr)
65
+ }
66
+ return out
67
+ }
68
+
69
+ /**
70
+ * Largest-Triangle-Three-Buckets: the indices of the `target` points that best
71
+ * preserve the visual shape of `values` plotted against their index. The
72
+ * first and last points are always kept. Non-finite values are skipped as
73
+ * candidates, so a gap cannot be "chosen" as a representative point.
74
+ */
75
+ export function lttb(values: ReadonlyArray<number>, target: number, xs?: ReadonlyArray<number>): number[] {
76
+ const n = values.length
77
+ // The x of point j: its index unless the caller positions points by value
78
+ // (a numeric or log axis), where the triangles must use the real spacing.
79
+ const xAt = (j: number) => (xs && Number.isFinite(xs[j]) ? xs[j]! : j)
80
+ if (target >= n || n <= 2) return values.map((_, i) => i)
81
+ if (target < 3) return [0, n - 1]
82
+ const out: number[] = [0]
83
+ const every = (n - 2) / (target - 2)
84
+ let a = 0
85
+ for (let i = 0; i < target - 2; i += 1) {
86
+ // The next bucket's centroid is the far corner of every candidate triangle.
87
+ const nextStart = Math.floor((i + 1) * every) + 1
88
+ const nextEnd = Math.min(Math.floor((i + 2) * every) + 1, n)
89
+ let avgX = 0
90
+ let avgY = 0
91
+ let cnt = 0
92
+ for (let j = nextStart; j < nextEnd; j += 1) {
93
+ const v = values[j]!
94
+ if (!Number.isFinite(v)) continue
95
+ avgX += xAt(j)
96
+ avgY += v
97
+ cnt += 1
98
+ }
99
+ if (cnt) { avgX /= cnt; avgY /= cnt } else { avgX = (xAt(nextStart) + xAt(Math.min(nextEnd, n - 1))) / 2; avgY = values[a]! }
100
+ const start = Math.floor(i * every) + 1
101
+ const end = Math.min(Math.floor((i + 1) * every) + 1, n)
102
+ const ax = xAt(a)
103
+ const ay = Number.isFinite(values[a]!) ? values[a]! : 0
104
+ let best = -1
105
+ let bestArea = -1
106
+ for (let j = start; j < end; j += 1) {
107
+ const v = values[j]!
108
+ if (!Number.isFinite(v)) continue
109
+ const area = Math.abs((ax - avgX) * (v - ay) - (ax - xAt(j)) * (avgY - ay))
110
+ if (area > bestArea) { bestArea = area; best = j }
111
+ }
112
+ if (best < 0) best = start
113
+ out.push(best)
114
+ a = best
115
+ }
116
+ out.push(n - 1)
117
+ return out
118
+ }
119
+
120
+ /**
121
+ * The indices of the minimum and maximum in each of `buckets` equal slices of
122
+ * `values`, plus the first and last point. Keeps every spike, which is what
123
+ * monitoring data wants and what LTTB will sometimes smooth over.
124
+ */
125
+ export function minMaxIndices(values: ReadonlyArray<number>, buckets: number): number[] {
126
+ const n = values.length
127
+ if (buckets >= n || n <= 2) return values.map((_, i) => i)
128
+ const keep = new Set<number>([0, n - 1])
129
+ const size = n / Math.max(1, buckets)
130
+ for (let b = 0; b < buckets; b += 1) {
131
+ const start = Math.floor(b * size)
132
+ const end = Math.min(n, Math.floor((b + 1) * size))
133
+ let lo = -1
134
+ let hi = -1
135
+ for (let j = start; j < end; j += 1) {
136
+ const v = values[j]!
137
+ if (!Number.isFinite(v)) continue
138
+ if (lo < 0 || v < values[lo]!) lo = j
139
+ if (hi < 0 || v > values[hi]!) hi = j
140
+ }
141
+ if (lo >= 0) keep.add(lo)
142
+ if (hi >= 0) keep.add(hi)
143
+ }
144
+ return [...keep].sort((x, y) => x - y)
145
+ }
146
+
147
+ /** The decimation settings a spec asks for, defaults filled in. */
148
+ function resolveDecimate(cfg: ChartDecimateConfig | undefined, plotW: number) {
149
+ if (cfg === false) return null
150
+ const o = typeof cfg === 'object' ? cfg : {}
151
+ const target = Math.max(8, Math.round(o.target ?? plotW))
152
+ return { method: o.method ?? 'lttb', target, threshold: Math.max(target, Math.round(o.threshold ?? target)) }
153
+ }
154
+
155
+ /**
156
+ * Thin a spec's line / area series to about one point per pixel of plot
157
+ * width. Nothing happens below the threshold, for a spec that says
158
+ * `decimate: false`, or when any series draws bars, candles or boxes (those
159
+ * marks ARE the data; thinning them would hide categories). Every series is
160
+ * decimated separately and the union of the kept indices is used, so a spike
161
+ * that only one series has still survives.
162
+ */
163
+ export function decimateSpec(spec: ChartSpec, plotW: number, cfg: ChartDecimateConfig | undefined = spec.decimate): ChartSpec {
164
+ const opt = resolveDecimate(cfg, plotW)
165
+ if (!opt) return spec
166
+ const n = spec.categories.length
167
+ if (n <= opt.threshold || !spec.series.length) return spec
168
+ const lineLike = (t: string | undefined) => t === 'line' || t === 'area'
169
+ if (!lineLike(spec.type) && !spec.series.every((s) => lineLike(s.type))) return spec
170
+ if (spec.series.some((s) => (s.type && !lineLike(s.type)) || s.ohlc || s.boxes)) return spec
171
+ // On a numeric axis the points sit where their values are, so the
172
+ // triangles are measured in those positions (log10 of them on a log axis).
173
+ const numeric = (spec.xAxis?.type ?? spec.xType) === 'number'
174
+ const isLog = numeric && spec.xAxis?.scale === 'log'
175
+ const xs = numeric
176
+ ? spec.categories.map((c) => { const v = Number(c); return isLog ? (v > 0 ? Math.log10(v) : Number.NaN) : v })
177
+ : undefined
178
+ const keep = new Set<number>()
179
+ for (const s of spec.series) {
180
+ const idx = opt.method === 'minmax' ? minMaxIndices(s.values, Math.floor(opt.target / 2)) : lttb(s.values, opt.target, xs)
181
+ for (const i of idx) keep.add(i)
182
+ }
183
+ const indices = [...keep].sort((a, b) => a - b)
184
+ if (indices.length >= n) return spec
185
+ return pickCategories(spec, indices)
186
+ }
@@ -0,0 +1,91 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { buildChartPdf, printChart } from './chart-export-pdf'
3
+
4
+ const ascii = (bytes: Uint8Array) => Array.from(bytes, (b) => String.fromCharCode(b)).join('')
5
+
6
+ describe('buildChartPdf', () => {
7
+ // A fake JPEG: the SOI marker and a few bytes. The writer never inspects it.
8
+ const jpeg = { bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0xff, 0xd9]), width: 800, height: 400 }
9
+
10
+ it('writes a one-page PDF with the image as a DCT XObject and the title as text', () => {
11
+ const out = buildChartPdf(jpeg, { title: 'Revenue (Q1)', subtitle: 'by region', caption: 'Source: ledger' })
12
+ const text = ascii(out)
13
+ expect(text.startsWith('%PDF-1.4')).toBe(true)
14
+ expect(text).toContain('/Type /Catalog')
15
+ expect(text).toContain('/Count 1')
16
+ expect(text).toContain('/Subtype /Image')
17
+ expect(text).toContain('/Filter /DCTDecode')
18
+ expect(text).toContain(`/Length ${jpeg.bytes.length}`)
19
+ expect(text).toContain('/BaseFont /Helvetica')
20
+ // Parentheses in the title are escaped inside the string literal.
21
+ expect(text).toContain('(Revenue \\(Q1\\)) Tj')
22
+ expect(text).toContain('(by region) Tj')
23
+ expect(text).toContain('(Source: ledger) Tj')
24
+ expect(text).toContain('/Im1 Do')
25
+ expect(text.trimEnd().endsWith('%%EOF')).toBe(true)
26
+ // The JPEG bytes are in there verbatim.
27
+ expect(text).toContain(ascii(jpeg.bytes))
28
+ })
29
+
30
+ it('has an xref table whose offsets point at each object', () => {
31
+ const out = buildChartPdf(jpeg)
32
+ const text = ascii(out)
33
+ const startxref = Number(/startxref\n(\d+)\n%%EOF/.exec(text)![1])
34
+ expect(text.slice(startxref, startxref + 4)).toBe('xref')
35
+ // Line 0 is "xref", line 1 "0 7", then one row per object from 0.
36
+ const rows = text.slice(startxref).split('\n').slice(2, 9)
37
+ for (let n = 1; n <= 6; n += 1) {
38
+ const offset = Number(rows[n]!.slice(0, 10))
39
+ expect(text.slice(offset, offset + `${n} 0 obj`.length)).toBe(`${n} 0 obj`)
40
+ }
41
+ })
42
+
43
+ it('picks landscape for a wide chart and portrait for a tall one, unless told otherwise', () => {
44
+ expect(ascii(buildChartPdf(jpeg))).toContain('/MediaBox [0 0 841.89 595.28]')
45
+ expect(ascii(buildChartPdf({ ...jpeg, width: 400, height: 800 }))).toContain('/MediaBox [0 0 595.28 841.89]')
46
+ expect(ascii(buildChartPdf(jpeg, { orientation: 'portrait', page: 'Letter' }))).toContain('/MediaBox [0 0 612.00 792.00]')
47
+ })
48
+
49
+ it('scales the image to fit inside the margins', () => {
50
+ const text = ascii(buildChartPdf(jpeg, { page: 'A4' }))
51
+ const m = /q ([\d.]+) 0 0 ([\d.]+) ([\d.]+) ([\d.]+) cm \/Im1 Do Q/.exec(text)!
52
+ const [w, h, x, y] = [1, 2, 3, 4].map((i) => Number(m[i])) as [number, number, number, number]
53
+ expect(w).toBeLessThanOrEqual(841.89 - 80)
54
+ expect(h).toBeCloseTo(w / 2, 1)
55
+ expect(x).toBeGreaterThanOrEqual(40)
56
+ expect(y).toBeGreaterThanOrEqual(40)
57
+ })
58
+ })
59
+
60
+ describe('printChart', () => {
61
+ afterEach(() => vi.restoreAllMocks())
62
+
63
+ it('opens a window with the standalone svg and prints it', () => {
64
+ const host = document.createElement('div')
65
+ host.innerHTML = '<svg class="sv-grid-chart-svg" viewBox="0 0 100 50"><rect class="sv-grid-chart-bar" width="10" height="10" fill="#f00"/></svg>'
66
+ document.body.appendChild(host)
67
+ const written: string[] = []
68
+ const fake = {
69
+ document: { write: (s: string) => written.push(s), close: () => {}, readyState: 'complete' },
70
+ focus: vi.fn(),
71
+ print: vi.fn(),
72
+ addEventListener: vi.fn(),
73
+ }
74
+ vi.spyOn(window, 'open').mockReturnValue(fake as unknown as Window)
75
+ vi.useFakeTimers()
76
+ expect(printChart(host, { title: 'Q1 <sales>' })).toBe(true)
77
+ vi.runAllTimers()
78
+ vi.useRealTimers()
79
+ expect(fake.print).toHaveBeenCalledTimes(1)
80
+ expect(written.join('')).toContain('<h1>Q1 &lt;sales&gt;</h1>')
81
+ expect(written.join('')).toContain('sv-grid-chart-bar')
82
+ host.remove()
83
+ })
84
+
85
+ it('reports a blocked popup instead of throwing', () => {
86
+ const host = document.createElement('div')
87
+ host.innerHTML = '<svg class="sv-grid-chart-svg" viewBox="0 0 100 50"></svg>'
88
+ vi.spyOn(window, 'open').mockReturnValue(null)
89
+ expect(printChart(host)).toBe(false)
90
+ })
91
+ })