@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,106 @@
1
+ /**
2
+ * A small sample spec per chart type, for a type gallery: the builder's
3
+ * thumbnails, the docs. One tiny seeded dataset, so every card draws the
4
+ * same numbers and the shapes are what differ. Lives in its own module and
5
+ * is imported only by the builder, so a grid that never opens it never pays.
6
+ */
7
+ import type { ChartSpec, ChartType } from './chart-types'
8
+ import { rowsToBoxSpec, rowsToChartSpec, rowsToHistogramSpec, rowsToRangeSpec, rowsToScatterSpec, specToCalendar, specToSankey, specToTreemap } from './chart'
9
+
10
+ type Order = { day: string; region: string; channel: string; units: number; cost: number; revenue: number }
11
+
12
+ let cached: Order[] | null = null
13
+ function rows(): Order[] {
14
+ if (cached) return cached
15
+ let seed = 7
16
+ const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
17
+ const regions = ['North', 'South', 'East', 'West']
18
+ const channels = ['Web', 'Retail', 'Partner']
19
+ const out: Order[] = []
20
+ const start = Date.UTC(2026, 0, 1)
21
+ for (let i = 0; i < 160; i += 1) {
22
+ const day = new Date(start + Math.floor(rnd() * 180) * 86_400_000).toISOString().slice(0, 10)
23
+ const units = 1 + Math.floor(rnd() * 12)
24
+ const price = 40 + rnd() * 160
25
+ const revenue = Math.round(units * price)
26
+ out.push({ day, region: regions[Math.floor(rnd() * 4)]!, channel: channels[Math.floor(rnd() * 3)]!, units, cost: Math.round(revenue * (0.5 + rnd() * 0.3)), revenue })
27
+ }
28
+ cached = out
29
+ return out
30
+ }
31
+
32
+ /** The label a type gallery shows for each type. */
33
+ export const CHART_TYPE_LABELS: Record<ChartType, string> = {
34
+ bar: 'Bar', line: 'Line', area: 'Area', pie: 'Pie', scatter: 'Scatter', heatmap: 'Heat map', waterfall: 'Waterfall',
35
+ funnel: 'Funnel', radar: 'Radar', calendar: 'Calendar', gauge: 'Gauge', treemap: 'Tree map', sankey: 'Sankey',
36
+ candlestick: 'Candlestick', ohlc: 'OHLC', boxplot: 'Box plot', histogram: 'Histogram', 'range-bar': 'Range bar',
37
+ 'range-area': 'Range area', lollipop: 'Lollipop', dumbbell: 'Dumbbell', pareto: 'Pareto', stream: 'Stream',
38
+ sunburst: 'Sunburst', 'radial-bar': 'Radial bar', 'radial-column': 'Radial column', nightingale: 'Nightingale',
39
+ chord: 'Chord', bullet: 'Bullet',
40
+ }
41
+
42
+ /**
43
+ * A complete, small spec of the given type, drawn from the shared sample
44
+ * dataset. Pass a `palette` to colour it like the chart it stands in for.
45
+ */
46
+ export function sampleChartSpec(type: ChartType, palette?: string[]): ChartSpec {
47
+ const data = rows()
48
+ const byRegion = rowsToChartSpec(data, { type: 'bar', category: 'region', value: 'revenue' })
49
+ const byRegionChannel = rowsToChartSpec(data, { type: 'bar', category: 'region', value: 'revenue', series: 'channel' })
50
+ const byMonth = rowsToChartSpec(data, { type: 'line', category: 'day', value: 'revenue', bucket: 'month' })
51
+ const byMonthChannel = rowsToChartSpec(data, { type: 'area', category: 'day', value: 'revenue', series: 'channel', bucket: 'month' })
52
+ const ranged = () => rowsToRangeSpec(
53
+ byRegion.categories.map((c, i) => ({ region: c, cost: byRegion.series[0]!.values[i]! * 0.6, revenue: byRegion.series[0]!.values[i]! })),
54
+ { category: 'region', low: 'cost', high: 'revenue' },
55
+ )
56
+ const ohlc = () => {
57
+ const v = byMonth.series[0]!.values
58
+ const bars = v.map((c, i) => { const o = i ? v[i - 1]! : c * 0.95; return { o, c, h: Math.max(o, c) * 1.04, l: Math.min(o, c) * 0.96 } })
59
+ return { categories: byMonth.categories, series: [{ label: 'Revenue', values: v, ohlc: bars }], xType: 'ordinal-time' as const }
60
+ }
61
+ let spec: ChartSpec
62
+ switch (type) {
63
+ case 'line': spec = { ...byMonthChannel, type: 'line' }; break
64
+ case 'area': spec = { ...byMonthChannel, type: 'area', stacked: true }; break
65
+ case 'lollipop': spec = { ...byRegion, type: 'lollipop' }; break
66
+ case 'dumbbell': spec = { ...ranged(), type: 'dumbbell' }; break
67
+ case 'range-bar': spec = ranged(); break
68
+ case 'range-area': spec = { ...byMonth, type: 'range-area', series: [{ label: 'Band', values: byMonth.series[0]!.values.map((v) => v * 1.15), lowValues: byMonth.series[0]!.values.map((v) => v * 0.85) }] }; break
69
+ case 'pareto': spec = { ...rowsToChartSpec(data, { type: 'bar', category: 'channel', value: 'units' }), type: 'pareto' }; break
70
+ case 'radial-column': spec = { ...byRegionChannel, type: 'radial-column', stacked: true }; break
71
+ case 'radial-bar': spec = { ...byRegion, type: 'radial-bar' }; break
72
+ case 'nightingale': spec = { ...byMonth, type: 'nightingale' }; break
73
+ case 'pie': spec = { ...byRegion, type: 'pie', innerRadius: 0.55 }; break
74
+ case 'treemap': spec = { ...byRegionChannel, type: 'treemap', treemap: specToTreemap(byRegionChannel) }; break
75
+ case 'sunburst': spec = { ...byRegionChannel, type: 'sunburst', tree: specToTreemap(byRegionChannel) }; break
76
+ case 'funnel': spec = { type: 'funnel', categories: ['Visits', 'Sign-ups', 'Trials', 'Paid'], series: [{ label: 'Users', values: [12400, 5100, 2300, 880] }] }; break
77
+ case 'waterfall': spec = { type: 'waterfall', categories: ['Start', 'Web', 'Retail', 'Partner', 'Returns', 'End'], series: [{ label: 'P&L', values: [120, 48, 31, 22, -19, 0] }], waterfallTotals: [true, false, false, false, false, true] }; break
78
+ case 'sankey': { const f = specToSankey(byRegionChannel); spec = { ...byRegionChannel, type: 'sankey', sankeyNodes: f.nodes, sankeyLinks: f.links }; break }
79
+ case 'chord': { const f = specToSankey(byRegionChannel); spec = { ...byRegionChannel, type: 'chord', sankeyNodes: f.nodes, sankeyLinks: f.links }; break }
80
+ case 'radar': spec = { ...byRegionChannel, type: 'radar' }; break
81
+ case 'heatmap': spec = { ...byRegionChannel, type: 'heatmap' }; break
82
+ case 'scatter': spec = rowsToScatterSpec(data.slice(0, 80), { x: 'cost', y: 'revenue', series: 'channel', r: 'units' }); break
83
+ case 'boxplot': spec = rowsToBoxSpec(data, { category: 'region', value: 'revenue' }); break
84
+ case 'histogram': spec = rowsToHistogramSpec(data, { value: 'revenue', bins: 10 }); break
85
+ case 'gauge': spec = { type: 'gauge', categories: [], series: [], gaugeValue: 72, gaugeMin: 0, gaugeMax: 100, gaugeTarget: 80, gaugeUnit: '%', gaugeRanges: [{ from: 0, to: 50, color: '#ef4444' }, { from: 50, to: 75, color: '#f59e0b' }, { from: 75, to: 100, color: '#16a34a' }] }; break
86
+ case 'bullet': spec = { type: 'bullet', categories: byRegion.categories, series: [{ label: 'Revenue', values: byRegion.series[0]!.values, targets: byRegion.series[0]!.values.map((v, i) => v * (0.9 + (i % 3) * 0.1)) }] }; break
87
+ case 'calendar': { const daily = rowsToChartSpec(data, { type: 'bar', category: 'day', value: 'units' }); spec = { ...daily, type: 'calendar', calendarValues: specToCalendar(daily) }; break }
88
+ case 'stream': spec = { ...byMonthChannel, type: 'stream', stacked: true, stackOffset: 'wiggle' }; break
89
+ case 'candlestick': spec = { type: 'candlestick', ...ohlc() }; break
90
+ case 'ohlc': spec = { type: 'ohlc', ...ohlc() }; break
91
+ case 'bar':
92
+ default: spec = { ...byRegionChannel, type: 'bar' }
93
+ }
94
+ return palette ? { ...spec, palette } : spec
95
+ }
96
+
97
+ /** A sample spec trimmed for a thumbnail: no titles, no axis labels, small. */
98
+ export function sampleChartThumb(type: ChartType, palette?: string[], width = 150, height = 92): ChartSpec {
99
+ const s = sampleChartSpec(type, palette)
100
+ return {
101
+ ...s, width, height, title: undefined, yAxisTitle: undefined, xAxisTitle: undefined, referenceLines: undefined,
102
+ xAxis: { ...(s.xAxis ?? {}), labels: false, title: undefined },
103
+ yAxis: { ...(s.yAxis ?? {}), labels: false, title: undefined },
104
+ y2Axis: { ...(s.y2Axis ?? {}), labels: false, title: undefined },
105
+ }
106
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Scales, ticks, number and date formatting, and the colour helpers the chart
3
+ * engine shares across every chart family. DOM-free.
4
+ */
5
+ import type { ChartCategoryTick, NiceScale } from './chart-types'
6
+
7
+ // Number formatting lives in its own leaf (see chart-format.ts for why) and
8
+ // is re-exported here so every importer of chart-scale keeps working.
9
+ export { fmtTick, formatChartValue } from './chart-format'
10
+
11
+ /** Series colours used when a {@link ChartSeries} sets none, in order. */
12
+ export const DEFAULT_PALETTE = [
13
+ '#2563eb',
14
+ '#16a34a',
15
+ '#f59e0b',
16
+ '#ef4444',
17
+ '#8b5cf6',
18
+ '#0ea5e9',
19
+ '#ec4899',
20
+ '#14b8a6',
21
+ ]
22
+
23
+ /** @internal Two-decimal rounding for SVG coordinates. */
24
+ export function round(n: number): number {
25
+ return Math.round(n * 100) / 100
26
+ }
27
+
28
+ /** @internal Nice-number rounding used by niceScale. */
29
+ export function niceNum(range: number, roundIt: boolean): number {
30
+ if (range <= 0) return 1
31
+ const exp = Math.floor(Math.log10(range))
32
+ const f = range / Math.pow(10, exp)
33
+ let nf: number
34
+ if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
35
+ else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
36
+ return nf * Math.pow(10, exp)
37
+ }
38
+
39
+ // ---- Color helpers for heatmap / pattern fills ----------------------
40
+
41
+ /** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
42
+ const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
43
+ /** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
44
+ const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
45
+ /** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
46
+ * above the dark grid surface instead of near-white, so empty / low cells read
47
+ * as "cold" rather than as glaring white rectangles. */
48
+ const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
49
+ const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
50
+
51
+ /** @internal Pick the colour-ramp stops for a heatmap / calendar. */
52
+ export function resolveColorScale(
53
+ scale: 'sequential' | 'diverging' | string[] | undefined,
54
+ vMin: number,
55
+ vMax: number,
56
+ theme: 'light' | 'dark' = 'light',
57
+ ): string[] {
58
+ if (Array.isArray(scale) && scale.length >= 2) return scale
59
+ const dark = theme === 'dark'
60
+ if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
61
+ return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
62
+ }
63
+ return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
64
+ }
65
+
66
+ /** Sample a hex color from an array of hex stops at fractional position t.
67
+ * Linearly interpolates between the two nearest stops in RGB space. */
68
+ export function sampleGradient(stops: string[], t: number): string {
69
+ if (!stops.length) return '#888'
70
+ const clamped = Math.max(0, Math.min(1, t))
71
+ if (stops.length === 1) return stops[0]!
72
+ const pos = clamped * (stops.length - 1)
73
+ const i = Math.floor(pos)
74
+ const frac = pos - i
75
+ const a = hexToRgb(stops[i]!)
76
+ const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
77
+ if (!a || !b) return stops[i] ?? '#888'
78
+ const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
79
+ const toHex = (n: number) => n.toString(16).padStart(2, '0')
80
+ return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
81
+ }
82
+
83
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
84
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
85
+ if (!m) return null
86
+ const n = parseInt(m[1]!, 16)
87
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
88
+ }
89
+
90
+ /** Pick a black or white text color that has the better contrast against
91
+ * the given background. Uses the WCAG relative-luminance heuristic. */
92
+ export function pickContrastText(bgHex: string): string {
93
+ const rgb = hexToRgb(bgHex)
94
+ if (!rgb) return '#0f172a'
95
+ const lin = (c: number) => {
96
+ const s = c / 255
97
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
98
+ }
99
+ const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
100
+ return L > 0.5 ? '#0f172a' : '#ffffff'
101
+ }
102
+
103
+ /** Pick the largest power of 10 that fits at the bottom of [min,max], and
104
+ * the smallest that covers the top, then enumerate decade boundaries. Used
105
+ * by log-scale axes (yScale: 'log'). */
106
+ export function niceLogScale(min: number, max: number): NiceScale {
107
+ // Only positive values are plottable on a log scale; callers should
108
+ // strip non-positive values before passing them in.
109
+ if (!Number.isFinite(min) || min <= 0) min = 1
110
+ if (!Number.isFinite(max) || max <= min) max = min * 10
111
+ const lo = Math.floor(Math.log10(min))
112
+ const hi = Math.ceil(Math.log10(max))
113
+ const ticks: number[] = []
114
+ for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
115
+ return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
116
+ }
117
+
118
+ /**
119
+ * @internal Tick values for a logarithmic axis between two positive bounds:
120
+ * every decade, plus the 2 and 5 of each decade when the range spans fewer
121
+ * than three, so a two-decade axis does not read as three lonely labels.
122
+ */
123
+ export function logTicks(min: number, max: number): number[] {
124
+ if (!(min > 0) || !(max > min)) return []
125
+ const lo = Math.floor(Math.log10(min))
126
+ const hi = Math.ceil(Math.log10(max))
127
+ const minors = hi - lo < 3 ? [2, 5] : []
128
+ const out: number[] = []
129
+ for (let p = lo; p <= hi; p += 1) {
130
+ const d = Math.pow(10, p)
131
+ for (const m of [1, ...minors]) {
132
+ const v = d * m
133
+ if (v >= min * 0.999 && v <= max * 1.001) out.push(v)
134
+ }
135
+ }
136
+ return out
137
+ }
138
+
139
+ /** @internal Map a value to a fractional position [0..1] across the axis domain.
140
+ * Pass the appropriate fn into projection code so linear / log share the
141
+ * same plumbing. Returns null for non-positive values on log. */
142
+ export function project(value: number, min: number, max: number, isLog: boolean): number | null {
143
+ if (!Number.isFinite(value)) return null
144
+ if (isLog) {
145
+ if (value <= 0 || min <= 0) return null
146
+ return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
147
+ }
148
+ return (value - min) / (max - min)
149
+ }
150
+
151
+ /** Round a [min,max] domain out to nice tick boundaries. */
152
+ export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
153
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
154
+ min = 0
155
+ max = 1
156
+ }
157
+ if (min === max) {
158
+ if (min === 0) max = 1
159
+ else {
160
+ min = Math.min(0, min)
161
+ max = Math.max(0, max)
162
+ }
163
+ if (min === max) max = min + 1
164
+ }
165
+ const range = niceNum(max - min, false)
166
+ const step = niceNum(range / Math.max(1, tickCount), true)
167
+ const nMin = Math.floor(min / step) * step
168
+ const nMax = Math.ceil(max / step) * step
169
+ const ticks: number[] = []
170
+ for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
171
+ return { min: nMin, max: nMax, step, ticks }
172
+ }
173
+
174
+ /**
175
+ * A value-axis domain that honours an axis config on top of the data extent:
176
+ * a pinned `min` / `max` (hard edges - marks past them are clipped), an exact
177
+ * `tickInterval`, a `tickCount`, and `nice: false` for the raw extent with
178
+ * evenly spaced ticks. With no config this is exactly {@link niceScale}.
179
+ */
180
+ export function axisScale(
181
+ dataMin: number,
182
+ dataMax: number,
183
+ axis: { min?: number; max?: number; nice?: boolean; tickCount?: number; tickInterval?: number } = {},
184
+ ): NiceScale {
185
+ let min = axis.min ?? dataMin
186
+ let max = axis.max ?? dataMax
187
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
188
+ min = Number.isFinite(min) ? min : 0
189
+ max = Number.isFinite(max) ? max : min + 1
190
+ }
191
+ const nice = axis.nice !== false
192
+ const interval = axis.tickInterval
193
+ if (interval && interval > 0 && Number.isFinite(interval)) {
194
+ // An explicit interval is the whole axis: the ends land on multiples of it
195
+ // (unless pinned) and every tick is one interval apart.
196
+ if (nice && axis.min == null) min = Math.floor(min / interval) * interval
197
+ if (nice && axis.max == null) max = Math.ceil(max / interval) * interval
198
+ if (max <= min) max = min + interval
199
+ const ticks: number[] = []
200
+ const first = Math.ceil(min / interval - 1e-9) * interval
201
+ // Cap the count so a tiny interval on a huge domain cannot hang the layout.
202
+ // `+ 0` turns the -0 that `Math.ceil` hands back at the origin into a plain 0.
203
+ for (let v = first, n = 0; v <= max + interval * 1e-6 && n < 1000; v += interval, n += 1) ticks.push(round(v) + 0)
204
+ return { min, max, step: interval, ticks }
205
+ }
206
+ if (nice) {
207
+ const ns = niceScale(min, max, axis.tickCount ?? 4)
208
+ if (axis.min == null && axis.max == null) return ns
209
+ // A pinned end wins over the rounding, and ticks past it go.
210
+ const lo = axis.min ?? ns.min
211
+ const hi = axis.max ?? ns.max
212
+ return { min: lo, max: hi, step: ns.step, ticks: ns.ticks.filter((t) => t >= lo - 1e-9 && t <= hi + 1e-9) }
213
+ }
214
+ if (max <= min) max = min + 1
215
+ const n = Math.max(1, Math.round(axis.tickCount ?? 4))
216
+ const step = (max - min) / n
217
+ const ticks = Array.from({ length: n + 1 }, (_, i) => round(min + step * i))
218
+ return { min, max, step, ticks }
219
+ }
220
+
221
+ /** @internal One day in milliseconds. */
222
+ export const DAY = 86_400_000
223
+ /**
224
+ * @internal Calendar-aligned tick timestamps across [min, max], at most about
225
+ * `target` of them.
226
+ *
227
+ * The unit is the finest of day, week (Mondays), month, quarter, year and
228
+ * multiples of years whose count fits the target, and every tick sits on a
229
+ * calendar boundary in UTC (the axis parses 'YYYY-MM-DD' categories as UTC
230
+ * midnight). The old version stepped a fixed 30 or 365 days from the epoch,
231
+ * so a tick labelled "2025" stood weeks away from New Year and a thirty-month
232
+ * axis carried two labels on a plot with room for eight.
233
+ */
234
+ export function dateTicks(tMin: number, tMax: number, target = 6): number[] {
235
+ if (!(tMax > tMin)) return [tMin]
236
+ // A little over the target beats dropping to the next unit, which halves
237
+ // or thirds the count: ten quarters on a plot with room for nine.
238
+ const want = Math.max(2, target) * 1.3
239
+ const span = tMax - tMin
240
+ // Steps as [days, months]: a year is twelve months, so multiples of twelve
241
+ // land on the first of January.
242
+ const steps: Array<[number, number]> = [[1, 0], [2, 0], [7, 0], [14, 0], [0, 1], [0, 3], [0, 6], [0, 12], [0, 24], [0, 60], [0, 120]]
243
+ let [d, m] = steps[steps.length - 1]!
244
+ for (const [sd, sm] of steps) {
245
+ if (span / (sd * DAY + sm * 30.44 * DAY) <= want) { d = sd; m = sm; break }
246
+ }
247
+ const from = new Date(tMin)
248
+ const out: number[] = []
249
+ if (d) {
250
+ let t = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate())
251
+ // Weekly steps sit on Mondays; UTC day 0 is Sunday.
252
+ if (d >= 7) t += ((8 - new Date(t).getUTCDay()) % 7) * DAY
253
+ while (t < tMin) t += d * DAY
254
+ for (; t <= tMax + 1; t += d * DAY) out.push(t)
255
+ } else {
256
+ let months = Math.ceil((from.getUTCFullYear() * 12 + from.getUTCMonth()) / m) * m
257
+ const at = (mo: number) => Date.UTC(Math.floor(mo / 12), mo % 12, 1)
258
+ if (at(months) < tMin) months += m
259
+ for (let t = at(months); t <= tMax + 1; months += m, t = at(months)) out.push(t)
260
+ }
261
+ return out.length < 2 ? [tMin, tMax] : out
262
+ }
263
+ /**
264
+ * Tick positions for an ordinal (evenly spaced) date axis, as INDICES into
265
+ * `times`.
266
+ *
267
+ * A time axis can put a tick anywhere, because x is a function of the
268
+ * timestamp. An ordinal axis cannot: x is a function of the index, so a tick
269
+ * has to land on a point that exists. This picks the first point of each
270
+ * calendar unit - day, week, month, year, whichever gets closest to `target`
271
+ * ticks without going over - so labels sit on real sessions and a weekend or a
272
+ * holiday never stretches the spacing.
273
+ */
274
+ export function ordinalDateTicks(times: number[], target = 6): number[] {
275
+ if (times.length <= 1) return times.length ? [0] : []
276
+ const keyOf: Record<string, (d: Date) => number | string> = {
277
+ day: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`,
278
+ // Weeks start on Monday: day 0 of the epoch was a Thursday, so +3 puts
279
+ // the boundary there (+4 put it on Sunday, and weekly ticks sat on Sundays).
280
+ week: (d) => Math.floor((Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / DAY + 3) / 7),
281
+ month: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}`,
282
+ year: (d) => d.getUTCFullYear(),
283
+ }
284
+ let chosen: number[] | null = null
285
+ for (const unit of ['day', 'week', 'month', 'year'] as const) {
286
+ const at: number[] = []
287
+ let prev: number | string | null = null
288
+ for (let i = 0; i < times.length; i += 1) {
289
+ const t = times[i]!
290
+ if (!Number.isFinite(t)) continue
291
+ const k = keyOf[unit]!(new Date(t))
292
+ if (k !== prev) at.push(i)
293
+ prev = k
294
+ }
295
+ chosen = at
296
+ if (at.length <= target * 2) break
297
+ }
298
+ let out = chosen ?? []
299
+ // Even the coarsest unit can be too dense (a decade of yearly points), and a
300
+ // single trading day yields one boundary. Thin, or fall back to plain strides.
301
+ if (out.length > target) {
302
+ const stride = Math.ceil(out.length / target)
303
+ out = out.filter((_, i) => i % stride === 0)
304
+ }
305
+ if (out.length < 2) {
306
+ const stride = Math.max(1, Math.ceil(times.length / target))
307
+ out = times.map((_, i) => i).filter((i) => i % stride === 0)
308
+ }
309
+ return out
310
+ }
311
+
312
+ /** @internal Date label sized to the gap between ticks: days and weeks read
313
+ * "Jan 5", months and quarters "Jun 25", years "2025". Read in UTC: the axis
314
+ * parses 'YYYY-MM-DD' as UTC midnight, and a local-time read of that in the
315
+ * Americas labelled every tick with the day before. Sized to the tick step
316
+ * rather than the axis span, so a thirty-month axis on quarters does not
317
+ * read "2024, 2024, 2024, 2025". */
318
+ export function fmtDate(t: number, step: number): string {
319
+ const d = new Date(t)
320
+ if (step < 28 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
321
+ if (step < 360 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit', timeZone: 'UTC' })
322
+ return String(d.getUTCFullYear())
323
+ }
324
+
325
+ /**
326
+ * @internal A category on a date axis, written out for a tooltip or a
327
+ * crosshair pill: "Jun 2025" when every category is the first of a month,
328
+ * "2025" when every one is New Year, otherwise "Jun 1, 2025". `grain` is
329
+ * what {@link dateGrain} found for the axis.
330
+ */
331
+ export function fmtDateFull(t: number, grain: 'day' | 'month' | 'year' = 'day'): string {
332
+ const d = new Date(t)
333
+ if (grain === 'year') return String(d.getUTCFullYear())
334
+ if (grain === 'month') return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC' })
335
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })
336
+ }
337
+
338
+ /** @internal The calendar grain a list of UTC timestamps sits on. */
339
+ export function dateGrain(times: number[]): 'day' | 'month' | 'year' {
340
+ let month = true, year = true, any = false
341
+ for (const t of times) {
342
+ if (!Number.isFinite(t)) continue
343
+ any = true
344
+ const d = new Date(t)
345
+ if (d.getUTCDate() !== 1 || d.getUTCHours() !== 0) { month = false; year = false; break }
346
+ if (d.getUTCMonth() !== 0) year = false
347
+ }
348
+ return !any ? 'day' : year ? 'year' : month ? 'month' : 'day'
349
+ }
350
+
351
+ /**
352
+ * @internal Category-axis labels, thinned so they do not overlap.
353
+ *
354
+ * A category axis used to emit one tick per category however many there were.
355
+ * At 5000 categories that is 5000 `<text>` nodes stacked into an unreadable
356
+ * grey band - the labels were the single biggest thing the renderer had to put
357
+ * in the DOM, and none of them could be read. The time and ordinal-time axes
358
+ * already thinned themselves (`dateTicks` / `ordinalDateTicks`); this brings
359
+ * the plain category axis in line.
360
+ *
361
+ * The first category always gets a label, so a thinned axis still starts where
362
+ * the data does.
363
+ */
364
+ export function thinCategoryTicks(
365
+ categories: string[],
366
+ xCenter: (i: number) => number,
367
+ slot: number,
368
+ rotated: boolean,
369
+ ): ChartCategoryTick[] {
370
+ // Rotated labels run diagonally and pack far tighter than upright ones. 18px
371
+ // is deliberately just under the spacing a 40-category chart at 800px already
372
+ // had, so charts that read fine before are untouched and only genuinely
373
+ // overlapping axes get thinned.
374
+ const minPx = rotated ? 18 : 60
375
+ const step = Math.max(1, Math.ceil(minPx / Math.max(slot, 0.001)))
376
+ const out: ChartCategoryTick[] = []
377
+ for (let i = 0; i < categories.length; i += step) out.push({ label: categories[i]!, x: xCenter(i) })
378
+ return out
379
+ }
380
+
381
+ /** @internal Normalize one `errors` entry to an absolute low/high pair around `value`. */
382
+ export function errorSpan(
383
+ e: number | { lo: number; hi: number } | null | undefined,
384
+ value: number,
385
+ ): { lo: number; hi: number } | null {
386
+ if (e == null) return null
387
+ if (typeof e === 'number') {
388
+ if (!Number.isFinite(e)) return null
389
+ const m = Math.abs(e)
390
+ return { lo: value - m, hi: value + m }
391
+ }
392
+ if (!Number.isFinite(e.lo) || !Number.isFinite(e.hi)) return null
393
+ return { lo: Math.min(e.lo, e.hi), hi: Math.max(e.lo, e.hi) }
394
+ }