@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,714 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ arcPath,
4
+ binValues,
5
+ buildChart,
6
+ heikinAshi,
7
+ paretoSpec,
8
+ rowsToDirectSpec,
9
+ rowsToHistogramSpec,
10
+ rowsToRangeSpec,
11
+ resolveResponsive,
12
+ matchResponsiveRules,
13
+ sliceChartWindow,
14
+ streamBaseline,
15
+ type ChartSpec,
16
+ type ChartType,
17
+ } from './chart'
18
+ import { chartSpecToCsv } from './chart-export'
19
+
20
+ const W = 400
21
+ const H = 240
22
+
23
+ describe('histogram', () => {
24
+ const sample = Array.from({ length: 200 }, (_, i) => ((i * 7919) % 100) + (i % 3) * 0.5)
25
+ it('binValues splits the sample into touching bins with rounded edges', () => {
26
+ const b = binValues(sample, { bins: 10 })
27
+ expect(b.counts).toHaveLength(10)
28
+ expect(b.edges).toHaveLength(11)
29
+ expect(b.counts.reduce((a, c) => a + c, 0)).toBe(200)
30
+ expect(b.edges[0]).toBe(Math.min(...sample))
31
+ expect(b.categories[0]).toBe(String((b.edges[0]! + b.edges[1]!) / 2))
32
+ // Sturges by default: log2(200) + 1 = 8.6 -> 9 bins.
33
+ expect(binValues(sample).counts).toHaveLength(9)
34
+ expect(binValues(sample, { method: 'sqrt' }).counts).toHaveLength(15)
35
+ expect(binValues(sample, { binWidth: 25 }).edges).toEqual([0, 25, 50, 75, 100])
36
+ expect(binValues([]).counts).toEqual([])
37
+ })
38
+ it('lays out as touching bars on a numeric axis labelled at the edges', () => {
39
+ const spec = { ...rowsToHistogramSpec(sample.map((v) => ({ v })), { value: 'v', bins: 10 }), width: W, height: H }
40
+ const geo = buildChart(spec)
41
+ expect(geo.bars).toHaveLength(10)
42
+ // Touching: each bar starts where the previous one ends (within a px).
43
+ for (let i = 1; i < geo.bars.length; i += 1) {
44
+ expect(Math.abs(geo.bars[i]!.x - (geo.bars[i - 1]!.x + geo.bars[i - 1]!.w))).toBeLessThanOrEqual(1)
45
+ }
46
+ // The ticks are the edges, and the first tick sits at the first bar's left.
47
+ expect(geo.xTicks[0]!.label).toBe(String(spec.binEdges![0]))
48
+ expect(Math.abs(geo.xTicks[0]!.x - geo.bars[0]!.x)).toBeLessThanOrEqual(1)
49
+ expect(geo.axes!.x!.type).toBe('number')
50
+ })
51
+ it('labels its edges at the precision the bin width needs, never two edges alike', () => {
52
+ // 1170 latencies from 26 to 1320 in 30 bins: 44ms wide, so the edges are
53
+ // whole numbers with separators rather than "1.3k, 1.3k".
54
+ const geo = buildChart({ ...rowsToHistogramSpec([26, 1320, 400, 800, 1000, 1256].map((v) => ({ v })), { value: 'v', bins: 30 }), width: 900, height: 240 })
55
+ const labels = geo.xTicks.map((t) => t.label)
56
+ expect(new Set(labels).size).toBe(labels.length)
57
+ expect(labels[0]).toBe('26')
58
+ expect(labels.at(-1)).toBe('1,320')
59
+ // Sub-unit bins keep the decimals their width needs.
60
+ const g2 = buildChart({ ...rowsToHistogramSpec([0.1, 0.9, 0.5, 0.3].map((v) => ({ v })), { value: 'v', bins: 4 }), width: 400, height: 240 })
61
+ expect(g2.xTicks.map((t) => t.label)).toEqual(['0.10', '0.30', '0.50', '0.70', '0.90'])
62
+ })
63
+ it('a split histogram shares one set of edges across its series', () => {
64
+ const rows = sample.map((v, i) => ({ v, g: i % 2 ? 'a' : 'b' }))
65
+ const spec = rowsToHistogramSpec(rows, { value: 'v', series: 'g', bins: 8 })
66
+ expect(spec.series).toHaveLength(2)
67
+ expect(spec.series[0]!.values.length).toBe(8)
68
+ expect(spec.series[0]!.values.reduce((a, c) => a + c, 0) + spec.series[1]!.values.reduce((a, c) => a + c, 0)).toBe(200)
69
+ })
70
+ it('is reachable from rowsToDirectSpec', () => {
71
+ const spec = rowsToDirectSpec('histogram', sample.map((v) => ({ v })), { value: 'v', bins: 5 })
72
+ expect(spec?.type).toBe('histogram')
73
+ expect(spec?.binEdges).toHaveLength(6)
74
+ })
75
+ })
76
+
77
+ describe('range bar, range area, dumbbell', () => {
78
+ const temps: ChartSpec = {
79
+ type: 'range-bar',
80
+ categories: ['Mon', 'Tue', 'Wed'],
81
+ series: [{ label: 'Temp', values: [18, 22, 15], lowValues: [8, 11, 4] }],
82
+ width: W,
83
+ height: H,
84
+ }
85
+ it('a range bar floats between lowValues and values, with no zero baseline', () => {
86
+ const geo = buildChart(temps)
87
+ expect(geo.bars).toHaveLength(3)
88
+ expect(geo.bars[0]!.lo).toBe(8)
89
+ expect(geo.bars[0]!.value).toBe(18)
90
+ // Domain starts near the lowest low, not at 0.
91
+ expect(geo.axes!.y.min).toBeGreaterThanOrEqual(0)
92
+ expect(geo.axes!.y.min).toBeLessThanOrEqual(8)
93
+ const b = geo.bars[0]!
94
+ // The bar spans exactly from y(18) to y(8).
95
+ const sc = (v: number) => geo.plot.y + geo.plot.h - ((v - geo.axes!.y.min) / (geo.axes!.y.max - geo.axes!.y.min)) * geo.plot.h
96
+ expect(b.y).toBeCloseTo(sc(18), 0)
97
+ expect(b.y + b.h).toBeCloseTo(sc(8), 0)
98
+ })
99
+ it('a range area fills between the two envelopes and strokes both edges', () => {
100
+ const geo = buildChart({ ...temps, type: 'range-area' })
101
+ expect(geo.lines).toHaveLength(1)
102
+ expect(geo.lines[0]!.areaPath).toContain('Z')
103
+ expect((geo.lines[0]!.path.match(/M/g) ?? []).length).toBe(2)
104
+ expect(geo.bars).toHaveLength(0)
105
+ // The renderer draws the band as the mark (a stronger fill, thin edges) on this flag.
106
+ expect(geo.lines[0]!.range).toBe(true)
107
+ expect(buildChart({ ...temps, type: 'area' }).lines[0]!.range).toBeUndefined()
108
+ })
109
+ it('a dumbbell draws a stem with two dots', () => {
110
+ const geo = buildChart({ ...temps, type: 'dumbbell' })
111
+ expect(geo.stems).toHaveLength(3)
112
+ expect(geo.stems[0]!.dumbbell).toBe(true)
113
+ expect(geo.stems[0]!.value2).toBe(8)
114
+ expect(geo.stems[0]!.y0).toBeGreaterThan(geo.stems[0]!.y1)
115
+ })
116
+ it('rowsToRangeSpec pairs a low and a high field per category, and the direct dispatcher reduces two measures', () => {
117
+ const rows = [
118
+ { day: 'Mon', lo: 8, hi: 18 },
119
+ { day: 'Tue', lo: 11, hi: 22 },
120
+ { day: 'Wed', hi: 4, lo: 15 }, // swapped on purpose
121
+ ]
122
+ const spec = rowsToRangeSpec(rows, { category: 'day', low: 'lo', high: 'hi' })
123
+ expect(spec.type).toBe('range-bar')
124
+ expect(spec.series[0]!.lowValues).toEqual([8, 11, 4])
125
+ expect(spec.series[0]!.values).toEqual([18, 22, 15])
126
+ const direct = rowsToDirectSpec('range-bar', rows, { category: 'day', value: 'lo', value2: 'hi' })
127
+ expect(direct?.series[0]!.lowValues).toEqual([8, 11, 4])
128
+ expect(direct?.series[0]!.values).toEqual([18, 22, 15])
129
+ })
130
+ it('lowValues slice with the zoom window and export to CSV', () => {
131
+ const cut = sliceChartWindow(temps, 1, 2)
132
+ expect(cut.series[0]!.lowValues).toEqual([11, 4])
133
+ const csv = chartSpecToCsv(temps)
134
+ expect(csv.split('\n')[0]).toContain('Temp low')
135
+ expect(csv.split('\n')[1]).toContain('8')
136
+ })
137
+ })
138
+
139
+ describe('lollipop, category scatter, pareto', () => {
140
+ const base: ChartSpec = {
141
+ type: 'lollipop',
142
+ categories: ['a', 'b', 'c', 'd'],
143
+ series: [{ label: 's', values: [3, 1, 4, 2] }],
144
+ width: W,
145
+ height: H,
146
+ }
147
+ it('a lollipop is one stem per value from the axis, with no bar', () => {
148
+ const geo = buildChart(base)
149
+ expect(geo.stems).toHaveLength(4)
150
+ expect(geo.stems.every((s) => !s.dumbbell)).toBe(true)
151
+ expect(geo.bars).toHaveLength(0)
152
+ // The stem grows from the zero baseline, like a bar does.
153
+ expect(geo.axes!.y.min).toBe(0)
154
+ expect(geo.stems[0]!.y0).toBeCloseTo(geo.plot.y + geo.plot.h, 0)
155
+ })
156
+ it('a scatter series on a category axis draws markers and no path', () => {
157
+ const geo = buildChart({ ...base, type: 'bar', series: [{ label: 'bars', values: [3, 1, 4, 2] }, { label: 'dots', values: [2, 2, 3, 3], type: 'scatter' }] })
158
+ expect(geo.bars).toHaveLength(4)
159
+ expect(geo.lines).toHaveLength(1)
160
+ expect(geo.lines[0]!.path).toBe('')
161
+ expect(geo.lines[0]!.points.filter((p) => p.defined)).toHaveLength(4)
162
+ })
163
+ it('a pareto sorts the bars and adds a cumulative line on a 0..100 right axis with a threshold line', () => {
164
+ const spec = paretoSpec({ ...base, type: 'pareto', series: [{ label: 'defects', values: [3, 1, 4, 2] }] })
165
+ expect(spec.categories).toEqual(['c', 'a', 'd', 'b'])
166
+ expect(spec.series[1]!.values).toEqual([40, 70, 90, 100])
167
+ expect(spec.series[1]!.axis).toBe('right')
168
+ const geo = buildChart({ ...base, type: 'pareto', series: [{ label: 'defects', values: [3, 1, 4, 2] }] })
169
+ expect(geo.axes!.y2).toMatchObject({ min: 0, max: 100 })
170
+ expect(geo.bars).toHaveLength(4)
171
+ expect(geo.lines).toHaveLength(1)
172
+ expect(geo.referenceLines.some((r) => r.label === '80%')).toBe(true)
173
+ })
174
+ })
175
+
176
+ describe('stream graph', () => {
177
+ const layers = [
178
+ [1, 3, 2, 4],
179
+ [2, 2, 3, 1],
180
+ [1, 1, 2, 2],
181
+ ]
182
+ it('streamBaseline: zero is flat, silhouette centres the total, wiggle stays finite', () => {
183
+ expect(streamBaseline(layers, 'zero')).toEqual([0, 0, 0, 0])
184
+ expect(streamBaseline(layers, 'silhouette')).toEqual([-2, -3, -3.5, -3.5])
185
+ const w = streamBaseline(layers, 'wiggle')
186
+ expect(w).toHaveLength(4)
187
+ expect(w.every(Number.isFinite)).toBe(true)
188
+ expect(w[0]).toBe(0)
189
+ })
190
+ it('lays out stacked areas on the baseline and stretches the domain below zero', () => {
191
+ const spec: ChartSpec = {
192
+ type: 'stream',
193
+ stacked: true,
194
+ stackOffset: 'silhouette',
195
+ categories: ['a', 'b', 'c', 'd'],
196
+ series: layers.map((v, i) => ({ label: `L${i}`, values: v })),
197
+ width: W,
198
+ height: H,
199
+ }
200
+ const geo = buildChart(spec)
201
+ expect(geo.lines).toHaveLength(3)
202
+ expect(geo.lines.every((l) => l.areaPath.includes('Z'))).toBe(true)
203
+ expect(geo.axes!.y.min).toBeLessThan(0)
204
+ // The bottom layer's first point sits at the baseline plus its value.
205
+ const sc = (v: number) => geo.plot.y + geo.plot.h - ((v - geo.axes!.y.min) / (geo.axes!.y.max - geo.axes!.y.min)) * geo.plot.h
206
+ expect(geo.lines[0]!.points[0]!.y).toBeCloseTo(sc(-2 + 1), 0)
207
+ })
208
+ })
209
+
210
+ describe('candle styles', () => {
211
+ const ohlc = [
212
+ { o: 10, h: 12, l: 9, c: 11 },
213
+ { o: 11, h: 13, l: 10, c: 10.5 },
214
+ { o: 10.5, h: 11, l: 9.5, c: 10.8 },
215
+ ]
216
+ const spec = (candleStyle?: ChartSpec['candleStyle']): ChartSpec => ({
217
+ type: 'candlestick',
218
+ categories: ['d1', 'd2', 'd3'],
219
+ series: [{ label: 'px', values: ohlc.map((k) => k.c), ohlc }],
220
+ candleStyle,
221
+ width: W,
222
+ height: H,
223
+ })
224
+ it('classic: hollow means up', () => {
225
+ const geo = buildChart(spec())
226
+ expect(geo.candles.map((k) => [k.up, k.hollow])).toEqual([[true, true], [false, false], [true, true]])
227
+ })
228
+ it('hollow: colour follows the previous close, fill follows the body', () => {
229
+ const geo = buildChart(spec('hollow'))
230
+ // d2 closes below d1 (10.5 < 11): down colour; body closes below open: filled.
231
+ // d3 closes above d2 (10.8 > 10.5): up colour; body up: hollow.
232
+ expect(geo.candles.map((k) => [k.up, k.hollow])).toEqual([[true, true], [false, false], [true, true]])
233
+ const alt = buildChart({ ...spec('hollow'), series: [{ label: 'px', values: [11, 12, 11.5], ohlc: [ohlc[0]!, { o: 11, h: 13, l: 10, c: 12 }, { o: 12, h: 12.5, l: 11, c: 11.5 }] }] })
234
+ // d3: closes below the previous close (down colour) but... open 12 > close 11.5: filled.
235
+ expect(alt.candles[2]).toMatchObject({ up: false, hollow: false })
236
+ const alt2 = buildChart({ ...spec('hollow'), series: [{ label: 'px', values: [11, 9, 9.5], ohlc: [ohlc[0]!, { o: 11, h: 11, l: 8, c: 9 }, { o: 9, h: 10, l: 8.5, c: 9.5 }] }] })
237
+ // d3: up vs previous close, and body up: up + hollow.
238
+ expect(alt2.candles[2]).toMatchObject({ up: true, hollow: true })
239
+ })
240
+ it('heikin-ashi smooths the bars before layout', () => {
241
+ const ha = heikinAshi(ohlc)
242
+ expect(ha[0]).toEqual({ o: 10.5, c: 10.5, h: 12, l: 9 })
243
+ expect(ha[1]!.o).toBeCloseTo(10.5, 6)
244
+ expect(ha[1]!.c).toBeCloseTo((11 + 13 + 10 + 10.5) / 4, 6)
245
+ expect(heikinAshi([ohlc[0]!, null, ohlc[2]!])[1]).toBeNull()
246
+ const geo = buildChart(spec('heikin-ashi'))
247
+ expect(geo.candles[0]!.o).toBeCloseTo(10.5, 6)
248
+ })
249
+ })
250
+
251
+ describe('polar family', () => {
252
+ it('arcPath draws wedges, sectors and full rings', () => {
253
+ expect(arcPath(0, 0, 0, 10, -Math.PI / 2, 0)).toMatch(/^M0,0 L/)
254
+ expect(arcPath(0, 0, 5, 10, -Math.PI / 2, 0)).toContain('A5,5')
255
+ expect((arcPath(0, 0, 5, 10, 0, Math.PI * 2).match(/A/g) ?? []).length).toBe(4)
256
+ expect(arcPath(0, 0, 5, 10, 0, 0)).toBe('')
257
+ })
258
+ const tree = { name: 'root', children: [
259
+ { name: 'A', children: [{ name: 'A1', value: 3 }, { name: 'A2', value: 1 }] },
260
+ { name: 'B', value: 4 },
261
+ ] }
262
+ it('a sunburst gives every node an arc, level 1 summing to a full turn, tinted by depth', () => {
263
+ const geo = buildChart({ type: 'sunburst', categories: [], series: [], tree, width: W, height: H })
264
+ expect(geo.arcs).toHaveLength(4)
265
+ const level1 = geo.arcs.filter((a) => a.depth === 0)
266
+ expect(level1.reduce((s, a) => s + (a.a1 - a.a0), 0)).toBeCloseTo(Math.PI * 2, 6)
267
+ const a = level1.find((x) => x.label === 'A')!
268
+ expect(a.a1 - a.a0).toBeCloseTo(Math.PI, 6)
269
+ const a1 = geo.arcs.find((x) => x.label === 'A1')!
270
+ expect(a1.depth).toBe(1)
271
+ expect(a1.nodePath).toEqual(['A', 'A1'])
272
+ expect(a1.r0).toBeGreaterThanOrEqual(a.r1 - 1)
273
+ expect(a1.color).not.toBe(a.color)
274
+ expect(geo.legend.map((l) => l.label)).toEqual(['A', 'B'])
275
+ // The alias: treemap feeds a sunburst too.
276
+ expect(buildChart({ type: 'sunburst', categories: [], series: [], treemap: tree, width: W, height: H }).arcs).toHaveLength(4)
277
+ })
278
+ it('a radial bar sweeps each category in proportion to the largest value', () => {
279
+ const geo = buildChart({ type: 'radial-bar', categories: ['a', 'b'], series: [{ label: 's', values: [50, 100] }], width: W, height: H })
280
+ expect(geo.arcs).toHaveLength(2)
281
+ expect(geo.arcs[0]!.a1 - geo.arcs[0]!.a0).toBeCloseTo(Math.PI, 3)
282
+ expect(geo.arcs[1]!.a1 - geo.arcs[1]!.a0).toBeCloseTo(Math.PI * 2 * 0.9999, 3)
283
+ expect(geo.arcs[0]!.trackPath).toBeTruthy()
284
+ expect(geo.arcs[0]!.r1).toBeLessThanOrEqual(geo.arcs[1]!.r0 + 1)
285
+ })
286
+ it('radial columns take an angular slot each with radius from the value; a nightingale uses sqrt', () => {
287
+ const spec: ChartSpec = { type: 'radial-column', categories: ['a', 'b', 'c', 'd'], series: [{ label: 's', values: [1, 4, 2, 4] }], width: W, height: H }
288
+ const col = buildChart(spec)
289
+ expect(col.arcs).toHaveLength(4)
290
+ expect(col.arcs[0]!.a1 - col.arcs[0]!.a0).toBeCloseTo((Math.PI * 2) / 4 * 0.85, 3)
291
+ expect(col.polarAxes).toHaveLength(4)
292
+ expect(col.polarRings.length).toBeGreaterThan(0)
293
+ const rose = buildChart({ ...spec, type: 'nightingale' })
294
+ const R = rose.radarCenter!.r
295
+ const rc = col.radarCenter!.r
296
+ // value 1 of max 4: linear radius is a quarter of the way, sqrt is half.
297
+ const colT = (col.arcs[0]!.r1 - col.arcs[0]!.r0) / (rc - col.arcs[0]!.r0)
298
+ const roseT = (rose.arcs[0]!.r1 - rose.arcs[0]!.r0) / (R - rose.arcs[0]!.r0)
299
+ expect(colT).toBeCloseTo(0.25, 2)
300
+ expect(roseT).toBeCloseTo(0.5, 2)
301
+ // Petals touch: no gap.
302
+ expect(rose.arcs[0]!.a1).toBeCloseTo(rose.arcs[1]!.a0, 6)
303
+ })
304
+ it('stacked radial columns stack radially', () => {
305
+ const geo = buildChart({ type: 'radial-column', stacked: true, categories: ['a'], series: [{ label: 'x', values: [2] }, { label: 'y', values: [3] }], width: W, height: H })
306
+ expect(geo.arcs).toHaveLength(2)
307
+ expect(geo.arcs[1]!.r0).toBeCloseTo(geo.arcs[0]!.r1, 6)
308
+ expect(geo.arcs[0]!.a0).toBeCloseTo(geo.arcs[1]!.a0, 6)
309
+ })
310
+ it('a chord gives each group an arc proportional to its flow and a ribbon per link', () => {
311
+ const geo = buildChart({
312
+ type: 'chord',
313
+ categories: [],
314
+ series: [],
315
+ sankeyNodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
316
+ sankeyLinks: [{ source: 'a', target: 'b', value: 3 }, { source: 'b', target: 'c', value: 1 }, { source: 'a', target: 'c', value: 2 }],
317
+ width: W,
318
+ height: H,
319
+ })
320
+ expect(geo.arcs).toHaveLength(3)
321
+ expect(geo.chordRibbons).toHaveLength(3)
322
+ const spanOf = (id: string) => { const a = geo.arcs.find((x) => x.label === id)!; return a.a1 - a.a0 }
323
+ // a flows 5, b 4, c 3: spans in that ratio.
324
+ expect(spanOf('a') / spanOf('c')).toBeCloseTo(5 / 3, 2)
325
+ expect(geo.chordRibbons[0]!.path).toContain('Q')
326
+ expect(geo.legend).toHaveLength(3)
327
+ })
328
+ })
329
+
330
+ describe('bullet', () => {
331
+ it('draws one row per category with ranges, a measure bar and a target tick', () => {
332
+ const geo = buildChart({
333
+ type: 'bullet',
334
+ categories: ['Revenue', 'Profit'],
335
+ series: [{ label: 'Actual', values: [270, 23], targets: [250, 26] }],
336
+ bulletRanges: [{ from: 0, to: 150, color: '#eee' }, { from: 150, to: 225, color: '#ddd' }, { from: 225, to: 300, color: '#ccc' }],
337
+ width: W,
338
+ height: H,
339
+ })
340
+ expect(geo.bullets).toHaveLength(2)
341
+ const b = geo.bullets[0]!
342
+ expect(b.ranges).toHaveLength(3)
343
+ expect(b.targetX).not.toBeNull()
344
+ expect(b.targetX!).toBeGreaterThan(b.x)
345
+ expect(b.targetX!).toBeLessThan(b.x + b.w)
346
+ expect(b.measureW).toBeGreaterThan(b.targetX! - b.x)
347
+ expect(geo.orientation).toBe('horizontal')
348
+ expect(geo.catTicks).toHaveLength(2)
349
+ expect(geo.valueTicks.length).toBeGreaterThan(1)
350
+ })
351
+ it('a second series supplies the targets when the first has none', () => {
352
+ const geo = buildChart({ type: 'bullet', categories: ['a'], series: [{ label: 'v', values: [5] }, { label: 't', values: [8] }], width: W, height: H })
353
+ expect(geo.bullets[0]!.target).toBe(8)
354
+ const direct = rowsToDirectSpec('bullet', [{ k: 'a', v: 5, t: 8 }], { category: 'k', value: 'v', value2: 't' })
355
+ expect(direct?.series[0]!.targets).toEqual([8])
356
+ })
357
+ })
358
+
359
+ describe('funnel shapes', () => {
360
+ const spec = (funnelShape?: ChartSpec['funnelShape']): ChartSpec => ({
361
+ type: 'funnel', categories: ['visit', 'signup', 'pay'], series: [{ label: 's', values: [100, 40, 10] }], funnelShape, width: W, height: H,
362
+ })
363
+ it('a cone tapers to a point and a pyramid widens to the base', () => {
364
+ const cone = buildChart(spec('cone'))
365
+ const last = cone.funnelSegments[2]!.path
366
+ // The bottom edge of the last cone segment has zero width: both corners at cx.
367
+ const nums = last.match(/[\d.]+,[\d.]+/g)!.map((p) => p.split(',').map(Number))
368
+ expect(Math.abs(nums[2]![0]! - nums[3]![0]!)).toBeLessThan(0.01)
369
+ const pyr = buildChart(spec('pyramid'))
370
+ const first = pyr.funnelSegments[0]!.path.match(/[\d.]+,[\d.]+/g)!.map((p) => p.split(',').map(Number))
371
+ expect(Math.abs(first[0]![0]! - first[1]![0]!)).toBeLessThan(0.01)
372
+ expect(buildChart(spec()).funnelSegments).toHaveLength(3)
373
+ })
374
+ })
375
+
376
+ describe('every new type survives an empty spec', () => {
377
+ const TYPES: ChartType[] = ['histogram', 'range-bar', 'range-area', 'lollipop', 'dumbbell', 'pareto', 'stream', 'sunburst', 'radial-bar', 'radial-column', 'nightingale', 'bullet', 'chord']
378
+ it('returns the empty geometry rather than throwing', () => {
379
+ for (const type of TYPES) {
380
+ const geo = buildChart({ type, categories: [], series: [], width: W, height: H })
381
+ expect(geo.width, type).toBe(W)
382
+ expect(geo.bars.length + geo.arcs.length + geo.stems.length + geo.bullets.length + geo.lines.length, type).toBe(0)
383
+ }
384
+ })
385
+ })
386
+
387
+ describe('stack groups', () => {
388
+ const spec = (extra: Partial<ChartSpec> = {}): ChartSpec => ({
389
+ type: 'bar',
390
+ categories: ['a', 'b'],
391
+ series: [
392
+ { label: 'plan-x', values: [10, 20], stack: 'plan' },
393
+ { label: 'plan-y', values: [20, 10], stack: 'plan' },
394
+ { label: 'actual-x', values: [25, 5], stack: 'actual' },
395
+ { label: 'actual-y', values: [5, 25], stack: 'actual' },
396
+ { label: 'lone', values: [40, 8] },
397
+ ],
398
+ width: W,
399
+ height: H,
400
+ ...extra,
401
+ })
402
+
403
+ it('draws one slot per named stack plus one per lone series, side by side', () => {
404
+ const geo = buildChart(spec())
405
+ const at = (label: string) => geo.bars.filter((b) => b.label === 'a' && b.series === label)
406
+ const x = (label: string) => at(label)[0]!.x
407
+ // The two series of a stack share an x; the stacks and the lone bar differ.
408
+ expect(x('plan-x')).toBe(x('plan-y'))
409
+ expect(x('actual-x')).toBe(x('actual-y'))
410
+ expect(new Set([x('plan-x'), x('actual-x'), x('lone')]).size).toBe(3)
411
+ expect(x('plan-x')).toBeLessThan(x('actual-x'))
412
+ expect(x('actual-x')).toBeLessThan(x('lone'))
413
+ // Three slots of equal width.
414
+ const widths = new Set(geo.bars.filter((b) => b.label === 'a').map((b) => b.w))
415
+ expect(widths.size).toBe(1)
416
+ // The second series of a stack sits on the first.
417
+ const px = at('plan-x')[0]!
418
+ const py = at('plan-y')[0]!
419
+ expect(Math.abs(py.y + py.h - px.y)).toBeLessThanOrEqual(1)
420
+ })
421
+
422
+ it('sizes the axis to the tallest stack, not to the sum of every series', () => {
423
+ const geo = buildChart(spec())
424
+ // Stacks total 30 each, the lone bar 40: the axis tops out near 40, not 100.
425
+ expect(geo.axes!.y.max).toBeGreaterThanOrEqual(40)
426
+ expect(geo.axes!.y.max).toBeLessThan(60)
427
+ })
428
+
429
+ it('with `stacked` on, unnamed series join the default stack and the named ones keep theirs', () => {
430
+ const geo = buildChart(spec({ stacked: true, series: [
431
+ { label: 'plan-x', values: [10, 20], stack: 'plan' },
432
+ { label: 'plan-y', values: [20, 10], stack: 'plan' },
433
+ { label: 'u1', values: [5, 5] },
434
+ { label: 'u2', values: [5, 5] },
435
+ ] }))
436
+ const xs = new Set(geo.bars.filter((b) => b.label === 'a').map((b) => b.x))
437
+ expect(xs.size).toBe(2)
438
+ const u1 = geo.bars.find((b) => b.label === 'a' && b.series === 'u1')!
439
+ const u2 = geo.bars.find((b) => b.label === 'a' && b.series === 'u2')!
440
+ expect(u1.x).toBe(u2.x)
441
+ expect(Math.abs(u2.y + u2.h - u1.y)).toBeLessThanOrEqual(1)
442
+ })
443
+
444
+ it('a plain stacked chart and a plain grouped chart are unchanged', () => {
445
+ const plain = buildChart({ type: 'bar', categories: ['a'], series: [{ label: 's1', values: [1] }, { label: 's2', values: [2] }], width: W, height: H })
446
+ expect(new Set(plain.bars.map((b) => b.x)).size).toBe(2)
447
+ const stacked = buildChart({ type: 'bar', categories: ['a'], series: [{ label: 's1', values: [1] }, { label: 's2', values: [2] }], stacked: true, width: W, height: H })
448
+ expect(new Set(stacked.bars.map((b) => b.x)).size).toBe(1)
449
+ expect(stacked.axes!.y.max).toBeGreaterThanOrEqual(3)
450
+ })
451
+
452
+ const areas = (extra: Partial<ChartSpec> = {}): ChartSpec => ({
453
+ type: 'area',
454
+ categories: ['a', 'b', 'c'],
455
+ series: [
456
+ { label: 'a1', values: [10, 20, 30], stack: 'one' },
457
+ { label: 'a2', values: [10, 10, 10], stack: 'one' },
458
+ { label: 'b1', values: [5, 5, 5], stack: 'two' },
459
+ { label: 'lone', values: [40, 40, 40] },
460
+ ],
461
+ width: W,
462
+ height: H,
463
+ ...extra,
464
+ })
465
+ const yOf = (geo: ReturnType<typeof buildChart>) => {
466
+ const { min, max } = geo.axes!.y
467
+ return (v: number) => geo.plot.y + geo.plot.h - ((v - min) / (max - min)) * geo.plot.h
468
+ }
469
+ const lineOf = (geo: ReturnType<typeof buildChart>, label: string) => geo.lines.find((l) => l.label === label)!
470
+
471
+ it('areas naming a stack accumulate within it and independently of another stack', () => {
472
+ const geo = buildChart(areas())
473
+ const y = yOf(geo)
474
+ // a2 sits on a1 (10 + 10 = 20 at a), b1 and the lone area sit on the axis.
475
+ expect(lineOf(geo, 'a2').points[0]!.y).toBeCloseTo(y(20), 0)
476
+ expect(lineOf(geo, 'a1').points[0]!.y).toBeCloseTo(y(10), 0)
477
+ expect(lineOf(geo, 'b1').points[0]!.y).toBeCloseTo(y(5), 0)
478
+ expect(lineOf(geo, 'lone').points[0]!.y).toBeCloseTo(y(40), 0)
479
+ // The pile's second layer closes back onto the first, not the axis.
480
+ expect(lineOf(geo, 'a2').areaPath).not.toContain(`L${geo.plot.x + geo.plot.w},${geo.plot.y + geo.plot.h}`)
481
+ })
482
+
483
+ it('the domain covers the tallest area pile, not the sum of every area', () => {
484
+ const geo = buildChart(areas())
485
+ // Piles: one = 40 at c, two = 5, lone = 40; a naive sum would be 85.
486
+ expect(geo.axes!.y.max).toBeGreaterThanOrEqual(40)
487
+ expect(geo.axes!.y.max).toBeLessThan(60)
488
+ })
489
+
490
+ it('stacked100 normalises each area pile to its own total', () => {
491
+ const geo = buildChart(areas({ stacked100: true, series: [
492
+ { label: 'a1', values: [10, 20, 30], stack: 'one' },
493
+ { label: 'a2', values: [10, 10, 10], stack: 'one' },
494
+ { label: 'b1', values: [5, 5, 5], stack: 'two' },
495
+ ] }))
496
+ const y = yOf(geo)
497
+ expect(geo.axes!.y.max).toBe(100)
498
+ // a1 is half of pile one at 'a'; a2 tops the pile at 100; b1 alone is 100.
499
+ expect(lineOf(geo, 'a1').points[0]!.y).toBeCloseTo(y(50), 0)
500
+ expect(lineOf(geo, 'a2').points[0]!.y).toBeCloseTo(y(100), 0)
501
+ expect(lineOf(geo, 'b1').points[0]!.y).toBeCloseTo(y(100), 0)
502
+ })
503
+
504
+ it('stackOffset wiggle runs per pile, and a named pile streams without spec.stacked', () => {
505
+ const geo = buildChart(areas({ stackOffset: 'silhouette', series: [
506
+ { label: 'a1', values: [10, 20, 30], stack: 'one' },
507
+ { label: 'a2', values: [10, 10, 10], stack: 'one' },
508
+ { label: 'b1', values: [5, 5, 5], stack: 'two' },
509
+ ] }))
510
+ const y = yOf(geo)
511
+ // A silhouette centres each pile on zero: pile one's first layer starts
512
+ // at -total/2 (-10 at a), pile two's at -2.5.
513
+ expect(geo.axes!.y.min).toBeLessThan(0)
514
+ const a1Base = lineOf(geo, 'a1').areaPath
515
+ expect(a1Base.length).toBeGreaterThan(0)
516
+ expect(lineOf(geo, 'a2').points[0]!.y).toBeCloseTo(y(10), 0)
517
+ expect(lineOf(geo, 'b1').points[0]!.y).toBeCloseTo(y(2.5), 0)
518
+ })
519
+ })
520
+
521
+ describe('pie callouts', () => {
522
+ const pie = (extra: Partial<ChartSpec> = {}): ChartSpec => ({
523
+ type: 'pie',
524
+ categories: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Sliver'],
525
+ series: [{ label: 'share', values: [40, 30, 20, 9.5, 0.5] }],
526
+ width: W,
527
+ height: H,
528
+ ...extra,
529
+ })
530
+
531
+ it('outside labels shrink the pie and give every slice over 1.5% a leader and a text anchor', () => {
532
+ const plain = buildChart(pie())
533
+ const out = buildChart(pie({ dataLabels: { placement: 'outside' } }))
534
+ expect(plain.slices.every((sl) => !sl.callout)).toBe(true)
535
+ expect(out.slices[0]!.arc!.r).toBeLessThan(plain.slices[0]!.arc!.r)
536
+ const withCallout = out.slices.filter((sl) => sl.callout)
537
+ expect(withCallout.map((sl) => sl.label)).toEqual(['Alpha', 'Beta', 'Gamma', 'Delta'])
538
+ for (const sl of withCallout) {
539
+ const c = sl.callout!
540
+ // The leader starts just outside the arc and the text sits beyond the run.
541
+ const cx = sl.arc!.cx
542
+ const cy = sl.arc!.cy
543
+ expect(Math.hypot(c.x1 - cx, c.y1 - cy)).toBeGreaterThan(sl.arc!.r)
544
+ expect(c.anchor).toBe(c.x3 >= cx ? 'start' : 'end')
545
+ expect(c.ty).toBe(c.y2)
546
+ expect(Math.abs(c.tx - c.x3)).toBe(4)
547
+ }
548
+ })
549
+
550
+ it('labels on one side never overlap: neighbours are pushed at least 13px apart', () => {
551
+ // Eight thin slices on the right half crowd one side.
552
+ const geo = buildChart(pie({
553
+ categories: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'rest'],
554
+ series: [{ label: 's', values: [3, 3, 3, 3, 3, 3, 3, 3, 76] }],
555
+ dataLabels: { placement: 'outside' },
556
+ }))
557
+ const right = geo.slices.filter((sl) => sl.callout?.anchor === 'start').map((sl) => sl.callout!.ty).sort((a, b) => a - b)
558
+ expect(right.length).toBeGreaterThanOrEqual(6)
559
+ for (let k = 1; k < right.length; k += 1) expect(right[k]! - right[k - 1]!).toBeGreaterThanOrEqual(13)
560
+ // And they stay inside the frame.
561
+ expect(right[0]!).toBeGreaterThanOrEqual(0)
562
+ expect(right[right.length - 1]!).toBeLessThanOrEqual(H)
563
+ })
564
+
565
+ it('the callout text stays inside the chart on a narrow pane: the radius makes room for the widest label', () => {
566
+ // 300px wide with long names: "Samsung Internet 3%" is about 120px of
567
+ // text, and the pie used to keep a 34px margin and push it off the SVG.
568
+ const geo = buildChart(pie({
569
+ width: 300, height: 260,
570
+ categories: ['Samsung Internet', 'DuckDuckGo Browser', 'Chrome', 'Safari'],
571
+ series: [{ label: 's', values: [3, 4, 60, 33] }],
572
+ dataLabels: { placement: 'outside' },
573
+ }))
574
+ const est = (label: string, max: number) => (Math.min(label.length, 18, max) + 4) * 6.3
575
+ for (const sl of geo.slices) {
576
+ const c = sl.callout!
577
+ expect(c).toBeTruthy()
578
+ expect(c.maxChars).toBeGreaterThanOrEqual(4)
579
+ const textEnd = c.anchor === 'start' ? c.tx + est(sl.label, c.maxChars) : c.tx - est(sl.label, c.maxChars)
580
+ expect(textEnd).toBeGreaterThanOrEqual(0)
581
+ expect(textEnd).toBeLessThanOrEqual(300)
582
+ }
583
+ // The pie itself keeps a quarter of the short side however long the names.
584
+ expect(geo.slices[0]!.arc!.r).toBeGreaterThanOrEqual(65)
585
+ // Short names cost less radius than long ones.
586
+ const short = buildChart(pie({ width: 300, height: 260, categories: ['A', 'B', 'C', 'D'], series: [{ label: 's', values: [3, 4, 60, 33] }], dataLabels: { placement: 'outside' } }))
587
+ expect(short.slices[0]!.arc!.r).toBeGreaterThan(geo.slices[0]!.arc!.r)
588
+ })
589
+
590
+ it('the gutters are per side, and the pie sits centred in what they leave', () => {
591
+ // Every small slice on the left with a long name; one big slice on the right.
592
+ const geo = buildChart(pie({
593
+ width: 444, height: 320,
594
+ categories: ['Chrome', 'Safari', 'Edge', 'Firefox', 'Samsung Internet', 'Other'],
595
+ series: [{ label: 's', values: [61.2, 19.8, 6.9, 3.4, 2.6, 5.1] }],
596
+ dataLabels: { placement: 'outside' },
597
+ }))
598
+ const arc = geo.slices[0]!.arc!
599
+ // Sized by the widest label on either side the radius was 70; the right
600
+ // side only needs room for "Chrome 62%".
601
+ expect(arc.r).toBeGreaterThanOrEqual(85)
602
+ expect(arc.cx).toBeGreaterThan(444 / 2)
603
+ // Each label still ends inside the chart.
604
+ const est = (label: string, max: number) => (Math.min(label.length, 18, max) + 4) * 6.3
605
+ for (const sl of geo.slices) {
606
+ const c = sl.callout
607
+ if (!c) continue
608
+ const textEnd = c.anchor === 'start' ? c.tx + est(sl.label, c.maxChars) : c.tx - est(sl.label, c.maxChars)
609
+ expect(textEnd).toBeGreaterThanOrEqual(0)
610
+ expect(textEnd).toBeLessThanOrEqual(444)
611
+ }
612
+ })
613
+
614
+ it('a hidden label config or an inside placement leaves the pie alone', () => {
615
+ const plain = buildChart(pie())
616
+ expect(buildChart(pie({ dataLabels: { placement: 'outside', show: false } })).slices[0]!.arc!.r).toBe(plain.slices[0]!.arc!.r)
617
+ expect(buildChart(pie({ dataLabels: { placement: 'inside' } })).slices.some((sl) => sl.callout)).toBe(false)
618
+ })
619
+ })
620
+
621
+ describe('series end labels', () => {
622
+ const spec = (extra: Partial<ChartSpec> = {}): ChartSpec => ({
623
+ type: 'line',
624
+ categories: ['a', 'b', 'c', 'd'],
625
+ series: [
626
+ { label: 'North', values: [1, 2, 3, 4] },
627
+ { label: 'South', values: [4, 3, 2, 1] },
628
+ { label: 'Gapped', values: [2, 2, 2, NaN] },
629
+ { label: 'Bars', values: [1, 1, 1, 1], type: 'bar' },
630
+ ],
631
+ width: W,
632
+ height: H,
633
+ seriesLabels: true,
634
+ ...extra,
635
+ })
636
+
637
+ it('names every line at its last defined point and reserves a gutter for the text', () => {
638
+ const geo = buildChart(spec())
639
+ const plain = buildChart(spec({ seriesLabels: false }))
640
+ expect(geo.plot.w).toBeLessThan(plain.plot.w)
641
+ // Sorted top to bottom, the way a reader scans the gutter.
642
+ expect(geo.seriesLabels.map((l) => l.text)).toEqual(['North', 'Gapped', 'South'])
643
+ const north = geo.seriesLabels.find((l) => l.series === 'North')!
644
+ const last = geo.lines.find((l) => l.label === 'North')!.points[3]!
645
+ expect(north.x).toBeCloseTo(last.x + 6, 1)
646
+ expect(north.color).toBe(geo.lines.find((l) => l.label === 'North')!.color)
647
+ // The gapped series is labelled at its third point, not at the NaN.
648
+ const gapped = geo.seriesLabels.find((l) => l.series === 'Gapped')!
649
+ expect(gapped.x).toBeCloseTo(geo.lines.find((l) => l.label === 'Gapped')!.points[2]!.x + 6, 1)
650
+ })
651
+
652
+ it('pushes labels that end together apart by a line height and keeps them inside the plot', () => {
653
+ const geo = buildChart(spec({ series: [
654
+ { label: 'one', values: [1, 2, 3, 4] },
655
+ { label: 'two', values: [4, 3, 2, 4] },
656
+ { label: 'three', values: [2, 2, 2, 4.05] },
657
+ ] }))
658
+ const ys = geo.seriesLabels.map((l) => l.y).sort((a, b) => a - b)
659
+ expect(ys).toHaveLength(3)
660
+ expect(ys[1]! - ys[0]!).toBeGreaterThanOrEqual(12)
661
+ expect(ys[2]! - ys[1]!).toBeGreaterThanOrEqual(12)
662
+ expect(ys[0]!).toBeGreaterThanOrEqual(geo.plot.y)
663
+ expect(ys[2]!).toBeLessThanOrEqual(geo.plot.y + geo.plot.h)
664
+ })
665
+
666
+ it('a formatter writes the text from the label and the last value', () => {
667
+ const geo = buildChart(spec({ seriesLabels: { formatter: (s, v) => `${s} (${v})` } }))
668
+ expect(geo.seriesLabels.map((l) => l.text).sort()).toEqual(['Gapped (2)', 'North (4)', 'South (1)'])
669
+ })
670
+ })
671
+
672
+ describe('responsive rules', () => {
673
+ const base: ChartSpec = {
674
+ type: 'line',
675
+ categories: ['a', 'b'],
676
+ series: [{ label: 's', values: [1, 2] }],
677
+ yAxis: { title: 'Units', gridLines: true },
678
+ seriesLabels: true,
679
+ responsive: [
680
+ { maxWidth: 480, spec: { seriesLabels: false, yAxis: { title: undefined } } },
681
+ { maxWidth: 320, spec: { yAxis: { gridLines: false } }, legend: false },
682
+ { minWidth: 1200, spec: { title: 'Wide' } },
683
+ ],
684
+ }
685
+
686
+ it('matchResponsiveRules keeps spec order and every rule that fits the size', () => {
687
+ expect(matchResponsiveRules(base.responsive, 300, 200).map((r) => r.maxWidth)).toEqual([480, 320])
688
+ expect(matchResponsiveRules(base.responsive, 400, 200).map((r) => r.maxWidth)).toEqual([480])
689
+ expect(matchResponsiveRules(base.responsive, 800, 200)).toEqual([])
690
+ expect(matchResponsiveRules(base.responsive, 1400, 200).map((r) => r.minWidth)).toEqual([1200])
691
+ expect(matchResponsiveRules(undefined, 100, 100)).toEqual([])
692
+ })
693
+
694
+ it('resolveResponsive merges every matching patch in order, axes one level deep, and returns the input when none match', () => {
695
+ expect(resolveResponsive(base, 800, 300)).toBe(base)
696
+ const narrow = resolveResponsive(base, 300, 300)
697
+ expect(narrow.seriesLabels).toBe(false)
698
+ expect(narrow.yAxis).toEqual({ title: undefined, gridLines: false })
699
+ expect(narrow.responsive).toBe(base.responsive)
700
+ const wide = resolveResponsive(base, 1400, 300)
701
+ expect(wide.title).toBe('Wide')
702
+ expect(wide.seriesLabels).toBe(true)
703
+ })
704
+
705
+ it('buildChart applies the rules at the spec size and a rule cannot resize the chart', () => {
706
+ const wide = buildChart({ ...base, width: 800, height: 300 })
707
+ const narrow = buildChart({ ...base, width: 300, height: 300, responsive: [{ maxWidth: 480, spec: { seriesLabels: false, width: 999, height: 999 } }] })
708
+ expect(wide.seriesLabels).toHaveLength(1)
709
+ expect(narrow.seriesLabels).toHaveLength(0)
710
+ expect(narrow.width).toBe(300)
711
+ expect(narrow.height).toBe(300)
712
+ expect(narrow.grid.y).toBe(true)
713
+ })
714
+ })