@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,1863 @@
1
+ /**
2
+ * Cartesian layouts: bar / line / area (combo, stacked, dual axis), horizontal
3
+ * bars and scatter. Every mark is positioned in SVG coordinates against the
4
+ * axes chart-axes.ts resolves.
5
+ */
6
+ import type {
7
+ ChartSeriesLabel,
8
+ ChartAxisTick,
9
+ ChartBar,
10
+ ChartBox,
11
+ ChartBullet,
12
+ ChartCandle,
13
+ ChartCategoryTick,
14
+ ChartDataLabelConfig,
15
+ ChartDrawingGeo,
16
+ ChartErrorBar,
17
+ ChartGeometry,
18
+ ChartLine,
19
+ ChartLinePoint,
20
+ ChartLineStyle,
21
+ ChartMarker,
22
+ ChartMarkerShape,
23
+ ChartRefBandGeo,
24
+ ChartRefLineGeo,
25
+ ChartRefLineGeoV,
26
+ ChartScatterDot,
27
+ ChartSeries,
28
+ ChartSpec,
29
+ ChartStem,
30
+ LayoutCtx,
31
+ NiceScale,
32
+ OhlcBar,
33
+ ResolvedAxis,
34
+ ResolvedSeries,
35
+ } from './chart-types'
36
+ import {
37
+ axisScale,
38
+ dateTicks,
39
+ logTicks,
40
+ niceLogScale,
41
+ errorSpan,
42
+ fmtDate,
43
+ fmtTick,
44
+ formatChartValue,
45
+ ordinalDateTicks,
46
+ project,
47
+ round,
48
+ thinCategoryTicks,
49
+ } from './chart-scale'
50
+ import { bollingerBands, computeOverlayFit, regressionFit } from './chart-indicators'
51
+ import { axisDomain, axisTickLabel } from './chart-axes'
52
+ import { heikinAshi } from './chart-financial'
53
+
54
+ type XY = { x: number; y: number }
55
+ type Step = NonNullable<ChartSeries['step']>
56
+
57
+ // ---- Path builders --------------------------------------------------------
58
+
59
+ /**
60
+ * Build an SVG path from a list of (x,y) pairs, optionally smoothed via
61
+ * monotone cubic interpolation (preserves local extrema - no overshoots).
62
+ * Breaks the path at `defined === false` gaps unless `connectNulls` is set,
63
+ * and draws steps instead of straight segments when `step` is set.
64
+ */
65
+ export function buildLinePath(
66
+ pts: Array<{ x: number; y: number; defined: boolean }>,
67
+ smooth: boolean,
68
+ opts: { connectNulls?: boolean; step?: Step } = {},
69
+ ): string {
70
+ const runs = definedRuns(pts, !!opts.connectNulls)
71
+ return runs.map((run) => runPath(run, smooth, opts.step)).filter(Boolean).join(' ')
72
+ }
73
+
74
+ /** Split points into runs of defined points; one run when gaps are bridged. */
75
+ function definedRuns(
76
+ pts: Array<{ x: number; y: number; defined: boolean }>,
77
+ connect: boolean,
78
+ ): XY[][] {
79
+ const runs: XY[][] = []
80
+ let cur: XY[] = []
81
+ for (const p of pts) {
82
+ if (p.defined) cur.push({ x: p.x, y: p.y })
83
+ else if (cur.length && !connect) {
84
+ runs.push(cur)
85
+ cur = []
86
+ }
87
+ }
88
+ if (cur.length) runs.push(cur)
89
+ return runs
90
+ }
91
+
92
+ /** The path for one contiguous run: straight, smoothed or stepped. */
93
+ function runPath(run: XY[], smooth: boolean, step?: Step): string {
94
+ if (!run.length) return ''
95
+ if (step) return polyline(stepPoints(run, step))
96
+ if (smooth) return monotoneCubicPath(run)
97
+ return polyline(run)
98
+ }
99
+
100
+ function polyline(pts: XY[]): string {
101
+ return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
102
+ }
103
+
104
+ /** Insert the corner point between each pair so the run draws as steps. */
105
+ function stepPoints(run: XY[], step: Step): XY[] {
106
+ const out: XY[] = []
107
+ for (let i = 0; i < run.length; i += 1) {
108
+ const p = run[i]!
109
+ if (i > 0) {
110
+ const q = run[i - 1]!
111
+ if (step === 'before') out.push({ x: q.x, y: p.y })
112
+ else if (step === 'after') out.push({ x: p.x, y: q.y })
113
+ else {
114
+ const mx = round((q.x + p.x) / 2)
115
+ out.push({ x: mx, y: q.y }, { x: mx, y: p.y })
116
+ }
117
+ }
118
+ out.push(p)
119
+ }
120
+ return out
121
+ }
122
+
123
+ /**
124
+ * A closed shape between a top run and a bottom run (an area over its
125
+ * baseline, a band between two envelopes), in the run's own style.
126
+ */
127
+ function closedBetween(top: XY[], bottom: XY[], smooth: boolean, step?: Step): string {
128
+ if (!top.length || !bottom.length) return ''
129
+ const up = runPath(top, smooth, step)
130
+ const back = runPath(bottom.slice().reverse(), smooth, step).replace(/^M/, 'L')
131
+ return `${up} ${back} Z`
132
+ }
133
+
134
+ /**
135
+ * The baseline a stacked area sits on per category. `'zero'` is the axis;
136
+ * `'silhouette'` centres the total on zero; `'wiggle'` picks the baseline
137
+ * that minimises the weighted change of slope across the layers (Byron and
138
+ * Wattenberg's stream graph), which is what makes a stream read as flowing
139
+ * rather than as a stack. Gaps count as 0 so the stack stays continuous.
140
+ */
141
+ export function streamBaseline(layers: ReadonlyArray<ReadonlyArray<number>>, offset: 'zero' | 'wiggle' | 'silhouette'): number[] {
142
+ const n = layers.length
143
+ const m = layers[0]?.length ?? 0
144
+ const base = new Array<number>(m).fill(0)
145
+ if (!n || !m || offset === 'zero') return base
146
+ const v = (i: number, j: number) => {
147
+ const x = layers[i]![j]
148
+ return Number.isFinite(x) ? (x as number) : 0
149
+ }
150
+ if (offset === 'silhouette') {
151
+ for (let j = 0; j < m; j += 1) {
152
+ let total = 0
153
+ for (let i = 0; i < n; i += 1) total += v(i, j)
154
+ base[j] = -total / 2
155
+ }
156
+ return base
157
+ }
158
+ let y = 0
159
+ for (let j = 1; j < m; j += 1) {
160
+ let s1 = 0
161
+ let s2 = 0
162
+ for (let i = 0; i < n; i += 1) {
163
+ const sij0 = v(i, j)
164
+ const sij1 = v(i, j - 1)
165
+ let s3 = (sij0 - sij1) / 2
166
+ for (let k = 0; k < i; k += 1) s3 += v(k, j) - v(k, j - 1)
167
+ s1 += sij0
168
+ s2 += s3 * sij0
169
+ }
170
+ base[j - 1] = y
171
+ if (s1) y -= s2 / s1
172
+ }
173
+ base[m - 1] = y
174
+ return base
175
+ }
176
+
177
+ /** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
178
+ * Slope at each point chosen so the curve passes through every (xi, yi)
179
+ * AND stays monotonic between them; control points sit 1/3 of the way
180
+ * to the neighbours along that tangent. */
181
+ function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
182
+ const n = pts.length
183
+ if (n === 0) return ''
184
+ if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
185
+ if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
186
+ // Secant slopes between adjacent points.
187
+ const dx: number[] = new Array(n - 1)
188
+ const m: number[] = new Array(n - 1)
189
+ for (let i = 0; i < n - 1; i += 1) {
190
+ const d = pts[i + 1]!.x - pts[i]!.x
191
+ dx[i] = d
192
+ m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
193
+ }
194
+ // Tangent at each point: average of neighbouring slopes, with sign
195
+ // checks that flatten the tangent when slopes change sign.
196
+ const tan: number[] = new Array(n)
197
+ tan[0] = m[0]!
198
+ tan[n - 1] = m[n - 2]!
199
+ for (let i = 1; i < n - 1; i += 1) {
200
+ if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
201
+ else tan[i] = (m[i - 1]! + m[i]!) / 2
202
+ }
203
+ // Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
204
+ for (let i = 0; i < n - 1; i += 1) {
205
+ if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
206
+ const a = tan[i]! / m[i]!
207
+ const b = tan[i + 1]! / m[i]!
208
+ const h = Math.hypot(a, b)
209
+ if (h > 3) {
210
+ tan[i] = (3 / h) * a * m[i]!
211
+ tan[i + 1] = (3 / h) * b * m[i]!
212
+ }
213
+ }
214
+ // Build the Bezier path. Each segment: control points at 1/3 of dx.
215
+ let path = `M${pts[0]!.x},${pts[0]!.y}`
216
+ for (let i = 0; i < n - 1; i += 1) {
217
+ const h = dx[i]!
218
+ const c1x = pts[i]!.x + h / 3
219
+ const c1y = pts[i]!.y + (tan[i]! * h) / 3
220
+ const c2x = pts[i + 1]!.x - h / 3
221
+ const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
222
+ path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
223
+ }
224
+ return path
225
+ }
226
+
227
+ // ---- Markers --------------------------------------------------------------
228
+
229
+ /**
230
+ * The SVG path for a point marker of `shape` centred on (cx, cy) with
231
+ * half-size `size`. `'circle'` and `'none'` return `''`: circles draw as
232
+ * `<circle>` (which keeps every existing selector working), and `'none'` draws
233
+ * nothing. Exposed so a legend swatch or a custom mark can match the series.
234
+ */
235
+ export function markerPath(shape: ChartMarkerShape, cx: number, cy: number, size: number): string {
236
+ const s = Math.max(0.5, size)
237
+ switch (shape) {
238
+ case 'square':
239
+ return `M${round(cx - s)},${round(cy - s)} h${round(2 * s)} v${round(2 * s)} h${round(-2 * s)} Z`
240
+ case 'diamond':
241
+ return `M${round(cx)},${round(cy - s * 1.3)} L${round(cx + s * 1.3)},${round(cy)} L${round(cx)},${round(cy + s * 1.3)} L${round(cx - s * 1.3)},${round(cy)} Z`
242
+ case 'triangle':
243
+ return `M${round(cx)},${round(cy - s * 1.2)} L${round(cx + s * 1.2)},${round(cy + s)} L${round(cx - s * 1.2)},${round(cy + s)} Z`
244
+ case 'cross':
245
+ return `M${round(cx - s)},${round(cy - s)} L${round(cx + s)},${round(cy + s)} M${round(cx - s)},${round(cy + s)} L${round(cx + s)},${round(cy - s)}`
246
+ default:
247
+ return ''
248
+ }
249
+ }
250
+
251
+ /** The marker for point `i` of a series, or undefined for the default dot. */
252
+ function markerAt(s: ResolvedSeries, i: number): ChartMarker | undefined {
253
+ const base: ChartMarker = typeof s.marker === 'string' ? { shape: s.marker } : s.marker ?? {}
254
+ const over = s.markers?.[i] ?? undefined
255
+ const color = over?.color ?? base.color ?? s.colors?.[i] ?? undefined
256
+ const shape = over?.shape ?? base.shape
257
+ const size = over?.size ?? base.size
258
+ if (shape == null && size == null && color == null) return undefined
259
+ const m: ChartMarker = {}
260
+ if (shape != null) m.shape = shape
261
+ if (size != null) m.size = size
262
+ if (color != null) m.color = color
263
+ return m
264
+ }
265
+
266
+ /** Stroke / fill style from the series, or undefined when it sets none. */
267
+ function styleOf(s: ResolvedSeries): ChartLineStyle | undefined {
268
+ const st: ChartLineStyle = {}
269
+ let any = false
270
+ if (s.strokeWidth != null) { st.strokeWidth = s.strokeWidth; any = true }
271
+ if (s.dash != null) { st.dash = Array.isArray(s.dash) ? s.dash.join(' ') : s.dash; any = true }
272
+ if (s.opacity != null) { st.opacity = s.opacity; any = true }
273
+ if (s.gradient) {
274
+ const g = s.gradient === true ? {} : s.gradient
275
+ st.gradient = { from: g.from ?? s.color, to: g.to ?? 'transparent' }
276
+ any = true
277
+ }
278
+ return any ? st : undefined
279
+ }
280
+
281
+ // ---- Data labels ----------------------------------------------------------
282
+
283
+ /** One positioned data label. `onBar` means it sits on a mark and should be
284
+ * painted in the contrast colour. */
285
+ export type ChartDataLabel = {
286
+ x: number
287
+ y: number
288
+ text: string
289
+ anchor: 'start' | 'middle' | 'end'
290
+ onBar: boolean
291
+ series: string
292
+ /** Rotation in degrees about (x, y), from `dataLabels.rotation`. */
293
+ angle?: number
294
+ /** A leader from the mark to a label that was pushed away from it. */
295
+ leader?: { x1: number; y1: number; x2: number; y2: number }
296
+ }
297
+
298
+ /**
299
+ * Place the data labels for a laid-out cartesian chart: one per bar and one
300
+ * per defined line point, positioned by `placement`, formatted by `formatter`
301
+ * (or `fmt`), and thinned so no label overlaps an earlier one when
302
+ * `hideOverlap` is on (the default).
303
+ *
304
+ * Pure, so the renderer derives it and a test can assert on it. Pie slices
305
+ * label themselves (a percentage at the centroid) and are not handled here.
306
+ */
307
+ export function layoutDataLabels(
308
+ geo: ChartGeometry,
309
+ cfg: ChartDataLabelConfig,
310
+ fmt: (v: number, series?: string) => string,
311
+ opts: { stacked?: boolean; dense?: boolean; share?: boolean } = {},
312
+ ): ChartDataLabel[] {
313
+ if (cfg.show === false) return []
314
+ const out: ChartDataLabel[] = []
315
+ // On a 100% chart the axis reads shares whatever `valueFormat` says, and a
316
+ // label on the bar reads the same: "18%", not the "$18" (or, with a percent
317
+ // format, the "1800%") the raw value gave. A formatter still gets the raw
318
+ // value; the tooltip keeps it too.
319
+ const totals = opts.share ? new Map<string, number>() : null
320
+ if (totals) {
321
+ for (const b of geo.bars) {
322
+ const key = b.index === undefined ? b.label : String(b.index)
323
+ totals.set(key, (totals.get(key) ?? 0) + Math.abs(b.value))
324
+ }
325
+ }
326
+ const shareOf = (b: ChartBar) => {
327
+ const total = totals!.get(b.index === undefined ? b.label : String(b.index)) || 0
328
+ return total ? `${Math.round((Math.abs(b.value) / total) * 100)}%` : ''
329
+ }
330
+ const text = (v: number, category: string, series: string, bar?: ChartBar) =>
331
+ cfg.formatter ? cfg.formatter(v, { category, series }) : totals && bar ? shareOf(bar) : fmt(v, series)
332
+ const horizontal = geo.orientation === 'horizontal'
333
+ const placement = cfg.placement ?? (opts.stacked ? 'inside' : horizontal ? 'outside' : 'top')
334
+ const inside = placement === 'inside' || placement === 'center'
335
+ for (const b of geo.bars) {
336
+ const t = text(b.value, b.label, b.series, b)
337
+ if (!t) continue
338
+ if (horizontal) {
339
+ if (inside) {
340
+ if (b.w <= 18 || b.h < 8) continue
341
+ out.push({ x: b.x + b.w / 2, y: b.y + b.h / 2 + 3, text: t, anchor: 'middle', onBar: true, series: b.series })
342
+ } else {
343
+ if (b.h < 8) continue
344
+ const pos = b.value >= 0
345
+ out.push({ x: pos ? b.x + b.w + 3 : b.x - 3, y: b.y + b.h / 2 + 3, text: t, anchor: pos ? 'start' : 'end', onBar: false, series: b.series })
346
+ }
347
+ } else if (inside) {
348
+ if (b.h <= 13 || b.w < 14) continue
349
+ out.push({ x: b.x + b.w / 2, y: b.y + b.h / 2 + 3, text: t, anchor: 'middle', onBar: true, series: b.series })
350
+ } else {
351
+ if (b.w < 6) continue
352
+ const pos = b.value >= 0
353
+ out.push({ x: b.x + b.w / 2, y: pos ? b.y - 3 : b.y + b.h + 11, text: t, anchor: 'middle', onBar: false, series: b.series })
354
+ }
355
+ }
356
+ if (!opts.dense) {
357
+ for (const line of geo.lines) {
358
+ for (const p of line.points) {
359
+ if (!p.defined) continue
360
+ const t = text(p.value, p.label, line.label)
361
+ if (!t) continue
362
+ out.push({ x: p.x, y: inside ? p.y + 3 : p.y - 7, text: t, anchor: 'middle', onBar: false, series: line.label })
363
+ }
364
+ }
365
+ }
366
+ const angle = cfg.rotation ?? 0
367
+ if (angle) for (const l of out) l.angle = angle
368
+ if (cfg.hideOverlap === false && !cfg.connector) return out
369
+ // Greedy thinning in draw order: a label is kept only when its estimated
370
+ // box (10px type, ~5.6px a glyph) misses every box already kept. A rotated
371
+ // label past 45 degrees stands more than it lies, so its box is turned.
372
+ // With `connector`, a label that collides is pushed away from its mark in
373
+ // 12px steps (up, or right on a horizontal chart) and keeps a leader back
374
+ // to where it started; after four steps it gives up and is dropped.
375
+ const kept: Array<{ x0: number; y0: number; x1: number; y1: number }> = []
376
+ const steep = Math.abs(angle) >= 45
377
+ const boxOf = (l: ChartDataLabel) => {
378
+ const len = l.text.length * 5.6 + 2
379
+ const w = steep ? 10 : len
380
+ const h = steep ? len : 11
381
+ const x0 = l.anchor === 'middle' ? l.x - w / 2 : l.anchor === 'end' ? l.x - w : l.x
382
+ const y0 = steep ? l.y - h : l.y - 10
383
+ return { x0, y0, x1: x0 + w, y1: y0 + h }
384
+ }
385
+ const hits = (b: { x0: number; y0: number; x1: number; y1: number }) =>
386
+ kept.some((k) => b.x0 < k.x1 && b.x1 > k.x0 && b.y0 < k.y1 && b.y1 > k.y0)
387
+ const STEP = 12
388
+ const MAX_STEPS = 4
389
+ const pushed: ChartDataLabel[] = []
390
+ for (const l of out) {
391
+ let box = boxOf(l)
392
+ if (!hits(box)) { kept.push(box); pushed.push(l); continue }
393
+ if (!cfg.connector || l.onBar) continue
394
+ const from = { x: l.x, y: l.y }
395
+ let moved: ChartDataLabel | null = null
396
+ for (let step = 1; step <= MAX_STEPS; step += 1) {
397
+ const cand: ChartDataLabel = horizontal
398
+ ? { ...l, x: l.x + STEP * step }
399
+ : { ...l, y: l.y - STEP * step }
400
+ box = boxOf(cand)
401
+ if (!hits(box)) { moved = cand; break }
402
+ }
403
+ if (!moved) continue
404
+ // The leader runs from the mark's edge (where the label started) to the
405
+ // near edge of the moved label.
406
+ moved.leader = horizontal
407
+ ? { x1: from.x, y1: from.y - 3, x2: moved.x - 2, y2: moved.y - 3 }
408
+ : { x1: from.x, y1: from.y + 1, x2: moved.x, y2: moved.y + 2 }
409
+ kept.push(box)
410
+ pushed.push(moved)
411
+ }
412
+ return pushed
413
+ }
414
+
415
+ // ---- The x axis -----------------------------------------------------------
416
+
417
+ type XLayout = {
418
+ /** Positions by value (time or number) rather than by index. */
419
+ continuous: 'time' | 'number' | null
420
+ /** A logarithmic number axis. */
421
+ log?: boolean
422
+ /** Parsed per-category values on a continuous or ordinal-time axis. */
423
+ values: number[] | null
424
+ min: number
425
+ max: number
426
+ /** Pixel x at the centre of category `i`. */
427
+ xCenter: (i: number) => number
428
+ /** Pixel x for a value on a continuous axis (NaN otherwise). */
429
+ xOfValue: (v: number) => number
430
+ ticks: ChartCategoryTick[]
431
+ /** Uniform room per category. */
432
+ slot: number
433
+ /** Room a bar group gets: the slot, or the smallest gap between two
434
+ * neighbouring values on a numeric axis. */
435
+ barSlot: number
436
+ /** Left edge of category `i`'s bar group. */
437
+ barX0: (i: number) => number
438
+ rotated: boolean
439
+ angle: number
440
+ }
441
+
442
+ /**
443
+ * Lay out the x axis for the cartesian core: where each category sits, how
444
+ * wide a bar group may be, and which ticks to label.
445
+ *
446
+ * Four modes. `'category'` and `'ordinal-time'` position by index (the latter
447
+ * labels from the dates); `'time'` positions by timestamp; `'number'` by the
448
+ * parsed value. Either parsing mode falls back to the category axis when no
449
+ * category parses, so a stray non-date does not blank the chart.
450
+ */
451
+ function layoutX(
452
+ spec: ChartSpec,
453
+ ax: ResolvedAxis,
454
+ padL: number,
455
+ plotW: number,
456
+ autoRotated: boolean,
457
+ ): XLayout {
458
+ const cats = spec.categories
459
+ const n = cats.length
460
+ const slot = plotW / Math.max(1, n)
461
+ const reversed = ax.reversed
462
+ const byIndex = (i: number) => {
463
+ const x = padL + slot * i + slot / 2
464
+ return round(reversed ? padL + plotW - (x - padL) : x)
465
+ }
466
+ const barByIndex = (i: number) => {
467
+ const x = padL + slot * i
468
+ return reversed ? padL + plotW - (x - padL) - slot : x
469
+ }
470
+ const angleOf = (rot: boolean) =>
471
+ typeof ax.labelRotation === 'number' ? ax.labelRotation : rot ? -40 : 0
472
+ const labelsOn = ax.labels
473
+ const fmtX = (v: number, i: number) => (ax.formatter ? ax.formatter(v, i) : ax.format ? formatChartValue(v, ax.format, spec) : fmtTick(v))
474
+
475
+ if (ax.type === 'number') {
476
+ const vals = cats.map((c) => Number(c))
477
+ const finite = vals.filter(Number.isFinite)
478
+ // A histogram's categories are bin midpoints; its axis runs edge to edge
479
+ // and is labelled at the edges, so the domain is the edges' extent. Bins
480
+ // stay linear: a log axis would squeeze the low bins to nothing.
481
+ const edges = spec.binEdges?.filter(Number.isFinite) ?? []
482
+ const positive = finite.filter((v) => v > 0)
483
+ const isLog = ax.scale === 'log' && edges.length < 2 && positive.length > 0
484
+ if (isLog) {
485
+ // Decades from the smallest positive value to the largest; `min` / `max`
486
+ // pin either end when they are positive too. Non-positive categories
487
+ // have no place on the axis and land at NaN, which the marks skip.
488
+ const nice = niceLogScale(Math.min(...positive), Math.max(...positive))
489
+ const lo = ax.min != null && ax.min > 0 ? ax.min : nice.min
490
+ const hi = ax.max != null && ax.max > lo ? ax.max : Math.max(nice.max, lo * 10)
491
+ const xOfValue = (v: number) => {
492
+ const t = project(v, lo, hi, true)
493
+ if (t === null) return Number.NaN
494
+ return round(padL + (reversed ? 1 - t : t) * plotW)
495
+ }
496
+ const xCenter = (i: number) => (Number.isFinite(vals[i]) && vals[i]! > 0 ? xOfValue(vals[i]!) : byIndex(i))
497
+ const px = [...new Set(positive)].map(xOfValue).sort((a, b) => a - b)
498
+ let minGap = Infinity
499
+ for (let i = 1; i < px.length; i += 1) minGap = Math.min(minGap, px[i]! - px[i - 1]!)
500
+ const barSlot = Number.isFinite(minGap) ? minGap : slot
501
+ const ticks: ChartCategoryTick[] = logTicks(lo, hi).map((v, i) => ({ label: labelsOn ? fmtX(v, i) : '', x: xOfValue(v) }))
502
+ return {
503
+ continuous: 'number', log: true, values: vals, min: lo, max: hi,
504
+ xCenter, xOfValue, ticks, slot, barSlot,
505
+ barX0: (i) => xCenter(i) - barSlot / 2,
506
+ rotated: false, angle: angleOf(false),
507
+ }
508
+ }
509
+ if (finite.length) {
510
+ const dom = edges.length >= 2
511
+ ? axisScale(Math.min(...edges), Math.max(...edges), { ...ax, nice: spec.xAxis?.nice === true })
512
+ : axisScale(Math.min(...finite), Math.max(...finite), ax)
513
+ const span = dom.max - dom.min || 1
514
+ const xOfValue = (v: number) => {
515
+ if (!Number.isFinite(v)) return Number.NaN
516
+ let t = (v - dom.min) / span
517
+ if (reversed) t = 1 - t
518
+ return round(padL + t * plotW)
519
+ }
520
+ const xCenter = (i: number) => (Number.isFinite(vals[i]) ? xOfValue(vals[i]!) : byIndex(i))
521
+ // Bars on a numeric axis sit where their value is, so the room a group
522
+ // gets is the smallest distance between two neighbouring values.
523
+ const sorted = [...new Set(finite)].sort((a, b) => a - b)
524
+ let minGap = Infinity
525
+ for (let i = 1; i < sorted.length; i += 1) minGap = Math.min(minGap, sorted[i]! - sorted[i - 1]!)
526
+ const barSlot = edges.length >= 2
527
+ ? ((edges[1]! - edges[0]!) / span) * plotW
528
+ : Number.isFinite(minGap) ? (minGap / span) * plotW : slot
529
+ // Bins label their edges; anything else labels the nice ticks. A dense
530
+ // set of edges thins to about one label per 40px. An edge is written
531
+ // at the precision its width needs: the compact tick form gave a 44ms
532
+ // wide bin edges reading "1.3k, 1.3k", and 26.2 read as "26".
533
+ const tickVals = edges.length >= 2 ? thinNumbers(edges, Math.max(1, Math.floor(plotW / 40))) : dom.ticks
534
+ const edgeW = edges.length >= 2 ? Math.abs(edges[1]! - edges[0]!) : 1
535
+ const edgeDecimals = edgeW >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(edgeW)) + 1)
536
+ const fmtEdge = (v: number, i: number) =>
537
+ ax.formatter || ax.format ? fmtX(v, i) : v.toLocaleString(undefined, { minimumFractionDigits: edgeDecimals, maximumFractionDigits: edgeDecimals })
538
+ const ticks: ChartCategoryTick[] = tickVals.map((v, i) => ({ label: labelsOn ? (edges.length >= 2 ? fmtEdge(v, i) : fmtX(v, i)) : '', x: xOfValue(v) }))
539
+ return {
540
+ continuous: 'number', values: vals, min: dom.min, max: dom.max,
541
+ xCenter, xOfValue, ticks, slot, barSlot,
542
+ barX0: (i) => xCenter(i) - barSlot / 2,
543
+ rotated: false, angle: angleOf(false),
544
+ }
545
+ }
546
+ }
547
+
548
+ const isDate = ax.type === 'time' || ax.type === 'ordinal-time'
549
+ const timeVals = isDate ? cats.map((c) => Date.parse(c)) : null
550
+ const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
551
+ if (timeOk && ax.type === 'time') {
552
+ const finite = timeVals!.filter(Number.isFinite)
553
+ const tMin = ax.min ?? Math.min(...finite)
554
+ const tMax = ax.max ?? Math.max(...finite)
555
+ const tSpan = tMax - tMin || 1
556
+ const xOfValue = (t: number) => {
557
+ if (!Number.isFinite(t)) return Number.NaN
558
+ let f = (t - tMin) / tSpan
559
+ if (reversed) f = 1 - f
560
+ return round(padL + f * plotW)
561
+ }
562
+ const xCenter = (i: number) => (Number.isFinite(timeVals![i]) ? xOfValue(timeVals![i]!) : byIndex(i))
563
+ // About one label per 90px: "Jun 25" is 40px of type and needs air.
564
+ const at = dateTicks(tMin, tMax, Math.max(2, Math.floor(plotW / 90)))
565
+ const step = at.length > 1 ? at[1]! - at[0]! : tSpan
566
+ const ticks: ChartCategoryTick[] = at.map((t, i) => ({
567
+ label: labelsOn ? (ax.formatter ? ax.formatter(t, i) : fmtDate(t, step)) : '',
568
+ x: xOfValue(t),
569
+ }))
570
+ // Bars stay uniform on a time axis (their width would otherwise depend on
571
+ // the calendar), which is what every existing time-axis chart drew.
572
+ return {
573
+ continuous: 'time', values: timeVals, min: tMin, max: tMax,
574
+ xCenter, xOfValue, ticks, slot, barSlot: slot, barX0: barByIndex,
575
+ rotated: false, angle: angleOf(false),
576
+ }
577
+ }
578
+ if (timeOk) {
579
+ // Ordinal: ticks land on points that exist, labelled from their dates.
580
+ const finite = timeVals!.filter(Number.isFinite)
581
+ const tSpan = Math.max(...finite) - Math.min(...finite) || 1
582
+ const at = ordinalDateTicks(timeVals!)
583
+ const step = at.length > 1 ? Math.abs(timeVals![at[1]!]! - timeVals![at[0]!]!) || tSpan : tSpan
584
+ const ticks: ChartCategoryTick[] = at.map((i, k) => ({
585
+ label: labelsOn ? (ax.formatter ? ax.formatter(timeVals![i]!, k) : fmtDate(timeVals![i]!, step)) : '',
586
+ x: byIndex(i),
587
+ }))
588
+ return {
589
+ continuous: null, values: timeVals, min: 0, max: n - 1,
590
+ xCenter: byIndex, xOfValue: () => Number.NaN, ticks, slot, barSlot: slot, barX0: barByIndex,
591
+ rotated: false, angle: angleOf(false),
592
+ }
593
+ }
594
+ const angle = angleOf(autoRotated)
595
+ const rotated = angle !== 0
596
+ const ticks = labelsOn
597
+ ? thinCategoryTicks(cats, byIndex, slot, rotated)
598
+ : []
599
+ return {
600
+ continuous: null, values: null, min: 0, max: n - 1,
601
+ xCenter: byIndex, xOfValue: () => Number.NaN, ticks, slot, barSlot: slot, barX0: barByIndex,
602
+ rotated, angle,
603
+ }
604
+ }
605
+
606
+ /** Every n-th of a list, first and last always included. */
607
+ function thinNumbers(vals: number[], max: number): number[] {
608
+ if (vals.length <= max) return vals
609
+ const step = Math.ceil(vals.length / max)
610
+ const out = vals.filter((_, i) => i % step === 0)
611
+ if (out[out.length - 1] !== vals[vals.length - 1]) out.push(vals[vals.length - 1]!)
612
+ return out
613
+ }
614
+
615
+ /** Resolve an x-axis reference value (label, date, number) to a pixel. */
616
+ function resolveXValue(spec: ChartSpec, xl: XLayout, value: number | string): number {
617
+ if (xl.continuous) {
618
+ const v = typeof value === 'number' ? value : xl.continuous === 'time' ? Date.parse(value) : Number(value)
619
+ if (Number.isFinite(v)) return xl.xOfValue(v)
620
+ }
621
+ if (typeof value === 'number') return xl.xCenter(value)
622
+ const i = spec.categories.indexOf(value)
623
+ if (i >= 0) return xl.xCenter(i)
624
+ // A date string on an ordinal axis: find the category with that day.
625
+ if (xl.values) {
626
+ const t = Date.parse(value)
627
+ if (Number.isFinite(t)) {
628
+ const j = xl.values.findIndex((v) => v === t)
629
+ if (j >= 0) return xl.xCenter(j)
630
+ }
631
+ }
632
+ return Number.NaN
633
+ }
634
+
635
+ /** Bottom pad for the x axis labels at a given angle. */
636
+ function xLabelPad(spec: ChartSpec, ax: ResolvedAxis, xl: XLayout): number {
637
+ if (!ax.labels) return 8
638
+ if (!xl.rotated) return 28
639
+ if (typeof ax.labelRotation !== 'number') return 54
640
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, Math.min(c.length, 18)), 0)
641
+ const rad = (Math.abs(xl.angle) * Math.PI) / 180
642
+ return 28 + Math.round(Math.min(72, maxLabel * 6 * Math.sin(rad)))
643
+ }
644
+
645
+ // ---- Scatter --------------------------------------------------------------
646
+
647
+ /** @internal Lay out the scatter family. Called by buildChart. */
648
+ export function layoutScatter(ctx: LayoutCtx): ChartGeometry {
649
+ const { spec, series, empty, width, height, frame, axes } = ctx
650
+ const padL = axes.y.width ?? (axes.y.labels ? 48 : 12) + (axes.y.title ? 16 : 0)
651
+ const padR = 12
652
+ const padT = 10 + frame.top
653
+ const padB = (axes.x.labels ? 28 : 8) + (axes.x.title ? 16 : 0) + frame.bottom
654
+ const plotW = Math.max(1, width - padL - padR)
655
+ const plotH = Math.max(1, height - padT - padB)
656
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
657
+
658
+ let xMin = Infinity
659
+ let xMax = -Infinity
660
+ let yMin = Infinity
661
+ let yMax = -Infinity
662
+ let rMin = Infinity
663
+ let rMax = -Infinity
664
+ for (const s of series) {
665
+ for (const pt of s.points ?? []) {
666
+ if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
667
+ if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
668
+ if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
669
+ }
670
+ }
671
+ if (xMin === Infinity) return { ...empty, plot }
672
+ // A log x axis takes its decades from the positive points only.
673
+ const xLog = axes.x.scale === 'log' && series.some((s) => (s.points ?? []).some((pt) => pt.x > 0))
674
+ const xDom = xLog
675
+ ? (() => {
676
+ let lo = Infinity
677
+ for (const s of series) for (const pt of s.points ?? []) if (pt.x > 0 && pt.x < lo) lo = pt.x
678
+ const nice = niceLogScale(lo, xMax)
679
+ const min = axes.x.min != null && axes.x.min > 0 ? axes.x.min : nice.min
680
+ const max = axes.x.max != null && axes.x.max > min ? axes.x.max : Math.max(nice.max, min * 10)
681
+ return { min, max, step: 10, ticks: logTicks(min, max) }
682
+ })()
683
+ : axisScale(xMin, xMax, axes.x)
684
+ const yDom = axisScale(yMin, yMax, axes.y)
685
+ const hasR = rMax > rMin
686
+ const xOf = (v: number) => {
687
+ if (xLog) {
688
+ const t = project(v, xDom.min, xDom.max, true)
689
+ if (t === null) return Number.NaN
690
+ return round(padL + (axes.x.reversed ? 1 - t : t) * plotW)
691
+ }
692
+ let t = (v - xDom.min) / (xDom.max - xDom.min || 1)
693
+ if (axes.x.reversed) t = 1 - t
694
+ return round(padL + t * plotW)
695
+ }
696
+ const yOf = (v: number) => {
697
+ let t = (v - yDom.min) / (yDom.max - yDom.min || 1)
698
+ if (axes.y.reversed) t = 1 - t
699
+ return round(padT + plotH - t * plotH)
700
+ }
701
+ const rOf = (r?: number) =>
702
+ hasR && r != null && Number.isFinite(r)
703
+ ? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
704
+ : 5
705
+
706
+ const scatterPoints: ChartScatterDot[] = []
707
+ for (const s of series) {
708
+ ;(s.points ?? []).forEach((pt, i) => {
709
+ if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) return
710
+ if (xLog && pt.x <= 0) return
711
+ scatterPoints.push({
712
+ cx: xOf(pt.x),
713
+ cy: yOf(pt.y),
714
+ r: rOf(pt.r),
715
+ color: s.colors?.[i] ?? s.color,
716
+ label: pt.label ?? '',
717
+ series: s.label,
718
+ x: pt.x,
719
+ y: pt.y,
720
+ })
721
+ })
722
+ }
723
+ // ---- Regression overlays: y on x, drawn as a curve across the plot -------
724
+ // A scatter's `overlay` is a regression of y on the points' real x (a
725
+ // category chart's overlay runs on the index). The curve is sampled at
726
+ // 48 x positions across the domain, evenly in the axis's own space, so a
727
+ // log axis gets a curve that reads straight where it should.
728
+ const overlays: ChartLine[] = []
729
+ for (const s of series) {
730
+ if (!s.overlay) continue
731
+ const pts = (s.points ?? []).filter((pt) => Number.isFinite(pt.x) && Number.isFinite(pt.y) && (!xLog || pt.x > 0))
732
+ const fit = regressionFit(pts.map((pt) => pt.y), s.overlay, pts.map((pt) => pt.x))
733
+ if (!fit || !fit.equation) continue
734
+ const samples = 48
735
+ const linePts: ChartLinePoint[] = []
736
+ for (let i = 0; i <= samples; i += 1) {
737
+ const t = i / samples
738
+ const x = xLog ? Math.pow(10, Math.log10(xDom.min) + t * (Math.log10(xDom.max) - Math.log10(xDom.min))) : xDom.min + t * (xDom.max - xDom.min)
739
+ const y = fit.predict(x)
740
+ const inside = Number.isFinite(y) && y >= yDom.min && y <= yDom.max
741
+ linePts.push({ x: xOf(x), y: inside ? yOf(y) : NaN, label: fmtTick(x), value: y, defined: inside })
742
+ }
743
+ overlays.push({
744
+ path: buildLinePath(linePts, false),
745
+ areaPath: '',
746
+ color: s.overlayColor ?? s.color,
747
+ label: `${s.label} (${s.overlay})`,
748
+ points: linePts,
749
+ r2: fit.r2,
750
+ equation: fit.equation,
751
+ })
752
+ }
753
+
754
+ const referenceLines: ChartRefLineGeo[] = []
755
+ const referenceLinesV: ChartRefLineGeoV[] = []
756
+ for (const ref of spec.referenceLines ?? []) {
757
+ const v = Number(ref.value)
758
+ if (!Number.isFinite(v)) continue
759
+ if (ref.axis === 'x') {
760
+ referenceLinesV.push({ x: xOf(v), label: ref.label ?? fmtTick(v), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
761
+ } else {
762
+ referenceLines.push({ y: yOf(v), label: ref.label ?? formatChartValue(v, spec.valueFormat, spec), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
763
+ }
764
+ }
765
+ const referenceBands: ChartRefBandGeo[] = []
766
+ for (const b of spec.referenceBands ?? []) {
767
+ const f = Number(b.from)
768
+ const t = Number(b.to)
769
+ if (!Number.isFinite(f) || !Number.isFinite(t)) continue
770
+ if (b.axis === 'x') {
771
+ const x0 = xOf(Math.min(f, t))
772
+ const x1 = xOf(Math.max(f, t))
773
+ referenceBands.push({ x: Math.min(x0, x1), y: padT, w: Math.abs(x1 - x0), h: plotH, color: b.color ?? '#64748b', opacity: b.opacity ?? 0.08, label: b.label ?? '', axis: 'x' })
774
+ } else {
775
+ const y0 = yOf(Math.max(f, t))
776
+ const y1 = yOf(Math.min(f, t))
777
+ referenceBands.push({ x: padL, y: Math.min(y0, y1), w: plotW, h: Math.abs(y1 - y0), color: b.color ?? '#64748b', opacity: b.opacity ?? 0.08, label: b.label ?? '', axis: 'y' })
778
+ }
779
+ }
780
+ return {
781
+ ...empty,
782
+ plot,
783
+ axes: {
784
+ y: { min: yDom.min, max: yDom.max, log: false, reversed: axes.y.reversed },
785
+ y2: null,
786
+ slot: plotW,
787
+ count: 0,
788
+ x: { type: 'number', min: xDom.min, max: xDom.max, reversed: axes.x.reversed, values: [], ...(xLog ? { log: true } : {}) },
789
+ },
790
+ grid: { x: axes.x.gridLines, y: axes.y.gridLines },
791
+ scatterPoints,
792
+ overlays,
793
+ referenceLines,
794
+ referenceLinesV,
795
+ referenceBands,
796
+ yTicks: yDom.ticks.map((value, i) => ({ value, y: yOf(value), label: axes.y.labels ? axisTickLabel(value, i, axes.y, spec, formatChartValue) : '' })),
797
+ xTicks: xDom.ticks.map((value, i) => ({
798
+ label: axes.x.labels ? (axes.x.formatter ? axes.x.formatter(value, i) : axes.x.format ? formatChartValue(value, axes.x.format, spec) : fmtTick(value)) : '',
799
+ x: xOf(value),
800
+ })),
801
+ }
802
+ }
803
+
804
+ // ---- Horizontal bars ------------------------------------------------------
805
+
806
+ /**
807
+ * @internal Lay out the horizontal bars family. Called by buildChart.
808
+ * Categories run down the left, bars grow rightward. Bars-only (no combo).
809
+ */
810
+ export function layoutHorizontalBars(ctx: LayoutCtx): ChartGeometry {
811
+ const { spec, series, empty, width, height, stacked, frame, axes } = ctx
812
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
813
+ const padL = axes.x.labels
814
+ ? Math.min(150, 18 + maxLabel * 6.4) + (axes.y.title ? 16 : 0)
815
+ : 12 + (axes.y.title ? 16 : 0)
816
+ const padR = 16
817
+ const padT = 8 + frame.top
818
+ const padB = (axes.y.labels ? 26 : 8) + (axes.x.title ? 16 : 0) + frame.bottom
819
+ const plotW = Math.max(1, width - padL - padR)
820
+ const plotH = Math.max(1, height - padT - padB)
821
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
822
+
823
+ const refs = (spec.referenceLines ?? [])
824
+ .filter((r) => r.axis !== 'x')
825
+ .map((r) => Number(r.value))
826
+ .concat((spec.referenceBands ?? []).filter((b) => b.axis !== 'x').flatMap((b) => [Number(b.from), Number(b.to)]))
827
+ const isLog = axes.y.scale === 'log'
828
+ const dom = spec.stacked100 ? axisScale(0, 100) : axisDomain(series, spec.categories, stacked, refs, isLog, axes.y)
829
+ const xOf = (v: number) => {
830
+ let t = project(v, dom.min, dom.max, isLog)
831
+ if (t === null) return Number.NaN
832
+ if (axes.y.reversed) t = 1 - t
833
+ return round(padL + t * plotW)
834
+ }
835
+
836
+ const n = spec.categories.length
837
+ const slot = plotH / Math.max(1, n)
838
+ const groupPad = slot * 0.2
839
+ const inner = slot - groupPad
840
+ const bandTop = (i: number) => padT + slot * (axes.x.reversed ? n - 1 - i : i) + groupPad / 2
841
+ const xBase = xOf(isLog ? dom.min : Math.min(Math.max(0, dom.min), dom.max))
842
+
843
+ const bars: ChartBar[] = []
844
+ if (stacked) {
845
+ const totals = spec.stacked100
846
+ ? spec.categories.map(
847
+ (_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
848
+ )
849
+ : null
850
+ const pos = new Array(n).fill(0)
851
+ const neg = new Array(n).fill(0)
852
+ for (const s of series) {
853
+ s.values.forEach((v, i) => {
854
+ if (!Number.isFinite(v)) return
855
+ const vp = totals ? (v / totals[i]!) * 100 : v
856
+ let xL: number
857
+ let xR: number
858
+ if (vp >= 0) {
859
+ xL = xOf(pos[i])
860
+ xR = xOf(pos[i] + vp)
861
+ pos[i] += vp
862
+ } else {
863
+ xL = xOf(neg[i] + vp)
864
+ xR = xOf(neg[i])
865
+ neg[i] += vp
866
+ }
867
+ if (!Number.isFinite(xL) || !Number.isFinite(xR)) return
868
+ bars.push({
869
+ x: Math.min(xL, xR),
870
+ y: round(bandTop(i)),
871
+ w: round(Math.abs(xR - xL)),
872
+ h: round(Math.max(1, inner)),
873
+ color: s.colors?.[i] ?? s.color,
874
+ label: spec.categories[i] ?? String(i),
875
+ series: s.label,
876
+ value: v,
877
+ index: i,
878
+ ...(s.opacity != null ? { opacity: s.opacity } : {}),
879
+ })
880
+ })
881
+ }
882
+ } else {
883
+ const barH = inner / series.length
884
+ series.forEach((s, bi) => {
885
+ s.values.forEach((v, i) => {
886
+ if (!Number.isFinite(v)) return
887
+ if (isLog && v <= 0) return
888
+ const xV = xOf(v)
889
+ if (!Number.isFinite(xV)) return
890
+ bars.push({
891
+ x: Math.min(xV, xBase),
892
+ y: round(bandTop(i) + barH * bi),
893
+ w: round(Math.max(1, Math.abs(xV - xBase))),
894
+ h: round(Math.max(1, barH - 1)),
895
+ color: s.colors?.[i] ?? s.color,
896
+ label: spec.categories[i] ?? String(i),
897
+ series: s.label,
898
+ value: v,
899
+ index: i,
900
+ ...(s.opacity != null ? { opacity: s.opacity } : {}),
901
+ })
902
+ })
903
+ })
904
+ }
905
+
906
+ const valueTicks: ChartCategoryTick[] = dom.ticks.map((value, i) => ({
907
+ label: !axes.y.labels
908
+ ? ''
909
+ : spec.stacked100
910
+ ? `${fmtTick(value)}%`
911
+ : axisTickLabel(value, i, axes.y, spec, formatChartValue),
912
+ x: xOf(value),
913
+ }))
914
+ const catTicks: ChartAxisTick[] = axes.x.labels
915
+ ? spec.categories.map((label, i) => ({ value: i, y: round(bandTop(i) + inner / 2), label }))
916
+ : []
917
+ const referenceLinesV: ChartRefLineGeoV[] = []
918
+ const referenceLines: ChartRefLineGeo[] = []
919
+ for (const ref of spec.referenceLines ?? []) {
920
+ if (ref.axis === 'x') {
921
+ // A category reference on a horizontal chart is a horizontal line.
922
+ const i = typeof ref.value === 'number' ? ref.value : spec.categories.indexOf(ref.value)
923
+ if (i < 0 || !Number.isFinite(i)) continue
924
+ referenceLines.push({ y: round(bandTop(i) + inner / 2), label: ref.label ?? String(ref.value), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
925
+ continue
926
+ }
927
+ const v = Number(ref.value)
928
+ const x = xOf(v)
929
+ if (!Number.isFinite(x)) continue
930
+ referenceLinesV.push({ x, label: ref.label ?? formatChartValue(v, spec.valueFormat, spec), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
931
+ }
932
+ const referenceBands: ChartRefBandGeo[] = []
933
+ for (const b of spec.referenceBands ?? []) {
934
+ if (b.axis === 'x') {
935
+ const i0 = typeof b.from === 'number' ? b.from : spec.categories.indexOf(b.from)
936
+ const i1 = typeof b.to === 'number' ? b.to : spec.categories.indexOf(b.to)
937
+ if (i0 < 0 || i1 < 0) continue
938
+ const y0 = bandTop(Math.min(i0, i1)) - groupPad / 2
939
+ const y1 = bandTop(Math.max(i0, i1)) + inner + groupPad / 2
940
+ referenceBands.push({ x: padL, y: round(Math.min(y0, y1)), w: plotW, h: round(Math.abs(y1 - y0)), color: b.color ?? '#64748b', opacity: b.opacity ?? 0.08, label: b.label ?? '', axis: 'y' })
941
+ continue
942
+ }
943
+ const x0 = xOf(Number(b.from))
944
+ const x1 = xOf(Number(b.to))
945
+ if (!Number.isFinite(x0) || !Number.isFinite(x1)) continue
946
+ referenceBands.push({ x: Math.min(x0, x1), y: padT, w: Math.abs(x1 - x0), h: plotH, color: b.color ?? '#64748b', opacity: b.opacity ?? 0.08, label: b.label ?? '', axis: 'x' })
947
+ }
948
+
949
+ return {
950
+ ...empty,
951
+ plot,
952
+ axes: {
953
+ y: { min: dom.min, max: dom.max, log: isLog, reversed: axes.y.reversed },
954
+ y2: null,
955
+ slot,
956
+ count: n,
957
+ xReversed: axes.x.reversed,
958
+ labels: spec.categories,
959
+ },
960
+ grid: { x: axes.y.gridLines, y: axes.x.gridLines },
961
+ bars,
962
+ orientation: 'horizontal',
963
+ valueTicks,
964
+ catTicks,
965
+ referenceLines,
966
+ referenceLinesV,
967
+ referenceBands,
968
+ xLabelRotated: false,
969
+ }
970
+ }
971
+
972
+ // ---- Cartesian core (bar / line / area, combo, dual axis) ------------------
973
+
974
+ /** @internal Lay out the cartesian family. Called by buildChart. */
975
+ export function layoutCartesian(ctx: LayoutCtx): ChartGeometry {
976
+ const { spec, series, empty, width, height, stacked, frame, axes } = ctx
977
+ const leftSeries = series.filter((s) => s.axis === 'left')
978
+ const rightSeries = series.filter((s) => s.axis === 'right')
979
+ const hasRightAxis = rightSeries.length > 0
980
+
981
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
982
+ // Grouped (nested) category axis: valid only when the spans cover every leaf.
983
+ const validGroups =
984
+ spec.categoryGroups &&
985
+ spec.categoryGroups.length > 0 &&
986
+ axes.x.type !== 'time' &&
987
+ spec.orientation !== 'horizontal' &&
988
+ spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
989
+ ? spec.categoryGroups
990
+ : null
991
+ const groupTierH = validGroups ? 18 : 0
992
+ const padL = axes.y.width ?? (axes.y.labels ? 48 : 12) + (axes.y.title ? 16 : 0)
993
+ // End labels need their own gutter past the right axis: the longest name
994
+ // at about 6.2px a character, capped so a long label cannot eat the plot.
995
+ const endLabelText = spec.seriesLabels ? seriesLabelTexts(spec, series) : null
996
+ const endLabelPad = endLabelText
997
+ ? Math.min(Math.floor(width / 3), 8 + Math.max(0, ...endLabelText.values()) * 6.2)
998
+ : 0
999
+ const padR = (hasRightAxis ? (axes.y2.width ?? (axes.y2.labels ? 48 : 12)) : 12) + (axes.y2.title ? 16 : 0) + endLabelPad
1000
+ const padT = 10 + frame.top
1001
+ // The x layout needs the plot width, and the bottom pad needs the x layout's
1002
+ // rotation. Width does not depend on the pad, so lay x out first at the
1003
+ // final width and settle the pad after.
1004
+ const plotW = Math.max(1, width - padL - padR)
1005
+ // Labels tilt when the widest one does not fit its slot, at about 6.2px a
1006
+ // character plus a little air. Judged by count and length alone, twelve
1007
+ // three-letter months stood tilted across a thousand-pixel chart.
1008
+ const autoRotated = maxLabel * 6.2 + 8 > plotW / Math.max(1, spec.categories.length)
1009
+ const xl = layoutX(spec, axes.x, padL, plotW, autoRotated)
1010
+ const padB = xLabelPad(spec, axes.x, xl) + (axes.x.title ? 16 : 0) + groupTierH + frame.bottom
1011
+ const plotH = Math.max(1, height - padT - padB)
1012
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1013
+
1014
+ const yRefs = (spec.referenceLines ?? []).filter((r) => r.axis !== 'x')
1015
+ const yBands = (spec.referenceBands ?? []).filter((b) => b.axis !== 'x')
1016
+ const refsLeft = yRefs.filter((r) => r.axis !== 'right').map((r) => Number(r.value))
1017
+ .concat(yBands.filter((b) => b.axis !== 'right').flatMap((b) => [Number(b.from), Number(b.to)]))
1018
+ const refsRight = yRefs.filter((r) => r.axis === 'right').map((r) => Number(r.value))
1019
+ .concat(yBands.filter((b) => b.axis === 'right').flatMap((b) => [Number(b.from), Number(b.to)]))
1020
+ const leftLog = axes.y.scale === 'log'
1021
+ const rightLog = axes.y2.scale === 'log'
1022
+ // A stream graph's areas stack on a wiggle / silhouette baseline rather than
1023
+ // the axis, and the baseline is part of the domain: the layers run from the
1024
+ // lowest baseline to the highest baseline-plus-total.
1025
+ // An area belongs to a pile: its `stack` name, the default pile when the
1026
+ // chart is stacked, or none. Piles are per axis, so a dual-axis stack lines
1027
+ // up to its own scale; the key below is what every per-pile map hangs on.
1028
+ const areaPile = (s: ResolvedSeries): string | null =>
1029
+ s.kind !== 'area' ? null : s.stack ? `${s.axis}\u0000${s.stack}` : stacked ? s.axis : null
1030
+ const areaPiles = [...new Set(series.map(areaPile).filter((k): k is string => k !== null))]
1031
+ const streamOffset = spec.stackOffset && spec.stackOffset !== 'zero' && !spec.stacked100 ? spec.stackOffset : null
1032
+ const streamBase = new Map<string, number[]>()
1033
+ const streamExtra: Record<'left' | 'right', number[]> = { left: [], right: [] }
1034
+ if (streamOffset) {
1035
+ for (const pile of areaPiles) {
1036
+ const members = series.filter((s) => areaPile(s) === pile)
1037
+ const layers = members.map((s) => s.values)
1038
+ const base = streamBaseline(layers, streamOffset)
1039
+ streamBase.set(pile, base)
1040
+ const axis = members[0]!.axis
1041
+ base.forEach((b, j) => {
1042
+ let total = 0
1043
+ for (const l of layers) total += Number.isFinite(l[j]) ? l[j]! : 0
1044
+ streamExtra[axis].push(b, b + total)
1045
+ })
1046
+ }
1047
+ }
1048
+ const leftDom = spec.stacked100
1049
+ ? axisScale(0, 100)
1050
+ : axisDomain(leftSeries, spec.categories, stacked, refsLeft.concat(streamExtra.left), leftLog, axes.y)
1051
+ const rightDom = hasRightAxis
1052
+ ? spec.stacked100
1053
+ ? axisScale(0, 100)
1054
+ : axisDomain(rightSeries, spec.categories, stacked, refsRight.concat(streamExtra.right), rightLog, axes.y2)
1055
+ : null
1056
+
1057
+ /** Map a data value to a y pixel. Returns NaN for non-positive values on
1058
+ * a log axis so callers can drop the point (line gap / missing bar). */
1059
+ const yOf = (dom: NiceScale, v: number, isLog = false, reversed = false) => {
1060
+ let t = project(v, dom.min, dom.max, isLog)
1061
+ if (t === null) return NaN
1062
+ if (reversed) t = 1 - t
1063
+ return round(padT + plotH - t * plotH)
1064
+ }
1065
+ const yLeft = (v: number) => yOf(leftDom, v, leftLog, axes.y.reversed)
1066
+ const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog, axes.y2.reversed)
1067
+ const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
1068
+ const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
1069
+ const revOf = (s: ResolvedSeries) => (s.axis === 'right' ? axes.y2.reversed : axes.y.reversed)
1070
+ const ySer = (s: ResolvedSeries, v: number) => yOf(domOf(s), v, isLogOf(s), revOf(s))
1071
+
1072
+ const n = spec.categories.length
1073
+ const { slot, xCenter, ticks: xTicks } = xl
1074
+
1075
+ // Parent-tier ticks for a grouped category axis: each spans its leaves.
1076
+ const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
1077
+ if (validGroups && !xl.values) {
1078
+ let start = 0
1079
+ for (const g of validGroups) {
1080
+ const x0 = round(padL + slot * start)
1081
+ const x1 = round(padL + slot * (start + g.span))
1082
+ categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
1083
+ start += g.span
1084
+ }
1085
+ }
1086
+
1087
+ // Everything that shares the category slot the way grouped bars do.
1088
+ const barLike = series.filter((s) => s.kind === 'bar' || s.kind === 'range-bar' || s.kind === 'lollipop' || s.kind === 'dumbbell')
1089
+ const bars: ChartBar[] = []
1090
+ const stems: ChartStem[] = []
1091
+ if (barLike.length) {
1092
+ // A histogram's bins touch: no gap between groups and none inside.
1093
+ const noGap = spec.type === 'histogram'
1094
+ const groupPad = noGap ? 0 : xl.barSlot * 0.2
1095
+ const inner = xl.barSlot - groupPad
1096
+ const x0 = (i: number) => xl.barX0(i) + groupPad / 2
1097
+ // The category slot is split into SLOTS side by side: a stack (every bar
1098
+ // series naming it, or every bar series when `stacked`) is one slot, and
1099
+ // a lone series, a range bar or a stem is its own. So `stack: 'plan'` on
1100
+ // two series and `stack: 'actual'` on two more gives two stacks per
1101
+ // category; a stacked chart with a lollipop gives the stack and the stem
1102
+ // their own room instead of one over the other.
1103
+ type Slot = { key: string; members: ResolvedSeries[]; stacked: boolean }
1104
+ const slots: Slot[] = []
1105
+ const stackSlots = new Map<string, Slot>()
1106
+ for (const s of barLike) {
1107
+ const key = s.kind === 'bar' ? (s.stack ?? (stacked ? '' : null)) : null
1108
+ if (key === null) { slots.push({ key: s.label, members: [s], stacked: false }); continue }
1109
+ let slot = stackSlots.get(key)
1110
+ if (!slot) { slot = { key, members: [], stacked: true }; stackSlots.set(key, slot); slots.push(slot) }
1111
+ slot.members.push(s)
1112
+ }
1113
+ const slotW = inner / slots.length
1114
+ slots.forEach((slot, si) => {
1115
+ if (!slot.stacked) return
1116
+ // Stack independently per axis so dual-axis stacks line up to their own scale.
1117
+ for (const axis of ['left', 'right'] as const) {
1118
+ const axisBars = slot.members.filter((s) => s.axis === axis)
1119
+ if (!axisBars.length) continue
1120
+ const yA = axis === 'right' ? yRight : yLeft
1121
+ // 100% mode normalizes each category to its absolute total.
1122
+ const totals = spec.stacked100
1123
+ ? spec.categories.map(
1124
+ (_, i) =>
1125
+ axisBars.reduce(
1126
+ (sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
1127
+ 0,
1128
+ ) || 1,
1129
+ )
1130
+ : null
1131
+ const pos = new Array(n).fill(0)
1132
+ const neg = new Array(n).fill(0)
1133
+ for (const s of axisBars) {
1134
+ s.values.forEach((v, i) => {
1135
+ if (!Number.isFinite(v)) return
1136
+ const vp = totals ? (v / totals[i]!) * 100 : v
1137
+ let yTop: number
1138
+ let yBot: number
1139
+ if (vp >= 0) {
1140
+ yTop = yA(pos[i] + vp)
1141
+ yBot = yA(pos[i])
1142
+ pos[i] += vp
1143
+ } else {
1144
+ yTop = yA(neg[i])
1145
+ yBot = yA(neg[i] + vp)
1146
+ neg[i] += vp
1147
+ }
1148
+ if (!Number.isFinite(yTop) || !Number.isFinite(yBot)) return
1149
+ bars.push({
1150
+ x: round(x0(i) + slotW * si),
1151
+ y: Math.min(yTop, yBot),
1152
+ w: round(Math.max(1, slots.length > 1 ? slotW - (noGap ? 0 : 1) : slotW)),
1153
+ h: round(Math.abs(yBot - yTop)),
1154
+ color: s.colors?.[i] ?? s.color,
1155
+ label: spec.categories[i] ?? String(i),
1156
+ series: s.label,
1157
+ value: v,
1158
+ index: i,
1159
+ ...(s.opacity != null ? { opacity: s.opacity } : {}),
1160
+ })
1161
+ })
1162
+ }
1163
+ }
1164
+ })
1165
+ // Lone series, range bars and stems: one slot each, side by side.
1166
+ const lone = slots.map((slot, si) => ({ slot, si })).filter(({ slot }) => !slot.stacked)
1167
+ if (lone.length) {
1168
+ const barW = slotW
1169
+ lone.forEach(({ slot, si }) => {
1170
+ const s = slot.members[0]!
1171
+ const bi = si
1172
+ const dom = domOf(s)
1173
+ const log = isLogOf(s)
1174
+ // Log axis: bars grow from the axis floor (dom.min) up to v rather
1175
+ // than from 0, since 0 is invalid in log space.
1176
+ const base = log ? ySer(s, dom.min) : ySer(s, Math.min(Math.max(0, dom.min), dom.max))
1177
+ const dotR = round(Math.min(5, Math.max(2.5, barW * 0.3)))
1178
+ s.values.forEach((v, i) => {
1179
+ if (!Number.isFinite(v)) return
1180
+ if (log && v <= 0) return
1181
+ const x = x0(i) + barW * bi
1182
+ const yV = ySer(s, v)
1183
+ if (!Number.isFinite(yV)) return
1184
+ const color = s.colors?.[i] ?? s.color
1185
+ const label = spec.categories[i] ?? String(i)
1186
+ if (s.kind === 'lollipop') {
1187
+ stems.push({ xCenter: round(x + barW / 2), y0: base, y1: yV, r: dotR, color, label, series: s.label, value: v, dumbbell: false })
1188
+ return
1189
+ }
1190
+ if (s.kind === 'dumbbell' || s.kind === 'range-bar') {
1191
+ const lo = s.lowValues?.[i]
1192
+ if (!Number.isFinite(lo) || (log && (lo as number) <= 0)) return
1193
+ const yLo = ySer(s, lo as number)
1194
+ if (!Number.isFinite(yLo)) return
1195
+ if (s.kind === 'dumbbell') {
1196
+ stems.push({ xCenter: round(x + barW / 2), y0: yLo, y1: yV, r: dotR, color, color2: s.overlayColor ?? color, label, series: s.label, value: v, value2: lo as number, dumbbell: true })
1197
+ return
1198
+ }
1199
+ bars.push({
1200
+ x: round(x),
1201
+ y: Math.min(yV, yLo),
1202
+ w: round(Math.max(1, barW - (noGap ? 0 : 1))),
1203
+ h: round(Math.max(1, Math.abs(yV - yLo))),
1204
+ color,
1205
+ label,
1206
+ series: s.label,
1207
+ value: v,
1208
+ index: i,
1209
+ lo: lo as number,
1210
+ ...(s.opacity != null ? { opacity: s.opacity } : {}),
1211
+ })
1212
+ return
1213
+ }
1214
+ bars.push({
1215
+ x: round(x),
1216
+ y: Math.min(yV, base),
1217
+ w: round(Math.max(1, barW - (noGap ? 0 : 1))),
1218
+ h: round(Math.max(1, Math.abs(yV - base))),
1219
+ color,
1220
+ label,
1221
+ series: s.label,
1222
+ value: v,
1223
+ index: i,
1224
+ ...(s.opacity != null ? { opacity: s.opacity } : {}),
1225
+ })
1226
+ })
1227
+ })
1228
+ }
1229
+ }
1230
+
1231
+ // Candlesticks / OHLC bars. Laid out once; the two marks differ only in how
1232
+ // the renderer paints them, so there is no second geometry pass.
1233
+ const candleSeries = series.filter((s) => s.kind === 'candle')
1234
+ const candles: ChartCandle[] = []
1235
+ if (candleSeries.length) {
1236
+ const upColor = spec.candleColors?.up ?? '#16a34a'
1237
+ const downColor = spec.candleColors?.down ?? '#ef4444'
1238
+ // Share the slot when two instruments are charted together, the same way
1239
+ // grouped bars do.
1240
+ const bodyW = Math.max(1, (xl.barSlot * 0.7) / candleSeries.length)
1241
+ const style = spec.candleStyle ?? 'classic'
1242
+ candleSeries.forEach((s, si) => {
1243
+ const log = isLogOf(s)
1244
+ const raw = s.ohlc ?? []
1245
+ const bars: ReadonlyArray<OhlcBar | null> = style === 'heikin-ashi' ? heikinAshi(raw) : raw
1246
+ let prevClose: number | null = null
1247
+ bars.forEach((k, i) => {
1248
+ if (!k) { prevClose = null; return }
1249
+ if (![k.o, k.h, k.l, k.c].every(Number.isFinite)) return
1250
+ // A log price axis is genuinely used for long histories, and a
1251
+ // non-positive price has no place on one.
1252
+ if (log && (k.o <= 0 || k.h <= 0 || k.l <= 0 || k.c <= 0)) return
1253
+ const centre = xl.barX0(i) + xl.barSlot / 2
1254
+ const x = centre - (bodyW * candleSeries.length) / 2 + bodyW * si
1255
+ const yOpen = ySer(s, k.o)
1256
+ const yClose = ySer(s, k.c)
1257
+ // Classic: colour and fill both follow close vs open. Hollow: colour
1258
+ // follows close vs the PREVIOUS close, fill follows close vs open.
1259
+ const bodyUp = k.c >= k.o
1260
+ const up = style === 'hollow' && prevClose != null ? k.c >= prevClose : bodyUp
1261
+ const hollow = style === 'hollow' ? bodyUp : up
1262
+ prevClose = k.c
1263
+ candles.push({
1264
+ x: round(x),
1265
+ w: round(bodyW),
1266
+ xCenter: round(x + bodyW / 2),
1267
+ yOpen,
1268
+ yClose,
1269
+ yHigh: ySer(s, k.h),
1270
+ yLow: ySer(s, k.l),
1271
+ bodyY: Math.min(yOpen, yClose),
1272
+ // A doji closes where it opened; keep it visible as a 1px line
1273
+ // rather than a zero-height rect that paints nothing.
1274
+ bodyH: Math.max(1, Math.abs(yClose - yOpen)),
1275
+ up,
1276
+ hollow,
1277
+ color: up ? upColor : downColor,
1278
+ label: spec.categories[i] ?? String(i),
1279
+ series: s.label,
1280
+ o: k.o,
1281
+ h: k.h,
1282
+ l: k.l,
1283
+ c: k.c,
1284
+ })
1285
+ })
1286
+ })
1287
+ }
1288
+
1289
+ // Box plots. Same slot-sharing as grouped bars and candles, so several
1290
+ // samples can sit side by side under one category.
1291
+ const boxSeries = series.filter((s) => s.kind === 'box')
1292
+ const boxes: ChartBox[] = []
1293
+ if (boxSeries.length) {
1294
+ const boxW = Math.max(1, (xl.barSlot * 0.6) / boxSeries.length)
1295
+ boxSeries.forEach((s, si) => {
1296
+ const log = isLogOf(s)
1297
+ ;(s.boxes ?? []).forEach((b, i) => {
1298
+ if (!b) return
1299
+ if (![b.min, b.q1, b.median, b.q3, b.max].every(Number.isFinite)) return
1300
+ if (log && b.min <= 0) return
1301
+ const centre = xl.barX0(i) + xl.barSlot / 2
1302
+ const x = centre - (boxW * boxSeries.length) / 2 + boxW * si
1303
+ const yQ1 = ySer(s, b.q1)
1304
+ const yQ3 = ySer(s, b.q3)
1305
+ boxes.push({
1306
+ x: round(x),
1307
+ w: round(boxW),
1308
+ xCenter: round(x + boxW / 2),
1309
+ yMin: ySer(s, b.min),
1310
+ yQ1,
1311
+ yMedian: ySer(s, b.median),
1312
+ yQ3,
1313
+ yMax: ySer(s, b.max),
1314
+ boxY: Math.min(yQ1, yQ3),
1315
+ // A sample with no spread would otherwise paint nothing at all.
1316
+ boxH: Math.max(1, Math.abs(yQ1 - yQ3)),
1317
+ outliers: (b.outliers ?? [])
1318
+ .filter((o) => Number.isFinite(o) && (!log || o > 0))
1319
+ .map((o) => ({ y: ySer(s, o), value: o })),
1320
+ color: s.color,
1321
+ label: spec.categories[i] ?? String(i),
1322
+ series: s.label,
1323
+ min: b.min,
1324
+ q1: b.q1,
1325
+ median: b.median,
1326
+ q3: b.q3,
1327
+ max: b.max,
1328
+ })
1329
+ })
1330
+ })
1331
+ }
1332
+
1333
+ // Error bars. Not a mark of their own: they annotate whatever the series
1334
+ // already draws, so this runs over every series carrying `errors` regardless
1335
+ // of kind, and the geometry sits in its own array so no existing loop changes.
1336
+ const errorBars: ChartErrorBar[] = []
1337
+ for (const s of series) {
1338
+ if (!s.errors) continue
1339
+ const log = isLogOf(s)
1340
+ s.errors.forEach((e, i) => {
1341
+ const v = s.values[i]
1342
+ if (!Number.isFinite(v)) return
1343
+ const span = errorSpan(e, v!)
1344
+ if (!span) return
1345
+ if (log && span.lo <= 0) return
1346
+ errorBars.push({
1347
+ xCenter: round(xl.barX0(i) + xl.barSlot / 2),
1348
+ yLo: ySer(s, span.lo),
1349
+ yHi: ySer(s, span.hi),
1350
+ cap: round(Math.min(6, xl.barSlot * 0.15)),
1351
+ color: s.color,
1352
+ label: spec.categories[i] ?? String(i),
1353
+ series: s.label,
1354
+ lo: span.lo,
1355
+ hi: span.hi,
1356
+ })
1357
+ })
1358
+ }
1359
+
1360
+ // Lines / areas. Stacked areas accumulate per pile; others fill to baseline.
1361
+ const lines: ChartLine[] = []
1362
+ const areaCum = new Map<string, number[]>()
1363
+ // 100% mode: per-pile per-category totals to normalize stacked areas to 100.
1364
+ const areaTotals = new Map<string, number[]>()
1365
+ const streamSeeded = new Set<string>()
1366
+ for (const pile of areaPiles) {
1367
+ areaCum.set(pile, new Array(n).fill(0))
1368
+ if (spec.stacked100) {
1369
+ const members = series.filter((s) => areaPile(s) === pile)
1370
+ areaTotals.set(
1371
+ pile,
1372
+ spec.categories.map(
1373
+ (_, i) =>
1374
+ members.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
1375
+ ),
1376
+ )
1377
+ }
1378
+ }
1379
+ for (const s of series) {
1380
+ // Bars, candles and boxes draw their own marks. Candles and boxes
1381
+ // especially: `values` holds their closes / medians so that tooltips, CSV
1382
+ // and overlays work, and without this guard that same array was ALSO drawn
1383
+ // as a line, laying a dotted close-line straight over every candle. Boxes
1384
+ // would do exactly the same thing through the median.
1385
+ if (s.kind === 'bar' || s.kind === 'candle' || s.kind === 'box') continue
1386
+ if (s.kind === 'range-bar' || s.kind === 'lollipop' || s.kind === 'dumbbell') continue
1387
+ const dom = domOf(s)
1388
+ const log = isLogOf(s)
1389
+ const yA = (v: number) => ySer(s, v)
1390
+ const pile = areaPile(s)
1391
+ const isStackedArea = pile !== null
1392
+ const px = (i: number) => xCenter(i)
1393
+ let pts: ChartLinePoint[]
1394
+ let baselinePts: XY[] | null = null
1395
+ const pointAt = (i: number, v: number, y: number, defined: boolean): ChartLinePoint => {
1396
+ const p: ChartLinePoint = { x: px(i), y, label: spec.categories[i] ?? String(i), value: v, defined }
1397
+ const m = markerAt(s, i)
1398
+ if (m) p.marker = m
1399
+ return p
1400
+ }
1401
+ if (isStackedArea) {
1402
+ // Stacked areas treat a gap as 0 so the stack stays continuous. A stream
1403
+ // starts its first layer on the wiggle baseline instead of the axis.
1404
+ const cum = areaCum.get(pile!)!
1405
+ const sb = streamBase.get(pile!)
1406
+ if (sb && !streamSeeded.has(pile!)) {
1407
+ for (let j = 0; j < n; j += 1) cum[j] = sb[j] ?? 0
1408
+ streamSeeded.add(pile!)
1409
+ }
1410
+ const prev = cum.slice()
1411
+ const totals = areaTotals.get(pile!)
1412
+ pts = s.values.map((v, i) => {
1413
+ const vv = Number.isFinite(v) ? v : 0
1414
+ // 100% mode positions by share of the category total; value stays original.
1415
+ const norm = totals ? (vv / totals[i]!) * 100 : vv
1416
+ const c = (cum[i] ?? 0) + norm
1417
+ cum[i] = c
1418
+ return pointAt(i, v, yA(c), Number.isFinite(v))
1419
+ })
1420
+ baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
1421
+ } else {
1422
+ pts = s.values.map((v, i) => {
1423
+ const ok = Number.isFinite(v) && Number.isFinite(yA(v))
1424
+ return pointAt(i, v, ok ? yA(v) : NaN, ok)
1425
+ })
1426
+ }
1427
+ // Build the line - smoothed via monotone cubic when requested, else
1428
+ // straight polylines. Either way, gaps break the path cleanly (unless the
1429
+ // series asks to bridge them). A scatter kind draws markers only.
1430
+ const smooth = !!s.smooth && !s.step
1431
+ const step = s.step
1432
+ const connect = !!s.connectNulls
1433
+ let path = s.kind === 'scatter' ? '' : buildLinePath(pts, smooth, { connectNulls: connect, step })
1434
+
1435
+ let areaPath = ''
1436
+ if (s.kind === 'range-area' && pts.length) {
1437
+ // The band between lowValues and values, with both edges stroked.
1438
+ const hi: XY[] = []
1439
+ const lo: XY[] = []
1440
+ pts.forEach((p, i) => {
1441
+ const l = s.lowValues?.[i]
1442
+ if (!p.defined || !Number.isFinite(l)) return
1443
+ const yl = yA(l as number)
1444
+ if (!Number.isFinite(yl)) return
1445
+ hi.push({ x: p.x, y: p.y })
1446
+ lo.push({ x: p.x, y: yl })
1447
+ })
1448
+ if (hi.length >= 2) {
1449
+ areaPath = closedBetween(hi, lo, smooth, step)
1450
+ path = `${runPath(hi, smooth, step)} ${runPath(lo, smooth, step)}`
1451
+ }
1452
+ } else if (s.kind === 'area' && pts.length) {
1453
+ if (baselinePts) {
1454
+ areaPath = closedBetween(pts.map((p) => ({ x: p.x, y: p.y })), baselinePts, smooth, step)
1455
+ } else {
1456
+ // One filled polygon per contiguous run of defined points.
1457
+ const baseY = round(yA(log ? dom.min : Math.min(Math.max(0, dom.min), dom.max)))
1458
+ areaPath = definedRuns(pts, connect)
1459
+ .map((run) => `${runPath(run, smooth, step)} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`)
1460
+ .join(' ')
1461
+ }
1462
+ }
1463
+
1464
+ // Confidence band: shaded envelope between upperValues / lowerValues.
1465
+ // Both arrays must be present and aligned to the value array.
1466
+ let bandPath = ''
1467
+ if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
1468
+ const upperPts: XY[] = []
1469
+ const lowerPts: XY[] = []
1470
+ for (let i = 0; i < s.values.length; i += 1) {
1471
+ const u = s.upperValues[i]!
1472
+ const lo = s.lowerValues[i]!
1473
+ if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
1474
+ if (log && (u <= 0 || lo <= 0)) continue
1475
+ upperPts.push({ x: px(i), y: yA(u) })
1476
+ lowerPts.push({ x: px(i), y: yA(lo) })
1477
+ }
1478
+ if (upperPts.length >= 2) bandPath = closedBetween(upperPts, lowerPts, smooth, step)
1479
+ }
1480
+
1481
+ const line: ChartLine = { path, areaPath, color: s.color, label: s.label, points: pts, bandPath }
1482
+ if (s.kind === 'range-area') line.range = true
1483
+ const style = styleOf(s)
1484
+ if (style) line.style = style
1485
+ if (smooth) line.smooth = true
1486
+ if (step) line.step = step
1487
+ lines.push(line)
1488
+ }
1489
+
1490
+ // Under `stacked100` the axis is a share of the total, not the measure, so
1491
+ // it is labelled as a percentage whatever `valueFormat` says. Formatting it
1492
+ // as currency gives an axis reading "$0 .. $100" for what are percentages -
1493
+ // which is what it did, unnoticed, while `stacked100` was reachable only
1494
+ // from config.
1495
+ const tickFor = (dom: NiceScale, ax: ResolvedAxis, log: boolean): ChartAxisTick[] =>
1496
+ dom.ticks.map((value, i) => ({
1497
+ value,
1498
+ y: yOf(dom, value, log, ax.reversed),
1499
+ label: !ax.labels
1500
+ ? ''
1501
+ : spec.stacked100
1502
+ ? `${round(value)}%`
1503
+ : axisTickLabel(value, i, ax, spec, formatChartValue),
1504
+ }))
1505
+
1506
+ const referenceLines: ChartRefLineGeo[] = []
1507
+ const referenceLinesV: ChartRefLineGeoV[] = []
1508
+ for (const ref of spec.referenceLines ?? []) {
1509
+ if (ref.axis === 'x') {
1510
+ const x = resolveXValue(spec, xl, ref.value)
1511
+ if (!Number.isFinite(x)) continue
1512
+ referenceLinesV.push({ x, label: ref.label ?? String(ref.value), color: ref.color ?? '#ef4444', dashed: ref.dashed !== false })
1513
+ continue
1514
+ }
1515
+ const onRight = ref.axis === 'right'
1516
+ const dom = onRight ? (rightDom ?? leftDom) : leftDom
1517
+ const log = onRight ? rightLog : leftLog
1518
+ const v = Number(ref.value)
1519
+ const y = yOf(dom, v, log, onRight ? axes.y2.reversed : axes.y.reversed)
1520
+ if (!Number.isFinite(y)) continue
1521
+ referenceLines.push({
1522
+ y,
1523
+ label: ref.label ?? formatChartValue(v, spec.valueFormat, spec),
1524
+ color: ref.color ?? '#ef4444',
1525
+ dashed: ref.dashed !== false,
1526
+ })
1527
+ }
1528
+
1529
+ const referenceBands: ChartRefBandGeo[] = []
1530
+ for (const b of spec.referenceBands ?? []) {
1531
+ const color = b.color ?? '#64748b'
1532
+ const opacity = b.opacity ?? 0.08
1533
+ const label = b.label ?? ''
1534
+ if (b.axis === 'x') {
1535
+ let x0 = resolveXValue(spec, xl, b.from)
1536
+ let x1 = resolveXValue(spec, xl, b.to)
1537
+ if (!Number.isFinite(x0) || !Number.isFinite(x1)) continue
1538
+ // On a category axis a band from A to B covers A's and B's whole slots.
1539
+ if (!xl.continuous) {
1540
+ const half = slot / 2
1541
+ if (x0 <= x1) { x0 -= half; x1 += half } else { x0 += half; x1 -= half }
1542
+ }
1543
+ referenceBands.push({ x: round(Math.min(x0, x1)), y: padT, w: round(Math.abs(x1 - x0)), h: plotH, color, opacity, label, axis: 'x' })
1544
+ continue
1545
+ }
1546
+ const onRight = b.axis === 'right'
1547
+ const dom = onRight ? (rightDom ?? leftDom) : leftDom
1548
+ const log = onRight ? rightLog : leftLog
1549
+ const rev = onRight ? axes.y2.reversed : axes.y.reversed
1550
+ const y0 = yOf(dom, Number(b.from), log, rev)
1551
+ const y1 = yOf(dom, Number(b.to), log, rev)
1552
+ if (!Number.isFinite(y0) || !Number.isFinite(y1)) continue
1553
+ referenceBands.push({ x: padL, y: Math.min(y0, y1), w: plotW, h: Math.abs(y1 - y0), color, opacity, label, axis: 'y' })
1554
+ }
1555
+
1556
+ // ---- Overlays: trendline / moving average ------------------------
1557
+ // For every series with an `overlay`, compute the smoothed values and
1558
+ // render as a dashed line in the source series' color (or overlayColor).
1559
+ const overlays: ChartLine[] = []
1560
+ for (const s of series) {
1561
+ if (!s.overlay) continue
1562
+ const log = isLogOf(s)
1563
+ const fit = computeOverlayFit(s.values, s.overlay, { ohlc: s.ohlc, volumes: s.volumes })
1564
+ const overlayVals = fit.values
1565
+ const color = s.overlayColor ?? s.color
1566
+ const toPts = (vals: ReadonlyArray<number>): ChartLinePoint[] => vals.map((v, i) => {
1567
+ const ok = Number.isFinite(v) && (!log || v > 0)
1568
+ return {
1569
+ x: xCenter(i),
1570
+ y: ok ? ySer(s, v) : NaN,
1571
+ label: spec.categories[i] ?? String(i),
1572
+ value: v,
1573
+ defined: ok,
1574
+ }
1575
+ })
1576
+ const pts = toPts(overlayVals)
1577
+ const path = buildLinePath(pts, !!s.smooth)
1578
+ // Bollinger: the middle line is the overlay; the band between the upper
1579
+ // and lower envelopes is its shaded `bandPath`, like a confidence band.
1580
+ let bandPath = ''
1581
+ const bb = /^bb:(\d+):(\d+(?:\.\d+)?)$/.exec(s.overlay)
1582
+ if (bb) {
1583
+ const { upper, lower } = bollingerBands(s.values, Number(bb[1]), Number(bb[2]))
1584
+ const up = toPts(upper).filter((p) => p.defined)
1585
+ const lo = toPts(lower).filter((p) => p.defined)
1586
+ if (up.length > 1 && lo.length > 1) {
1587
+ bandPath = up.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' ') + ' ' +
1588
+ lo.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ') + ' Z'
1589
+ }
1590
+ }
1591
+ overlays.push({
1592
+ path,
1593
+ areaPath: '',
1594
+ color,
1595
+ label: `${s.label} (${s.overlay})`,
1596
+ points: pts,
1597
+ ...(bandPath ? { bandPath } : {}),
1598
+ ...(fit.r2 !== undefined ? { r2: fit.r2 } : {}),
1599
+ ...(fit.equation ? { equation: fit.equation } : {}),
1600
+ })
1601
+ }
1602
+
1603
+ // ---- Last price: a pill on the axis at the last close ------------------
1604
+ if (spec.lastPriceLine) {
1605
+ const cfg = spec.lastPriceLine === true ? {} : spec.lastPriceLine
1606
+ const src = series.find((s) => s.ohlc) ?? series[0]
1607
+ if (src) {
1608
+ let last = -1
1609
+ for (let i = src.values.length - 1; i >= 0; i -= 1) {
1610
+ if (Number.isFinite(src.values[i])) { last = i; break }
1611
+ }
1612
+ if (last >= 0) {
1613
+ const v = src.values[last]!
1614
+ let prev = Number.NaN
1615
+ for (let i = last - 1; i >= 0; i -= 1) {
1616
+ if (Number.isFinite(src.values[i])) { prev = src.values[i]!; break }
1617
+ }
1618
+ const y = ySer(src, v)
1619
+ if (Number.isFinite(y)) {
1620
+ referenceLines.push({
1621
+ y,
1622
+ label: cfg.label ?? formatChartValue(v, spec.valueFormat, spec),
1623
+ color: cfg.color ?? (Number.isFinite(prev) && v < prev ? '#ef4444' : '#16a34a'),
1624
+ dashed: true,
1625
+ pill: true,
1626
+ })
1627
+ }
1628
+ }
1629
+ }
1630
+ }
1631
+
1632
+ // ---- Annotations: resolve data-space anchors to pixel coords ------
1633
+ const annotations: ChartGeometry['annotations'] = []
1634
+ for (const a of (spec.annotations ?? [])) {
1635
+ let ax: number | null = null
1636
+ let ay: number | null = null
1637
+ if ('category' in a.at) {
1638
+ const ci = spec.categories.indexOf(a.at.category)
1639
+ if (ci < 0) continue
1640
+ ax = xCenter(ci)
1641
+ // Anchor to the named series' value at that category, else just
1642
+ // mid-plot. Picks the first matching series if `series` is set.
1643
+ const seriesName = a.at.series
1644
+ const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
1645
+ if (s) {
1646
+ const v = s.values[ci]
1647
+ if (Number.isFinite(v)) ay = ySer(s, v as number)
1648
+ }
1649
+ if (ay == null) ay = padT + plotH / 2
1650
+ } else {
1651
+ // Raw x/y in data space. x is a value on a continuous axis, a
1652
+ // (fractional) category index otherwise; y projects through the left
1653
+ // axis. Either falls back to mid-plot when it cannot be placed.
1654
+ const rx = Number.isFinite(a.at.x) ? resolveXValue(spec, xl, a.at.x) : Number.NaN
1655
+ ax = Number.isFinite(rx) ? rx : padL + plotW / 2
1656
+ if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog, axes.y.reversed)
1657
+ else ay = padT + plotH / 2
1658
+ }
1659
+ if (ax != null && ay != null && Number.isFinite(ay)) {
1660
+ annotations.push({
1661
+ x: ax,
1662
+ y: ay,
1663
+ label: a.label,
1664
+ color: a.color ?? '#0f172a',
1665
+ placement: a.placement ?? 'top',
1666
+ shape: a.shape ?? 'dot',
1667
+ ...(a.text ? { text: a.text } : {}),
1668
+ })
1669
+ }
1670
+ }
1671
+
1672
+ // ---- Drawings: data-space anchors to pixels ----------------------------
1673
+ // A point that cannot be placed (a category the zoom window cut, a date
1674
+ // off the axis) drops the whole drawing; a half-resolved trend line would
1675
+ // point somewhere the reader never drew.
1676
+ const drawings: ChartDrawingGeo[] = []
1677
+ for (const d of spec.drawings ?? []) {
1678
+ const onRight = d.axis === 'right'
1679
+ const dom = onRight ? (rightDom ?? leftDom) : leftDom
1680
+ const log = onRight ? rightLog : leftLog
1681
+ const rev = onRight ? axes.y2.reversed : axes.y.reversed
1682
+ const pts: Array<{ x: number; y: number }> = []
1683
+ let ok = true
1684
+ for (const pt of d.points) {
1685
+ const x = resolveXValue(spec, xl, pt.x)
1686
+ const y = yOf(dom, pt.y, log, rev)
1687
+ if (!Number.isFinite(x) || !Number.isFinite(y)) { ok = false; break }
1688
+ pts.push({ x: round(x), y })
1689
+ }
1690
+ if (!ok || !pts.length) continue
1691
+ const color = d.color ?? '#0ea5e9'
1692
+ const geo: ChartDrawingGeo = { id: d.id, kind: d.kind, points: pts, color, ...(d.text ? { text: d.text } : {}) }
1693
+ if (d.kind === 'fib' && pts.length >= 2) {
1694
+ const y0 = d.points[0]!.y
1695
+ const y1 = d.points[1]!.y
1696
+ geo.levels = [0, 0.236, 0.382, 0.5, 0.618, 1].map((ratio) => {
1697
+ const v = y1 + (y0 - y1) * ratio
1698
+ return { ratio, y: yOf(dom, v, log, rev), label: `${(ratio * 100).toFixed(1)}% ${formatChartValue(v, spec.valueFormat, spec)}` }
1699
+ })
1700
+ }
1701
+ if (d.kind === 'hray') geo.label = formatChartValue(d.points[0]!.y, spec.valueFormat, spec)
1702
+ drawings.push(geo)
1703
+ }
1704
+
1705
+ return {
1706
+ ...empty,
1707
+ plot,
1708
+ // What a caller needs to put its own marks in this chart's coordinates.
1709
+ // Reported rather than recomputed, so a custom mark lands on exactly the
1710
+ // scale the built-in ones did - including the nice-scale rounding and the
1711
+ // "always include zero" rule, which are impossible to guess from outside.
1712
+ axes: {
1713
+ y: { min: leftDom.min, max: leftDom.max, log: leftLog, reversed: axes.y.reversed },
1714
+ y2: rightDom ? { min: rightDom.min, max: rightDom.max, log: rightLog, reversed: axes.y2.reversed } : null,
1715
+ slot,
1716
+ count: spec.categories.length,
1717
+ ...(xl.continuous
1718
+ ? { x: { type: xl.continuous, min: xl.min, max: xl.max, reversed: axes.x.reversed, values: xl.values ?? [], ...(xl.log ? { log: true } : {}) } }
1719
+ : {}),
1720
+ xReversed: axes.x.reversed,
1721
+ labels: spec.categories,
1722
+ },
1723
+ grid: { x: axes.x.gridLines, y: axes.y.gridLines },
1724
+ bars,
1725
+ candles,
1726
+ boxes,
1727
+ errorBars,
1728
+ stems,
1729
+ lines,
1730
+ yTicks: tickFor(leftDom, axes.y, leftLog),
1731
+ y2Ticks: rightDom ? tickFor(rightDom, axes.y2, rightLog) : [],
1732
+ hasRightAxis,
1733
+ xTicks,
1734
+ categoryGroupTicks,
1735
+ xLabelRotated: xl.rotated,
1736
+ xLabelAngle: xl.angle,
1737
+ referenceLines,
1738
+ referenceLinesV,
1739
+ referenceBands,
1740
+ overlays,
1741
+ annotations,
1742
+ drawings,
1743
+ seriesLabels: spec.seriesLabels ? layoutSeriesLabels(spec, lines, plot) : [],
1744
+ }
1745
+ }
1746
+
1747
+ /** The end-label text per line series, keyed by label, with its length. */
1748
+ function seriesLabelTexts(spec: ChartSpec, series: ResolvedSeries[]): Map<string, number> {
1749
+ const out = new Map<string, number>()
1750
+ const fmt = typeof spec.seriesLabels === 'object' ? spec.seriesLabels.formatter : undefined
1751
+ for (const s of series) {
1752
+ if (s.kind !== 'line' && s.kind !== 'area') continue
1753
+ const last = [...s.values].reverse().find((v) => Number.isFinite(v))
1754
+ if (last === undefined) continue
1755
+ out.set(s.label, (fmt ? fmt(s.label, last) : s.label).length)
1756
+ }
1757
+ return out
1758
+ }
1759
+
1760
+ /**
1761
+ * A name at each line's last defined point, in the gutter to its right. The
1762
+ * labels are sorted by y and pushed apart by a line height, from the top
1763
+ * down and then back up from the plot's bottom edge, so a bundle of lines
1764
+ * ending together reads as a list instead of a smear.
1765
+ */
1766
+ function layoutSeriesLabels(spec: ChartSpec, lines: ChartLine[], plot: { y: number; h: number }): ChartSeriesLabel[] {
1767
+ const GAP = 12
1768
+ const fmt = typeof spec.seriesLabels === 'object' ? spec.seriesLabels.formatter : undefined
1769
+ const items: ChartSeriesLabel[] = []
1770
+ for (const line of lines) {
1771
+ const last = [...line.points].reverse().find((p) => p.defined && Number.isFinite(p.y))
1772
+ if (!last) continue
1773
+ items.push({ x: round(last.x + 6), y: last.y, text: fmt ? fmt(line.label, last.value) : line.label, color: line.color, series: line.label })
1774
+ }
1775
+ items.sort((a, b) => a.y - b.y)
1776
+ for (let k = 1; k < items.length; k += 1) items[k]!.y = Math.max(items[k]!.y, items[k - 1]!.y + GAP)
1777
+ const floor = plot.y + plot.h - 2
1778
+ for (let k = items.length - 1; k >= 0; k -= 1) {
1779
+ const limit = k === items.length - 1 ? floor : items[k + 1]!.y - GAP
1780
+ items[k]!.y = Math.min(items[k]!.y, limit)
1781
+ }
1782
+ for (const it of items) it.y = round(Math.max(plot.y + 4, it.y))
1783
+ return items
1784
+ }
1785
+
1786
+ // ---- Bullet ---------------------------------------------------------------
1787
+
1788
+ /**
1789
+ * @internal Lay out the bullet family. Called by buildChart.
1790
+ *
1791
+ * One row per category: qualitative ranges (poor / ok / good) as full-height
1792
+ * bands, the measure as a bar at half height, the target as a tick. The
1793
+ * first series is the measure; `targets` on it (or a second series) sets the
1794
+ * ticks; `bulletRanges` (or `gaugeRanges`) draws the bands behind every row.
1795
+ */
1796
+ export function layoutBullet(ctx: LayoutCtx): ChartGeometry {
1797
+ const { spec, series, empty, width, height, frame, axes } = ctx
1798
+ const src = series[0]
1799
+ if (!src || !spec.categories.length) return { ...empty }
1800
+ const targets = src.targets ?? series[1]?.values ?? []
1801
+ const ranges = spec.bulletRanges ?? spec.gaugeRanges ?? []
1802
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
1803
+ const padL = axes.x.labels ? Math.min(150, 18 + maxLabel * 6.4) : 12
1804
+ const padR = 16
1805
+ const padT = 8 + frame.top
1806
+ const padB = (axes.y.labels ? 26 : 8) + (axes.y.title ? 16 : 0) + frame.bottom
1807
+ const plotW = Math.max(1, width - padL - padR)
1808
+ const plotH = Math.max(1, height - padT - padB)
1809
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1810
+ let vMax = 0
1811
+ for (const v of src.values) if (Number.isFinite(v) && v > vMax) vMax = v
1812
+ for (const t of targets) if (Number.isFinite(t) && t > vMax) vMax = t
1813
+ for (const r of ranges) if (Number.isFinite(r.to) && r.to > vMax) vMax = r.to
1814
+ const dom = axisScale(0, vMax > 0 ? vMax : 1, axes.y)
1815
+ const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
1816
+ const n = spec.categories.length
1817
+ const slot = plotH / n
1818
+ const rowPad = slot * 0.25
1819
+ const rowH = slot - rowPad
1820
+ const bullets: ChartBullet[] = []
1821
+ spec.categories.forEach((label, i) => {
1822
+ const v = src.values[i]
1823
+ const t = targets[i]
1824
+ const y = round(padT + slot * i + rowPad / 2)
1825
+ const value = Number.isFinite(v) ? (v as number) : 0
1826
+ bullets.push({
1827
+ x: padL,
1828
+ y,
1829
+ w: plotW,
1830
+ h: round(rowH),
1831
+ measureW: Math.max(0, xOf(Math.max(dom.min, Math.min(dom.max, value))) - padL),
1832
+ targetX: Number.isFinite(t) ? xOf(Math.max(dom.min, Math.min(dom.max, t as number))) : null,
1833
+ ranges: ranges.map((r) => {
1834
+ const x0 = xOf(Math.max(dom.min, Math.min(dom.max, r.from)))
1835
+ const x1 = xOf(Math.max(dom.min, Math.min(dom.max, r.to)))
1836
+ return { x: Math.min(x0, x1), w: Math.abs(x1 - x0), color: r.color }
1837
+ }),
1838
+ color: src.colors?.[i] ?? src.color,
1839
+ label,
1840
+ series: src.label,
1841
+ value,
1842
+ target: Number.isFinite(t) ? (t as number) : null,
1843
+ })
1844
+ })
1845
+ const valueTicks: ChartCategoryTick[] = dom.ticks.map((value, i) => ({
1846
+ label: axes.y.labels ? axisTickLabel(value, i, axes.y, spec, formatChartValue) : '',
1847
+ x: xOf(value),
1848
+ }))
1849
+ const catTicks: ChartAxisTick[] = axes.x.labels
1850
+ ? spec.categories.map((label, i) => ({ value: i, y: round(padT + slot * i + slot / 2), label }))
1851
+ : []
1852
+ return {
1853
+ ...empty,
1854
+ plot,
1855
+ axes: { y: { min: dom.min, max: dom.max, log: false }, y2: null, slot, count: n, labels: spec.categories },
1856
+ grid: { x: axes.y.gridLines, y: false },
1857
+ orientation: 'horizontal',
1858
+ bullets,
1859
+ valueTicks,
1860
+ catTicks,
1861
+ legend: [{ label: src.label, color: src.color }],
1862
+ }
1863
+ }