@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,286 @@
1
+ /**
2
+ * Flow layouts: waterfall, funnel and sankey.
3
+ */
4
+ import type { ChartBar, ChartLine, ChartAxisTick, ChartCategoryTick, ChartFunnelSegment, ChartSankeyNode, ChartSankeyLink, ChartGeometry, LayoutCtx } from './chart-types'
5
+ import { round, niceScale, formatChartValue, pickContrastText, DEFAULT_PALETTE } from './chart-scale'
6
+
7
+ // ---- Waterfall ----------------------------------------------------
8
+ // First series provides the values. Each non-total bar starts at the
9
+ // running cumulative sum; total bars (waterfallTotals[i]) span from 0 to
10
+ // that sum, and a total with a value of its own sets the sum to it first
11
+ // (the opening balance of a bridge, or a subtotal supplied by the caller).
12
+ // Color is derived from sign + total flag, with optional palette overrides
13
+ // via spec.waterfallColors.
14
+ /** @internal Lay out the waterfall family. Called by buildChart. */
15
+ export function layoutWaterfall(ctx: LayoutCtx): ChartGeometry {
16
+ const { spec, series, width, height, empty, frame, axes } = ctx
17
+ const src = series[0]
18
+ if (!src) return { ...empty }
19
+ const colors = spec.waterfallColors ?? {}
20
+ const positive = colors.positive ?? '#16a34a'
21
+ const negative = colors.negative ?? '#ef4444'
22
+ const total = colors.total ?? '#475569'
23
+
24
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
25
+ const padL = 48 + (axes.y.title ? 16 : 0)
26
+ const padR = 12
27
+ const padT = 10 + frame.top
28
+ const plotW = Math.max(1, width - padL - padR)
29
+ // The same rule as a bar chart: labels tilt when the widest does not fit
30
+ // its slot. Judged by count alone, nine categories on a 900px chart were
31
+ // flagged as rotated with no angle to go with it, and drew truncated.
32
+ const xLabelRotated = typeof axes.x.labelRotation === 'number' ? axes.x.labelRotation !== 0 : maxLabel * 6.2 + 8 > plotW / Math.max(1, spec.categories.length)
33
+ const xLabelAngle = typeof axes.x.labelRotation === 'number' ? axes.x.labelRotation : xLabelRotated ? -40 : 0
34
+ const padB = (xLabelRotated ? 54 : 28) + (axes.x.title ? 16 : 0) + frame.bottom
35
+ const plotH = Math.max(1, height - padT - padB)
36
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
37
+
38
+ // Compute the running cumulative + per-bar (from, to) pairs.
39
+ const totals = spec.waterfallTotals ?? []
40
+ const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
41
+ let cum = 0
42
+ spec.categories.forEach((_, i) => {
43
+ const v = src.values[i] ?? 0
44
+ const isTotal = !!totals[i]
45
+ if (isTotal) {
46
+ // An opening "Revenue 4300" is a total with a value: it anchors the
47
+ // running sum. Read as a computed subtotal it drew as zero and every
48
+ // bar after it hung below the axis.
49
+ if (Number.isFinite(v) && v !== 0) cum = v
50
+ pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
51
+ } else {
52
+ pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
53
+ cum += v
54
+ }
55
+ })
56
+ // Y-axis domain spans every visited level (including 0).
57
+ let dMin = 0, dMax = 0
58
+ for (const p of pairs) {
59
+ if (p.from < dMin) dMin = p.from
60
+ if (p.to < dMin) dMin = p.to
61
+ if (p.from > dMax) dMax = p.from
62
+ if (p.to > dMax) dMax = p.to
63
+ }
64
+ const dom = niceScale(dMin, dMax)
65
+ const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
66
+
67
+ const slotW = plotW / Math.max(1, spec.categories.length)
68
+ const barPad = slotW * 0.2
69
+ const barW = Math.max(1, slotW - barPad)
70
+ const bars: ChartBar[] = pairs.map((p, i) => {
71
+ const yTop = yOfW(Math.max(p.from, p.to))
72
+ const yBot = yOfW(Math.min(p.from, p.to))
73
+ const x = padL + slotW * i + barPad / 2
74
+ const color = p.isTotal ? total : p.value >= 0 ? positive : negative
75
+ return {
76
+ x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
77
+ color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value, index: i,
78
+ }
79
+ })
80
+ // Thin connector lines between bar tops -> running total reads cleanly.
81
+ const connectors: ChartLine[] = [{
82
+ path: pairs
83
+ .map((p, i) => {
84
+ const x0 = padL + slotW * i + barPad / 2 + barW
85
+ const y = yOfW(p.to)
86
+ const x1 = padL + slotW * (i + 1) + barPad / 2
87
+ // Skip the final connector beyond the last bar.
88
+ return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
89
+ })
90
+ .filter(Boolean)
91
+ .join(' '),
92
+ areaPath: '',
93
+ color: 'var(--sg-muted, #94a3b8)',
94
+ label: '',
95
+ points: [],
96
+ }]
97
+ const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
98
+ label,
99
+ x: round(padL + slotW * i + slotW / 2),
100
+ }))
101
+ const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
102
+ value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
103
+ }))
104
+ return {
105
+ ...empty,
106
+ plot,
107
+ bars,
108
+ lines: connectors,
109
+ yTicks,
110
+ xTicks,
111
+ xLabelRotated,
112
+ xLabelAngle,
113
+ }
114
+ }
115
+
116
+ // ---- Funnel -------------------------------------------------------
117
+ // One series of strictly-decreasing values gets rendered as a stack
118
+ // of horizontal trapezoids: each level's width is proportional to its
119
+ // value relative to the largest, slope automatically links level N+1
120
+ // narrower than level N. Labels show value, conversion vs. top, and
121
+ // step drop-off.
122
+ /** @internal Lay out the funnel family. Called by buildChart. */
123
+ export function layoutFunnel(ctx: LayoutCtx): ChartGeometry {
124
+ const { spec, series, width, height, empty, frame } = ctx
125
+ const src = series[0]
126
+ if (!src || !src.values.length) return { ...empty }
127
+ const padL = 20, padR = 20, padT = 16 + frame.top, padB = 16 + frame.bottom
128
+ const plotW = Math.max(1, width - padL - padR)
129
+ const plotH = Math.max(1, height - padT - padB)
130
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
131
+ const n = src.values.length
132
+ const stepH = plotH / n
133
+ const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
134
+ const top = src.values[0] ?? 0
135
+ const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
136
+ const cx = padL + plotW / 2
137
+ const palette = spec.palette ?? DEFAULT_PALETTE
138
+ const shape = spec.funnelShape ?? 'trapezoid'
139
+ const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
140
+ const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
141
+ const y0 = padT + stepH * i
142
+ const y1 = y0 + stepH
143
+ let path: string
144
+ if (shape === 'pyramid') {
145
+ // Widest at the bottom: a pyramid of levels, each a slice of one big
146
+ // triangle. The top level is the point.
147
+ const wTop = (plotW * i) / n
148
+ const wBot = (plotW * (i + 1)) / n
149
+ path = `M${cx - wTop / 2},${y0} L${cx + wTop / 2},${y0} L${cx + wBot / 2},${y1} L${cx - wBot / 2},${y1} Z`
150
+ } else if (shape === 'cone') {
151
+ // One triangle, sliced: the width at each level follows its share of
152
+ // the height, so the whole funnel is a cone that tapers to a point.
153
+ const wTop = plotW * (1 - i / n)
154
+ const wBot = plotW * (1 - (i + 1) / n)
155
+ path = `M${cx - wTop / 2},${y0} L${cx + wTop / 2},${y0} L${cx + wBot / 2},${y1} L${cx - wBot / 2},${y1} Z`
156
+ } else {
157
+ const w0 = widthAt(v)
158
+ const w1 = widthAt(next)
159
+ path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
160
+ }
161
+ const color = src.color ?? palette[i % palette.length]!
162
+ return {
163
+ path, color,
164
+ label: spec.categories[i] ?? src.label,
165
+ value: v,
166
+ conversion: top > 0 ? v / top : 0,
167
+ dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
168
+ cx,
169
+ cy: (y0 + y1) / 2,
170
+ textColor: pickContrastText(color),
171
+ }
172
+ })
173
+ return {
174
+ ...empty,
175
+ plot,
176
+ funnelSegments: segments,
177
+ }
178
+ }
179
+
180
+ // ---- Sankey -------------------------------------------------------
181
+ // Multi-column flow layout. Each node assigned to a column by longest
182
+ // path from any source. Within a column, nodes are stacked vertically;
183
+ // height proportional to max(totalIn, totalOut). Links render as
184
+ // bezier ribbons whose width is the link value (in pixels).
185
+ /** @internal Lay out the sankey family. Called by buildChart. */
186
+ export function layoutSankey(ctx: LayoutCtx): ChartGeometry {
187
+ const { spec, width, height, empty, frame } = ctx
188
+ const nodes = spec.sankeyNodes ?? []
189
+ const links = spec.sankeyLinks ?? []
190
+ if (!nodes.length || !links.length) return { ...empty }
191
+ const padL = 10, padR = 10, padT = 14 + frame.top, padB = 14 + frame.bottom
192
+ const plotW = Math.max(1, width - padL - padR)
193
+ const plotH = Math.max(1, height - padT - padB)
194
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
195
+ const palette = spec.palette ?? DEFAULT_PALETTE
196
+ const nodeById = new Map(nodes.map((n) => [n.id, n]))
197
+ // Column = longest path from any node with no incoming edges.
198
+ const targets = new Set(links.map((l) => l.target))
199
+ const sources = nodes.filter((n) => !targets.has(n.id))
200
+ const column = new Map<string, number>()
201
+ function visit(id: string, depth: number, seen: Set<string>) {
202
+ if (seen.has(id)) return
203
+ seen.add(id)
204
+ const cur = column.get(id) ?? 0
205
+ if (depth > cur || !column.has(id)) column.set(id, depth)
206
+ for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
207
+ seen.delete(id)
208
+ }
209
+ for (const s of sources) visit(s.id, 0, new Set())
210
+ // Cover any nodes with no path from a source (orphan rings).
211
+ for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
212
+ const maxCol = Math.max(...column.values())
213
+ const cols = maxCol + 1
214
+ const nodeW = 14
215
+ const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
216
+ // Totals per node.
217
+ const totalIn = new Map<string, number>()
218
+ const totalOut = new Map<string, number>()
219
+ for (const l of links) {
220
+ totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
221
+ totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
222
+ }
223
+ // Per-column groups + max total in that column.
224
+ const byCol: Map<number, string[]> = new Map()
225
+ for (const n of nodes) {
226
+ const c = column.get(n.id) ?? 0
227
+ const arr = byCol.get(c) ?? []
228
+ arr.push(n.id); byCol.set(c, arr)
229
+ }
230
+ // Per-column total height + node height scale.
231
+ let maxColTotal = 0
232
+ for (const ids of byCol.values()) {
233
+ const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
234
+ if (t > maxColTotal) maxColTotal = t
235
+ }
236
+ if (maxColTotal === 0) return { ...empty, plot }
237
+ const nodeGapPx = 8
238
+ const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
239
+ const placed: ChartSankeyNode[] = []
240
+ for (const [c, ids] of byCol) {
241
+ const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
242
+ const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
243
+ let yCursor = padT + (plotH - totalH) / 2
244
+ const xCol = padL + c * (nodeW + gapBetweenColumns)
245
+ ids.forEach((id, idx) => {
246
+ const node = nodeById.get(id)!
247
+ const h = heights[idx]!
248
+ placed.push({
249
+ id,
250
+ label: node.label ?? id,
251
+ color: node.color ?? palette[(placed.length) % palette.length]!,
252
+ x: xCol, y: yCursor, w: nodeW, h,
253
+ column: c,
254
+ totalIn: totalIn.get(id) ?? 0,
255
+ totalOut: totalOut.get(id) ?? 0,
256
+ })
257
+ yCursor += h + nodeGapPx
258
+ })
259
+ }
260
+ const placedById = new Map(placed.map((n) => [n.id, n]))
261
+ // Per-node sub-cursor so multiple links from one node stack vertically.
262
+ const inCursor = new Map<string, number>()
263
+ const outCursor = new Map<string, number>()
264
+ const builtLinks: ChartSankeyLink[] = []
265
+ // Sort links so wider ribbons render first (so thin ribbons stack on top).
266
+ const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
267
+ for (const link of sortedLinks) {
268
+ const a = placedById.get(link.source)
269
+ const b = placedById.get(link.target)
270
+ if (!a || !b) continue
271
+ const linkH = Math.max(1, link.value * heightScale)
272
+ const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
273
+ const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
274
+ outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
275
+ inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
276
+ const x0 = a.x + a.w
277
+ const x1 = b.x
278
+ const mid = (x0 + x1) / 2
279
+ const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
280
+ builtLinks.push({
281
+ path, color: link.color ?? a.color, width: linkH,
282
+ source: link.source, target: link.target, value: link.value,
283
+ })
284
+ }
285
+ return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
286
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Number formatting for chart values: the compact `1.2k` tick label and
3
+ * `formatChartValue`, which honours a spec's `valueFormat`, `locale` and
4
+ * `currency`.
5
+ *
6
+ * A leaf of its own, and deliberately small. The chart engine and the lazily
7
+ * loaded summary (chart-summary.ts) both need it, and a bundler hoists
8
+ * whatever the base chunk and a lazy chunk share into a third chunk. When
9
+ * that shared module was all of chart-scale.ts the hoist cost the base
10
+ * bundle a kilobyte of gzip locality for no new code; two functions cost a
11
+ * few dozen bytes. chart-scale.ts re-exports both, so importers of the old
12
+ * home keep working.
13
+ */
14
+ import { getNumberFormatter } from './cell-formatting'
15
+ import type { ChartFormatLocale, ChartValueFormat } from './chart-types'
16
+
17
+ /** @internal Locale-free compact tick label (1.2k / 1.2M). */
18
+ export function fmtTick(n: number): string {
19
+ const abs = Math.abs(n)
20
+ if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
21
+ if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
22
+ return String(Math.round(n * 100) / 100)
23
+ }
24
+
25
+ /**
26
+ * Format a numeric value for display, honouring an optional `valueFormat`.
27
+ *
28
+ * Two modes, on purpose. With no `locale` and no `currency` this is the original
29
+ * locale-free output: the compact `1.2k` / `1.2M` base, currency prefixed with
30
+ * `$` (sign outside), percent multiplied by 100 and suffixed `%`. Set either one
31
+ * and it switches to `Intl.NumberFormat`, so separators, the decimal mark and the
32
+ * compact suffixes all follow the locale.
33
+ *
34
+ * Keeping the old path as the default is deliberate rather than lazy. `Intl`'s
35
+ * compact notation is not the same string even for `en-US` (`1.2K`, capital),
36
+ * so formatting everything through it would silently restyle every axis in every
37
+ * existing chart. Opting in is the only version of this that is not a surprise.
38
+ */
39
+ export function formatChartValue(
40
+ n: number,
41
+ format?: ChartValueFormat,
42
+ opts?: ChartFormatLocale,
43
+ ): string {
44
+ if (!Number.isFinite(n)) return ''
45
+ const localized = opts && (opts.locale || opts.currency)
46
+ if (localized) {
47
+ // Compact notation because these are axis ticks and data labels, where a
48
+ // full-precision number is what makes an axis unreadable.
49
+ const style = format === 'currency' ? 'currency' : format === 'percent' ? 'percent' : 'decimal'
50
+ const o: Intl.NumberFormatOptions = { notation: 'compact', maximumFractionDigits: 1 }
51
+ if (style === 'currency') {
52
+ o.style = 'currency'
53
+ // `style: 'currency'` throws without a code, so fall back to the symbol
54
+ // this used to hard-code rather than refusing to draw the chart.
55
+ o.currency = opts!.currency || 'USD'
56
+ } else if (style === 'percent') {
57
+ o.style = 'percent'
58
+ }
59
+ return getNumberFormatter(opts!.locale, o).format(n)
60
+ }
61
+ if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
62
+ if (format === 'percent') {
63
+ const p = n * 100
64
+ return `${Math.round(p * 10) / 10}%`
65
+ }
66
+ return fmtTick(n)
67
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Grid layouts: heatmap and the calendar heatmap.
3
+ */
4
+ import type { ChartAxisTick, ChartCategoryTick, ChartCalendarCell, ChartHeatmapCell, ChartGeometry, LayoutCtx } from './chart-types'
5
+ import { round, formatChartValue, resolveColorScale, sampleGradient, pickContrastText, thinCategoryTicks } from './chart-scale'
6
+
7
+ // ---- Calendar heatmap --------------------------------------------
8
+ // GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
9
+ // cell is a small square shaded by `calendarValues[i].value` via the
10
+ // sequential color scale. Days with no value render blank (border only)
11
+ // so missing data is visually obvious.
12
+ /** @internal Lay out the calendar family. Called by buildChart. */
13
+ export function layoutCalendar(ctx: LayoutCtx): ChartGeometry {
14
+ const { spec, width, height, theme, empty, frame } = ctx
15
+ const values = spec.calendarValues ?? []
16
+ if (!values.length && !spec.calendarStart) return { ...empty }
17
+ // Build a value lookup + figure out the date range.
18
+ const valueByDate = new Map<string, number>()
19
+ let vMin = Infinity, vMax = -Infinity
20
+ for (const v of values) {
21
+ valueByDate.set(v.date, v.value)
22
+ if (Number.isFinite(v.value)) {
23
+ if (v.value < vMin) vMin = v.value
24
+ if (v.value > vMax) vMax = v.value
25
+ }
26
+ }
27
+ if (vMin === Infinity) { vMin = 0; vMax = 1 }
28
+ if (vMin === vMax) vMax = vMin + 1
29
+ const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
30
+ const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
31
+ // Determine date range. If calendarStart/End set, use them, otherwise
32
+ // span the data + round to whole weeks (Sun..Sat).
33
+ const sorted = values.map((v) => v.date).sort()
34
+ const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
35
+ const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
36
+ const start = new Date(startStr + 'T00:00:00Z')
37
+ const end = new Date(endStr + 'T00:00:00Z')
38
+ // Roll start back to its Sunday, end forward to its Saturday.
39
+ start.setUTCDate(start.getUTCDate() - start.getUTCDay())
40
+ end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
41
+ const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
42
+ const weeks = Math.ceil(totalDays / 7)
43
+ const padL = 36, padR = 80, padT = 26 + frame.top, padB = 16 + frame.bottom
44
+ const plotW = Math.max(1, width - padL - padR)
45
+ const plotH = Math.max(1, height - padT - padB)
46
+ // Cell size: fit weeks across width, 7 rows down height.
47
+ const cellW = Math.floor(plotW / weeks)
48
+ const cellH = Math.floor(plotH / 7)
49
+ const cellSize = Math.max(6, Math.min(cellW, cellH))
50
+ const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
51
+ const cells: ChartCalendarCell[] = []
52
+ let lastMonth = -1
53
+ const monthTicks: ChartCategoryTick[] = []
54
+ for (let i = 0; i < totalDays; i += 1) {
55
+ const day = new Date(start.getTime() + i * 86_400_000)
56
+ const col = Math.floor(i / 7)
57
+ const row = i % 7
58
+ const date = day.toISOString().slice(0, 10)
59
+ const has = valueByDate.has(date)
60
+ const v = valueByDate.get(date) ?? 0
61
+ cells.push({
62
+ x: padL + col * cellSize,
63
+ y: padT + row * cellSize,
64
+ size: cellSize,
65
+ date, value: v,
66
+ defined: has,
67
+ color: has ? colorAt(v) : 'transparent',
68
+ })
69
+ // A month label only for a month inside the range: the end is rolled
70
+ // forward to its Saturday, and a December that ends on a Thursday used
71
+ // to pick up a "Jan" for the two padding days of the next year.
72
+ if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth && date >= startStr && date <= endStr) {
73
+ lastMonth = day.getUTCMonth()
74
+ monthTicks.push({
75
+ label: day.toLocaleDateString(undefined, { month: 'short' }),
76
+ x: padL + col * cellSize,
77
+ })
78
+ }
79
+ }
80
+ const legend = Array.from({ length: 5 }, (_, i) => {
81
+ const t = i / 4
82
+ const value = legendValue(vMin, vMax, t)
83
+ return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
84
+ })
85
+ return {
86
+ ...empty,
87
+ plot,
88
+ calendarCells: cells,
89
+ calendarMonthTicks: monthTicks,
90
+ calendarLegend: legend,
91
+ }
92
+ }
93
+
94
+ // ---- Heatmap ------------------------------------------------------
95
+ // Each series is one row, series.values are the cells across categories.
96
+ // Color comes from a sequential/diverging/custom palette mapped to the
97
+ // global value range. Cell text contrasts black/white against the cell.
98
+ /**
99
+ * A legend step between the extremes, rounded to the precision the range
100
+ * earns: whole numbers when the steps are a unit or more apart, so a count
101
+ * legend reads "20, 94, 167" rather than "20, 93.5, 167", and enough
102
+ * decimals to tell the steps apart when they are not.
103
+ */
104
+ function legendValue(vMin: number, vMax: number, t: number): number {
105
+ const raw = vMin + (vMax - vMin) * t
106
+ const step = Math.abs(vMax - vMin) / 4
107
+ if (!(step > 0)) return raw
108
+ const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step)) + 1)
109
+ return Number(raw.toFixed(decimals))
110
+ }
111
+
112
+ /** @internal Lay out the heatmap family. Called by buildChart. */
113
+ export function layoutHeatmap(ctx: LayoutCtx): ChartGeometry {
114
+ const { spec, series, width, height, theme, empty, frame } = ctx
115
+ if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
116
+ // Layout: left gutter for row labels, bottom for column labels.
117
+ const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
118
+ const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
119
+ const padR = 64 // room for the right-side legend bar
120
+ const padT = 12 + frame.top
121
+ const padB = 32 + frame.bottom
122
+ const plotW = Math.max(1, width - padL - padR)
123
+ const plotH = Math.max(1, height - padT - padB)
124
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
125
+ const cellW = plotW / spec.categories.length
126
+ const cellH = plotH / series.length
127
+ // Resolve value range across the whole matrix.
128
+ let vMin = Infinity, vMax = -Infinity
129
+ for (const s of series) for (const v of s.values) {
130
+ if (!Number.isFinite(v)) continue
131
+ if (v < vMin) vMin = v
132
+ if (v > vMax) vMax = v
133
+ }
134
+ if (vMin === Infinity) { vMin = 0; vMax = 1 }
135
+ if (vMin === vMax) vMax = vMin + 1
136
+ // Pick the palette stops.
137
+ const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
138
+ const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
139
+ const heatmapCells: ChartHeatmapCell[] = []
140
+ series.forEach((s, ri) => {
141
+ s.values.forEach((v, ci) => {
142
+ if (!Number.isFinite(v)) return
143
+ const color = colorAt(v)
144
+ heatmapCells.push({
145
+ x: round(padL + cellW * ci),
146
+ y: round(padT + cellH * ri),
147
+ w: round(cellW),
148
+ h: round(cellH),
149
+ color,
150
+ textColor: pickContrastText(color),
151
+ value: v,
152
+ rowLabel: s.label,
153
+ colLabel: spec.categories[ci] ?? '',
154
+ })
155
+ })
156
+ })
157
+ const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
158
+ value: i,
159
+ y: round(padT + cellH * i + cellH / 2),
160
+ label: s.label,
161
+ }))
162
+ // Column labels thin out the way a bar chart's do: twenty-four hours at
163
+ // 33px a cell wrote "00:0001:0002:00" across the foot of the matrix.
164
+ const heatmapColTicks: ChartCategoryTick[] = thinCategoryTicks(spec.categories, (i) => round(padL + cellW * i + cellW / 2), cellW, false)
165
+ // Legend: sample 5 stops across the range.
166
+ const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
167
+ const t = i / 4
168
+ const value = legendValue(vMin, vMax, t)
169
+ return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
170
+ })
171
+ return {
172
+ ...empty,
173
+ plot,
174
+ heatmapCells,
175
+ heatmapRowTicks,
176
+ heatmapColTicks,
177
+ heatmapLegend,
178
+ }
179
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Hierarchy layouts: the squarified tree-map.
3
+ */
4
+ import type { ChartTreemapCell, TreeNode, ChartGeometry, LayoutCtx } from './chart-types'
5
+ import { round, pickContrastText, DEFAULT_PALETTE } from './chart-scale'
6
+
7
+ // ---- Tree-map -----------------------------------------------------
8
+ // Squarified tree-map (Bruls et al. 2000): each level recursively
9
+ // partitions its rectangle in proportion to its children, picking the
10
+ // split orientation that keeps aspect ratios closest to 1.
11
+ /** @internal Lay out the treemap family. Called by buildChart. */
12
+ export function layoutTreemap(ctx: LayoutCtx): ChartGeometry {
13
+ const { spec, width, height, empty, frame } = ctx
14
+ const root = spec.treemap ?? spec.tree
15
+ if (!root) return { ...empty }
16
+ const padL = 4, padR = 4, padT = 4 + frame.top, padB = 4 + frame.bottom
17
+ const plotW = Math.max(1, width - padL - padR)
18
+ const plotH = Math.max(1, height - padT - padB)
19
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
20
+ const palette = spec.palette ?? DEFAULT_PALETTE
21
+ const cells: ChartTreemapCell[] = []
22
+ function totalOf(n: TreeNode): number {
23
+ if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
24
+ return Math.max(0, n.value ?? 0)
25
+ }
26
+ function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
27
+ if (!items.length || w <= 0 || h <= 0) return
28
+ const totals = items.map(totalOf)
29
+ const sum = totals.reduce((a, b) => a + b, 0)
30
+ if (sum <= 0) return
31
+ // Process largest-first so big items dominate the first row.
32
+ const ordered = items
33
+ .map((n, i) => ({ node: n, value: totals[i]! }))
34
+ .sort((a, b) => b.value - a.value)
35
+ let cx = x, cy = y, cw = w, ch = h, remaining = sum
36
+ let row: typeof ordered = []
37
+ const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
38
+ if (rowSum <= 0) return Infinity
39
+ const rowArea = rowSum * scale
40
+ const rowSide = rowArea / shortSide
41
+ let worst = 0
42
+ for (const v of vals) {
43
+ const cell = v * scale
44
+ const long = cell / rowSide
45
+ const r = Math.max(shortSide / long, long / shortSide)
46
+ if (r > worst) worst = r
47
+ }
48
+ return worst
49
+ }
50
+ function flushRow() {
51
+ if (!row.length) return
52
+ const rowSum = row.reduce((a, b) => a + b.value, 0)
53
+ const scale = (cw * ch) / remaining
54
+ const horizontal = cw >= ch
55
+ const shortSide = horizontal ? ch : cw
56
+ const rowSide = (rowSum * scale) / shortSide
57
+ let offset = 0
58
+ for (const it of row) {
59
+ const cellSize = (it.value * scale) / rowSide
60
+ const cx2 = horizontal ? cx : cx + offset
61
+ const cy2 = horizontal ? cy + offset : cy
62
+ const ww = horizontal ? rowSide : cellSize
63
+ const hh = horizontal ? cellSize : rowSide
64
+ const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
65
+ // Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
66
+ if (it.node.children?.length) {
67
+ cells.push({
68
+ x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
69
+ color, textColor: pickContrastText(color),
70
+ name: it.node.name, value: it.value, depth,
71
+ })
72
+ const labelH = Math.min(18, hh * 0.25)
73
+ squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
74
+ } else {
75
+ cells.push({
76
+ x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
77
+ color, textColor: pickContrastText(color),
78
+ name: it.node.name, value: it.value, depth,
79
+ })
80
+ }
81
+ offset += cellSize
82
+ }
83
+ // Shrink the remaining strip.
84
+ if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
85
+ remaining -= rowSum
86
+ row = []
87
+ }
88
+ for (const it of ordered) {
89
+ const scale = (cw * ch) / remaining
90
+ const shortSide = Math.min(cw, ch)
91
+ const rowSum = row.reduce((a, b) => a + b.value, 0)
92
+ const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
93
+ const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
94
+ if (row.length && nextWorst > currWorst) {
95
+ flushRow()
96
+ }
97
+ row.push(it)
98
+ }
99
+ flushRow()
100
+ }
101
+ const seedItems = root.children ?? [root]
102
+ squarify(seedItems, padL, padT, plotW, plotH, 0)
103
+ return { ...empty, plot, treemapCells: cells }
104
+ }
105
+
106
+ /**
107
+ * The subtree at `path` (a list of node names from the root's children down),
108
+ * or the tree itself for an empty path. What a drilldown shows after each
109
+ * click: the clicked node becomes the root. Null when the path does not exist.
110
+ */
111
+ export function drillTree(tree: TreeNode, path: ReadonlyArray<string>): TreeNode | null {
112
+ let node: TreeNode | undefined = tree
113
+ for (const name of path) {
114
+ node = node?.children?.find((c) => c.name === name)
115
+ if (!node) return null
116
+ }
117
+ return node ?? null
118
+ }
119
+
120
+ /**
121
+ * The path (node names from the root's children) to the first node called
122
+ * `name`, depth first, or null. What a click on a leaf's label needs.
123
+ */
124
+ export function pathTo(tree: TreeNode, name: string): string[] | null {
125
+ const walk = (n: TreeNode, acc: string[]): string[] | null => {
126
+ for (const c of n.children ?? []) {
127
+ const next = [...acc, c.name]
128
+ if (c.name === name) return next
129
+ const deeper = walk(c, next)
130
+ if (deeper) return deeper
131
+ }
132
+ return null
133
+ }
134
+ return walk(tree, [])
135
+ }