@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,674 @@
1
+ /**
2
+ * Polar layouts: pie / donut, radar, the gauge dial, and the arc family
3
+ * (sunburst, radial bar / column, nightingale, chord).
4
+ */
5
+ import type { ChartArc, ChartChordRibbon, ChartRadarAxis, ChartRadarSeries, ChartGaugeLayout, ChartPieSlice, ChartGeometry, LayoutCtx, TreeNode } from './chart-types'
6
+ import { round, DEFAULT_PALETTE, axisScale, pickContrastText, sampleGradient } from './chart-scale'
7
+
8
+ // ---- Radar --------------------------------------------------------
9
+ // Polar coordinates: each `category` is a spoke (axis); each `series`
10
+ // contributes a polygon connecting its values across the spokes. All
11
+ // series share the same scale (max across every value). Concentric
12
+ // ring count derived from data, capped at 5 for legibility.
13
+ /** @internal Lay out the radar family. Called by buildChart. */
14
+ export function layoutRadar(ctx: LayoutCtx): ChartGeometry {
15
+ const { spec, series, width, height, empty, frame } = ctx
16
+ if (!series.length || !spec.categories.length) return { ...empty }
17
+ const padL = 30, padR = 30, padT = 24 + frame.top, padB = 24 + frame.bottom
18
+ const plotW = Math.max(1, width - padL - padR)
19
+ const plotH = Math.max(1, height - padT - padB)
20
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
21
+ const cx = padL + plotW / 2
22
+ const cy = padT + plotH / 2
23
+ const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20)
24
+ const axes = spec.categories
25
+ const k = axes.length
26
+ let vMax = 0
27
+ for (const s of series) for (const v of s.values) {
28
+ if (Number.isFinite(v) && v > vMax) vMax = v
29
+ }
30
+ if (vMax === 0) vMax = 1
31
+ // The rim is the data maximum unless `yAxis.min` / `max` pin it, the way
32
+ // every value axis is pinned: scores out of 100 then fill the dial to the
33
+ // same rim on every chart, and a point past the rim sits on it.
34
+ const vMin = spec.yAxis?.min ?? 0
35
+ const vTop = spec.yAxis?.max ?? Math.max(vMax, vMin + 1)
36
+ const span = vTop > vMin ? vTop - vMin : 1
37
+ const ringCount = 5
38
+ const ringValues = Array.from({ length: ringCount }, (_, i) => vMin + ((i + 1) / ringCount) * span)
39
+ /** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
40
+ * proceed clockwise so axes lay out left-to-right when k <= 4. */
41
+ const angleAt = (i: number) => -Math.PI / 2 + (i / k) * Math.PI * 2
42
+ const pointAt = (i: number, v: number) => {
43
+ const t = Math.max(0, Math.min(1, (v - vMin) / span))
44
+ const a = angleAt(i)
45
+ return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) }
46
+ }
47
+ const radarAxes: ChartRadarAxis[] = axes.map((label, i) => {
48
+ const p = pointAt(i, vTop)
49
+ return { label, x: p.x, y: p.y }
50
+ })
51
+ const radarSeriesGeo: ChartRadarSeries[] = series.map((s, si) => {
52
+ const pts = s.values.map((v, i) => {
53
+ const safe = Number.isFinite(v) ? v : vMin
54
+ const p = pointAt(i, safe)
55
+ return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' }
56
+ })
57
+ const path = pts.length
58
+ ? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
59
+ : ''
60
+ const palette = spec.palette ?? DEFAULT_PALETTE
61
+ return { label: s.label, color: s.color ?? palette[si % palette.length]!, path, points: pts }
62
+ })
63
+ return {
64
+ ...empty,
65
+ plot,
66
+ radarRings: ringValues,
67
+ radarAxes,
68
+ radarSeries: radarSeriesGeo,
69
+ radarCenter: { cx, cy, r },
70
+ }
71
+ }
72
+
73
+ // ---- Gauge --------------------------------------------------------
74
+ // Semicircle dial: track arc + value arc + optional colored range bands
75
+ // + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
76
+ /** @internal Lay out the gauge family. Called by buildChart. */
77
+ export function layoutGauge(ctx: LayoutCtx): ChartGeometry {
78
+ const { spec, width, height, empty, frame } = ctx
79
+ const min = spec.gaugeMin ?? 0
80
+ const max = spec.gaugeMax ?? 100
81
+ const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0))
82
+ const target = spec.gaugeTarget
83
+ const cx = width / 2
84
+ const innerH = Math.max(1, height - frame.top - frame.bottom)
85
+ const cy = frame.top + innerH * 0.78
86
+ const r = Math.min(width * 0.42, innerH * 0.65)
87
+ // Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
88
+ const A0 = Math.PI
89
+ const A1 = 2 * Math.PI
90
+ const angleAt = (v: number) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0)
91
+ const arc = (a0: number, a1: number, radius: number): string => {
92
+ const x1 = cx + radius * Math.cos(a0)
93
+ const y1 = cy + radius * Math.sin(a0)
94
+ const x2 = cx + radius * Math.cos(a1)
95
+ const y2 = cy + radius * Math.sin(a1)
96
+ const large = a1 - a0 > Math.PI ? 1 : 0
97
+ return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`
98
+ }
99
+ const trackPath = arc(A0, A1, r)
100
+ const valuePath = arc(A0, angleAt(value), r)
101
+ // Bands sit on their own inner ring, well clear of the value arc's 16px
102
+ // stroke at `r`. They are context, not the reading: a band covering most
103
+ // of the scale (an error-rate dial where anything above 0.45 is red) used
104
+ // to out-shout the value arc completely, so the dial looked pegged at
105
+ // maximum when the actual value was 9 percent.
106
+ const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
107
+ path: arc(angleAt(band.from), angleAt(band.to), r - 16),
108
+ color: band.color, from: band.from, to: band.to,
109
+ }))
110
+ let targetPx: ChartGaugeLayout['target'] = null
111
+ if (target != null && Number.isFinite(target)) {
112
+ const a = angleAt(Math.max(min, Math.min(max, target)))
113
+ const inner = r - 12
114
+ const outer = r + 4
115
+ targetPx = {
116
+ x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
117
+ x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
118
+ }
119
+ }
120
+ // Tick marks just outside the track: a major tick every 1/4 of the scale,
121
+ // with 4 minor ticks between each. Gives the dial a measured, instrument feel.
122
+ const ticks: ChartGaugeLayout['ticks'] = []
123
+ const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR
124
+ for (let i = 0; i <= TICK_TOTAL; i++) {
125
+ const a = A0 + (i / TICK_TOTAL) * (A1 - A0)
126
+ const major = i % TICK_MINOR === 0
127
+ const inner = r + 9
128
+ const outer = r + (major ? 17 : 13)
129
+ ticks.push({
130
+ x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
131
+ x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
132
+ major,
133
+ })
134
+ }
135
+ // Pointer needle: a kite (long tip toward the value, short counterweight
136
+ // tail) pivoting on a center hub.
137
+ //
138
+ // The proportions matter more than they look, and the counterweight was the
139
+ // whole problem. A tail sticking out past the hub is a short, solid triangle;
140
+ // the pointer is a 130px taper that thins to nothing. The compact shape wins
141
+ // the eye, so on a near-full dial the needle read as an arrow pointing at the
142
+ // MINIMUM. Keeping the tail inside the hub radius removes the competing
143
+ // point without giving up the pivot, and the shoulders sit outside the hub so
144
+ // the needle actually has a visible base to taper from.
145
+ const aV = angleAt(value)
146
+ const tipR = r - 14, tailR = 5, baseR = 9
147
+ const aPerp = aV + Math.PI / 2
148
+ const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
149
+ const needlePath =
150
+ `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
151
+ // Color the value arc by the band the value currently sits in.
152
+ // First match wins, and bands are half-open [from, to). Bands normally
153
+ // share endpoints - green 0..0.3, amber 0.3..0.45, red 0.45..5 - and with
154
+ // an inclusive `to` plus last-match-wins, a value sitting exactly ON a
155
+ // boundary took the colour of the band ABOVE it. An error rate of 0.45
156
+ // against a 0.45 amber ceiling read as red.
157
+ let valueColor: string | null = null
158
+ const bands = spec.gaugeRanges ?? []
159
+ for (const band of bands) {
160
+ if (value >= band.from && value < band.to) { valueColor = band.color; break }
161
+ }
162
+ // The very top of the scale belongs to the last band that reaches it,
163
+ // which the half-open test above would otherwise exclude.
164
+ if (valueColor == null) {
165
+ for (const band of bands) if (value >= band.from && value <= band.to) valueColor = band.color
166
+ }
167
+ return {
168
+ ...empty,
169
+ plot: { x: 0, y: 0, w: width, h: height },
170
+ gauge: {
171
+ cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
172
+ ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
173
+ minLabel: { x: cx - r, y: cy + 20 },
174
+ maxLabel: { x: cx + r, y: cy + 20 },
175
+ value, min, max, unit: spec.gaugeUnit ?? '',
176
+ },
177
+ }
178
+ }
179
+
180
+ /** @internal Lay out the pie family. Called by buildChart. */
181
+ export function layoutPie(ctx: LayoutCtx): ChartGeometry {
182
+ const { spec, series, empty, width, height, palette, frame } = ctx
183
+ const s = series[0]
184
+ if (!s) return empty
185
+ const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
186
+ const innerH = Math.max(1, height - frame.top - frame.bottom)
187
+ const cy = frame.top + innerH / 2
188
+ // Callout labels need a margin round the pie for the leaders and the text:
189
+ // 34px above and below, and on each side the leader's horizontal run plus
190
+ // the widest label the callouts will write THERE (the label truncated to 18
191
+ // characters, a space and the percentage, at about 6.3px a character). The
192
+ // side is the slice's mid angle, which depends on the values alone. Sized
193
+ // by the widest label on either side, a pie whose small slices all sat on
194
+ // the left kept the same room on the right for a "Chrome 62%" and drew at
195
+ // half the radius the pane had; the pie now sits centred in what the two
196
+ // gutters leave. Without the text in the sum at all, a pie in a narrow
197
+ // pane pushed its labels past the edge and the reader saw "%" and a letter.
198
+ const callouts = spec.dataLabels?.placement === 'outside' && spec.dataLabels.show !== false
199
+ let leftW = 0
200
+ let rightW = 0
201
+ if (callouts) {
202
+ let a = -Math.PI / 2
203
+ spec.categories.forEach((c, i) => {
204
+ const frac = Math.max(0, s.values[i] ?? 0) / total
205
+ const mid = a + (frac * Math.PI * 2) / 2
206
+ a += frac * Math.PI * 2
207
+ if (frac < 0.015) return
208
+ const w = (Math.min(c.length, 18) + 4) * 6.3
209
+ if (Math.cos(mid) >= 0) rightW = Math.max(rightW, w)
210
+ else leftW = Math.max(leftW, w)
211
+ })
212
+ }
213
+ const leftGutter = callouts ? 22 + 4 + leftW : 10
214
+ const rightGutter = callouts ? 22 + 4 + rightW : 10
215
+ // The pie keeps at least a quarter of the short side; when the labels want
216
+ // more than that leaves, they are truncated to what fits instead.
217
+ const minR = Math.max(12, Math.min(width, innerH) * 0.25)
218
+ const room = (width - leftGutter - rightGutter) / 2
219
+ const r = Math.max(minR, Math.min(innerH / 2 - (callouts ? 34 : 10), room))
220
+ // Centred in the room the gutters leave. When even the floor radius does
221
+ // not fit between them, the pie sits in the middle and both sides truncate
222
+ // to what is left, rather than one side keeping its text and the other
223
+ // running off the edge.
224
+ const cx = callouts && room >= r ? leftGutter + room : width / 2
225
+ const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
226
+ const ir = r * innerFrac
227
+ let angle = -Math.PI / 2
228
+ const slices: ChartPieSlice[] = s.values.map((v, i) => {
229
+ const frac = Math.max(0, v) / total
230
+ const a0 = angle
231
+ const a1 = angle + frac * Math.PI * 2
232
+ angle = a1
233
+ const large = a1 - a0 > Math.PI ? 1 : 0
234
+ const mid = (a0 + a1) / 2
235
+ const labelR = (r + ir) / 2 || r * 0.6
236
+ const ox0 = cx + r * Math.cos(a0)
237
+ const oy0 = cy + r * Math.sin(a0)
238
+ const ox1 = cx + r * Math.cos(a1)
239
+ const oy1 = cy + r * Math.sin(a1)
240
+ let path: string
241
+ if (frac >= 0.999) {
242
+ path = ir
243
+ ? `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z` +
244
+ `M${round(cx - ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx + ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx - ir)},${round(cy)} Z`
245
+ : `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z`
246
+ } else if (ir > 0) {
247
+ const ix0 = cx + ir * Math.cos(a0)
248
+ const iy0 = cy + ir * Math.sin(a0)
249
+ const ix1 = cx + ir * Math.cos(a1)
250
+ const iy1 = cy + ir * Math.sin(a1)
251
+ path =
252
+ `M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
253
+ `L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
254
+ } else {
255
+ path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
256
+ }
257
+ const catLabel = spec.categories[i] ?? String(i)
258
+ return {
259
+ path,
260
+ // The same precedence as a bar: a category colour, then the series'
261
+ // per-point colour (a tree node's, say), then the palette.
262
+ color: spec.categoryColors?.[catLabel] ?? s.colors?.[i] ?? palette[i % palette.length]!,
263
+ label: catLabel,
264
+ value: v,
265
+ percent: frac * 100,
266
+ cx: round(cx + labelR * Math.cos(mid)),
267
+ cy: round(cy + labelR * Math.sin(mid)),
268
+ arc: { a0, a1, r, ir, cx, cy },
269
+ }
270
+ })
271
+ if (callouts) layoutPieCallouts(slices, cx, cy, r, frame.top, frame.top + innerH, width)
272
+ return {
273
+ ...empty,
274
+ slices,
275
+ legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? s.colors?.[i] ?? palette[i % palette.length]! })),
276
+ donut: ir > 0 ? { cx: round(cx), cy: round(cy), r: round(ir), total: s.values.reduce((a, b) => a + Math.max(0, b), 0) } : null,
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Callout labels round a pie: a short leader from the slice's edge along its
282
+ * mid-angle, a horizontal run, then the text outside. Labels on each side are
283
+ * pushed apart from the top down so they never overlap, and a sliver under
284
+ * 1.5% gets no callout at all (its leader would land on a neighbour's).
285
+ */
286
+ function layoutPieCallouts(slices: ChartPieSlice[], cx: number, cy: number, r: number, top: number, bottom: number, width: number): void {
287
+ const GAP = 13
288
+ // Characters of label that fit between the leader's run and the edge on
289
+ // each side, less the " NN%" the renderer appends; at least four so a name
290
+ // is recognisable.
291
+ const charsFor = (room: number) => Math.max(4, Math.floor((room - r - 26) / 6.3) - 4)
292
+ const maxCharsRight = charsFor(width - cx)
293
+ const maxCharsLeft = charsFor(cx)
294
+ const sides: Array<{ slice: ChartPieSlice; mid: number; ty: number; right: boolean }> = []
295
+ for (const slice of slices) {
296
+ if (!slice.arc || slice.percent < 1.5) continue
297
+ const mid = (slice.arc.a0 + slice.arc.a1) / 2
298
+ sides.push({ slice, mid, ty: cy + Math.sin(mid) * (r + 16), right: Math.cos(mid) >= 0 })
299
+ }
300
+ for (const right of [true, false]) {
301
+ const list = sides.filter((c) => c.right === right).sort((a, b) => a.ty - b.ty)
302
+ // Push down from the top, then back up from the bottom, so a crowded side
303
+ // spreads out rather than piling below the plot.
304
+ for (let k = 1; k < list.length; k += 1) list[k]!.ty = Math.max(list[k]!.ty, list[k - 1]!.ty + GAP)
305
+ const floor = bottom - 6
306
+ for (let k = list.length - 1; k >= 0; k -= 1) {
307
+ const limit = k === list.length - 1 ? floor : list[k + 1]!.ty - GAP
308
+ list[k]!.ty = Math.min(list[k]!.ty, limit)
309
+ }
310
+ for (const c of list) if (c.ty < top + 6) c.ty = top + 6
311
+ for (const c of list) {
312
+ const x1 = cx + Math.cos(c.mid) * (r + 2)
313
+ const y1 = cy + Math.sin(c.mid) * (r + 2)
314
+ const x2 = cx + Math.cos(c.mid) * (r + 12)
315
+ const y2 = c.ty
316
+ const x3 = right ? cx + r + 22 : cx - r - 22
317
+ c.slice.callout = {
318
+ x1: round(x1), y1: round(y1), x2: round(x2), y2: round(y2), x3: round(x3),
319
+ tx: round(x3 + (right ? 4 : -4)), ty: round(c.ty), anchor: right ? 'start' : 'end',
320
+ maxChars: right ? maxCharsRight : maxCharsLeft,
321
+ }
322
+ }
323
+ }
324
+ }
325
+
326
+ // ---- Arcs ---------------------------------------------------------
327
+ // The one path builder every new polar type shares. Angles are radians,
328
+ // clockwise, with 12 o'clock at -PI/2 (the convention the pie already uses).
329
+
330
+ /**
331
+ * An annular sector from angle `a0` to `a1` between radii `r0` and `r1`, as
332
+ * an SVG path. `r0 = 0` gives a wedge; a span of a full turn gives a ring.
333
+ * Exposed so a custom mark can match the built-in polar types exactly.
334
+ */
335
+ export function arcPath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): string {
336
+ const TAU = Math.PI * 2
337
+ const span = Math.max(0, Math.min(TAU, a1 - a0))
338
+ if (span <= 0 || r1 <= 0) return ''
339
+ const px = (r: number, a: number) => `${round(cx + r * Math.cos(a))},${round(cy + r * Math.sin(a))}`
340
+ if (span >= TAU - 1e-6) {
341
+ // Two half-arcs, because a single arc cannot describe a full circle.
342
+ const outer = `M${px(r1, a0)} A${round(r1)},${round(r1)} 0 1 1 ${px(r1, a0 + Math.PI)} A${round(r1)},${round(r1)} 0 1 1 ${px(r1, a0)} Z`
343
+ if (r0 <= 0) return outer
344
+ return `${outer} M${px(r0, a0)} A${round(r0)},${round(r0)} 0 1 0 ${px(r0, a0 + Math.PI)} A${round(r0)},${round(r0)} 0 1 0 ${px(r0, a0)} Z`
345
+ }
346
+ const large = span > Math.PI ? 1 : 0
347
+ if (r0 <= 0) {
348
+ return `M${round(cx)},${round(cy)} L${px(r1, a0)} A${round(r1)},${round(r1)} 0 ${large} 1 ${px(r1, a1)} Z`
349
+ }
350
+ return (
351
+ `M${px(r1, a0)} A${round(r1)},${round(r1)} 0 ${large} 1 ${px(r1, a1)} ` +
352
+ `L${px(r0, a1)} A${round(r0)},${round(r0)} 0 ${large} 0 ${px(r0, a0)} Z`
353
+ )
354
+ }
355
+
356
+ /** The centre of the plot and the largest radius that fits, after the frame. */
357
+ function polarBox(ctx: LayoutCtx, pad = 24) {
358
+ const { width, height, frame } = ctx
359
+ const innerH = Math.max(1, height - frame.top - frame.bottom)
360
+ const cx = width / 2
361
+ const cy = frame.top + innerH / 2
362
+ const r = Math.max(10, Math.min(width, innerH) / 2 - pad)
363
+ return { cx, cy, r, plot: { x: cx - r, y: cy - r, w: 2 * r, h: 2 * r } }
364
+ }
365
+
366
+ /** A tint of `color` for a deeper ring: towards white on a light surface,
367
+ * towards the dark surface on a dark one. */
368
+ function tint(color: string, depth: number, theme: 'light' | 'dark'): string {
369
+ if (depth <= 0) return color
370
+ return sampleGradient([color, theme === 'dark' ? '#111827' : '#ffffff'], Math.min(0.6, depth * 0.22))
371
+ }
372
+
373
+ const START = -Math.PI / 2
374
+ const TAU = Math.PI * 2
375
+
376
+ // ---- Sunburst -----------------------------------------------------
377
+ // A radial treemap: the root's children share the first ring in proportion to
378
+ // their totals, each child's children share that child's angular span in the
379
+ // next ring out, and so on. Colour follows the top-level branch, tinted per
380
+ // depth, so a whole limb of the hierarchy reads as one hue.
381
+ /** @internal Lay out the sunburst family. Called by buildChart. */
382
+ export function layoutSunburst(ctx: LayoutCtx): ChartGeometry {
383
+ const { spec, empty, palette, theme } = ctx
384
+ const root = spec.tree ?? spec.treemap
385
+ if (!root) return { ...empty }
386
+ const { cx, cy, r, plot } = polarBox(ctx, 12)
387
+ const totalOf = (n: TreeNode): number =>
388
+ n.children?.length ? n.children.reduce((s, c) => s + totalOf(c), 0) : Math.max(0, n.value ?? 0)
389
+ const depthOf = (n: TreeNode): number => (n.children?.length ? 1 + Math.max(...n.children.map(depthOf)) : 0)
390
+ const seed = root.children ?? [root]
391
+ const total = seed.reduce((s, c) => s + totalOf(c), 0)
392
+ if (total <= 0) return { ...empty, plot }
393
+ const depth = Math.max(1, depthOf({ name: '', children: seed }))
394
+ const innerFrac = Math.min(0.6, Math.max(0, spec.innerRadius ?? 0.18))
395
+ const r0 = r * innerFrac
396
+ const ringW = (r - r0) / depth
397
+ const arcs: ChartArc[] = []
398
+ const walk = (nodes: TreeNode[], a0: number, a1: number, d: number, color: string | null, path: string[]) => {
399
+ const sum = nodes.reduce((s, c) => s + totalOf(c), 0)
400
+ if (sum <= 0) return
401
+ let a = a0
402
+ nodes.forEach((n, i) => {
403
+ const v = totalOf(n)
404
+ if (v <= 0) return
405
+ const span = ((a1 - a0) * v) / sum
406
+ const c = n.color ?? (d === 0 ? palette[i % palette.length]! : color!)
407
+ const fill = d === 0 ? c : tint(c, d, theme)
408
+ const ri = r0 + ringW * d
409
+ const ro = ri + ringW - 1
410
+ const mid = a + span / 2
411
+ const lr = (ri + ro) / 2
412
+ const nodePath = [...path, n.name]
413
+ arcs.push({
414
+ path: arcPath(cx, cy, ri, ro, a, a + span),
415
+ color: fill,
416
+ textColor: pickContrastText(fill),
417
+ label: n.name,
418
+ series: path[path.length - 1] ?? '',
419
+ value: v,
420
+ a0: a,
421
+ a1: a + span,
422
+ r0: ri,
423
+ r1: ro,
424
+ cx,
425
+ cy,
426
+ depth: d,
427
+ nodePath,
428
+ lx: round(cx + lr * Math.cos(mid)),
429
+ ly: round(cy + lr * Math.sin(mid)),
430
+ })
431
+ if (n.children?.length) walk(n.children, a, a + span, d + 1, c, nodePath)
432
+ a += span
433
+ })
434
+ }
435
+ walk(seed, START, START + TAU, 0, null, [])
436
+ return {
437
+ ...empty,
438
+ plot,
439
+ arcs,
440
+ legend: seed.map((n, i) => ({ label: n.name, color: n.color ?? palette[i % palette.length]! })),
441
+ donut: r0 > 0 ? { cx: round(cx), cy: round(cy), r: round(r0), total } : null,
442
+ }
443
+ }
444
+
445
+ // ---- Radial bar ---------------------------------------------------
446
+ // One ring per category (per series), sweeping clockwise from 12 o'clock by
447
+ // value over the largest value. A faint full track behind each ring shows
448
+ // the scale. The first category is the innermost ring.
449
+ /** @internal Lay out the radial bar family. Called by buildChart. */
450
+ export function layoutRadialBar(ctx: LayoutCtx): ChartGeometry {
451
+ const { spec, series, empty, palette } = ctx
452
+ if (!series.length || !spec.categories.length) return { ...empty }
453
+ const { cx, cy, r, plot } = polarBox(ctx, 12)
454
+ let vMax = 0
455
+ for (const s of series) for (const v of s.values) if (Number.isFinite(v) && v > vMax) vMax = v
456
+ if (vMax <= 0) vMax = 1
457
+ const n = spec.categories.length
458
+ const rings = n * series.length
459
+ const innerFrac = Math.min(0.6, Math.max(0, spec.innerRadius ?? 0.2))
460
+ const r0 = r * innerFrac
461
+ const ringW = (r - r0) / Math.max(1, rings)
462
+ const gap = Math.min(3, ringW * 0.2)
463
+ const arcs: ChartArc[] = []
464
+ for (let i = 0; i < n; i += 1) {
465
+ series.forEach((s, si) => {
466
+ const v = s.values[i]
467
+ // A zero (a hidden category, or no data) draws nothing rather than an
468
+ // empty path, so the ring simply disappears the way a pie slice does.
469
+ if (!Number.isFinite(v) || (v as number) <= 0) return
470
+ const k = i * series.length + si
471
+ const ri = r0 + ringW * k + gap / 2
472
+ const ro = ri + ringW - gap
473
+ const span = Math.min(TAU * 0.9999, (Math.max(0, v as number) / vMax) * TAU)
474
+ const color = series.length === 1 ? (spec.categoryColors?.[spec.categories[i]!] ?? s.colors?.[i] ?? palette[i % palette.length]!) : s.colors?.[i] ?? s.color
475
+ const mid = START + span / 2
476
+ const lr = (ri + ro) / 2
477
+ arcs.push({
478
+ path: arcPath(cx, cy, ri, ro, START, START + span),
479
+ trackPath: arcPath(cx, cy, ri, ro, START, START + TAU),
480
+ color,
481
+ textColor: pickContrastText(color),
482
+ label: spec.categories[i]!,
483
+ series: s.label,
484
+ value: v as number,
485
+ a0: START,
486
+ a1: START + span,
487
+ r0: ri,
488
+ r1: ro,
489
+ cx,
490
+ cy,
491
+ lx: round(cx + lr * Math.cos(mid)),
492
+ ly: round(cy + lr * Math.sin(mid)),
493
+ })
494
+ })
495
+ }
496
+ return {
497
+ ...empty,
498
+ plot,
499
+ arcs,
500
+ legend: series.length === 1
501
+ ? spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! }))
502
+ : empty.legend,
503
+ }
504
+ }
505
+
506
+ // ---- Radial column / nightingale ----------------------------------
507
+ // Bars on a polar axis: each category owns an angular slot, each series a
508
+ // sub-slot (grouped) or a radial layer (stacked). Radius follows the value
509
+ // through nice rings, which the renderer draws as the axis. A nightingale
510
+ // (Florence's rose) is the same with area-proportional radii (sqrt of the
511
+ // value) and no gap between the petals, so a value twice as big looks twice
512
+ // as big rather than four times.
513
+ /** @internal Lay out the radial column and nightingale families. Called by buildChart. */
514
+ export function layoutRadialColumn(ctx: LayoutCtx): ChartGeometry {
515
+ const { spec, series, empty, palette, stacked } = ctx
516
+ if (!series.length || !spec.categories.length) return { ...empty }
517
+ const rose = spec.type === 'nightingale'
518
+ const { cx, cy, r, plot } = polarBox(ctx, 22)
519
+ const n = spec.categories.length
520
+ const stack = stacked || (rose && series.length > 1)
521
+ // Domain over values, or stacked totals.
522
+ let vMax = 0
523
+ if (stack) {
524
+ for (let i = 0; i < n; i += 1) {
525
+ let t = 0
526
+ for (const s of series) t += Number.isFinite(s.values[i]) ? Math.max(0, s.values[i]!) : 0
527
+ if (t > vMax) vMax = t
528
+ }
529
+ } else {
530
+ for (const s of series) for (const v of s.values) if (Number.isFinite(v) && v > vMax) vMax = v
531
+ }
532
+ const dom = axisScale(0, vMax > 0 ? vMax : 1, spec.yAxis ?? {})
533
+ const innerFrac = Math.min(0.5, Math.max(0, spec.innerRadius ?? (rose ? 0 : 0.12)))
534
+ const r0 = r * innerFrac
535
+ const radiusOf = (v: number) => {
536
+ const t = Math.max(0, Math.min(1, (v - dom.min) / (dom.max - dom.min || 1)))
537
+ return r0 + (r - r0) * (rose ? Math.sqrt(t) : t)
538
+ }
539
+ const slot = TAU / n
540
+ const pad = rose ? 0 : slot * 0.15
541
+ const arcs: ChartArc[] = []
542
+ for (let i = 0; i < n; i += 1) {
543
+ const s0 = START + slot * i + pad / 2
544
+ const s1 = START + slot * (i + 1) - pad / 2
545
+ if (stack) {
546
+ let cum = 0
547
+ series.forEach((s) => {
548
+ const v = s.values[i]
549
+ if (!Number.isFinite(v) || (v as number) <= 0) return
550
+ const ri = radiusOf(cum)
551
+ cum += v as number
552
+ const ro = radiusOf(cum)
553
+ const color = series.length === 1 ? (spec.categoryColors?.[spec.categories[i]!] ?? s.colors?.[i] ?? palette[i % palette.length]!) : s.colors?.[i] ?? s.color
554
+ const mid = (s0 + s1) / 2
555
+ const lr = (ri + ro) / 2
556
+ arcs.push({ path: arcPath(cx, cy, ri, ro, s0, s1), color, textColor: pickContrastText(color), label: spec.categories[i]!, series: s.label, value: v as number, a0: s0, a1: s1, r0: ri, r1: ro, cx, cy, lx: round(cx + lr * Math.cos(mid)), ly: round(cy + lr * Math.sin(mid)) })
557
+ })
558
+ } else {
559
+ const sub = (s1 - s0) / series.length
560
+ series.forEach((s, si) => {
561
+ const v = s.values[i]
562
+ if (!Number.isFinite(v) || (v as number) <= 0) return
563
+ const a0 = s0 + sub * si
564
+ const a1 = a0 + sub * (series.length > 1 ? 0.92 : 1)
565
+ const ro = radiusOf(v as number)
566
+ const color = series.length === 1 ? (spec.categoryColors?.[spec.categories[i]!] ?? s.colors?.[i] ?? palette[i % palette.length]!) : s.colors?.[i] ?? s.color
567
+ const mid = (a0 + a1) / 2
568
+ const lr = (r0 + ro) / 2
569
+ arcs.push({ path: arcPath(cx, cy, r0, ro, a0, a1), color, textColor: pickContrastText(color), label: spec.categories[i]!, series: s.label, value: v as number, a0, a1, r0, r1: ro, cx, cy, lx: round(cx + lr * Math.cos(mid)), ly: round(cy + lr * Math.sin(mid)) })
570
+ })
571
+ }
572
+ }
573
+ const polarAxes: ChartRadarAxis[] = !ctx.axes.x.labels ? [] : spec.categories.map((label, i) => {
574
+ const a = START + slot * i + slot / 2
575
+ const lr = r + 10
576
+ return { label, x: round(cx + lr * Math.cos(a)), y: round(cy + lr * Math.sin(a)) }
577
+ })
578
+ return {
579
+ ...empty,
580
+ plot,
581
+ arcs,
582
+ polarAxes,
583
+ polarRings: dom.ticks.filter((t) => t > dom.min).map((t) => radiusOf(t)),
584
+ radarCenter: { cx, cy, r },
585
+ legend: series.length === 1
586
+ ? spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! }))
587
+ : empty.legend,
588
+ }
589
+ }
590
+
591
+ // ---- Chord --------------------------------------------------------
592
+ // Flows between groups round a circle. Each node gets an arc proportional to
593
+ // everything flowing in or out of it; each link a ribbon from its sub-span on
594
+ // the source arc to its sub-span on the target arc, coloured by the source.
595
+ /** @internal Lay out the chord family. Called by buildChart. */
596
+ export function layoutChord(ctx: LayoutCtx): ChartGeometry {
597
+ const { spec, empty, palette } = ctx
598
+ const nodes = spec.sankeyNodes ?? []
599
+ const links = (spec.sankeyLinks ?? []).filter((l) => Number.isFinite(l.value) && l.value > 0)
600
+ if (!nodes.length || !links.length) return { ...empty }
601
+ const { cx, cy, r, plot } = polarBox(ctx, 28)
602
+ const totals = new Map<string, number>()
603
+ for (const l of links) {
604
+ totals.set(l.source, (totals.get(l.source) ?? 0) + l.value)
605
+ if (l.target !== l.source) totals.set(l.target, (totals.get(l.target) ?? 0) + l.value)
606
+ }
607
+ const ordered = nodes.filter((n) => (totals.get(n.id) ?? 0) > 0)
608
+ const grand = ordered.reduce((s, n) => s + (totals.get(n.id) ?? 0), 0)
609
+ if (grand <= 0) return { ...empty, plot }
610
+ const padAngle = Math.min(0.06, TAU / (ordered.length * 8))
611
+ const usable = TAU - padAngle * ordered.length
612
+ const ringW = Math.max(8, r * 0.09)
613
+ const rInner = r - ringW
614
+ const arcs: ChartArc[] = []
615
+ const span = new Map<string, { a0: number; a1: number; cursor: number; color: string }>()
616
+ let a = START
617
+ ordered.forEach((n, i) => {
618
+ const t = totals.get(n.id) ?? 0
619
+ const s = (usable * t) / grand
620
+ const color = n.color ?? palette[i % palette.length]!
621
+ span.set(n.id, { a0: a, a1: a + s, cursor: a, color })
622
+ const mid = a + s / 2
623
+ const lr = r + 12
624
+ arcs.push({
625
+ path: arcPath(cx, cy, rInner, r, a, a + s),
626
+ color,
627
+ textColor: pickContrastText(color),
628
+ label: n.label ?? n.id,
629
+ series: '',
630
+ value: t,
631
+ a0: a,
632
+ a1: a + s,
633
+ r0: rInner,
634
+ r1: r,
635
+ cx,
636
+ cy,
637
+ lx: round(cx + lr * Math.cos(mid)),
638
+ ly: round(cy + lr * Math.sin(mid)),
639
+ })
640
+ a += s + padAngle
641
+ })
642
+ // Sub-spans: outgoing links first on the source, then incoming on the target,
643
+ // each consuming an angle proportional to its value.
644
+ const px = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
645
+ const chordRibbons: ChartChordRibbon[] = []
646
+ const take = (id: string, value: number) => {
647
+ const g = span.get(id)
648
+ if (!g) return null
649
+ const w = ((g.a1 - g.a0) * value) / (totals.get(id) ?? 1)
650
+ const out = { a0: g.cursor, a1: g.cursor + w }
651
+ g.cursor += w
652
+ return out
653
+ }
654
+ const sorted = links.slice().sort((x, y) => y.value - x.value)
655
+ for (const l of sorted) {
656
+ const s = take(l.source, l.value)
657
+ const t = l.target === l.source ? s : take(l.target, l.value)
658
+ if (!s || !t) continue
659
+ const largeS = s.a1 - s.a0 > Math.PI ? 1 : 0
660
+ const largeT = t.a1 - t.a0 > Math.PI ? 1 : 0
661
+ const path =
662
+ `M${px(rInner, s.a0)} A${round(rInner)},${round(rInner)} 0 ${largeS} 1 ${px(rInner, s.a1)} ` +
663
+ `Q${round(cx)},${round(cy)} ${px(rInner, t.a0)} A${round(rInner)},${round(rInner)} 0 ${largeT} 1 ${px(rInner, t.a1)} ` +
664
+ `Q${round(cx)},${round(cy)} ${px(rInner, s.a0)} Z`
665
+ chordRibbons.push({ path, color: l.color ?? span.get(l.source)!.color, source: l.source, target: l.target, value: l.value })
666
+ }
667
+ return {
668
+ ...empty,
669
+ plot,
670
+ arcs,
671
+ chordRibbons,
672
+ legend: ordered.map((n, i) => ({ label: n.label ?? n.id, color: n.color ?? palette[i % palette.length]! })),
673
+ }
674
+ }