@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,573 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildChart, chartScales, chartStyleVars, type ChartSpec, type ChartType } from './chart'
3
+ import { axisScale } from './chart-scale'
4
+ import { layoutDataLabels, markerPath } from './chart-cartesian'
5
+
6
+ const line = (extra: Partial<ChartSpec> = {}): ChartSpec => ({
7
+ type: 'line',
8
+ categories: ['Jan', 'Feb', 'Mar', 'Apr'],
9
+ series: [{ label: 'Sales', values: [40, 90, 60, 120] }],
10
+ width: 400,
11
+ height: 240,
12
+ ...extra,
13
+ })
14
+
15
+ describe('axisScale', () => {
16
+ it('is niceScale when nothing is configured', () => {
17
+ expect(axisScale(3, 97)).toEqual(axisScale(3, 97, {}))
18
+ expect(axisScale(3, 97).min).toBe(0)
19
+ expect(axisScale(3, 97).max).toBe(100)
20
+ })
21
+ it('pins min / max and drops the ticks outside them', () => {
22
+ const s = axisScale(3, 97, { min: 10, max: 90 })
23
+ expect(s.min).toBe(10)
24
+ expect(s.max).toBe(90)
25
+ expect(s.ticks.every((t) => t >= 10 && t <= 90)).toBe(true)
26
+ })
27
+ it('honours an exact tickInterval', () => {
28
+ const s = axisScale(0, 100, { tickInterval: 25 })
29
+ expect(s.ticks).toEqual([0, 25, 50, 75, 100])
30
+ expect(s.step).toBe(25)
31
+ })
32
+ it('nice: false keeps the raw extent with evenly spaced ticks', () => {
33
+ const s = axisScale(3, 97, { nice: false, tickCount: 2 })
34
+ expect(s.min).toBe(3)
35
+ expect(s.max).toBe(97)
36
+ expect(s.ticks).toEqual([3, 50, 97])
37
+ })
38
+ it('never loops forever on a tiny interval over a huge range', () => {
39
+ const s = axisScale(0, 1e9, { tickInterval: 1 })
40
+ expect(s.ticks.length).toBeLessThanOrEqual(1000)
41
+ })
42
+ })
43
+
44
+ describe('value axis config', () => {
45
+ it('yAxis.min / max pin the domain', () => {
46
+ const geo = buildChart(line({ yAxis: { min: 0, max: 500 } }))
47
+ expect(geo.axes!.y).toMatchObject({ min: 0, max: 500 })
48
+ expect(geo.yTicks.every((t) => t.value >= 0 && t.value <= 500)).toBe(true)
49
+ })
50
+ it('the flat yAxisTitle and yScale still fill the axis object', () => {
51
+ const a = buildChart(line({ yAxisTitle: 'Units', yScale: 'log' }))
52
+ const b = buildChart(line({ yAxis: { title: 'Units', scale: 'log' } }))
53
+ expect(a.axes!.y).toEqual(b.axes!.y)
54
+ expect(a.plot).toEqual(b.plot)
55
+ })
56
+ it('a formatter owns the tick labels', () => {
57
+ const geo = buildChart(line({ yAxis: { formatter: (v) => `${v} u` } }))
58
+ expect(geo.yTicks.every((t) => t.label.endsWith(' u'))).toBe(true)
59
+ })
60
+ it('per-axis format beats the spec valueFormat', () => {
61
+ const geo = buildChart(line({ valueFormat: 'currency', yAxis: { format: 'percent' } }))
62
+ expect(geo.yTicks.some((t) => t.label.includes('%'))).toBe(true)
63
+ expect(geo.yTicks.some((t) => t.label.includes('$'))).toBe(false)
64
+ })
65
+ it('reversed flips the value axis', () => {
66
+ const geo = buildChart(line({ yAxis: { reversed: true } }))
67
+ const sc = chartScales(geo)!
68
+ expect(sc.yOf(geo.axes!.y.max)).toBeGreaterThan(sc.yOf(geo.axes!.y.min))
69
+ // The line's highest value is now drawn LOWEST on the plot.
70
+ const pts = geo.lines[0]!.points
71
+ const top = pts.reduce((a, b) => (b.value > a.value ? b : a))
72
+ expect(top.y).toBe(Math.max(...pts.map((p) => p.y)))
73
+ expect(sc.yInvert(sc.yOf(90))).toBeCloseTo(90, 6)
74
+ })
75
+ it('labels: false keeps the ticks but blanks the text and shrinks the gutter', () => {
76
+ const on = buildChart(line())
77
+ const off = buildChart(line({ yAxis: { labels: false } }))
78
+ expect(off.yTicks.length).toBe(on.yTicks.length)
79
+ expect(off.yTicks.every((t) => t.label === '')).toBe(true)
80
+ expect(off.plot.x).toBeLessThan(on.plot.x)
81
+ })
82
+ it('width fixes the left gutter', () => {
83
+ const geo = buildChart(line({ yAxis: { width: 80 } }))
84
+ expect(geo.plot.x).toBe(80)
85
+ })
86
+ it('gridLines flags travel to the geometry', () => {
87
+ expect(buildChart(line()).grid).toEqual({ x: false, y: true })
88
+ expect(buildChart(line({ xAxis: { gridLines: true }, yAxis: { gridLines: false } })).grid).toEqual({ x: true, y: false })
89
+ })
90
+ it('tickInterval on the right axis', () => {
91
+ const geo = buildChart(
92
+ line({
93
+ series: [
94
+ { label: 'a', values: [1, 2, 3, 4] },
95
+ { label: 'b', values: [10, 20, 30, 40], axis: 'right' },
96
+ ],
97
+ y2Axis: { tickInterval: 20 },
98
+ }),
99
+ )
100
+ expect(geo.y2Ticks.map((t) => t.value)).toEqual([0, 20, 40])
101
+ })
102
+ })
103
+
104
+ describe('numeric x axis', () => {
105
+ it('positions marks by value, not by index', () => {
106
+ const geo = buildChart({
107
+ type: 'line',
108
+ categories: ['0', '1', '10'],
109
+ series: [{ label: 's', values: [1, 2, 3] }],
110
+ xType: 'number',
111
+ width: 400,
112
+ height: 200,
113
+ })
114
+ const [p0, p1, p2] = geo.lines[0]!.points
115
+ expect(p1!.x - p0!.x).toBeCloseTo((p2!.x - p0!.x) / 10, 0)
116
+ expect(geo.axes!.x).toMatchObject({ type: 'number', min: 0, max: 10 })
117
+ expect(geo.axes!.x!.values).toEqual([0, 1, 10])
118
+ })
119
+ it('sizes bars from the smallest gap between neighbouring values', () => {
120
+ const geo = buildChart({
121
+ type: 'bar',
122
+ categories: ['0', '5', '10', '12'],
123
+ series: [{ label: 's', values: [1, 2, 3, 4] }],
124
+ xAxis: { type: 'number' },
125
+ width: 400,
126
+ height: 200,
127
+ })
128
+ const widths = new Set(geo.bars.map((b) => b.w))
129
+ expect(widths.size).toBe(1)
130
+ const w = geo.bars[0]!.w
131
+ // A gap of 2 in a domain of 12 over the plot width, minus the group pad.
132
+ expect(w).toBeLessThan((2 / 12) * geo.plot.w)
133
+ expect(w).toBeGreaterThan((2 / 12) * geo.plot.w * 0.6)
134
+ })
135
+ it('labels the ticks through the x formatter', () => {
136
+ const geo = buildChart({
137
+ type: 'line',
138
+ categories: ['0', '50', '100'],
139
+ series: [{ label: 's', values: [1, 2, 3] }],
140
+ xAxis: { type: 'number', formatter: (v) => `${v}%` },
141
+ width: 400,
142
+ height: 200,
143
+ })
144
+ expect(geo.xTicks.every((t) => t.label.endsWith('%'))).toBe(true)
145
+ })
146
+ it('falls back to a category axis when nothing parses', () => {
147
+ const geo = buildChart(line({ xAxis: { type: 'number' } }))
148
+ expect(geo.axes!.x).toBeUndefined()
149
+ expect(geo.xTicks.map((t) => t.label)).toEqual(['Jan', 'Feb', 'Mar', 'Apr'])
150
+ })
151
+ it('xOfValue / xInvertValue round-trip on every axis kind', () => {
152
+ const cat = chartScales(buildChart(line()))!
153
+ expect(cat.xOfValue('Mar')).toBe(cat.xOf(2))
154
+ expect(Number.isNaN(cat.xOfValue('Nope'))).toBe(true)
155
+ expect(cat.xInvertValue(cat.xOf(2))).toBeCloseTo(2, 6)
156
+
157
+ const num = chartScales(
158
+ buildChart({ type: 'line', categories: ['0', '10'], series: [{ label: 's', values: [1, 2] }], xType: 'number', width: 400, height: 200 }),
159
+ )!
160
+ expect(num.xOfValue(5)).toBeCloseTo((num.xOfValue(0) + num.xOfValue(10)) / 2, 6)
161
+ expect(num.xInvertValue(num.xOfValue(7.5))).toBeCloseTo(7.5, 6)
162
+
163
+ const time = chartScales(
164
+ buildChart({
165
+ type: 'line',
166
+ categories: ['2026-01-01', '2026-01-11'],
167
+ series: [{ label: 's', values: [1, 2] }],
168
+ xType: 'time',
169
+ width: 400,
170
+ height: 200,
171
+ }),
172
+ )!
173
+ const mid = Date.parse('2026-01-06')
174
+ expect(time.xOfValue('2026-01-06')).toBeCloseTo(time.xOfValue(mid), 6)
175
+ expect(time.xOfValue(new Date(mid))).toBeCloseTo(time.xOfValue(mid), 6)
176
+ expect(time.xInvertValue(time.xOfValue(mid))).toBeCloseTo(mid, 0)
177
+ })
178
+ it('a time axis puts its ticks on calendar boundaries and fills the plot', () => {
179
+ const months = Array.from({ length: 30 }, (_, i) => new Date(Date.UTC(2024, 3 + i, 1)).toISOString().slice(0, 10))
180
+ const geo = buildChart({ type: 'line', xType: 'time', categories: months, series: [{ label: 's', values: months.map((_, i) => i) }], width: 900, height: 300 })
181
+ const labels = geo.xTicks.map((t) => t.label)
182
+ // Thirty months on a 900px plot: quarters, each at the first of its month.
183
+ expect(labels.length).toBeGreaterThanOrEqual(6)
184
+ expect(labels.length).toBeLessThanOrEqual(11)
185
+ expect(labels[0]).toMatch(/^(Apr|Jul) 24$/)
186
+ const year = buildChart({ type: 'line', xType: 'time', categories: ['2020-06-15', '2026-03-01'], series: [{ label: 's', values: [1, 2] }], width: 600, height: 300 })
187
+ // Six years: one tick per New Year, labelled by the year it starts.
188
+ expect(year.xTicks.map((t) => t.label)).toEqual(['2021', '2022', '2023', '2024', '2025', '2026'])
189
+ const days = buildChart({ type: 'line', xType: 'time', categories: ['2026-01-05', '2026-01-19'], series: [{ label: 's', values: [1, 2] }], width: 400, height: 300 })
190
+ // Two weeks: Mondays.
191
+ expect(days.xTicks.map((t) => t.label)).toEqual(['Jan 5', 'Jan 12', 'Jan 19'])
192
+ })
193
+ it('an ordinal date axis of trading days ticks on Mondays', () => {
194
+ // Weekdays only, five weeks: the ticks are the first session of each week.
195
+ const days: string[] = []
196
+ for (let d = new Date(Date.UTC(2026, 7, 10)); days.length < 25; d.setUTCDate(d.getUTCDate() + 1)) {
197
+ if (d.getUTCDay() !== 0 && d.getUTCDay() !== 6) days.push(d.toISOString().slice(0, 10))
198
+ }
199
+ const geo = buildChart({ type: 'line', xType: 'ordinal-time', categories: days, series: [{ label: 's', values: days.map((_, i) => i) }], width: 600, height: 300 })
200
+ const labels = geo.xTicks.map((t) => t.label)
201
+ expect(labels[0]).toBe('Aug 10')
202
+ expect(labels).toEqual(['Aug 10', 'Aug 17', 'Aug 24', 'Aug 31', 'Sep 7'])
203
+ })
204
+ it('reversed runs the category axis right to left', () => {
205
+ const fwd = buildChart(line())
206
+ const rev = buildChart(line({ xAxis: { reversed: true } }))
207
+ expect(rev.lines[0]!.points[0]!.x).toBeGreaterThan(rev.lines[0]!.points[3]!.x)
208
+ expect(rev.lines[0]!.points[0]!.x).toBe(fwd.lines[0]!.points[3]!.x)
209
+ expect(chartScales(rev)!.xInvert(rev.lines[0]!.points[0]!.x)).toBe(0)
210
+ })
211
+ })
212
+
213
+ describe('reference lines and bands', () => {
214
+ it('an x reference line resolves by category label', () => {
215
+ const geo = buildChart(line({ referenceLines: [{ axis: 'x', value: 'Mar', label: 'Launch' }] }))
216
+ expect(geo.referenceLinesV).toHaveLength(1)
217
+ expect(geo.referenceLinesV[0]!.x).toBe(geo.lines[0]!.points[2]!.x)
218
+ expect(geo.referenceLinesV[0]!.label).toBe('Launch')
219
+ })
220
+ it('an x reference line resolves by date on a time axis and by number on a numeric one', () => {
221
+ const t = buildChart({
222
+ type: 'line',
223
+ categories: ['2026-01-01', '2026-01-11'],
224
+ series: [{ label: 's', values: [1, 2] }],
225
+ xType: 'time',
226
+ referenceLines: [{ axis: 'x', value: '2026-01-06' }],
227
+ width: 400,
228
+ height: 200,
229
+ })
230
+ const [a, b] = t.lines[0]!.points
231
+ expect(t.referenceLinesV[0]!.x).toBeCloseTo((a!.x + b!.x) / 2, 0)
232
+ const n = buildChart({
233
+ type: 'line',
234
+ categories: ['0', '10'],
235
+ series: [{ label: 's', values: [1, 2] }],
236
+ xType: 'number',
237
+ referenceLines: [{ axis: 'x', value: 2.5 }],
238
+ width: 400,
239
+ height: 200,
240
+ })
241
+ const [c, d] = n.lines[0]!.points
242
+ expect(n.referenceLinesV[0]!.x).toBeCloseTo(c!.x + (d!.x - c!.x) / 4, 0)
243
+ })
244
+ it('an unknown x reference is dropped, not drawn at NaN', () => {
245
+ const geo = buildChart(line({ referenceLines: [{ axis: 'x', value: 'Nope' }] }))
246
+ expect(geo.referenceLinesV).toHaveLength(0)
247
+ })
248
+ it('a value band is a full-width rectangle between its two values', () => {
249
+ const geo = buildChart(line({ referenceBands: [{ from: 50, to: 100, color: 'red', label: 'target' }] }))
250
+ const sc = chartScales(geo)!
251
+ expect(geo.referenceBands).toHaveLength(1)
252
+ const b = geo.referenceBands[0]!
253
+ expect(b.axis).toBe('y')
254
+ expect(b.x).toBe(geo.plot.x)
255
+ expect(b.w).toBe(geo.plot.w)
256
+ expect(b.y).toBeCloseTo(sc.yOf(100), 6)
257
+ expect(b.h).toBeCloseTo(sc.yOf(50) - sc.yOf(100), 6)
258
+ expect(b.color).toBe('red')
259
+ expect(b.label).toBe('target')
260
+ })
261
+ it('a category band covers the whole slots from its first to its last category', () => {
262
+ const geo = buildChart(line({ referenceBands: [{ axis: 'x', from: 'Feb', to: 'Mar' }] }))
263
+ const b = geo.referenceBands[0]!
264
+ expect(b.axis).toBe('x')
265
+ expect(b.x).toBeCloseTo(geo.plot.x + geo.axes!.slot, 0)
266
+ expect(b.w).toBeCloseTo(geo.axes!.slot * 2, 0)
267
+ expect(b.y).toBe(geo.plot.y)
268
+ expect(b.h).toBe(geo.plot.h)
269
+ })
270
+ it('a band stretches the value domain like a reference line does', () => {
271
+ const geo = buildChart(line({ referenceBands: [{ from: 200, to: 300 }] }))
272
+ expect(geo.axes!.y.max).toBeGreaterThanOrEqual(300)
273
+ })
274
+ it('horizontal bars flip: a value band is vertical, a category band is horizontal', () => {
275
+ const geo = buildChart({
276
+ type: 'bar',
277
+ orientation: 'horizontal',
278
+ categories: ['a', 'b', 'c'],
279
+ series: [{ label: 's', values: [1, 2, 3] }],
280
+ referenceBands: [{ from: 1, to: 2 }, { axis: 'x', from: 'a', to: 'b' }],
281
+ referenceLines: [{ axis: 'x', value: 'c' }],
282
+ width: 400,
283
+ height: 200,
284
+ })
285
+ const [v, c] = geo.referenceBands
286
+ expect(v!.axis).toBe('x')
287
+ expect(v!.h).toBe(geo.plot.h)
288
+ expect(c!.axis).toBe('y')
289
+ expect(c!.w).toBe(geo.plot.w)
290
+ expect(geo.referenceLines).toHaveLength(1)
291
+ })
292
+ })
293
+
294
+ describe('title, subtitle and caption', () => {
295
+ const TYPES: ChartType[] = ['bar', 'line', 'area', 'pie', 'scatter', 'heatmap', 'waterfall', 'funnel', 'radar', 'calendar', 'gauge', 'treemap', 'sankey', 'candlestick', 'boxplot']
296
+ const fixture = (type: ChartType): ChartSpec => {
297
+ const base: ChartSpec = { type, categories: ['a', 'b', 'c'], series: [{ label: 's', values: [1, 2, 3] }], width: 400, height: 240, title: 'Title', subtitle: 'Sub', caption: 'Source: tests' }
298
+ if (type === 'scatter') base.series = [{ label: 's', values: [], points: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }]
299
+ if (type === 'calendar') base.calendarValues = [{ date: '2026-01-05', value: 1 }, { date: '2026-02-10', value: 2 }]
300
+ if (type === 'gauge') base.gaugeValue = 40
301
+ if (type === 'treemap') base.treemap = { name: 'root', children: [{ name: 'a', value: 1 }, { name: 'b', value: 2 }] }
302
+ if (type === 'sankey') { base.sankeyNodes = [{ id: 'a' }, { id: 'b' }]; base.sankeyLinks = [{ source: 'a', target: 'b', value: 3 }] }
303
+ if (type === 'candlestick') base.series = [{ label: 's', values: [2, 3, 4], ohlc: [{ o: 1, h: 3, l: 1, c: 2 }, { o: 2, h: 4, l: 2, c: 3 }, { o: 3, h: 5, l: 2, c: 4 }] }]
304
+ if (type === 'boxplot') base.series = [{ label: 's', values: [2, 3, 4], boxes: [{ min: 1, q1: 1.5, median: 2, q3: 2.5, max: 3 }, { min: 2, q1: 2.5, median: 3, q3: 3.5, max: 4 }, { min: 3, q1: 3.5, median: 4, q3: 4.5, max: 5 }] }]
305
+ return base
306
+ }
307
+ it('reserves room above and below on every chart type', () => {
308
+ for (const type of TYPES) {
309
+ const plain = buildChart({ ...fixture(type), title: undefined, subtitle: undefined, caption: undefined })
310
+ const framed = buildChart(fixture(type))
311
+ expect(framed.frame.top, `${type} frame.top`).toBeGreaterThan(0)
312
+ expect(framed.frame.bottom, `${type} frame.bottom`).toBeGreaterThan(0)
313
+ expect(framed.frame.title?.text).toBe('Title')
314
+ expect(framed.frame.subtitle?.text).toBe('Sub')
315
+ expect(framed.frame.caption?.text).toBe('Source: tests')
316
+ expect(plain.frame.top).toBe(0)
317
+ // The plot (or, for the radial types, the centre) moves down by the
318
+ // reserved room, so a title never sits on top of the marks.
319
+ if (framed.plot.h !== framed.height) {
320
+ expect(framed.plot.y, `${type} plot.y`).toBeGreaterThanOrEqual(plain.plot.y + framed.frame.top)
321
+ }
322
+ // The radial types recentre inside the smaller box, so what matters is
323
+ // that the top of the disc clears the reserved room.
324
+ if (type === 'pie') expect(framed.donut === null ? framed.slices[0]!.cy : framed.donut.cy).toBeGreaterThan(framed.frame.top)
325
+ if (type === 'gauge') expect(framed.gauge!.cy - framed.gauge!.r).toBeGreaterThanOrEqual(framed.frame.top)
326
+ }
327
+ })
328
+ it('the title is centred and the caption sits bottom-left', () => {
329
+ const geo = buildChart(line({ title: 'T', caption: 'C' }))
330
+ expect(geo.frame.title!.x).toBe(200)
331
+ expect(geo.frame.caption!.x).toBeLessThan(20)
332
+ expect(geo.frame.caption!.y).toBeGreaterThan(geo.plot.y + geo.plot.h)
333
+ })
334
+ })
335
+
336
+ describe('missing values, steps and markers', () => {
337
+ const gappy = (extra: Partial<ChartSpec['series'][number]> = {}, spec: Partial<ChartSpec> = {}): ChartSpec => ({
338
+ type: 'line',
339
+ categories: ['a', 'b', 'c', 'd'],
340
+ series: [{ label: 's', values: [1, Number.NaN, 3, 4], ...extra }],
341
+ width: 400,
342
+ height: 200,
343
+ ...spec,
344
+ })
345
+ it('a gap breaks the path by default', () => {
346
+ const geo = buildChart(gappy())
347
+ expect((geo.lines[0]!.path.match(/M/g) ?? []).length).toBe(2)
348
+ expect(geo.lines[0]!.points[1]!.defined).toBe(false)
349
+ })
350
+ it('connectNulls bridges the gap and still draws no marker there', () => {
351
+ const geo = buildChart(gappy({ connectNulls: true }))
352
+ expect((geo.lines[0]!.path.match(/M/g) ?? []).length).toBe(1)
353
+ expect(geo.lines[0]!.points[1]!.defined).toBe(false)
354
+ })
355
+ it('nullAs: zero plots the gap at 0, on the series or on the spec', () => {
356
+ const a = buildChart(gappy({ nullAs: 'zero' }))
357
+ expect(a.lines[0]!.points[1]).toMatchObject({ defined: true, value: 0 })
358
+ const b = buildChart(gappy({}, { nullAs: 'zero' }))
359
+ expect(b.lines[0]!.points[1]).toMatchObject({ defined: true, value: 0 })
360
+ // and the area / bar kinds see the same zero
361
+ const c = buildChart({ ...gappy({}, { nullAs: 'zero' }), type: 'bar' })
362
+ expect(c.bars).toHaveLength(4)
363
+ })
364
+ it('step draws the corner point between neighbours', () => {
365
+ const geo = buildChart(gappy({ values: [1, 2, 3, 4], step: 'after' }))
366
+ const [p0, p1] = geo.lines[0]!.points
367
+ expect(geo.lines[0]!.path).toContain(`L${p1!.x},${p0!.y}`)
368
+ const mid = buildChart(gappy({ values: [1, 2, 3, 4], step: 'middle' }))
369
+ const [q0, q1] = mid.lines[0]!.points
370
+ const mx = Math.round(((q0!.x + q1!.x) / 2) * 100) / 100
371
+ expect(mid.lines[0]!.path).toContain(`L${mx},${q0!.y}`)
372
+ // step wins over smooth: no cubic segments
373
+ const both = buildChart(gappy({ values: [1, 2, 3, 4], step: 'before', smooth: true }))
374
+ expect(both.lines[0]!.path).not.toContain('C')
375
+ })
376
+ it('steps carry into the area fill', () => {
377
+ const geo = buildChart({ ...gappy({ values: [1, 2, 3, 4], step: 'after' }), type: 'area' })
378
+ expect(geo.lines[0]!.areaPath).toContain('Z')
379
+ const [p0, p1] = geo.lines[0]!.points
380
+ expect(geo.lines[0]!.areaPath).toContain(`L${p1!.x},${p0!.y}`)
381
+ })
382
+ it('a series marker and per-point overrides land on the points', () => {
383
+ const geo = buildChart(gappy({ values: [1, 2, 3, 4], marker: 'square', markers: [null, { shape: 'diamond', color: 'red' }, null, { shape: 'none' }] }))
384
+ const pts = geo.lines[0]!.points
385
+ expect(pts[0]!.marker).toEqual({ shape: 'square' })
386
+ expect(pts[1]!.marker).toEqual({ shape: 'diamond', color: 'red' })
387
+ expect(pts[3]!.marker).toEqual({ shape: 'none' })
388
+ expect(buildChart(gappy({ values: [1, 2, 3, 4] })).lines[0]!.points[0]!.marker).toBeUndefined()
389
+ })
390
+ it('per-point colours reach bars, points and scatter dots', () => {
391
+ const bars = buildChart({ ...gappy({ values: [1, 2, 3, 4], colors: [null, 'red', null, null] }), type: 'bar' })
392
+ expect(bars.bars[1]!.color).toBe('red')
393
+ expect(bars.bars[0]!.color).not.toBe('red')
394
+ const pts = buildChart(gappy({ values: [1, 2, 3, 4], colors: [null, null, 'blue', null] }))
395
+ expect(pts.lines[0]!.points[2]!.marker).toEqual({ color: 'blue' })
396
+ const sc = buildChart({
397
+ type: 'scatter',
398
+ categories: [],
399
+ series: [{ label: 's', values: [], points: [{ x: 1, y: 1 }, { x: 2, y: 2 }], colors: ['green', null] }],
400
+ width: 300,
401
+ height: 200,
402
+ })
403
+ expect(sc.scatterPoints[0]!.color).toBe('green')
404
+ })
405
+ it('series style resolves onto the line', () => {
406
+ const geo = buildChart(gappy({ values: [1, 2, 3, 4], strokeWidth: 4, dash: [6, 2], opacity: 0.5, gradient: true }))
407
+ expect(geo.lines[0]!.style).toEqual({ strokeWidth: 4, dash: '6 2', opacity: 0.5, gradient: { from: geo.lines[0]!.color, to: 'transparent' } })
408
+ expect(buildChart(gappy()).lines[0]!.style).toBeUndefined()
409
+ const bar = buildChart({ ...gappy({ values: [1, 2, 3, 4], opacity: 0.3 }), type: 'bar' })
410
+ expect(bar.bars[0]!.opacity).toBe(0.3)
411
+ })
412
+ it('markerPath draws every shape but circle and none', () => {
413
+ expect(markerPath('circle', 0, 0, 3)).toBe('')
414
+ expect(markerPath('none', 0, 0, 3)).toBe('')
415
+ for (const s of ['square', 'diamond', 'triangle', 'cross'] as const) {
416
+ expect(markerPath(s, 10, 10, 3).startsWith('M')).toBe(true)
417
+ }
418
+ })
419
+ })
420
+
421
+ describe('x label rotation', () => {
422
+ it('auto tilts labels that do not fit their slot and reports the angle', () => {
423
+ const geo = buildChart(line({ categories: ['January 2026', 'February 2026', 'March 2026', 'April 2026'], width: 320 }))
424
+ expect(geo.xLabelRotated).toBe(true)
425
+ expect(geo.xLabelAngle).toBe(-40)
426
+ // With room for them they stay upright, so a wide chart never tilts short labels.
427
+ const roomy = buildChart(line({ categories: ['January 2026', 'February 2026', 'March 2026', 'April 2026'], width: 900 }))
428
+ expect(roomy.xLabelRotated).toBe(false)
429
+ })
430
+ it('an explicit angle is honoured and 0 never rotates', () => {
431
+ const geo = buildChart(line({ categories: ['January 2026', 'February 2026', 'March 2026', 'April 2026'], width: 320, xAxis: { labelRotation: -90 } }))
432
+ expect(geo.xLabelAngle).toBe(-90)
433
+ expect(geo.xLabelRotated).toBe(true)
434
+ const flat = buildChart(line({ categories: ['January 2026', 'February 2026', 'March 2026', 'April 2026'], width: 320, xAxis: { labelRotation: 0 } }))
435
+ expect(flat.xLabelRotated).toBe(false)
436
+ expect(flat.plot.h).toBeGreaterThan(geo.plot.h)
437
+ })
438
+ })
439
+
440
+ describe('log x axis', () => {
441
+ const spec = (extra: Partial<ChartSpec> = {}): ChartSpec => ({
442
+ type: 'line',
443
+ categories: ['1', '10', '100', '1000'],
444
+ series: [{ label: 'gain', values: [0, 3, 6, 9] }],
445
+ xAxis: { type: 'number', scale: 'log' },
446
+ width: 520,
447
+ height: 300,
448
+ ...extra,
449
+ })
450
+
451
+ it('places decades at equal pixel spacing and reports the log flag', () => {
452
+ const geo = buildChart(spec())
453
+ const xs = geo.lines[0]!.points.map((p) => p.x)
454
+ expect(xs[1]! - xs[0]!).toBeCloseTo(xs[2]! - xs[1]!, 0)
455
+ expect(xs[2]! - xs[1]!).toBeCloseTo(xs[3]! - xs[2]!, 0)
456
+ expect(geo.axes!.x!.log).toBe(true)
457
+ expect(geo.axes!.x!.min).toBe(1)
458
+ expect(geo.axes!.x!.max).toBe(1000)
459
+ })
460
+
461
+ it('labels the decades, with 2 and 5 minors under three decades, and drops non-positive categories', () => {
462
+ const geo = buildChart(spec())
463
+ expect(geo.xTicks.map((t) => t.label)).toEqual(['1', '10', '100', '1k'])
464
+ const two = buildChart(spec({ categories: ['1', '10', '100'], series: [{ label: 'g', values: [0, 1, 2] }] }))
465
+ expect(two.xTicks.map((t) => t.label)).toEqual(['1', '2', '5', '10', '20', '50', '100'])
466
+ const bad = buildChart(spec({ categories: ['0', '-5', '10', '100'], series: [{ label: 'g', values: [1, 2, 3, 4] }] }))
467
+ const pts = bad.lines[0]!.points
468
+ // A category at or below zero has no position on a log axis; the point
469
+ // is undefined and the line starts at the first positive one.
470
+ expect(pts[2]!.x).toBeLessThan(pts[3]!.x)
471
+ expect(bad.axes!.x!.min).toBe(10)
472
+ })
473
+
474
+ it('chartScales.xOfValue and xInvertValue invert each other through log10', () => {
475
+ const geo = buildChart(spec())
476
+ const sc = chartScales(geo)!
477
+ for (const v of [1, 3, 10, 55, 400, 1000]) expect(sc.xInvertValue(sc.xOfValue(v))).toBeCloseTo(v, 6)
478
+ // Halfway across the plot is the geometric middle, not the arithmetic one.
479
+ const mid = sc.xInvertValue(geo.plot.x + geo.plot.w / 2)
480
+ expect(mid).toBeCloseTo(Math.sqrt(1 * 1000), 3)
481
+ expect(Number.isNaN(sc.xOfValue(0))).toBe(true)
482
+ })
483
+
484
+ it('a vertical reference line at 50 lands between 10 and 100 by log distance, and min / max pin the ends', () => {
485
+ const geo = buildChart(spec({ referenceLines: [{ value: 50, axis: 'x', label: 'cut' }] }))
486
+ const x = geo.referenceLinesV[0]!.x
487
+ const [, p10, p100] = geo.lines[0]!.points
488
+ expect(x).toBeGreaterThan(p10!.x)
489
+ expect(x).toBeLessThan(p100!.x)
490
+ // log10(50) is 0.699 of the way from 10 to 100.
491
+ expect((x - p10!.x) / (p100!.x - p10!.x)).toBeCloseTo(Math.log10(5), 1)
492
+ const pinned = buildChart(spec({ xAxis: { type: 'number', scale: 'log', min: 0.1, max: 10000 } }))
493
+ expect(pinned.axes!.x!.min).toBe(0.1)
494
+ expect(pinned.axes!.x!.max).toBe(10000)
495
+ })
496
+
497
+ it('a scatter takes the same log x axis and skips points at or below zero', () => {
498
+ const geo = buildChart({
499
+ type: 'scatter',
500
+ categories: [],
501
+ series: [{ label: 'p', values: [], points: [{ x: 1, y: 1 }, { x: 10, y: 2 }, { x: 100, y: 3 }, { x: 0, y: 9 }] }],
502
+ xAxis: { scale: 'log' },
503
+ width: 520,
504
+ height: 300,
505
+ })
506
+ expect(geo.scatterPoints).toHaveLength(3)
507
+ const xs = geo.scatterPoints.map((d) => d.cx)
508
+ expect(xs[1]! - xs[0]!).toBeCloseTo(xs[2]! - xs[1]!, 0)
509
+ expect(geo.axes!.x!.log).toBe(true)
510
+ })
511
+
512
+ it('a histogram keeps a linear axis even when asked for log', () => {
513
+ const geo = buildChart(spec({ type: 'histogram', binEdges: [0, 10, 20, 30, 40], categories: ['5', '15', '25', '35'], series: [{ label: 'n', values: [1, 2, 3, 4] }] }))
514
+ expect(geo.axes!.x!.log).toBeUndefined()
515
+ })
516
+ })
517
+
518
+ describe('data labels: rotation and connectors', () => {
519
+ // Narrow bars with wide labels: every neighbour overlaps.
520
+ const crowded: ChartSpec = {
521
+ type: 'bar',
522
+ categories: Array.from({ length: 12 }, (_, i) => `c${i}`),
523
+ series: [{ label: 's', values: Array.from({ length: 12 }, (_, i) => 1000 + i) }],
524
+ width: 260,
525
+ height: 200,
526
+ }
527
+ const labels = (cfg: Parameters<typeof layoutDataLabels>[1]) => {
528
+ const geo = buildChart(crowded)
529
+ return layoutDataLabels(geo, { show: true, ...cfg }, (v) => String(v))
530
+ }
531
+
532
+ it('thins overlapping labels by default and keeps them all with connectors, pushed apart with leaders', () => {
533
+ const thinned = labels({})
534
+ expect(thinned.length).toBeLessThan(12)
535
+ const connected = labels({ connector: true })
536
+ expect(connected.length).toBeGreaterThan(thinned.length)
537
+ const pushed = connected.filter((l) => l.leader)
538
+ expect(pushed.length).toBeGreaterThan(0)
539
+ for (const l of pushed) {
540
+ // Pushed up from the mark, with the leader starting at the original spot.
541
+ expect(l.y).toBeLessThan(l.leader!.y1)
542
+ expect(l.leader!.x1).toBe(l.x)
543
+ }
544
+ // Nothing kept overlaps anything else.
545
+ const boxes = connected.map((l) => ({ x0: l.x - (l.text.length * 5.6 + 2) / 2, x1: l.x + (l.text.length * 5.6 + 2) / 2, y0: l.y - 10, y1: l.y + 1 }))
546
+ for (let a = 0; a < boxes.length; a += 1)
547
+ for (let b = a + 1; b < boxes.length; b += 1) {
548
+ const p = boxes[a]!, q = boxes[b]!
549
+ expect(p.x0 < q.x1 && p.x1 > q.x0 && p.y0 < q.y1 && p.y1 > q.y0).toBe(false)
550
+ }
551
+ })
552
+
553
+ it('rotation stamps the angle on every label and a steep angle turns the overlap box', () => {
554
+ const flat = labels({ hideOverlap: false })
555
+ expect(flat.every((l) => l.angle === undefined)).toBe(true)
556
+ const tilted = labels({ rotation: -45, hideOverlap: false })
557
+ expect(tilted.every((l) => l.angle === -45)).toBe(true)
558
+ // Standing labels are 10px wide, so twelve of them fit where flat ones did not.
559
+ expect(labels({ rotation: -90 }).length).toBe(12)
560
+ expect(labels({}).length).toBeLessThan(12)
561
+ })
562
+ })
563
+
564
+ describe('chartStyleVars', () => {
565
+ it('maps the five fields to custom properties and skips unset ones', () => {
566
+ expect(chartStyleVars(undefined)).toBe('')
567
+ expect(chartStyleVars({})).toBe('')
568
+ const vars = chartStyleVars({ background: '#111', textColor: '#eee', gridColor: '#333', fontSize: 15, fontFamily: 'Inter, sans-serif' })
569
+ expect(vars).toBe('--sg-chart-bg:#111;--sg-bg:#111;--sg-fg:#eee;--sg-muted:#eee;--sg-border:#333;--sg-chart-font-scale:1.25;font-family:Inter, sans-serif')
570
+ expect(chartStyleVars({ fontSize: 0 })).toBe('')
571
+ expect(chartStyleVars({ gridColor: 'red' })).toBe('--sg-border:red')
572
+ })
573
+ })