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