@adia-ai/web-components 0.4.5 → 0.4.7

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 (316) hide show
  1. package/README.md +63 -24
  2. package/USAGE.md +604 -0
  3. package/components/accordion/accordion.d.ts +17 -0
  4. package/components/accordion/accordion.js +10 -117
  5. package/components/accordion/class.js +132 -0
  6. package/components/action-list/action-list.d.ts +15 -0
  7. package/components/action-list/action-list.js +9 -140
  8. package/components/action-list/class.js +156 -0
  9. package/components/agent-artifact/agent-artifact.d.ts +25 -0
  10. package/components/agent-artifact/agent-artifact.js +8 -181
  11. package/components/agent-artifact/class.js +200 -0
  12. package/components/agent-feedback-bar/agent-feedback-bar.d.ts +21 -0
  13. package/components/agent-feedback-bar/agent-feedback-bar.js +8 -143
  14. package/components/agent-feedback-bar/class.js +162 -0
  15. package/components/agent-questions/agent-questions.d.ts +23 -0
  16. package/components/agent-questions/agent-questions.js +8 -180
  17. package/components/agent-questions/class.js +199 -0
  18. package/components/agent-reasoning/agent-reasoning.d.ts +23 -0
  19. package/components/agent-reasoning/agent-reasoning.js +8 -494
  20. package/components/agent-reasoning/class.js +513 -0
  21. package/components/agent-suggestions/agent-suggestions.d.ts +21 -0
  22. package/components/agent-suggestions/agent-suggestions.js +8 -78
  23. package/components/agent-suggestions/class.js +97 -0
  24. package/components/agent-trace/agent-trace.d.ts +19 -0
  25. package/components/alert/alert.d.ts +29 -0
  26. package/components/alert/alert.js +8 -175
  27. package/components/alert/class.js +194 -0
  28. package/components/avatar/avatar.d.ts +27 -0
  29. package/components/avatar/avatar.js +9 -159
  30. package/components/avatar/class.js +173 -0
  31. package/components/badge/badge.d.ts +27 -0
  32. package/components/badge/badge.js +9 -75
  33. package/components/badge/class.js +93 -0
  34. package/components/block/block.d.ts +19 -0
  35. package/components/block/block.js +9 -15
  36. package/components/block/class.js +33 -0
  37. package/components/breadcrumb/breadcrumb.d.ts +23 -0
  38. package/components/breadcrumb/breadcrumb.js +8 -113
  39. package/components/breadcrumb/class.js +132 -0
  40. package/components/button/button.d.ts +34 -0
  41. package/components/button/button.js +15 -66
  42. package/components/button/class.js +80 -0
  43. package/components/calendar-picker/calendar-picker.a2ui.json +6 -1
  44. package/components/calendar-picker/calendar-picker.d.ts +27 -0
  45. package/components/calendar-picker/calendar-picker.js +8 -332
  46. package/components/calendar-picker/calendar-picker.yaml +51 -177
  47. package/components/calendar-picker/class.js +351 -0
  48. package/components/canvas/canvas.a2ui.json +6 -1
  49. package/components/canvas/canvas.d.ts +17 -0
  50. package/components/canvas/canvas.yaml +19 -36
  51. package/components/card/card.a2ui.json +3 -0
  52. package/components/card/card.d.ts +27 -0
  53. package/components/card/card.js +9 -50
  54. package/components/card/card.yaml +171 -433
  55. package/components/card/class.js +68 -0
  56. package/components/chart/chart.d.ts +41 -0
  57. package/components/chart/chart.js +8 -2131
  58. package/components/chart/class.js +2150 -0
  59. package/components/chart-legend/chart-legend.d.ts +27 -0
  60. package/components/chart-legend/chart-legend.js +8 -197
  61. package/components/chart-legend/class.js +215 -0
  62. package/components/chat-thread/chat-thread.d.ts +17 -0
  63. package/components/chat-thread/chat-thread.js +8 -157
  64. package/components/chat-thread/class.js +176 -0
  65. package/components/check/check.d.ts +30 -0
  66. package/components/check/check.js +11 -52
  67. package/components/check/class.js +68 -0
  68. package/components/code/class.js +501 -0
  69. package/components/code/code.d.ts +39 -0
  70. package/components/code/code.js +8 -482
  71. package/components/col/class.js +30 -0
  72. package/components/col/col.d.ts +23 -0
  73. package/components/col/col.js +10 -13
  74. package/components/color-picker/class.js +550 -0
  75. package/components/color-picker/color-picker.d.ts +37 -0
  76. package/components/color-picker/color-picker.js +8 -531
  77. package/components/command/class.js +364 -0
  78. package/components/command/command.a2ui.json +3 -0
  79. package/components/command/command.d.ts +19 -0
  80. package/components/command/command.js +8 -345
  81. package/components/command/command.yaml +105 -124
  82. package/components/demo-toggle/class.js +153 -0
  83. package/components/demo-toggle/demo-toggle.d.ts +23 -0
  84. package/components/demo-toggle/demo-toggle.js +8 -135
  85. package/components/description-list/class.js +86 -0
  86. package/components/description-list/description-list.d.ts +21 -0
  87. package/components/description-list/description-list.js +8 -67
  88. package/components/divider/class.js +57 -0
  89. package/components/divider/divider.d.ts +19 -0
  90. package/components/divider/divider.js +10 -40
  91. package/components/drawer/class.js +306 -0
  92. package/components/drawer/drawer.d.ts +25 -0
  93. package/components/drawer/drawer.js +8 -287
  94. package/components/embed/class.js +73 -0
  95. package/components/embed/embed.d.ts +23 -0
  96. package/components/embed/embed.js +9 -55
  97. package/components/empty-state/class.js +108 -0
  98. package/components/empty-state/empty-state.d.ts +21 -0
  99. package/components/empty-state/empty-state.js +9 -90
  100. package/components/feed/class.js +381 -0
  101. package/components/feed/feed.d.ts +19 -0
  102. package/components/feed/feed.js +9 -367
  103. package/components/field/class.js +266 -0
  104. package/components/field/field.d.ts +23 -0
  105. package/components/field/field.js +8 -247
  106. package/components/fields/class.js +106 -0
  107. package/components/fields/fields.d.ts +19 -0
  108. package/components/fields/fields.js +8 -87
  109. package/components/grid/class.js +31 -0
  110. package/components/grid/grid.d.ts +23 -0
  111. package/components/grid/grid.js +10 -14
  112. package/components/heatmap/class.js +305 -0
  113. package/components/heatmap/heatmap.d.ts +31 -0
  114. package/components/heatmap/heatmap.js +8 -286
  115. package/components/icon/class.js +54 -0
  116. package/components/icon/icon.d.ts +23 -0
  117. package/components/icon/icon.js +13 -40
  118. package/components/image/class.js +112 -0
  119. package/components/image/image.d.ts +33 -0
  120. package/components/image/image.js +9 -94
  121. package/components/index.js +1 -0
  122. package/components/input/class.js +773 -0
  123. package/components/input/input.a2ui.json +3 -0
  124. package/components/input/input.d.ts +61 -0
  125. package/components/input/input.js +8 -755
  126. package/components/input/input.yaml +171 -442
  127. package/components/inspector/class.js +142 -0
  128. package/components/inspector/inspector.a2ui.json +8 -1
  129. package/components/inspector/inspector.d.ts +17 -0
  130. package/components/inspector/inspector.js +8 -124
  131. package/components/inspector/inspector.yaml +15 -30
  132. package/components/kbd/class.js +34 -0
  133. package/components/kbd/kbd.a2ui.json +3 -0
  134. package/components/kbd/kbd.d.ts +17 -0
  135. package/components/kbd/kbd.js +10 -17
  136. package/components/kbd/kbd.yaml +54 -185
  137. package/components/link/class.js +187 -0
  138. package/components/link/link.d.ts +55 -0
  139. package/components/link/link.js +8 -168
  140. package/components/list/class.js +249 -0
  141. package/components/list/list.d.ts +23 -0
  142. package/components/list/list.js +9 -231
  143. package/components/menu/class.js +332 -0
  144. package/components/menu/menu.d.ts +21 -0
  145. package/components/menu/menu.js +11 -316
  146. package/components/modal/class.js +231 -0
  147. package/components/modal/modal.a2ui.json +5 -1
  148. package/components/modal/modal.d.ts +23 -0
  149. package/components/modal/modal.js +8 -212
  150. package/components/modal/modal.yaml +19 -39
  151. package/components/nav/class.js +150 -0
  152. package/components/nav/nav.d.ts +31 -0
  153. package/components/nav/nav.js +8 -131
  154. package/components/nav-group/class.js +152 -0
  155. package/components/nav-group/nav-group.d.ts +35 -0
  156. package/components/nav-group/nav-group.js +9 -134
  157. package/components/nav-item/class.js +86 -0
  158. package/components/nav-item/nav-item.d.ts +37 -0
  159. package/components/nav-item/nav-item.js +10 -69
  160. package/components/noodles/class.js +510 -0
  161. package/components/noodles/noodles.d.ts +33 -0
  162. package/components/noodles/noodles.js +9 -493
  163. package/components/option-card/class.js +167 -0
  164. package/components/option-card/option-card.d.ts +30 -0
  165. package/components/option-card/option-card.js +8 -149
  166. package/components/otp-input/class.js +180 -0
  167. package/components/otp-input/otp-input.a2ui.json +5 -1
  168. package/components/otp-input/otp-input.d.ts +25 -0
  169. package/components/otp-input/otp-input.js +9 -162
  170. package/components/otp-input/otp-input.yaml +45 -174
  171. package/components/page/class.js +97 -0
  172. package/components/page/page.d.ts +46 -0
  173. package/components/page/page.js +8 -79
  174. package/components/pagination/class.js +195 -0
  175. package/components/pagination/pagination.d.ts +23 -0
  176. package/components/pagination/pagination.js +9 -177
  177. package/components/pane/class.js +186 -0
  178. package/components/pane/pane.a2ui.json +12 -1
  179. package/components/pane/pane.css +10 -0
  180. package/components/pane/pane.d.ts +31 -0
  181. package/components/pane/pane.js +8 -143
  182. package/components/pane/pane.yaml +57 -157
  183. package/components/pipeline-status/class.js +189 -0
  184. package/components/pipeline-status/pipeline-status.a2ui.json +7 -1
  185. package/components/pipeline-status/pipeline-status.d.ts +21 -0
  186. package/components/pipeline-status/pipeline-status.js +9 -172
  187. package/components/pipeline-status/pipeline-status.yaml +34 -72
  188. package/components/popover/class.js +194 -0
  189. package/components/popover/popover.d.ts +23 -0
  190. package/components/popover/popover.js +9 -176
  191. package/components/progress/class.js +74 -0
  192. package/components/progress/progress.a2ui.json +3 -0
  193. package/components/progress/progress.d.ts +19 -0
  194. package/components/progress/progress.js +10 -57
  195. package/components/progress/progress.yaml +124 -287
  196. package/components/progress-row/class.js +110 -0
  197. package/components/progress-row/progress-row.d.ts +23 -0
  198. package/components/progress-row/progress-row.js +8 -92
  199. package/components/radio/class.js +83 -0
  200. package/components/radio/radio.d.ts +28 -0
  201. package/components/radio/radio.js +11 -67
  202. package/components/range/class.js +194 -0
  203. package/components/range/range.d.ts +31 -0
  204. package/components/range/range.js +9 -176
  205. package/components/rating/class.js +148 -0
  206. package/components/rating/rating.d.ts +33 -0
  207. package/components/rating/rating.js +9 -130
  208. package/components/richtext/class.js +87 -0
  209. package/components/richtext/richtext.a2ui.json +7 -1
  210. package/components/richtext/richtext.d.ts +19 -0
  211. package/components/richtext/richtext.js +8 -68
  212. package/components/richtext/richtext.yaml +30 -65
  213. package/components/row/class.js +50 -0
  214. package/components/row/row.d.ts +27 -0
  215. package/components/row/row.js +10 -33
  216. package/components/search/class.js +134 -0
  217. package/components/search/search.d.ts +35 -0
  218. package/components/search/search.js +10 -117
  219. package/components/segment/class.js +62 -0
  220. package/components/segment/segment.d.ts +25 -0
  221. package/components/segment/segment.js +10 -45
  222. package/components/segmented/class.js +165 -0
  223. package/components/segmented/segmented.a2ui.json +4 -0
  224. package/components/segmented/segmented.d.ts +24 -0
  225. package/components/segmented/segmented.js +10 -148
  226. package/components/segmented/segmented.yaml +41 -59
  227. package/components/select/class.js +408 -0
  228. package/components/select/select.d.ts +57 -0
  229. package/components/select/select.js +15 -396
  230. package/components/skeleton/class.js +52 -0
  231. package/components/skeleton/skeleton.d.ts +23 -0
  232. package/components/skeleton/skeleton.js +8 -34
  233. package/components/slider/class.js +184 -0
  234. package/components/slider/slider.d.ts +31 -0
  235. package/components/slider/slider.js +9 -166
  236. package/components/stack/class.js +28 -0
  237. package/components/stack/stack.d.ts +17 -0
  238. package/components/stack/stack.js +10 -11
  239. package/components/step-progress/class.js +98 -0
  240. package/components/step-progress/step-progress.d.ts +27 -0
  241. package/components/step-progress/step-progress.js +8 -79
  242. package/components/stepper/class.js +126 -0
  243. package/components/stepper/stepper.d.ts +19 -0
  244. package/components/stepper/stepper.js +9 -112
  245. package/components/stream/class.js +109 -0
  246. package/components/stream/stream.d.ts +19 -0
  247. package/components/stream/stream.js +8 -90
  248. package/components/swatch/class.js +131 -0
  249. package/components/swatch/swatch.d.ts +28 -0
  250. package/components/swatch/swatch.js +8 -112
  251. package/components/swiper/class.js +373 -0
  252. package/components/swiper/swiper.a2ui.json +4 -0
  253. package/components/swiper/swiper.d.ts +31 -0
  254. package/components/swiper/swiper.js +8 -354
  255. package/components/swiper/swiper.yaml +68 -212
  256. package/components/switch/class.js +63 -0
  257. package/components/switch/switch.a2ui.json +6 -1
  258. package/components/switch/switch.d.ts +30 -0
  259. package/components/switch/switch.js +11 -47
  260. package/components/switch/switch.yaml +70 -265
  261. package/components/table/class.js +1453 -0
  262. package/components/table/table.d.ts +37 -0
  263. package/components/table/table.js +8 -1435
  264. package/components/table-toolbar/class.js +680 -0
  265. package/components/table-toolbar/table-toolbar.d.ts +33 -0
  266. package/components/table-toolbar/table-toolbar.js +8 -689
  267. package/components/tabs/class.js +242 -0
  268. package/components/tabs/tabs.d.ts +21 -0
  269. package/components/tabs/tabs.js +8 -223
  270. package/components/tag/class.js +99 -0
  271. package/components/tag/tag.d.ts +27 -0
  272. package/components/tag/tag.js +8 -80
  273. package/components/text/class.js +46 -0
  274. package/components/text/text.d.ts +25 -0
  275. package/components/text/text.js +9 -28
  276. package/components/textarea/class.js +134 -0
  277. package/components/textarea/textarea.d.ts +31 -0
  278. package/components/textarea/textarea.js +11 -118
  279. package/components/timeline/class.js +176 -0
  280. package/components/timeline/timeline.d.ts +19 -0
  281. package/components/timeline/timeline.js +9 -162
  282. package/components/toast/class.js +92 -0
  283. package/components/toast/toast.d.ts +23 -0
  284. package/components/toast/toast.js +9 -76
  285. package/components/toggle-group/class.js +154 -0
  286. package/components/toggle-group/toggle-group.d.ts +19 -0
  287. package/components/toggle-group/toggle-group.js +11 -140
  288. package/components/toggle-scheme/class.js +286 -0
  289. package/components/toggle-scheme/toggle-scheme.a2ui.json +197 -0
  290. package/components/toggle-scheme/toggle-scheme.css +20 -0
  291. package/components/toggle-scheme/toggle-scheme.d.ts +41 -0
  292. package/components/toggle-scheme/toggle-scheme.js +17 -0
  293. package/components/toggle-scheme/toggle-scheme.yaml +173 -0
  294. package/components/toolbar/class.js +388 -0
  295. package/components/toolbar/toolbar.d.ts +23 -0
  296. package/components/toolbar/toolbar.js +10 -376
  297. package/components/tooltip/class.js +299 -0
  298. package/components/tooltip/tooltip.d.ts +27 -0
  299. package/components/tooltip/tooltip.js +8 -280
  300. package/components/tree/class.js +245 -0
  301. package/components/tree/tree.d.ts +15 -0
  302. package/components/tree/tree.js +9 -244
  303. package/components/upload/class.js +199 -0
  304. package/components/upload/upload.d.ts +27 -0
  305. package/components/upload/upload.js +11 -183
  306. package/core/element.d.ts +174 -0
  307. package/core/form.d.ts +108 -0
  308. package/core/index.d.ts +11 -0
  309. package/core/index.js +1 -0
  310. package/core/register.d.ts +25 -0
  311. package/core/register.js +58 -0
  312. package/core/signals.d.ts +94 -0
  313. package/core/template.d.ts +70 -0
  314. package/index.d.ts +315 -0
  315. package/package.json +25 -6
  316. package/traits/CATEGORIES.md +1 -1
@@ -0,0 +1,2150 @@
1
+ /**
2
+ * Non-side-effect class export for `<chart-ui>`.
3
+ *
4
+ * Importing this file gives you the class(es) without auto-registering the tag.
5
+ * Useful for test isolation, subclassing with tag-name override, or selective
6
+ * composition.
7
+ *
8
+ * The auto-register path stays at `@adia-ai/web-components/components/chart`
9
+ * (which imports this file + calls `defineIfFree()`).
10
+ *
11
+ * @see ../../USAGE.md#registration--auto-vs-explicit
12
+ */
13
+
14
+ /**
15
+ * <chart-ui type="bar" x="month" y="revenue" heading="Monthly Revenue"></chart-ui>
16
+ *
17
+ * Declarative chart component supporting bar, line, pie, donut, radar,
18
+ * sparkline, stacked-bar, grouped-bar, and multi-line chart types.
19
+ *
20
+ * Attributes:
21
+ * type — chart type (default: 'bar')
22
+ * heading — chart heading text
23
+ * x — key for X-axis data
24
+ * y — key(s) for Y-axis, comma-separated for multi-series
25
+ * hide-average — suppress the overlaid average line on bar/line (default: false — line shown)
26
+ * color — accent/success/warning/danger/info
27
+ * hide-grid — hide gridlines
28
+ * hide-values — hide value labels
29
+ * radius — bar corner radius (default: 4)
30
+ *
31
+ * JS API:
32
+ * .data = [{...}, ...] — array of data objects
33
+ */
34
+
35
+ import { UIElement } from '../../core/element.js';
36
+
37
+ /* ── Helpers ────────────────────────────────────────────────────── */
38
+
39
+ function niceScale(min, max, ticks = 5) {
40
+ const range = max - min || 1;
41
+ const rough = range / ticks;
42
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
43
+ const norm = rough / mag;
44
+ const nice = norm <= 1.5 ? 1 : norm <= 3 ? 2 : norm <= 7 ? 5 : 10;
45
+ const step = nice * mag;
46
+ const lo = Math.floor(min / step) * step;
47
+ const hi = Math.ceil(max / step) * step;
48
+ const result = [];
49
+ for (let v = lo; v <= hi + step * 0.5; v += step) result.push(+v.toFixed(10));
50
+ return result;
51
+ }
52
+
53
+ function fmt(v) {
54
+ if (v == null) return '';
55
+ const n = +v;
56
+ if (Number.isNaN(n)) return String(v);
57
+ if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
58
+ if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
59
+ return Number.isInteger(n) ? String(n) : n.toFixed(1);
60
+ }
61
+
62
+ function esc(s) {
63
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
64
+ }
65
+
66
+ /* Emit tooltip data-attributes for a datum shape. Consumed by the pointer
67
+ delegate below. All fields optional — unused ones are skipped. */
68
+ function tip({ label, value, pct, series }) {
69
+ let s = '';
70
+ if (label != null) s += ` data-tip-label="${esc(String(label))}"`;
71
+ if (value != null) s += ` data-tip-value="${value}"`;
72
+ if (pct != null) s += ` data-tip-pct="${pct}"`;
73
+ if (series != null) s += ` data-tip-series="${esc(String(series))}"`;
74
+ return s;
75
+ }
76
+
77
+ /* Polar helpers for pie/donut */
78
+ function polarX(cx, r, angle) { return cx + r * Math.cos(angle); }
79
+ function polarY(cy, r, angle) { return cy + r * Math.sin(angle); }
80
+
81
+ function arcPath(cx, cy, r, start, end) {
82
+ const x1 = polarX(cx, r, start);
83
+ const y1 = polarY(cy, r, start);
84
+ const x2 = polarX(cx, r, end);
85
+ const y2 = polarY(cy, r, end);
86
+ const large = end - start > Math.PI ? 1 : 0;
87
+ return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`;
88
+ }
89
+
90
+ function donutArcPath(cx, cy, outer, inner, start, end, radius = 0) {
91
+ // Radius is clamped to half the ring thickness. A radius of 0 renders
92
+ // a flat (original) wedge with sharp corners.
93
+ const ringHalf = (outer - inner) / 2;
94
+ const sliceAngle = end - start;
95
+ const r = Math.max(0, Math.min(radius, ringHalf));
96
+ const flatPath = () => {
97
+ const x1 = polarX(cx, outer, start);
98
+ const y1 = polarY(cy, outer, start);
99
+ const x2 = polarX(cx, outer, end);
100
+ const y2 = polarY(cy, outer, end);
101
+ const x3 = polarX(cx, inner, end);
102
+ const y3 = polarY(cy, inner, end);
103
+ const x4 = polarX(cx, inner, start);
104
+ const y4 = polarY(cy, inner, start);
105
+ const large = sliceAngle > Math.PI ? 1 : 0;
106
+ return `M ${x1} ${y1} A ${outer} ${outer} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${inner} ${inner} 0 ${large} 0 ${x4} ${y4} Z`;
107
+ };
108
+ if (r <= 0) return flatPath();
109
+
110
+ // Rounded-CORNER wedge (rounded rectangle-on-ring).
111
+ // Each end has two small corner arcs connected by a short radial line,
112
+ // so thin slices on thick rings don't collapse into pill shapes.
113
+ const aOuter = r / outer;
114
+ const aInner = r / inner;
115
+ if (sliceAngle <= (aOuter + aInner) * 1.05) return flatPath();
116
+
117
+ const sOuter = start + aOuter;
118
+ const eOuter = end - aOuter;
119
+ const sInner = start + aInner;
120
+ const eInner = end - aInner;
121
+ const large = (eOuter - sOuter) > Math.PI ? 1 : 0;
122
+
123
+ // Points: outer-arc ends, radial-line ends at each angular extremum.
124
+ const ox1 = polarX(cx, outer, sOuter);
125
+ const oy1 = polarY(cy, outer, sOuter);
126
+ const ox2 = polarX(cx, outer, eOuter);
127
+ const oy2 = polarY(cy, outer, eOuter);
128
+ const ix2 = polarX(cx, inner, eInner);
129
+ const iy2 = polarY(cy, inner, eInner);
130
+ const ix1 = polarX(cx, inner, sInner);
131
+ const iy1 = polarY(cy, inner, sInner);
132
+
133
+ // Radial-line endpoints: the corner pushes the radial inward by `r`,
134
+ // so the straight segment runs from (outer-r) down to (inner+r) at the
135
+ // end angle, and symmetrically at the start angle.
136
+ const rsOuter = polarX(cx, outer - r, start), rsOuterY = polarY(cy, outer - r, start);
137
+ const rsInner = polarX(cx, inner + r, start), rsInnerY = polarY(cy, inner + r, start);
138
+ const reOuter = polarX(cx, outer - r, end), reOuterY = polarY(cy, outer - r, end);
139
+ const reInner = polarX(cx, inner + r, end), reInnerY = polarY(cy, inner + r, end);
140
+
141
+ return `M ${ox1} ${oy1} ` +
142
+ `A ${outer} ${outer} 0 ${large} 1 ${ox2} ${oy2} ` + // outer arc (CW)
143
+ `A ${r} ${r} 0 0 1 ${reOuter} ${reOuterY} ` + // end outer corner
144
+ `L ${reInner} ${reInnerY} ` + // end radial line
145
+ `A ${r} ${r} 0 0 1 ${ix2} ${iy2} ` + // end inner corner
146
+ `A ${inner} ${inner} 0 ${large} 0 ${ix1} ${iy1} ` + // inner arc (CCW)
147
+ `A ${r} ${r} 0 0 1 ${rsInner} ${rsInnerY} ` + // start inner corner
148
+ `L ${rsOuter} ${rsOuterY} ` + // start radial line
149
+ `A ${r} ${r} 0 0 1 ${ox1} ${oy1} Z`; // start outer corner
150
+ }
151
+
152
+ /**
153
+ * Smooth path — converts points [{x,y}] to a cubic bezier SVG path.
154
+ * t = 0 → straight lines (polyline), t = 1 → maximum smoothing.
155
+ * Uses Catmull-Rom-to-Bezier spline conversion.
156
+ */
157
+ function smoothPath(points, t = 0.4) {
158
+ if (points.length < 2) return '';
159
+ if (t <= 0) return 'M' + points.map(p => `${p.x},${p.y}`).join(' L');
160
+
161
+ const n = points.length;
162
+ let d = `M${points[0].x},${points[0].y}`;
163
+
164
+ for (let i = 0; i < n - 1; i++) {
165
+ const p0 = points[Math.max(i - 1, 0)];
166
+ const p1 = points[i];
167
+ const p2 = points[i + 1];
168
+ const p3 = points[Math.min(i + 2, n - 1)];
169
+
170
+ const cp1x = p1.x + (p2.x - p0.x) * t / 3;
171
+ const cp1y = p1.y + (p2.y - p0.y) * t / 3;
172
+ const cp2x = p2.x - (p3.x - p1.x) * t / 3;
173
+ const cp2y = p2.y - (p3.y - p1.y) * t / 3;
174
+
175
+ d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
176
+ }
177
+
178
+ return d;
179
+ }
180
+
181
+ /** Build a closed area path from a smooth line path + baseline Y */
182
+ function smoothAreaPath(points, baselineY, t = 0.4) {
183
+ const line = smoothPath(points, t);
184
+ const last = points[points.length - 1];
185
+ const first = points[0];
186
+ return `${line} L${last.x},${baselineY} L${first.x},${baselineY} Z`;
187
+ }
188
+
189
+ /** Build a column-bar path with only the TOP corners rounded so the bar
190
+ * sits flush on its baseline axis. Used for bar / grouped-bar / composed
191
+ * bar series / stacked-bar single + top segments — the value end gets a
192
+ * cap, the axis end stays square. r is clamped to (w/2, h) to handle
193
+ * short bars without the arcs overlapping or escaping the rect. */
194
+ function topRoundedBarPath(x, y, w, h, r = 0) {
195
+ if (h <= 0 || w <= 0) return '';
196
+ const rr = Math.max(0, Math.min(r, w / 2, h));
197
+ if (rr === 0) {
198
+ return `M${x},${y} H${x + w} V${y + h} H${x} Z`;
199
+ }
200
+ return `M${x},${y + h} V${y + rr} Q${x},${y} ${x + rr},${y} H${x + w - rr} Q${x + w},${y} ${x + w},${y + rr} V${y + h} Z`;
201
+ }
202
+
203
+ /* ── Aspect ratios ─────────────────────────────────────────────── */
204
+
205
+ const ASPECTS = {
206
+ std: { ratio: 4 / 3 }, // default — balanced dataviz proportion
207
+ wide: { ratio: 16 / 9 }, // landscape / video (sparkline, timeline)
208
+ square: { ratio: 1 }, // pie / donut / radar
209
+ tall: { ratio: 3 / 4 }, // vertical column
210
+ };
211
+
212
+ /* ── Component ──────────────────────────────────────────────────── */
213
+
214
+ export class UIChart extends UIElement {
215
+ static properties = {
216
+ type: { type: String, default: 'bar', reflect: true },
217
+ heading: { type: String, default: '', reflect: true },
218
+ x: { type: String, default: '', reflect: true },
219
+ y: { type: String, default: '', reflect: true },
220
+ hideAverage: { type: Boolean, default: false, reflect: true, attribute: 'hide-average' },
221
+ color: { type: String, default: '', reflect: true },
222
+ hideGrid: { type: Boolean, default: false, reflect: true, attribute: 'hide-grid' },
223
+ hideValues: { type: Boolean, default: false, reflect: true, attribute: 'hide-values' },
224
+ radius: { type: Number, default: null, reflect: true },
225
+ smooth: { type: Number, default: 0.4, reflect: true },
226
+ aspect: { type: String, default: 'std', reflect: true },
227
+ size: { type: String, default: '', reflect: true },
228
+ format: { type: String, default: 'abbr', reflect: true },
229
+ };
230
+
231
+ static template = () => null;
232
+
233
+ #data = [];
234
+ #resizeObs = null;
235
+ #resizeRaf = null;
236
+ #lastW = 0;
237
+ #lastH = 0;
238
+ /* Set of series keys hidden by external chart-legend-ui[for=self] toggles.
239
+ Kept at render time; repopulated lookups rebuild with the new set. */
240
+ #hiddenSeriesKeys = new Set();
241
+
242
+ /** Resolves the corner radius: the `radius` prop if explicitly set,
243
+ * otherwise `--a-radius` from the host's computed style. Falls back
244
+ * to 6 if the token is unset (should not happen in practice). */
245
+ #resolveRadius() {
246
+ if (this.radius != null) return this.radius;
247
+ const val = getComputedStyle(this).getPropertyValue('--a-radius').trim();
248
+ const parsed = parseFloat(val);
249
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 6;
250
+ }
251
+
252
+ set data(arr) {
253
+ this.#data = Array.isArray(arr) ? arr : [];
254
+ this.#warnIfOverBudget();
255
+ this.#renderChart();
256
+ }
257
+
258
+ get data() {
259
+ return this.#data;
260
+ }
261
+
262
+ /* OD-CHART-11 — one-shot perf-budget warning. Chart-ui re-renders the
263
+ full SVG string on every data change; perf drops noticeably past
264
+ ~5,000 datums (the tipping point varies by type — scatter and
265
+ multi-line are the worst offenders). Authors over the budget should
266
+ downsample at the data layer (LTTB, uniform sampling, aggregation
267
+ by bucket) before setting .data. Override the threshold via the
268
+ `--chart-perf-budget` CSS token for ad-hoc testing. */
269
+ #perfBudgetWarned = false;
270
+ #warnIfOverBudget() {
271
+ if (this.#perfBudgetWarned) return;
272
+ const budget = this.#readPerfBudget();
273
+ if (this.#data.length > budget) {
274
+ console.warn(
275
+ `[chart-ui] .data has ${this.#data.length} rows which exceeds the ` +
276
+ `recommended perf budget of ${budget}. Consider downsampling at the ` +
277
+ `data layer (e.g., LTTB, bucket aggregation). Override the budget ` +
278
+ `via CSS: chart-ui { --chart-perf-budget: 10000 }.`
279
+ );
280
+ this.#perfBudgetWarned = true;
281
+ }
282
+ }
283
+ #readPerfBudget() {
284
+ const raw = getComputedStyle(this).getPropertyValue('--chart-perf-budget').trim();
285
+ const n = parseInt(raw, 10);
286
+ return Number.isFinite(n) && n > 0 ? n : 5000;
287
+ }
288
+
289
+ connected() {
290
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'img');
291
+ if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', this.heading || `${this.type} chart`);
292
+
293
+ /* Hydrate from inline `data="[…]"` HTML attribute. The canonical
294
+ entry point is the `.data` property (set programmatically), but
295
+ consumers commonly try the same declarative attribute shape that
296
+ every other chart prop accepts — `<chart-ui data='[…]' x="m"
297
+ y="v">`. UIElement's property system doesn't deserialize JSON
298
+ array attributes, so a static-HTML chart authored that way would
299
+ otherwise render empty. JSON-parse once at connect; malformed
300
+ payloads are ignored silently and a property assignment later
301
+ still wins. */
302
+ if (this.#data.length === 0 && this.hasAttribute('data')) {
303
+ try {
304
+ const parsed = JSON.parse(this.getAttribute('data'));
305
+ if (Array.isArray(parsed)) this.data = parsed;
306
+ } catch (_) { /* malformed JSON — leave empty, render() bails on no data */ }
307
+ }
308
+
309
+ /* Listen for canonical `toggle` events bubbled from external
310
+ chart-legend-ui[for=self] descendants. The handler filters by
311
+ target so other components dispatching `toggle` (accordion-ui,
312
+ agent-trace-ui, etc.) don't interfere. */
313
+ document.addEventListener('toggle', this.#onLegendToggle);
314
+
315
+ /* OD-CHART-06 — keyboard a11y. Chart becomes focusable; arrow keys
316
+ move a virtual focus across datums in DOM order, Enter/Space fires
317
+ chart-select, Escape clears focus. Per-datum focus dispatches the
318
+ same chart-hover event the pointer path uses, so tooltip-ui[for]
319
+ tracks keyboard focus transparently.
320
+ Deprecation warnings for `aspect=` and `heading=` also fire here
321
+ per OD-CHART-02 — one-shot per instance to keep the console clean. */
322
+ if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
323
+ this.addEventListener('keydown', this.#onKeydown);
324
+ this.addEventListener('focus', this.#onFocus);
325
+ this.addEventListener('blur', this.#onBlur);
326
+ /* Pointer/click handlers attached to the host (not per-render SVG) so
327
+ render() stays listener-graph-idempotent. Handlers use
328
+ e.target.closest('[data-tip-*]') so they only fire on real datums. */
329
+ this.addEventListener('pointerover', this.#onPointerOver);
330
+ this.addEventListener('pointermove', this.#onPointerMove);
331
+ this.addEventListener('pointerleave', this.#onPointerLeave);
332
+ this.addEventListener('pointerdown', this.#onPointerDown);
333
+ this.addEventListener('click', this.#onClick);
334
+ this.#warnDeprecatedAttrs();
335
+
336
+ this.#resizeObs = new ResizeObserver((entries) => {
337
+ const { inlineSize: w, blockSize: h } = entries[0].contentBoxSize[0];
338
+ if (!this.#data.length) return;
339
+ const rw = Math.round(w);
340
+ const rh = Math.round(h);
341
+ if (rw === this.#lastW && (!this.hasAttribute('resize') || rh === this.#lastH)) return;
342
+ this.#lastW = rw;
343
+ this.#lastH = rh;
344
+ if (this.#resizeRaf) return;
345
+ this.#resizeRaf = requestAnimationFrame(() => {
346
+ this.#resizeRaf = null;
347
+ this.#renderChart();
348
+ });
349
+ });
350
+ this.#resizeObs.observe(this);
351
+ }
352
+
353
+ render() {
354
+ this.#renderChart();
355
+ }
356
+
357
+ /**
358
+ * Compute layout dimensions from actual container width.
359
+ * Returns { width, height, pad, fontSize, labelSize, valueSize, barMinW }
360
+ */
361
+ #dims() {
362
+ const containerW = this.clientWidth || 300;
363
+ const containerH = this.clientHeight || 0;
364
+ const aspect = ASPECTS[this.aspect] || ASPECTS.std;
365
+ const n = this.#data.length || 1;
366
+
367
+ const width = Math.max(containerW, 120);
368
+ // When element has explicit height (resize handle, CSS height, inline style),
369
+ // use it. Otherwise derive from aspect ratio. Safe because resize sets
370
+ // overflow:auto which prevents content from pushing height.
371
+ const hasExplicitHeight = this.hasAttribute('resize') || this.style.height;
372
+ let height = hasExplicitHeight && containerH > 40
373
+ ? containerH
374
+ : Math.round(width / aspect.ratio);
375
+
376
+ // Cap against --chart-max-height (CSS-resolved to px by getComputedStyle).
377
+ // Keeps wide-viewport renders from producing absurdly tall charts while
378
+ // staying overridable — set --chart-max-height: none (or a larger value)
379
+ // per-chart to opt out.
380
+ const maxH = parseFloat(getComputedStyle(this).maxHeight);
381
+ if (isFinite(maxH) && maxH > 0 && height > maxH) height = Math.round(maxH);
382
+
383
+ // Scale-aware font sizes: consistent regardless of chart dimensions
384
+ // Use sm/md/lg or auto-detect from smallest dimension
385
+ const minDim = Math.min(width, height);
386
+ const sizeClass = this.size || (minDim < 150 ? 'sm' : minDim < 300 ? 'md' : 'lg');
387
+ const fontSize = sizeClass === 'sm' ? 9 : sizeClass === 'md' ? 10 : 11;
388
+ const labelSize = fontSize;
389
+ const valueSize = sizeClass === 'sm' ? 8 : sizeClass === 'md' ? 9 : 10;
390
+
391
+ // Padding scales with font size and axis visibility.
392
+ // When the grid is hidden (in-card sparkline-like use) we can zero
393
+ // out the Y-axis label gutter entirely — otherwise the plot area
394
+ // collapses at small sizes.
395
+ const hideGrid = this.hideGrid;
396
+ const yLabelW = hideGrid ? 0 : fontSize * 3.2;
397
+ const hasXLabels = !!this.x;
398
+ const pad = {
399
+ top: hideGrid ? 2 : fontSize * 1.6,
400
+ right: hideGrid ? 2 : fontSize * 1.2,
401
+ bottom: !hasXLabels || hideGrid ? 2 : fontSize * 2.2,
402
+ left: yLabelW,
403
+ };
404
+
405
+ // Bar width adapts to data count + container
406
+ const plotW = width - pad.left - pad.right;
407
+ const barMinW = Math.max(4, plotW / n * 0.6);
408
+
409
+ return { width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass };
410
+ }
411
+
412
+ /* ── Main render ──────────────────────────────────────────────── */
413
+
414
+ #emptySlotTemplate = null;
415
+
416
+ #renderChart() {
417
+ if (!this.isConnected) return;
418
+
419
+ /* Preserve the empty slot across re-renders. First render captures the
420
+ author-provided <* slot="empty"> into a template; subsequent renders
421
+ restore it so data-empty → data-present → data-empty transitions
422
+ don't lose the slotted empty-state-ui. Visibility is CSS-toggled via
423
+ [data-has-data] on the host. */
424
+ const existingEmpty = this.querySelector(':scope > [slot="empty"]');
425
+ if (existingEmpty && !this.#emptySlotTemplate) {
426
+ this.#emptySlotTemplate = existingEmpty.cloneNode(true);
427
+ }
428
+
429
+ this.innerHTML = '';
430
+ if (this.#emptySlotTemplate) this.appendChild(this.#emptySlotTemplate.cloneNode(true));
431
+
432
+ if (!this.#data.length) {
433
+ this.removeAttribute('data-has-data');
434
+ return;
435
+ }
436
+ this.setAttribute('data-has-data', '');
437
+
438
+ this.#injectSeriesColors();
439
+
440
+ if (this.heading) {
441
+ const headingEl = document.createElement('div');
442
+ headingEl.setAttribute('data-chart-heading', '');
443
+ headingEl.textContent = this.heading;
444
+ this.appendChild(headingEl);
445
+ }
446
+
447
+ const svgWrap = document.createElement('div');
448
+ if (this.type === 'sparkline') svgWrap.setAttribute('data-sparkline', '');
449
+ this.appendChild(svgWrap);
450
+
451
+ const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
452
+ svgWrap.appendChild(svgEl);
453
+
454
+ let svgContent = '';
455
+ let vb = '';
456
+
457
+ switch (this.type) {
458
+ case 'bar': ({ svg: svgContent, viewBox: vb } = this.#renderBar()); break;
459
+ case 'line': ({ svg: svgContent, viewBox: vb } = this.#renderLine()); break;
460
+ case 'pie': ({ svg: svgContent, viewBox: vb } = this.#renderPie()); break;
461
+ case 'donut': ({ svg: svgContent, viewBox: vb } = this.#renderDonut()); break;
462
+ case 'radar': ({ svg: svgContent, viewBox: vb } = this.#renderRadar()); break;
463
+ case 'sparkline': ({ svg: svgContent, viewBox: vb } = this.#renderSparkline()); break;
464
+ case 'segments': ({ svg: svgContent, viewBox: vb } = this.#renderSegments()); break;
465
+ case 'area': ({ svg: svgContent, viewBox: vb } = this.#renderArea()); break;
466
+ case 'scatter': ({ svg: svgContent, viewBox: vb } = this.#renderScatter()); break;
467
+ case 'radial-bar': ({ svg: svgContent, viewBox: vb } = this.#renderRadialBar()); break;
468
+ case 'gauge': ({ svg: svgContent, viewBox: vb } = this.#renderGauge()); break;
469
+ case 'funnel': ({ svg: svgContent, viewBox: vb } = this.#renderFunnel()); break;
470
+ case 'treemap': ({ svg: svgContent, viewBox: vb } = this.#renderTreemap()); break;
471
+ case 'sankey': ({ svg: svgContent, viewBox: vb } = this.#renderSankey()); break;
472
+ case 'composed': ({ svg: svgContent, viewBox: vb } = this.#renderComposed()); break;
473
+ case 'stacked-bar': ({ svg: svgContent, viewBox: vb } = this.#renderStackedBar()); break;
474
+ case 'grouped-bar': ({ svg: svgContent, viewBox: vb } = this.#renderGroupedBar()); break;
475
+ case 'multi-line': ({ svg: svgContent, viewBox: vb } = this.#renderMultiLine()); break;
476
+ default: ({ svg: svgContent, viewBox: vb } = this.#renderBar()); break;
477
+ }
478
+
479
+ svgEl.setAttribute('viewBox', vb);
480
+ svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
481
+ svgEl.innerHTML = svgContent;
482
+
483
+ /* Append legend for types that need it. Legend data is also exposed via
484
+ the public `legendData` getter so a sibling chart-legend-ui[for] can
485
+ mirror the same series. When an external legend is present (Phase 1b:
486
+ chart-legend-ui[for=self]), the internal one is suppressed so we
487
+ don't double-render — the #legendData remains populated for the
488
+ external to read. */
489
+ if (['pie', 'donut', 'stacked-bar', 'grouped-bar', 'multi-line', 'radial-bar'].includes(this.type)
490
+ && !this.#hasExternalLegend()) {
491
+ const legend = this.#buildLegend();
492
+ if (legend) this.appendChild(legend);
493
+ }
494
+
495
+ /* Notify external legend/tooltip consumers that legendData has refreshed. */
496
+ this.dispatchEvent(new CustomEvent('legend-update', { bubbles: true }));
497
+
498
+ /* Hover tooltip + custom events are wired in connected() — host-level
499
+ so they survive the innerHTML wipe at render. Internal tooltip
500
+ (#tipEl) remains for back-compat; Phase 2 retires it when
501
+ tooltip-ui[follows=pointer] lands. */
502
+ }
503
+
504
+ /* ── Per-series --color-{key} injection ──
505
+ For each declared series key (`y="revenue,users"`), emit an inline CSS
506
+ custom property on the host mapping the key to the categorical palette
507
+ slot it occupies. Consumers override `--color-revenue` at any ancestor
508
+ to recolor that series across chart + legend + (Phase 2) tooltip. */
509
+ #injectSeriesColors() {
510
+ const keys = this.#yKeys();
511
+ for (let i = 0; i < keys.length; i++) {
512
+ const slot = i % 10;
513
+ this.style.setProperty(`--color-${keys[i]}`, `var(--chart-${slot})`);
514
+ }
515
+ }
516
+
517
+ /* Render-time helpers — emit data-slice + series-key + inline style that
518
+ references `--color-{key}` with fallback to `--chart-{slot}`. Inline
519
+ style beats the stylesheet's data-slice rule, so consumer overrides of
520
+ --color-{key} at any ancestor recolor that series. Non-series-keyed
521
+ elements (pie/donut/segments categorical slots) keep data-slice only
522
+ and are coloured by the stylesheet. */
523
+ #seriesFill(slotIdx, seriesKey) {
524
+ if (!seriesKey) return ` data-slice="${slotIdx}"`;
525
+ return ` data-slice="${slotIdx}" data-series-key="${esc(seriesKey)}" style="fill: var(--color-${seriesKey}, var(--chart-${slotIdx}))"`;
526
+ }
527
+ #seriesStroke(slotIdx, seriesKey) {
528
+ if (!seriesKey) return ` data-slice="${slotIdx}"`;
529
+ return ` data-slice="${slotIdx}" data-series-key="${esc(seriesKey)}" style="stroke: var(--color-${seriesKey}, var(--chart-${slotIdx}))"`;
530
+ }
531
+
532
+ /* ── Tooltip ───────────────────────────────────────────────────── */
533
+
534
+ #tipEl = null;
535
+
536
+ disconnected() {
537
+ this.#resizeObs?.disconnect();
538
+ this.#resizeObs = null;
539
+ if (this.#resizeRaf) { cancelAnimationFrame(this.#resizeRaf); this.#resizeRaf = null; }
540
+ document.removeEventListener('toggle', this.#onLegendToggle);
541
+ document.removeEventListener('pointerdown', this.#pinnedTouchDismiss);
542
+ this.removeEventListener('keydown', this.#onKeydown);
543
+ this.removeEventListener('focus', this.#onFocus);
544
+ this.removeEventListener('blur', this.#onBlur);
545
+ this.removeEventListener('pointerover', this.#onPointerOver);
546
+ this.removeEventListener('pointermove', this.#onPointerMove);
547
+ this.removeEventListener('pointerleave', this.#onPointerLeave);
548
+ this.removeEventListener('pointerdown', this.#onPointerDown);
549
+ this.removeEventListener('click', this.#onClick);
550
+ this.#hideTooltip();
551
+ }
552
+
553
+ /* Detect whether an external chart-legend-ui or tooltip-ui is acting as
554
+ this chart's legend / tooltip via [for=self.id]. When present, the
555
+ internal counterpart is suppressed so we don't double-render. */
556
+ #hasExternalLegend() {
557
+ if (!this.id) return false;
558
+ return !!document.querySelector(`chart-legend-ui[for="${CSS.escape(this.id)}"]`);
559
+ }
560
+ #hasExternalTooltip() {
561
+ if (!this.id) return false;
562
+ return !!document.querySelector(`tooltip-ui[follows="pointer"][for="${CSS.escape(this.id)}"]`);
563
+ }
564
+
565
+ #onLegendToggle = (event) => {
566
+ const legend = event.target?.closest?.('chart-legend-ui[for]');
567
+ if (!legend || legend.getAttribute('for') !== this.id) return;
568
+ const { key, active } = event.detail || {};
569
+ if (!key) return;
570
+ if (active) this.#hiddenSeriesKeys.delete(key);
571
+ else this.#hiddenSeriesKeys.add(key);
572
+ this.#renderChart();
573
+ };
574
+
575
+ #isSeriesHidden(key) {
576
+ return !!key && this.#hiddenSeriesKeys.has(key);
577
+ }
578
+
579
+ /* ── Deprecation warnings (OD-CHART-02) ─────────────────────────
580
+ `aspect=` and `heading=` were part of the pre-composition model —
581
+ `aspect` is stale because parents now size the chart; `heading`
582
+ because card headers are the semantic location for chart titles.
583
+ Warn once per instance so the console stays readable. Attrs still
584
+ honored; removal planned for the next major. */
585
+ #deprecationWarned = false;
586
+ #warnDeprecatedAttrs() {
587
+ if (this.#deprecationWarned) return;
588
+ if (this.aspect && this.aspect !== 'std') {
589
+ console.warn(
590
+ `[chart-ui] aspect="${this.aspect}" is deprecated. ` +
591
+ `Parents should size the chart directly (width/height on the card ` +
592
+ `or container). The attribute will be removed in a future major.`
593
+ );
594
+ this.#deprecationWarned = true;
595
+ }
596
+ if (this.heading) {
597
+ console.warn(
598
+ `[chart-ui] heading="${this.heading}" is deprecated. ` +
599
+ `Place the title in an enclosing card-ui <header><span slot="heading"> ` +
600
+ `instead. The attribute will be removed in a future major.`
601
+ );
602
+ this.#deprecationWarned = true;
603
+ }
604
+ }
605
+
606
+ /* ── Number formatting (OD-CHART-03) ────────────────────────────
607
+ `format` attribute selects how datum values are displayed on axis
608
+ labels, bar/line value overlays, donut centers, etc. Falls back to
609
+ the local `fmt()` helper for `abbr` (existing behavior). Currency
610
+ prefix reads --chart-currency-prefix (default "$") so consumers
611
+ retune per locale without touching the format attr itself. */
612
+ #fmtValue(v) {
613
+ if (v == null || v === '') return '';
614
+ const n = +v;
615
+ if (!Number.isFinite(n)) return String(v);
616
+ const fmtAttr = this.format || 'abbr';
617
+ switch (fmtAttr) {
618
+ case 'decimal': return n.toFixed(2);
619
+ case 'percent': return `${(n * 100).toFixed(1)}%`;
620
+ case 'currency': {
621
+ /* --chart-currency-prefix is a CSS custom property storing a string.
622
+ getComputedStyle returns it with the surrounding quotes ('"$"'),
623
+ which must be stripped before concatenation. Empty/unset falls
624
+ back to '$'. */
625
+ let prefix = getComputedStyle(this).getPropertyValue('--chart-currency-prefix').trim();
626
+ if ((prefix.startsWith('"') && prefix.endsWith('"')) ||
627
+ (prefix.startsWith("'") && prefix.endsWith("'"))) {
628
+ prefix = prefix.slice(1, -1);
629
+ }
630
+ return `${prefix || '$'}${fmt(n)}`;
631
+ }
632
+ case 'abbr':
633
+ default: return fmt(n);
634
+ }
635
+ }
636
+
637
+ /* ── Keyboard nav (OD-CHART-06) ─────────────────────────────────
638
+ Virtual focus across data points in DOM order — ArrowLeft/Right or
639
+ ArrowUp/Down step by one; Home/End jump to first/last; Enter/Space
640
+ fires chart-select; Escape clears focus and fires chart-leave. On
641
+ focus change, the chart emits the same chart-hover event shape as
642
+ the pointer path so tooltip-ui[follows=pointer][for] tracks keyboard
643
+ focus without needing its own code path.
644
+
645
+ Per-datum focus indicator is a data-a11y-focus attribute on the
646
+ focused element + a data-a11y-focused attribute on the host; CSS
647
+ paints the outline. */
648
+ #focusedDatumIdx = -1;
649
+
650
+ #datums() {
651
+ /* `[data-tip-label]` OR `[data-tip-value]` — every datum emits at
652
+ least one, so this catches every renderer. `circle[data-hit]` is
653
+ included intentionally (line/scatter uses a hit overlay as its
654
+ hover target). */
655
+ return Array.from(this.querySelectorAll('[data-tip-label], [data-tip-value]'));
656
+ }
657
+
658
+ #setFocusedDatum(idx) {
659
+ const datums = this.#datums();
660
+ if (!datums.length) { this.#clearFocusedDatum(); return; }
661
+ this.#focusedDatumIdx = Math.max(0, Math.min(idx, datums.length - 1));
662
+ this.#paintFocusIndicator();
663
+ this.#emitHoverForFocused();
664
+ }
665
+
666
+ #paintFocusIndicator() {
667
+ const datums = this.#datums();
668
+ /* Clear previous focus marker. */
669
+ for (const d of datums) d.removeAttribute('data-a11y-focus');
670
+ const el = datums[this.#focusedDatumIdx];
671
+ if (!el) return this.removeAttribute('data-a11y-focused');
672
+ el.setAttribute('data-a11y-focus', '');
673
+ this.setAttribute('data-a11y-focused', '');
674
+ }
675
+
676
+ #clearFocusedDatum() {
677
+ this.#focusedDatumIdx = -1;
678
+ for (const d of this.#datums()) d.removeAttribute('data-a11y-focus');
679
+ this.removeAttribute('data-a11y-focused');
680
+ }
681
+
682
+ #emitHoverForFocused() {
683
+ const el = this.#datums()[this.#focusedDatumIdx];
684
+ if (!el) return;
685
+ const rect = el.getBoundingClientRect();
686
+ const synthEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
687
+ this.#emitHover(el, synthEvent);
688
+ }
689
+
690
+ #emitSelectForFocused() {
691
+ const el = this.#datums()[this.#focusedDatumIdx];
692
+ if (!el) return;
693
+ const rect = el.getBoundingClientRect();
694
+ const synthEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
695
+ this.dispatchEvent(new CustomEvent('chart-select', {
696
+ bubbles: true,
697
+ detail: this.#tipPayload(el, synthEvent),
698
+ }));
699
+ }
700
+
701
+ #onFocus = () => {
702
+ /* On first focus, select the first datum. Subsequent focuses keep
703
+ the prior position (common keyboard-nav convention). */
704
+ if (this.#focusedDatumIdx === -1) this.#setFocusedDatum(0);
705
+ };
706
+
707
+ #onBlur = () => {
708
+ this.#clearFocusedDatum();
709
+ if (this.#hoveredTarget) this.#emitLeave();
710
+ };
711
+
712
+ #onKeydown = (e) => {
713
+ const datums = this.#datums();
714
+ if (!datums.length) return;
715
+ switch (e.key) {
716
+ case 'ArrowRight':
717
+ case 'ArrowDown':
718
+ e.preventDefault();
719
+ this.#setFocusedDatum(this.#focusedDatumIdx + 1);
720
+ break;
721
+ case 'ArrowLeft':
722
+ case 'ArrowUp':
723
+ e.preventDefault();
724
+ this.#setFocusedDatum(Math.max(0, this.#focusedDatumIdx - 1));
725
+ break;
726
+ case 'Home':
727
+ e.preventDefault();
728
+ this.#setFocusedDatum(0);
729
+ break;
730
+ case 'End':
731
+ e.preventDefault();
732
+ this.#setFocusedDatum(datums.length - 1);
733
+ break;
734
+ case 'Enter':
735
+ case ' ':
736
+ e.preventDefault();
737
+ this.#emitSelectForFocused();
738
+ break;
739
+ case 'Escape':
740
+ e.preventDefault();
741
+ this.#clearFocusedDatum();
742
+ if (this.#hoveredTarget) this.#emitLeave();
743
+ break;
744
+ }
745
+ };
746
+
747
+ #hoveredTarget = null;
748
+
749
+ #onPointerOver = (e) => {
750
+ /* OD-CHART-07 — touch uses pointerdown to tap-to-pin; pointerover
751
+ is too noisy on touch devices (fires redundantly with down). */
752
+ if (e.pointerType === 'touch') return;
753
+ const t = e.target.closest('[data-tip-label], [data-tip-value]');
754
+ if (t) {
755
+ this.#showTooltip(t, e);
756
+ this.#emitHover(t, e);
757
+ }
758
+ };
759
+
760
+ #onPointerMove = (e) => {
761
+ if (e.pointerType === 'touch') return; /* touch handled via pointerdown */
762
+ const t = e.target.closest('[data-tip-label], [data-tip-value]');
763
+ if (!t) {
764
+ if (this.#hoveredTarget) this.#emitLeave();
765
+ return this.#hideTooltip();
766
+ }
767
+ this.#showTooltip(t, e);
768
+ if (t !== this.#hoveredTarget) this.#emitHover(t, e);
769
+ };
770
+
771
+ #onPointerLeave = (e) => {
772
+ if (e && e.pointerType === 'touch') return;
773
+ this.#hideTooltip();
774
+ if (this.#hoveredTarget) this.#emitLeave();
775
+ };
776
+
777
+ /* OD-CHART-07 — tap-to-pin on touch devices. Tapping a datum shows
778
+ the tooltip and emits chart-hover; tapping elsewhere (including
779
+ outside the chart via the document listener below) dismisses it.
780
+ Mouse + pen go through the existing pointerover path; pointerdown
781
+ here only processes touch so desktop behavior is unchanged. */
782
+ #onPointerDown = (e) => {
783
+ if (e.pointerType !== 'touch') return;
784
+ const t = e.target.closest('[data-tip-label], [data-tip-value]');
785
+ if (!t) {
786
+ /* Tap on empty plot area → dismiss any pinned tooltip. */
787
+ if (this.#hoveredTarget) this.#emitLeave();
788
+ return this.#hideTooltip();
789
+ }
790
+ this.#showTooltip(t, e);
791
+ if (t !== this.#hoveredTarget) this.#emitHover(t, e);
792
+ /* Attach document-level dismiss — removed when tap lands outside
793
+ the chart. Idempotent: re-attaching replaces the same listener
794
+ by reference. */
795
+ document.addEventListener('pointerdown', this.#pinnedTouchDismiss);
796
+ };
797
+
798
+ /* Document-level touch listener — dismiss pinned tooltip when the
799
+ user taps outside the chart. Only attached when a tooltip is
800
+ currently pinned via touch. Lazily attached/detached to avoid
801
+ perpetual document listeners on every page. */
802
+ #pinnedTouchDismiss = (e) => {
803
+ if (e.pointerType !== 'touch') return;
804
+ if (!this.contains(e.target)) {
805
+ this.#hideTooltip();
806
+ if (this.#hoveredTarget) this.#emitLeave();
807
+ document.removeEventListener('pointerdown', this.#pinnedTouchDismiss);
808
+ }
809
+ };
810
+
811
+ #onClick = (e) => {
812
+ const t = e.target.closest('[data-tip-label], [data-tip-value]');
813
+ if (!t) return;
814
+ this.dispatchEvent(new CustomEvent('chart-select', {
815
+ bubbles: true,
816
+ detail: this.#tipPayload(t, e),
817
+ }));
818
+ };
819
+
820
+ #emitHover(target, event) {
821
+ this.#hoveredTarget = target;
822
+ this.dispatchEvent(new CustomEvent('chart-hover', {
823
+ bubbles: true,
824
+ detail: this.#tipPayload(target, event),
825
+ }));
826
+ }
827
+
828
+ #emitLeave() {
829
+ this.#hoveredTarget = null;
830
+ this.dispatchEvent(new CustomEvent('chart-leave', { bubbles: true }));
831
+ }
832
+
833
+ /* Build the standard event payload from a hovered/clicked datum element.
834
+ OD-CHART-05 — per-X-column granularity. For multi-series renderers
835
+ (stacked-bar, grouped-bar, multi-line, composed), the pointer is
836
+ logically over "one X-axis column" and all series at that X are
837
+ hover-relevant. The detail therefore carries:
838
+ - top-level fields describing the specific datum the pointer
839
+ actually entered (label, value, pct, series, slot) — back-compat
840
+ with Phase 1-2 consumers.
841
+ - `payload` array with every series at this X column, each row
842
+ shaped { series, value, pct?, slot }. For single-series types,
843
+ payload has one entry containing the top-level datum.
844
+ Tooltip-ui[follows=pointer] renders one row per payload entry so
845
+ the card shows all series at that X, highlighting the hovered
846
+ series slightly. */
847
+ #tipPayload(target, event) {
848
+ const { tipLabel, tipValue, tipPct, tipSeries } = target.dataset;
849
+ const slot = target.dataset.slice != null ? Number(target.dataset.slice) : null;
850
+ const value = tipValue != null ? Number(tipValue) : null;
851
+ const pct = tipPct != null ? Number(tipPct) : null;
852
+
853
+ const hoveredLabel = tipLabel ?? null;
854
+ const hoveredSeries = tipSeries ?? null;
855
+
856
+ /* Build the per-X-column payload. Look up the data row whose x-key
857
+ matches the hovered label; enumerate declared y-keys; project a
858
+ row per (visible) series. For types without an x-key (pie/donut/
859
+ segments/radar — categorical) or with no y-keys, payload falls
860
+ back to a single entry matching the hovered datum. */
861
+ const payload = this.#buildXColumnPayload(hoveredLabel, hoveredSeries) ?? [{
862
+ series: hoveredSeries,
863
+ label: hoveredLabel,
864
+ value: Number.isFinite(value) ? value : (tipValue ?? null),
865
+ pct: Number.isFinite(pct) ? pct : null,
866
+ slot,
867
+ }];
868
+
869
+ return {
870
+ label: hoveredLabel,
871
+ value: Number.isFinite(value) ? value : (tipValue ?? null),
872
+ pct: Number.isFinite(pct) ? pct : null,
873
+ series: hoveredSeries,
874
+ slot,
875
+ payload,
876
+ pointerX: event?.clientX ?? null,
877
+ pointerY: event?.clientY ?? null,
878
+ };
879
+ }
880
+
881
+ #buildXColumnPayload(xLabel, hoveredSeries) {
882
+ if (xLabel == null) return null;
883
+ const xKey = this.x;
884
+ const yKeys = this.#yKeys();
885
+ if (!xKey || yKeys.length === 0) return null;
886
+ /* Find the row whose x-key value matches the hovered label. Raw
887
+ label string match — both sides came from the same source so no
888
+ type coercion needed. */
889
+ const row = this.#data.find(d => String(d[xKey] ?? '') === String(xLabel));
890
+ if (!row) return null;
891
+ const visible = yKeys.filter(k => !this.#isSeriesHidden(k));
892
+ /* Single-series types don't emit `data-tip-series` — the hovered
893
+ datum is implicitly the one and only series. Promote it so the
894
+ tooltip can still emphasize the row. */
895
+ const effectiveHovered = hoveredSeries || (visible.length === 1 ? visible[0] : null);
896
+ return visible.map((k, i) => {
897
+ const v = +(row[k] ?? 0);
898
+ return {
899
+ series: k,
900
+ label: xLabel,
901
+ value: Number.isFinite(v) ? v : null,
902
+ pct: null,
903
+ slot: i % 10,
904
+ hovered: k === effectiveHovered,
905
+ };
906
+ });
907
+ }
908
+
909
+ #showTooltip(target, event) {
910
+ /* Phase 2 follow-up — when an external tooltip-ui[follows=pointer][for=self]
911
+ is present on the page, it owns the tooltip affordance. Skip the
912
+ internal #tipEl entirely to avoid double-render / fighting for the
913
+ top-layer. The external tooltip subscribes to chart-hover events
914
+ emitted in #emitHover below. */
915
+ if (this.#hasExternalTooltip()) return;
916
+
917
+ const { tipLabel, tipValue, tipPct, tipSeries } = target.dataset;
918
+
919
+ if (!this.#tipEl) {
920
+ const el = document.createElement('div');
921
+ el.setAttribute('popover', 'manual');
922
+ el.setAttribute('role', 'tooltip');
923
+ el.classList.add('chart-tooltip-popup');
924
+ document.body.appendChild(el);
925
+ this.#tipEl = el;
926
+ }
927
+
928
+ const lines = [];
929
+ if (tipSeries) lines.push(`<span data-tip-role="series">${esc(tipSeries)}</span>`);
930
+ if (tipLabel) lines.push(`<span data-tip-role="label">${esc(tipLabel)}</span>`);
931
+ if (tipValue !== undefined) {
932
+ const pct = tipPct !== undefined ? ` <span data-tip-role="pct">(${tipPct}%)</span>` : '';
933
+ lines.push(`<span data-tip-role="value">${this.#fmtValue(tipValue)}${pct}</span>`);
934
+ }
935
+ this.#tipEl.innerHTML = lines.join('');
936
+
937
+ try { this.#tipEl.showPopover(); } catch (_) { /* popover not supported */ }
938
+
939
+ /* Follow the cursor — centered horizontally above, clamp to viewport
940
+ with an 8px edge-pad, flip below when there's no room above. */
941
+ const gap = 12;
942
+ const edgePad = 8;
943
+ const { clientX, clientY } = event;
944
+ const tw = this.#tipEl.offsetWidth || 0;
945
+ const th = this.#tipEl.offsetHeight || 0;
946
+ let x = clientX - tw / 2;
947
+ let y = clientY - th - gap;
948
+ if (x < edgePad) x = edgePad;
949
+ if (x + tw > window.innerWidth - edgePad) x = window.innerWidth - tw - edgePad;
950
+ if (y < edgePad) y = clientY + gap;
951
+ this.#tipEl.style.left = `${x}px`;
952
+ this.#tipEl.style.top = `${y}px`;
953
+ }
954
+
955
+ #hideTooltip() {
956
+ if (!this.#tipEl) return;
957
+ try { this.#tipEl.hidePopover(); } catch (_) { /* */ }
958
+ this.#tipEl.remove();
959
+ this.#tipEl = null;
960
+ }
961
+
962
+ /* ── Y keys helper ────────────────────────────────────────────── */
963
+
964
+ #yKeys() {
965
+ return this.y ? this.y.split(',').map(k => k.trim()).filter(Boolean) : [];
966
+ }
967
+
968
+ /* ── Grid + axes helper ───────────────────────────────────────── */
969
+
970
+ #gridAndAxes(width, height, ticks, labels, pad, dims) {
971
+ const p = pad;
972
+ const fs = dims?.fontSize || 10;
973
+ const ls = dims?.labelSize || fs;
974
+ let s = '';
975
+
976
+ // Reduce tick count at small sizes
977
+ let displayTicks = ticks;
978
+ if (dims?.sizeClass === 'sm' && ticks.length > 4) {
979
+ displayTicks = ticks.filter((_, i) => i % 2 === 0 || i === ticks.length - 1);
980
+ }
981
+
982
+ // hideGrid suppresses both gridlines AND axis labels — callers who
983
+ // want labels without gridlines can omit hide-grid and rely on token
984
+ // overrides to make gridlines transparent. This keeps compact in-card
985
+ // charts visually clean.
986
+ if (!this.hideGrid) {
987
+ const tickRange = ticks[ticks.length - 1] - ticks[0];
988
+ const safeRange = tickRange || 1; // Prevent division by zero
989
+ for (const t of displayTicks) {
990
+ const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
991
+ s += `<line data-grid x1="${p.left}" y1="${gy}" x2="${width - p.right}" y2="${gy}"/>`;
992
+ }
993
+
994
+ /* Y-axis labels */
995
+ for (const t of displayTicks) {
996
+ const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
997
+ s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
998
+ }
999
+
1000
+ /* X-axis labels — stride based on label width so they never overlap */
1001
+ if (labels) {
1002
+ const plotW = width - p.left - p.right;
1003
+ const step = plotW / labels.length;
1004
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1005
+ const labelPx = maxChars * ls * 0.6 + ls * 0.75;
1006
+ const stride = Math.max(1, Math.ceil(labelPx / step));
1007
+ const last = labels.length - 1;
1008
+ for (let i = 0; i < labels.length; i++) {
1009
+ if (i % stride !== 0 && i !== last) continue;
1010
+ if (i !== last && last - i < stride) continue;
1011
+ const lx = p.left + step * i + step / 2;
1012
+ s += `<text data-x-label x="${lx}" y="${height - fs * 0.5}" text-anchor="middle" font-size="${ls}">${esc(labels[i])}</text>`;
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ return s;
1018
+ }
1019
+
1020
+ /* ── Bar chart ────────────────────────────────────────────────── */
1021
+
1022
+ #renderBar() {
1023
+ const dims = this.#dims();
1024
+ const data = this.#data;
1025
+ const yKey = this.#yKeys()[0] || this.y;
1026
+ const vals = data.map(v => +(v[yKey] ?? 0));
1027
+ const labels = data.map(v => v[this.x] ?? '');
1028
+ const ticks = niceScale(0, Math.max(...vals), 5);
1029
+ const maxVal = ticks[ticks.length - 1];
1030
+
1031
+ const { width, height, pad } = dims;
1032
+ const plotH = height - pad.top - pad.bottom;
1033
+ const plotW = width - pad.left - pad.right;
1034
+ const barW = plotW / data.length;
1035
+ const barInner = barW * 0.6;
1036
+ const barGap = (barW - barInner) / 2;
1037
+
1038
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1039
+
1040
+ // At sm auto-hide value labels (they overlap at narrow widths).
1041
+ const showValues = !this.hideValues && dims.sizeClass !== 'sm';
1042
+ const showAverage = !this.hideAverage && vals.length > 1 && dims.sizeClass !== 'sm' && !this.hideGrid;
1043
+
1044
+ for (let i = 0; i < data.length; i++) {
1045
+ const v = vals[i];
1046
+ const barH = maxVal ? (v / maxVal) * plotH : 0;
1047
+ const bx = pad.left + barW * i + barGap;
1048
+ const by = pad.top + plotH - barH;
1049
+
1050
+ svg += `<path data-bar${tip({ label: labels[i], value: v })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
1051
+
1052
+ if (showValues) {
1053
+ svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
1054
+ }
1055
+ }
1056
+
1057
+ if (showAverage) {
1058
+ const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
1059
+ const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
1060
+ svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
1061
+ svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
1062
+ /* Wider invisible hit target so the thin dashed line is hoverable */
1063
+ svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}" stroke="transparent" stroke-width="12"/>`;
1064
+ }
1065
+
1066
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1067
+ }
1068
+
1069
+ /* ── Line chart ───────────────────────────────────────────────── */
1070
+
1071
+ #renderLine() {
1072
+ const dims = this.#dims();
1073
+ const data = this.#data;
1074
+ const yKey = this.#yKeys()[0] || this.y;
1075
+ const vals = data.map(v => +(v[yKey] ?? 0));
1076
+ const labels = data.map(v => v[this.x] ?? '');
1077
+ const ticks = niceScale(0, Math.max(...vals), 5);
1078
+ const maxVal = ticks[ticks.length - 1];
1079
+
1080
+ const { width, height, pad } = dims;
1081
+ const plotH = height - pad.top - pad.bottom;
1082
+ const plotW = width - pad.left - pad.right;
1083
+ const step = plotW / Math.max(data.length - 1, 1);
1084
+
1085
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1086
+
1087
+ const points = vals.map((v, i) => {
1088
+ const px = pad.left + step * i;
1089
+ const py = pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0);
1090
+ return { x: px, y: py, v, label: labels[i] };
1091
+ });
1092
+
1093
+ const baseline = pad.top + plotH;
1094
+ const t = Math.max(0, Math.min(1, this.smooth));
1095
+ svg += `<path data-area d="${smoothAreaPath(points, baseline, t)}"/>`;
1096
+ svg += `<path data-line d="${smoothPath(points, t)}"/>`;
1097
+
1098
+ // Density tuning for sm: smaller dots, no value labels.
1099
+ const isSm = dims.sizeClass === 'sm';
1100
+ const dotR = isSm ? 1.5 : 3;
1101
+ const hitR = Math.max(dotR, 10); // invisible hit-target for tooltip
1102
+ const showValues = !this.hideValues && !isSm;
1103
+ const showAverage = !this.hideAverage && vals.length > 1 && !isSm && !this.hideGrid;
1104
+
1105
+ for (const p of points) {
1106
+ svg += `<circle data-dot cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
1107
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
1108
+ if (showValues) {
1109
+ svg += `<text data-value x="${p.x}" y="${p.y - 8}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(p.v)}</text>`;
1110
+ }
1111
+ }
1112
+
1113
+ if (showAverage) {
1114
+ const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
1115
+ const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
1116
+ svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
1117
+ svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
1118
+ /* Wider invisible hit target so the thin dashed line is hoverable */
1119
+ svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}" stroke="transparent" stroke-width="12"/>`;
1120
+ }
1121
+
1122
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1123
+ }
1124
+
1125
+ /* ── Pie chart ────────────────────────────────────────────────── */
1126
+
1127
+ #renderPie() {
1128
+ const data = this.#data;
1129
+ const yKey = this.#yKeys()[0] || this.y;
1130
+ const vals = data.map(d => +(d[yKey] ?? 0));
1131
+ const total = vals.reduce((a, b) => a + b, 0) || 1;
1132
+ const labels = data.map(d => d[this.x] ?? '');
1133
+
1134
+ // Responsive viewBox so pie respects chart-ui's max-height and
1135
+ // doesn't push the legend past the container bounds.
1136
+ const dims = this.#dims();
1137
+ const { width, height } = dims;
1138
+ const cx = width / 2;
1139
+ const cy = height / 2;
1140
+ const r = Math.max(30, Math.min(width, height) * 0.42);
1141
+
1142
+ let svg = '';
1143
+ let angle = -Math.PI / 2;
1144
+
1145
+ for (let i = 0; i < vals.length; i++) {
1146
+ const slice = (vals[i] / total) * Math.PI * 2;
1147
+ if (slice === 0) { continue; }
1148
+ const end = angle + slice;
1149
+
1150
+ const pct = ((vals[i] / total) * 100).toFixed(1);
1151
+ const attrs = ` data-slice="${i % 10}"${tip({ label: labels[i], value: vals[i], pct })}`;
1152
+
1153
+ if (Math.abs(slice - Math.PI * 2) < 0.001) {
1154
+ /* Full circle — special case */
1155
+ svg += `<circle${attrs} cx="${cx}" cy="${cy}" r="${r}"/>`;
1156
+ } else {
1157
+ svg += `<path${attrs} d="${arcPath(cx, cy, r, angle, end)}"/>`;
1158
+ }
1159
+ angle = end;
1160
+ }
1161
+
1162
+ this.#legendData = data.map((d, i) => ({
1163
+ label: labels[i],
1164
+ value: vals[i],
1165
+ pct: ((vals[i] / total) * 100).toFixed(1),
1166
+ slot: i % 10,
1167
+ }));
1168
+
1169
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1170
+ }
1171
+
1172
+ /* ── Donut chart ──────────────────────────────────────────────── */
1173
+
1174
+ #renderDonut() {
1175
+ const data = this.#data;
1176
+ const yKey = this.#yKeys()[0] || this.y;
1177
+ const vals = data.map(d => +(d[yKey] ?? 0));
1178
+ const total = vals.reduce((a, b) => a + b, 0) || 1;
1179
+ const labels = data.map(d => d[this.x] ?? '');
1180
+
1181
+ const dims = this.#dims();
1182
+ const { width, height } = dims;
1183
+ const cx = width / 2;
1184
+ const cy = height / 2;
1185
+ const outer = Math.max(30, Math.min(width, height) * 0.42);
1186
+ const inner = outer * 0.72;
1187
+
1188
+ let svg = '';
1189
+ let angle = -Math.PI / 2;
1190
+
1191
+ for (let i = 0; i < vals.length; i++) {
1192
+ const slice = (vals[i] / total) * Math.PI * 2;
1193
+ if (slice === 0) continue;
1194
+ const end = angle + slice;
1195
+
1196
+ const pct = ((vals[i] / total) * 100).toFixed(1);
1197
+ const attrs = ` data-slice="${i % 10}"${tip({ label: labels[i], value: vals[i], pct })}`;
1198
+
1199
+ if (Math.abs(slice - Math.PI * 2) < 0.001) {
1200
+ /* Full ring */
1201
+ svg += `<circle${attrs} cx="${cx}" cy="${cy}" r="${(outer + inner) / 2}" fill="none" stroke-width="${outer - inner}" style="fill:none"/>`;
1202
+ svg += `<path${attrs} d="M ${cx - outer} ${cy} A ${outer} ${outer} 0 1 1 ${cx + outer} ${cy} A ${outer} ${outer} 0 1 1 ${cx - outer} ${cy} Z M ${cx - inner} ${cy} A ${inner} ${inner} 0 1 0 ${cx + inner} ${cy} A ${inner} ${inner} 0 1 0 ${cx - inner} ${cy} Z" fill-rule="evenodd"/>`;
1203
+ } else {
1204
+ svg += `<path${attrs} d="${donutArcPath(cx, cy, outer, inner, angle, end, this.#resolveRadius())}"/>`;
1205
+ }
1206
+ angle = end;
1207
+ }
1208
+
1209
+ /* Center total — font size tied to donut radius so it scales with the chart */
1210
+ const totalFs = Math.max(14, Math.round(outer * 0.32));
1211
+ const labelFs = Math.max(9, Math.round(outer * 0.16));
1212
+ svg += `<text data-donut-total x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(total)}</text>`;
1213
+ svg += `<text data-donut-label x="${cx}" y="${cy + totalFs}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">Total</text>`;
1214
+
1215
+ this.#legendData = data.map((d, i) => ({
1216
+ label: labels[i],
1217
+ value: vals[i],
1218
+ pct: ((vals[i] / total) * 100).toFixed(1),
1219
+ slot: i % 10,
1220
+ }));
1221
+
1222
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1223
+ }
1224
+
1225
+ /* ── Radar chart ──────────────────────────────────────────────── */
1226
+
1227
+ #renderRadar() {
1228
+ const data = this.#data;
1229
+ const yKey = this.#yKeys()[0] || this.y;
1230
+ const vals = data.map(d => +(d[yKey] ?? 0));
1231
+ const labels = data.map(d => d[this.x] ?? '');
1232
+ const maxVal = Math.max(...vals) || 1;
1233
+ const n = data.length;
1234
+
1235
+ // Derive size from container so viewBox ≈ rendered pixels (keeps
1236
+ // font-size and stroke widths at their intended visual scale).
1237
+ const dims = this.#dims();
1238
+ const { width, height, fontSize } = dims;
1239
+ const cx = width / 2;
1240
+ const cy = height / 2;
1241
+ // Leave label breathing room proportional to font size so labels
1242
+ // never clip at wide sizes or crowd the polygon at small sizes.
1243
+ const labelPad = fontSize * 3.5;
1244
+ const r = Math.max(40, Math.min(width, height) / 2 - labelPad);
1245
+
1246
+ let svg = '';
1247
+ const angleStep = (Math.PI * 2) / n;
1248
+
1249
+ /* Grid rings (3 levels) */
1250
+ for (let level = 1; level <= 3; level++) {
1251
+ const lr = (r * level) / 3;
1252
+ let ring = '';
1253
+ for (let i = 0; i < n; i++) {
1254
+ const a = -Math.PI / 2 + angleStep * i;
1255
+ const px = cx + lr * Math.cos(a);
1256
+ const py = cy + lr * Math.sin(a);
1257
+ ring += (i === 0 ? 'M' : 'L') + ` ${px} ${py}`;
1258
+ }
1259
+ ring += ' Z';
1260
+ svg += `<path data-grid d="${ring}"/>`;
1261
+ }
1262
+
1263
+ /* Axis lines */
1264
+ for (let i = 0; i < n; i++) {
1265
+ const a = -Math.PI / 2 + angleStep * i;
1266
+ const px = cx + r * Math.cos(a);
1267
+ const py = cy + r * Math.sin(a);
1268
+ svg += `<line data-grid x1="${cx}" y1="${cy}" x2="${px}" y2="${py}"/>`;
1269
+ }
1270
+
1271
+ /* Data polygon + per-vertex hit targets */
1272
+ let poly = '';
1273
+ const vertices = [];
1274
+ for (let i = 0; i < n; i++) {
1275
+ const a = -Math.PI / 2 + angleStep * i;
1276
+ const vr = (vals[i] / maxVal) * r;
1277
+ const px = cx + vr * Math.cos(a);
1278
+ const py = cy + vr * Math.sin(a);
1279
+ poly += (i === 0 ? 'M' : 'L') + ` ${px} ${py}`;
1280
+ vertices.push({ px, py });
1281
+ }
1282
+ poly += ' Z';
1283
+ svg += `<path data-radar-fill d="${poly}"/>`;
1284
+ svg += `<path data-radar-line d="${poly}"/>`;
1285
+
1286
+ const hitR = Math.max(fontSize, 10);
1287
+ for (let i = 0; i < n; i++) {
1288
+ const { px, py } = vertices[i];
1289
+ svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1290
+ }
1291
+
1292
+ /* Labels */
1293
+ const labelGap = fontSize * 1.3;
1294
+ for (let i = 0; i < n; i++) {
1295
+ const a = -Math.PI / 2 + angleStep * i;
1296
+ const lx = cx + (r + labelGap) * Math.cos(a);
1297
+ const ly = cy + (r + labelGap) * Math.sin(a);
1298
+ const anchor = Math.abs(Math.cos(a)) < 0.1 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end';
1299
+ svg += `<text data-x-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${fontSize}">${esc(labels[i])}</text>`;
1300
+ }
1301
+
1302
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1303
+ }
1304
+
1305
+ /* ── Sparkline ────────────────────────────────────────────────── */
1306
+
1307
+ #renderSparkline() {
1308
+ const data = this.#data;
1309
+ const yKey = this.#yKeys()[0] || this.y;
1310
+ const vals = data.map(d => +(d[yKey] ?? 0));
1311
+ const maxVal = Math.max(...vals) || 1;
1312
+ const minVal = Math.min(...vals);
1313
+ const range = maxVal - minVal || 1;
1314
+
1315
+ // Use the actual container dims so the aspect ratio matches what
1316
+ // the CSS allocates (otherwise the fixed 120×32 viewBox gets
1317
+ // stretched vertically in tall in-card slots).
1318
+ const containerW = this.clientWidth || 120;
1319
+ const containerH = this.clientHeight || 32;
1320
+ const w = Math.max(40, containerW);
1321
+ const h = Math.max(16, containerH);
1322
+ const step = w / Math.max(vals.length - 1, 1);
1323
+
1324
+ // Breathing room so the line doesn't clip at the top/bottom edges.
1325
+ const padY = Math.max(2, Math.min(6, h * 0.1));
1326
+
1327
+ const labels = data.map(d => d[this.x] ?? '');
1328
+ const points = vals.map((v, i) => ({
1329
+ x: step * i,
1330
+ y: h - ((v - minVal) / range) * (h - padY * 2) - padY,
1331
+ v,
1332
+ label: labels[i],
1333
+ }));
1334
+
1335
+ const t = Math.max(0, Math.min(1, this.smooth));
1336
+ let svg = '';
1337
+ svg += `<path data-area d="${smoothAreaPath(points, h, t)}"/>`;
1338
+ svg += `<path data-line d="${smoothPath(points, t)}"/>`;
1339
+
1340
+ /* Invisible hit-targets for tooltip — sized to fill each X-column */
1341
+ const hitR = Math.max(step / 2, 6);
1342
+ for (const p of points) {
1343
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
1344
+ }
1345
+
1346
+ return { svg, viewBox: `0 0 ${w} ${h}` };
1347
+ }
1348
+
1349
+ /* ── Area chart (filled line) ────────────────────────────────────
1350
+ Single-series axis chart emphasizing the filled region under the
1351
+ curve. Shares all infrastructure with #renderLine() — CSS rules
1352
+ scoped to :scope[type="area"] override the area-fill opacity and
1353
+ the line treatment to make the region dominant. Output is DOM-
1354
+ compatible with #renderLine() so legend / tooltip / events work
1355
+ identically. */
1356
+ #renderArea() {
1357
+ return this.#renderLine();
1358
+ }
1359
+
1360
+ /* ── Scatter (points only, no connecting line) ──────────────────
1361
+ Two-dimensional distribution — each datum is a dot positioned by
1362
+ (x, y). Unlike #renderLine(), no connecting path is emitted. The
1363
+ x-key is typically also numeric; when it's a category label, the
1364
+ dots land at category-indexed column centers. */
1365
+ #renderScatter() {
1366
+ const dims = this.#dims();
1367
+ const data = this.#data;
1368
+ const yKey = this.#yKeys()[0] || this.y;
1369
+ const vals = data.map(v => +(v[yKey] ?? 0));
1370
+ const labels = data.map(v => v[this.x] ?? '');
1371
+ const ticks = niceScale(0, Math.max(...vals), 5);
1372
+ const maxVal = ticks[ticks.length - 1];
1373
+
1374
+ const { width, height, pad } = dims;
1375
+ const plotH = height - pad.top - pad.bottom;
1376
+ const plotW = width - pad.left - pad.right;
1377
+ const step = plotW / Math.max(data.length - 1, 1);
1378
+
1379
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1380
+
1381
+ const dotR = dims.sizeClass === 'sm' ? 2.5 : 4;
1382
+ const hitR = Math.max(dotR * 2, 10);
1383
+
1384
+ for (let i = 0; i < vals.length; i++) {
1385
+ const px = pad.left + step * i;
1386
+ const py = pad.top + plotH - (maxVal ? (vals[i] / maxVal) * plotH : 0);
1387
+ svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
1388
+ svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1389
+ }
1390
+
1391
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1392
+ }
1393
+
1394
+ /* ── Radial bar (concentric rings, each ring one datum) ─────────
1395
+ Each datum gets its own ring in a concentric stack. The ring's
1396
+ sweep angle is proportional to value/max — e.g., v=max is a full
1397
+ ring, v=0 is nothing. Rings are clipped to a common max-radius
1398
+ track shown as a faint backing arc so empty values read visually.
1399
+ Center is empty — authors who want a value overlay use the
1400
+ `[slot=empty]` trick or compose in a sibling element. */
1401
+ #renderRadialBar() {
1402
+ const data = this.#data;
1403
+ const yKey = this.#yKeys()[0] || this.y;
1404
+ const vals = data.map(d => +(d[yKey] ?? 0));
1405
+ const labels = data.map(d => d[this.x] ?? '');
1406
+ const maxVal = Math.max(...vals) || 1;
1407
+
1408
+ const dims = this.#dims();
1409
+ const { width, height } = dims;
1410
+ const cx = width / 2;
1411
+ const cy = height / 2;
1412
+ const outerR = Math.max(30, Math.min(width, height) * 0.45);
1413
+ const innerR = outerR * 0.3;
1414
+ const ringCount = vals.length || 1;
1415
+ const bandW = (outerR - innerR) / ringCount;
1416
+ const gap = Math.min(2, bandW * 0.15);
1417
+
1418
+ let svg = '';
1419
+
1420
+ /* Backing tracks + filled arcs per datum, from inner to outer.
1421
+ Uses the stroke-dasharray technique: each ring is a single <circle>,
1422
+ rotated so the dash starts at 12 o'clock, with dasharray sized to
1423
+ (filled, remainder). Avoids the "full circle" vs "arc path" branch
1424
+ that produced rendering artifacts at 100% fills. */
1425
+ for (let i = 0; i < vals.length; i++) {
1426
+ const r0 = innerR + bandW * i + gap / 2;
1427
+ const r1 = innerR + bandW * (i + 1) - gap / 2;
1428
+ const mid = (r0 + r1) / 2;
1429
+ const thickness = r1 - r0;
1430
+ const circumference = 2 * Math.PI * mid;
1431
+ const filled = Math.max(0, Math.min(1, vals[i] / maxVal)) * circumference;
1432
+
1433
+ /* Backing ring — full circle, faint stroke */
1434
+ svg += `<circle data-radial-track cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}"/>`;
1435
+
1436
+ if (filled <= 0) continue;
1437
+
1438
+ const pct = ((vals[i] / maxVal) * 100).toFixed(1);
1439
+ const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
1440
+
1441
+ /* Filled arc — stroke-dasharray splits the circumference into
1442
+ (drawn, gap). Rotated -90° around the center so the dash begins
1443
+ at 12 o'clock and sweeps clockwise. stroke-linecap="butt" for
1444
+ full rings (so the ends don't overlap into a wedge artifact);
1445
+ "round" for partial arcs so ends read as bar caps. */
1446
+ const isFull = Math.abs(filled - circumference) < 0.5;
1447
+ const linecap = isFull ? 'butt' : 'round';
1448
+ const dashArray = isFull
1449
+ ? `${circumference} 0`
1450
+ : `${filled} ${circumference - filled}`;
1451
+
1452
+ svg += `<circle data-slice="${i % 10}"${tipAttrs} data-radial-bar cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}" stroke-linecap="${linecap}" stroke-dasharray="${dashArray}" transform="rotate(-90 ${cx} ${cy})"/>`;
1453
+ }
1454
+
1455
+ this.#legendData = data.map((d, i) => ({
1456
+ label: labels[i],
1457
+ key: labels[i],
1458
+ value: vals[i],
1459
+ pct: ((vals[i] / maxVal) * 100).toFixed(1),
1460
+ slot: i % 10,
1461
+ }));
1462
+
1463
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1464
+ }
1465
+
1466
+ /* ── Gauge (half-donut with center value) ───────────────────────
1467
+ Common KPI visualization: a 180° arc filled from the leftmost
1468
+ point (9 o'clock) clockwise to the current value. Center shows
1469
+ the value as a large number. Uses the same data-slice fill path
1470
+ as pie/donut for theme coherence. Accepts a single datum OR
1471
+ sums all data values — max is `maxVal` prop if set, otherwise
1472
+ taken as the sum of all values (treats the total as 100%). */
1473
+ #renderGauge() {
1474
+ const data = this.#data;
1475
+ const yKey = this.#yKeys()[0] || this.y;
1476
+ const vals = data.map(d => +(d[yKey] ?? 0));
1477
+ const sum = vals.reduce((a, b) => a + b, 0) || 1;
1478
+
1479
+ /* Gauge reads a single value + optional max from the first datum
1480
+ (v, max?) OR treats the first value as the numerator and the
1481
+ sum as the denominator for "X of Y" style metrics. */
1482
+ const primary = vals[0] ?? 0;
1483
+ const maxVal = data[0]?.max != null ? +data[0].max : (vals.length === 1 ? Math.max(primary, 1) : sum);
1484
+ const pct = Math.max(0, Math.min(1, primary / maxVal));
1485
+
1486
+ const dims = this.#dims();
1487
+ const { width, height } = dims;
1488
+
1489
+ /* Place the arc's visual center so the half-circle uses the full
1490
+ width below the value label. cy sits lower so the half-arc has
1491
+ room for the big center label above it. */
1492
+ const cx = width / 2;
1493
+ const cy = height * 0.68;
1494
+ const outerR = Math.max(40, Math.min(width * 0.45, height * 0.6));
1495
+ const innerR = outerR * 0.72;
1496
+ /* End-cap radius — fully-rounded pill ends matching the
1497
+ progress-ui convention (--progress-radius: var(--a-radius-full)).
1498
+ donutArcPath clamps the corner radius to (outerR - innerR) / 2,
1499
+ so passing the ring half-thickness gives full pill caps on both
1500
+ ends of the arc. */
1501
+ const capR = (outerR - innerR) / 2;
1502
+
1503
+ let svg = '';
1504
+
1505
+ /* Backing arc — full 180° upper semicircle, from 9 o'clock (angle π)
1506
+ clockwise through 12 o'clock (3π/2) to 3 o'clock (2π). donutArcPath
1507
+ produces a filled ring wedge; with start=π, end=2π the sliceAngle
1508
+ equals π so large-arc-flag=0 and the arc passes over the top. */
1509
+ svg += `<path data-radial-track d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, 2 * Math.PI, capR)}"/>`;
1510
+
1511
+ /* Filled portion — 0..180° proportional to pct. pct=0 draws nothing;
1512
+ pct=1 overlays the full backing arc. */
1513
+ if (pct > 0) {
1514
+ const fillEnd = Math.PI + Math.PI * pct;
1515
+ const tipAttrs = tip({ label: data[0]?.[this.x] ?? 'Value', value: primary, pct: (pct * 100).toFixed(1) });
1516
+ svg += `<path data-slice="0"${tipAttrs} data-gauge-fill d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, fillEnd, capR)}"/>`;
1517
+ }
1518
+
1519
+ /* Center value label */
1520
+ const totalFs = Math.max(18, Math.round(outerR * 0.42));
1521
+ const labelFs = Math.max(10, Math.round(outerR * 0.2));
1522
+ const labelY = cy - outerR * 0.15;
1523
+ svg += `<text data-gauge-value x="${cx}" y="${labelY}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
1524
+ if (maxVal !== primary) {
1525
+ svg += `<text data-gauge-max x="${cx}" y="${labelY + totalFs * 0.8}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">of ${this.#fmtValue(maxVal)}</text>`;
1526
+ }
1527
+
1528
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1529
+ }
1530
+
1531
+ /* ── Funnel (stage drop-off) ───────────────────────────────────
1532
+ Classic conversion funnel: stages listed top-to-bottom, each
1533
+ rendered as a trapezoid whose width shrinks proportional to its
1534
+ value. Stage labels live to the left, value + pct vs first stage
1535
+ to the right. Typical use: sales pipeline, signup funnel.
1536
+
1537
+ Trapezoid geometry: for stage i with value v_i and max value v_0:
1538
+ halfWidthTop = (v_i / v_max) * plotW / 2
1539
+ halfWidthBot = (v_{i+1} / v_max) * plotW / 2
1540
+ Last stage uses halfWidthBot = halfWidthTop (degrades to rect). */
1541
+ #renderFunnel() {
1542
+ const data = this.#data;
1543
+ const yKey = this.#yKeys()[0] || this.y;
1544
+ const vals = data.map(d => +(d[yKey] ?? 0));
1545
+ const labels = data.map(d => d[this.x] ?? '');
1546
+ const n = vals.length;
1547
+ if (n === 0) return { svg: '', viewBox: '0 0 100 100' };
1548
+
1549
+ const maxVal = Math.max(...vals) || 1;
1550
+
1551
+ const dims = this.#dims();
1552
+ const { width, height } = dims;
1553
+ const fs = dims.fontSize;
1554
+ const padX = Math.max(width * 0.18, 80); /* room for stage labels */
1555
+ const padY = fs * 0.8;
1556
+ const plotW = width - padX * 2;
1557
+ const plotH = height - padY * 2;
1558
+ const stageH = plotH / n;
1559
+ const gap = Math.max(2, stageH * 0.08);
1560
+
1561
+ const cx = width / 2;
1562
+
1563
+ let svg = '';
1564
+ for (let i = 0; i < n; i++) {
1565
+ const vTop = vals[i];
1566
+ const vBot = (i < n - 1) ? vals[i + 1] : vals[i];
1567
+ const halfTop = (vTop / maxVal) * (plotW / 2);
1568
+ const halfBot = (vBot / maxVal) * (plotW / 2);
1569
+ const y0 = padY + stageH * i + gap / 2;
1570
+ const y1 = padY + stageH * (i + 1) - gap / 2;
1571
+
1572
+ const pct = ((vTop / maxVal) * 100).toFixed(1);
1573
+ const tipAttrs = tip({ label: labels[i], value: vTop, pct });
1574
+
1575
+ /* Trapezoid path: top-left, top-right, bottom-right, bottom-left. */
1576
+ const d = `M ${cx - halfTop} ${y0} L ${cx + halfTop} ${y0} L ${cx + halfBot} ${y1} L ${cx - halfBot} ${y1} Z`;
1577
+ svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
1578
+
1579
+ /* Stage label — left-aligned outside the funnel */
1580
+ svg += `<text data-funnel-label x="${padX - 8}" y="${y0 + stageH / 2}" text-anchor="end" dominant-baseline="central" font-size="${fs}">${esc(labels[i])}</text>`;
1581
+
1582
+ /* Value + pct — right-aligned outside the funnel */
1583
+ svg += `<text data-funnel-value x="${width - padX + 8}" y="${y0 + stageH / 2}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${this.#fmtValue(vTop)}</text>`;
1584
+ if (i > 0) {
1585
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
1586
+ svg += `<text data-funnel-drop x="${width - padX + 8}" y="${y0 + stageH / 2 + fs * 1.1}" text-anchor="start" dominant-baseline="central" font-size="${fs * 0.85}">${dropPct}%</text>`;
1587
+ }
1588
+ }
1589
+
1590
+ this.#legendData = data.map((d, i) => ({
1591
+ label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
1592
+ }));
1593
+
1594
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1595
+ }
1596
+
1597
+ /* ── Treemap (hierarchical rect tiling) ────────────────────────
1598
+ Flat squarified treemap: each datum is a rect whose area is
1599
+ proportional to its value. Not nested — Phase 5 ships flat
1600
+ rectangles; a nested variant (children arrays) is a natural
1601
+ future extension.
1602
+
1603
+ Uses the Bruls/Huijbregts/Van Wijk squarified algorithm adapted
1604
+ for single-level data: sorts values desc, packs rows that hold
1605
+ aspect ratios close to 1, alternating row direction based on
1606
+ remaining container aspect. */
1607
+ #renderTreemap() {
1608
+ const data = this.#data;
1609
+ const yKey = this.#yKeys()[0] || this.y;
1610
+ const vals = data.map(d => +(d[yKey] ?? 0));
1611
+ const labels = data.map(d => d[this.x] ?? '');
1612
+ const n = vals.length;
1613
+ if (n === 0) return { svg: '', viewBox: '0 0 100 100' };
1614
+
1615
+ const dims = this.#dims();
1616
+ const { width, height, fontSize } = dims;
1617
+ const total = vals.reduce((a, b) => a + b, 0) || 1;
1618
+
1619
+ /* Sort indices by value desc — squarified needs sorted input but
1620
+ we keep original indices for color/label/hit mapping. */
1621
+ const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => vals[b] - vals[a]);
1622
+
1623
+ const rects = []; /* {i, x, y, w, h} in plot coords */
1624
+
1625
+ /* Scale areas so they fill the container. */
1626
+ const area = width * height;
1627
+ const scaled = order.map(i => (vals[i] / total) * area);
1628
+
1629
+ /* Squarified packer — iterative rows. */
1630
+ let x = 0, y = 0, w = width, h = height;
1631
+ let row = [];
1632
+ let rowStart = 0;
1633
+
1634
+ const worst = (row, width) => {
1635
+ if (row.length === 0) return Infinity;
1636
+ const sum = row.reduce((a, b) => a + b, 0);
1637
+ const max = Math.max(...row);
1638
+ const min = Math.min(...row);
1639
+ const wsq = width * width;
1640
+ const ssq = sum * sum;
1641
+ return Math.max((wsq * max) / ssq, ssq / (wsq * min));
1642
+ };
1643
+
1644
+ const layoutRow = (row, rowStart, x, y, w, h) => {
1645
+ const sum = row.reduce((a, b) => a + b, 0);
1646
+ const horizontal = w >= h;
1647
+ const rowH = horizontal ? sum / w : h;
1648
+ const rowW = horizontal ? w : sum / h;
1649
+
1650
+ let offset = 0;
1651
+ for (let k = 0; k < row.length; k++) {
1652
+ const frac = row[k] / sum;
1653
+ const idx = order[rowStart + k];
1654
+ if (horizontal) {
1655
+ const segW = frac * w;
1656
+ rects.push({ i: idx, x: x + offset, y, w: segW, h: rowH });
1657
+ offset += segW;
1658
+ } else {
1659
+ const segH = frac * h;
1660
+ rects.push({ i: idx, x, y: y + offset, w: rowW, h: segH });
1661
+ offset += segH;
1662
+ }
1663
+ }
1664
+ if (horizontal) return { x, y: y + rowH, w, h: h - rowH };
1665
+ return { x: x + rowW, y, w: w - rowW, h };
1666
+ };
1667
+
1668
+ let remaining = scaled.slice();
1669
+ while (remaining.length > 0) {
1670
+ row = [remaining[0]];
1671
+ rowStart = scaled.length - remaining.length;
1672
+ const shortSide = Math.min(w, h);
1673
+ let i = 1;
1674
+ while (i < remaining.length) {
1675
+ const next = row.concat(remaining[i]);
1676
+ if (worst(next, shortSide) > worst(row, shortSide)) break;
1677
+ row = next;
1678
+ i++;
1679
+ }
1680
+ const newRect = layoutRow(row, rowStart, x, y, w, h);
1681
+ x = newRect.x; y = newRect.y; w = newRect.w; h = newRect.h;
1682
+ remaining = remaining.slice(row.length);
1683
+ }
1684
+
1685
+ /* Emit rects + labels. */
1686
+ let svg = '';
1687
+ const pad = 2;
1688
+ for (const r of rects) {
1689
+ const pct = ((vals[r.i] / total) * 100).toFixed(1);
1690
+ const tipAttrs = tip({ label: labels[r.i], value: vals[r.i], pct });
1691
+ svg += `<rect data-slice="${r.i % 10}" data-treemap-tile${tipAttrs} x="${r.x + pad}" y="${r.y + pad}" width="${Math.max(0, r.w - pad * 2)}" height="${Math.max(0, r.h - pad * 2)}" rx="${this.#resolveRadius()}"/>`;
1692
+
1693
+ /* Three text-placement branches keyed on available tile size:
1694
+ - `tall` (h > ~2.5 line heights): label + value stacked, top-aligned
1695
+ - `short` (h ≥ ~1.2 line heights but < tall): label only, vertically
1696
+ centered — avoids the clipped-text look when tiles are squat
1697
+ - `tiny` (h too small for even one line): no text
1698
+ All branches gate on width > ~4× fontSize so labels don't overflow. */
1699
+ const labelX = r.x + 8;
1700
+ const canShowAny = r.w > fontSize * 4;
1701
+ const tall = canShowAny && r.h > fontSize * 2.5;
1702
+ const short = canShowAny && !tall && r.h > fontSize * 1.2;
1703
+
1704
+ if (tall) {
1705
+ svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(labels[r.i])}</text>`;
1706
+ svg += `<text data-treemap-value x="${labelX}" y="${r.y + fontSize * 2 + 6}" font-size="${fontSize * 0.9}" dominant-baseline="hanging">${this.#fmtValue(vals[r.i])}</text>`;
1707
+ } else if (short) {
1708
+ const cy = r.y + r.h / 2;
1709
+ svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(labels[r.i])}</text>`;
1710
+ }
1711
+ }
1712
+
1713
+ this.#legendData = data.map((d, i) => ({
1714
+ label: labels[i], value: vals[i], pct: ((vals[i] / total) * 100).toFixed(1), slot: i % 10,
1715
+ }));
1716
+
1717
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1718
+ }
1719
+
1720
+ /* ── Sankey (flow between source and target nodes) ─────────────
1721
+ Flow diagram with columns of nodes and curved bands connecting
1722
+ them. Data shape for Sankey differs from other chart types:
1723
+ data = [{ source: 'A', target: 'B', value: 10 }, ...]
1724
+ Nodes are auto-derived from unique source/target values.
1725
+ Lays out two columns (source-left, target-right) with node
1726
+ heights proportional to throughput. */
1727
+ #renderSankey() {
1728
+ const data = this.#data;
1729
+ if (!data.length) return { svg: '', viewBox: '0 0 100 100' };
1730
+
1731
+ /* Derive source + target node sets from the link data. */
1732
+ const sourceSet = new Map(); /* name → { outflow, y0, y1 } */
1733
+ const targetSet = new Map(); /* name → { inflow, y0, y1 } */
1734
+ for (const link of data) {
1735
+ const s = link.source ?? link.from ?? '';
1736
+ const t = link.target ?? link.to ?? '';
1737
+ const v = +(link.value ?? link.v ?? 0);
1738
+ if (!sourceSet.has(s)) sourceSet.set(s, { name: s, flow: 0 });
1739
+ if (!targetSet.has(t)) targetSet.set(t, { name: t, flow: 0 });
1740
+ sourceSet.get(s).flow += v;
1741
+ targetSet.get(t).flow += v;
1742
+ }
1743
+
1744
+ const dims = this.#dims();
1745
+ const { width, height, fontSize } = dims;
1746
+ const nodeW = Math.max(8, width * 0.03);
1747
+ const colPad = nodeW + fontSize * 5;
1748
+
1749
+ const sourceTotal = [...sourceSet.values()].reduce((a, s) => a + s.flow, 0) || 1;
1750
+ const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;
1751
+
1752
+ const nodeGap = fontSize * 0.6;
1753
+
1754
+ /* Assign y0/y1 to each source node, stacked top-to-bottom. */
1755
+ const sources = [...sourceSet.values()];
1756
+ const targets = [...targetSet.values()];
1757
+ const sourceTotalH = height - nodeGap * (sources.length - 1);
1758
+ const targetTotalH = height - nodeGap * (targets.length - 1);
1759
+
1760
+ let y = 0;
1761
+ for (const s of sources) {
1762
+ const h = (s.flow / sourceTotal) * sourceTotalH;
1763
+ s.y0 = y;
1764
+ s.y1 = y + h;
1765
+ s.cursor = y; /* running y for outgoing links */
1766
+ y += h + nodeGap;
1767
+ }
1768
+ y = 0;
1769
+ for (const t of targets) {
1770
+ const h = (t.flow / targetTotal) * targetTotalH;
1771
+ t.y0 = y;
1772
+ t.y1 = y + h;
1773
+ t.cursor = y;
1774
+ y += h + nodeGap;
1775
+ }
1776
+
1777
+ let svg = '';
1778
+
1779
+ const nodeRx = Math.min(this.#resolveRadius(), nodeW / 2);
1780
+
1781
+ /* Source nodes — left column */
1782
+ sources.forEach((s, i) => {
1783
+ svg += `<rect data-sankey-node data-slice="${i % 10}" x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
1784
+ svg += `<text data-sankey-label x="${colPad - nodeW - 6}" y="${(s.y0 + s.y1) / 2}" text-anchor="end" dominant-baseline="central" font-size="${fontSize}">${esc(s.name)}</text>`;
1785
+ });
1786
+
1787
+ /* Target nodes — right column */
1788
+ targets.forEach((t, i) => {
1789
+ svg += `<rect data-sankey-node data-slice="${(sources.length + i) % 10}" x="${width - colPad}" y="${t.y0}" width="${nodeW}" height="${t.y1 - t.y0}" rx="${nodeRx}"/>`;
1790
+ svg += `<text data-sankey-label x="${width - colPad + nodeW + 6}" y="${(t.y0 + t.y1) / 2}" text-anchor="start" dominant-baseline="central" font-size="${fontSize}">${esc(t.name)}</text>`;
1791
+ });
1792
+
1793
+ /* Links — bezier bands */
1794
+ for (const link of data) {
1795
+ const s = sourceSet.get(link.source ?? link.from ?? '');
1796
+ const t = targetSet.get(link.target ?? link.to ?? '');
1797
+ if (!s || !t) continue;
1798
+ const v = +(link.value ?? link.v ?? 0);
1799
+ if (v <= 0) continue;
1800
+
1801
+ const sH = (v / sourceTotal) * sourceTotalH;
1802
+ const tH = (v / targetTotal) * targetTotalH;
1803
+ const sTop = s.cursor;
1804
+ const sBot = sTop + sH;
1805
+ const tTop = t.cursor;
1806
+ const tBot = tTop + tH;
1807
+ s.cursor += sH;
1808
+ t.cursor += tH;
1809
+
1810
+ const x0 = colPad;
1811
+ const x1 = width - colPad;
1812
+ const mx = (x0 + x1) / 2;
1813
+ const tipAttrs = tip({ label: `${s.name} → ${t.name}`, value: v });
1814
+
1815
+ const path = `M ${x0} ${sTop} C ${mx} ${sTop}, ${mx} ${tTop}, ${x1} ${tTop} L ${x1} ${tBot} C ${mx} ${tBot}, ${mx} ${sBot}, ${x0} ${sBot} Z`;
1816
+ svg += `<path data-sankey-link${tipAttrs} d="${path}"/>`;
1817
+ }
1818
+
1819
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1820
+ }
1821
+
1822
+ /* ── Composed (bar + line overlay) ─────────────────────────────
1823
+ Multi-series combining a bar chart for primary values with a line
1824
+ overlay for secondary values. Data shape: each row has the x-key,
1825
+ a `bar` key, and a `line` key. Both are axis-aligned against the
1826
+ same Y scale (single axis for v1; dual-axis future extension).
1827
+
1828
+ Uses y="bar,line" as the series keys by convention. */
1829
+ #renderComposed() {
1830
+ const dims = this.#dims();
1831
+ const data = this.#data;
1832
+ const keys = this.#yKeys();
1833
+ const barKey = keys[0] || 'bar';
1834
+ const lineKey = keys[1] || 'line';
1835
+ const labels = data.map(d => d[this.x] ?? '');
1836
+ const barVals = data.map(d => +(d[barKey] ?? 0));
1837
+ const lineVals = data.map(d => +(d[lineKey] ?? 0));
1838
+ const allVals = [...barVals, ...lineVals];
1839
+ const ticks = niceScale(0, Math.max(...allVals), 5);
1840
+ const maxVal = ticks[ticks.length - 1];
1841
+
1842
+ const { width, height, pad } = dims;
1843
+ const plotH = height - pad.top - pad.bottom;
1844
+ const plotW = width - pad.left - pad.right;
1845
+ const barW = plotW / data.length;
1846
+ const barInner = barW * 0.6;
1847
+ const barGap = (barW - barInner) / 2;
1848
+ const step = plotW / Math.max(data.length - 1, 1);
1849
+
1850
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1851
+
1852
+ /* Bar series (slot 0) */
1853
+ if (!this.#isSeriesHidden(barKey)) {
1854
+ for (let i = 0; i < data.length; i++) {
1855
+ const v = barVals[i];
1856
+ const barH = maxVal ? (v / maxVal) * plotH : 0;
1857
+ const bx = pad.left + barW * i + barGap;
1858
+ const by = pad.top + plotH - barH;
1859
+ svg += `<path${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
1860
+ }
1861
+ }
1862
+
1863
+ /* Line series (slot 1) — anchored at bar center X positions */
1864
+ if (!this.#isSeriesHidden(lineKey)) {
1865
+ const points = lineVals.map((v, i) => ({
1866
+ x: pad.left + barW * i + barW / 2,
1867
+ y: pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0),
1868
+ v, label: labels[i],
1869
+ }));
1870
+ const t = Math.max(0, Math.min(1, this.smooth));
1871
+ svg += `<path data-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(points, t)}"/>`;
1872
+ for (const p of points) {
1873
+ svg += `<circle data-dot${this.#seriesFill(1, lineKey)} cx="${p.x}" cy="${p.y}" r="3"/>`;
1874
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
1875
+ }
1876
+ }
1877
+
1878
+ this.#legendData = [
1879
+ { label: barKey, key: barKey, slot: 0 },
1880
+ { label: lineKey, key: lineKey, slot: 1 },
1881
+ ];
1882
+
1883
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1884
+ }
1885
+
1886
+ /* ── Segments (single horizontal stacked bar) ──────────────────
1887
+ Categorical data rendered as one horizontal bar split into
1888
+ proportional colored slices. Good for compact in-card
1889
+ "distribution" widgets. Legend reuses the pie/donut path. */
1890
+ #renderSegments() {
1891
+ const data = this.#data;
1892
+ const yKey = this.#yKeys()[0] || this.y;
1893
+ const vals = data.map(d => +(d[yKey] ?? 0));
1894
+ const total = vals.reduce((a, b) => a + b, 0) || 1;
1895
+ const labels = data.map(d => d[this.x] ?? '');
1896
+
1897
+ // ViewBox matches actual render width so there's no horizontal
1898
+ // stretching — keeps rounded corners from flattening into ellipses
1899
+ // and matches the token-controlled gap at its intended px size.
1900
+ const cs = getComputedStyle(this);
1901
+ const w = Math.max(40, this.clientWidth || 200);
1902
+ const h = 24;
1903
+ const r = Math.min(this.#resolveRadius(), h / 2);
1904
+ const gap = Math.max(0, parseFloat(cs.getPropertyValue('--chart-segments-gap')) || 2);
1905
+
1906
+ // Compute segment widths, distributing rounding error so the last
1907
+ // segment lands exactly on the right edge.
1908
+ const widths = [];
1909
+ let acc = 0;
1910
+ for (let i = 0; i < vals.length; i++) {
1911
+ const nextAcc = (i === vals.length - 1) ? w : Math.round(((acc * total + vals[i] * w) / total));
1912
+ // Use the cumulative approach for sub-pixel accuracy.
1913
+ const endX = (i === vals.length - 1) ? w : ((vals.slice(0, i + 1).reduce((s, v) => s + v, 0)) / total) * w;
1914
+ const startX = (i === 0) ? 0 : ((vals.slice(0, i).reduce((s, v) => s + v, 0)) / total) * w;
1915
+ widths.push({ x: startX, w: Math.max(0, endX - startX) });
1916
+ acc = nextAcc;
1917
+ }
1918
+
1919
+ let svg = '';
1920
+ for (let i = 0; i < widths.length; i++) {
1921
+ const { x, w: segW } = widths[i];
1922
+ if (segW <= 0) continue;
1923
+
1924
+ // Only round the outer corners of the first/last visible segment.
1925
+ const isFirst = i === 0;
1926
+ const isLast = i === widths.length - 1;
1927
+ const drawW = Math.max(0, segW - (isLast ? 0 : gap));
1928
+
1929
+ const pct = ((vals[i] / total) * 100).toFixed(1);
1930
+ const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
1931
+ const a11yTitle = `<title>${esc(labels[i])}: ${vals[i]} (${pct}%)</title>`;
1932
+
1933
+ if ((isFirst && isLast) || (!isFirst && !isLast) || r === 0) {
1934
+ svg += `<rect data-slice="${i % 10}"${tipAttrs} x="${x}" y="0" width="${drawW}" height="${h}"${(isFirst || isLast) && r ? ` rx="${r}"` : ''}>${a11yTitle}</rect>`;
1935
+ } else {
1936
+ // Build a path with asymmetric rounded corners.
1937
+ const rr = r;
1938
+ const x2 = x + drawW;
1939
+ let d;
1940
+ if (isFirst) {
1941
+ d = `M ${x + rr} 0 H ${x2} V ${h} H ${x + rr} A ${rr} ${rr} 0 0 1 ${x} ${h - rr} V ${rr} A ${rr} ${rr} 0 0 1 ${x + rr} 0 Z`;
1942
+ } else { // isLast
1943
+ d = `M ${x} 0 H ${x2 - rr} A ${rr} ${rr} 0 0 1 ${x2} ${rr} V ${h - rr} A ${rr} ${rr} 0 0 1 ${x2 - rr} ${h} H ${x} Z`;
1944
+ }
1945
+ svg += `<path data-slice="${i % 10}"${tipAttrs} d="${d}">${a11yTitle}</path>`;
1946
+ }
1947
+ }
1948
+
1949
+ // Legend data (same shape pie/donut use).
1950
+ this.#legendData = data.map((d, i) => ({
1951
+ label: labels[i],
1952
+ value: vals[i],
1953
+ pct: ((vals[i] / total) * 100).toFixed(1),
1954
+ slot: i % 10,
1955
+ }));
1956
+
1957
+ return { svg, viewBox: `0 0 ${w} ${h}` };
1958
+ }
1959
+
1960
+ /* ── Stacked bar ──────────────────────────────────────────────── */
1961
+
1962
+ #renderStackedBar() {
1963
+ const data = this.#data;
1964
+ const keys = this.#yKeys();
1965
+ const labels = data.map(d => d[this.x] ?? '');
1966
+
1967
+ /* Get stacked totals for max */
1968
+ const totals = data.map(d => keys.reduce((sum, k) => sum + (+(d[k] ?? 0)), 0));
1969
+ const ticks = niceScale(0, Math.max(...totals), 5);
1970
+ const maxVal = ticks[ticks.length - 1];
1971
+
1972
+ const dims = this.#dims();
1973
+ const { width, height, pad } = dims;
1974
+ const plotH = height - pad.top - pad.bottom;
1975
+ const plotW = width - pad.left - pad.right;
1976
+ const barW = plotW / data.length;
1977
+ const barInner = barW * 0.6;
1978
+ const barGap = (barW - barInner) / 2;
1979
+
1980
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1981
+
1982
+ for (let i = 0; i < data.length; i++) {
1983
+ let stackY = pad.top + plotH;
1984
+ const segCount = keys.length;
1985
+ for (let k = 0; k < segCount; k++) {
1986
+ if (this.#isSeriesHidden(keys[k])) continue;
1987
+ const v = +(data[i][keys[k]] ?? 0);
1988
+ const segH = maxVal ? (v / maxVal) * plotH : 0;
1989
+ if (segH <= 0) { stackY -= segH; continue; }
1990
+ stackY -= segH;
1991
+ const bx = pad.left + barW * i + barGap;
1992
+ const by = stackY;
1993
+ const bh = segH;
1994
+ const isTop = k === segCount - 1;
1995
+ const r = this.#resolveRadius();
1996
+
1997
+ const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
1998
+
1999
+ if (isTop) {
2000
+ // Top segment (or single segment) — round top corners only.
2001
+ // Bottom edge sits on the next segment OR the axis baseline; either
2002
+ // way it should be flush, so we never round the bottom corners.
2003
+ svg += `<path${attrs} d="${topRoundedBarPath(bx, by, barInner, bh, r)}"/>`;
2004
+ } else {
2005
+ // Middle and bottom segments — flush on both ends. Bottom sits on
2006
+ // the axis baseline; middle sits between segments.
2007
+ svg += `<rect${attrs} x="${bx}" y="${by}" width="${barInner}" height="${bh}"/>`;
2008
+ }
2009
+ }
2010
+ }
2011
+
2012
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
2013
+
2014
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2015
+ }
2016
+
2017
+ /* ── Grouped bar ──────────────────────────────────────────────── */
2018
+
2019
+ #renderGroupedBar() {
2020
+ const data = this.#data;
2021
+ const keys = this.#yKeys();
2022
+ const labels = data.map(d => d[this.x] ?? '');
2023
+
2024
+ const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
2025
+ const ticks = niceScale(0, Math.max(...allVals), 5);
2026
+ const maxVal = ticks[ticks.length - 1];
2027
+
2028
+ const dims = this.#dims();
2029
+ const { width, height, pad } = dims;
2030
+ const plotH = height - pad.top - pad.bottom;
2031
+ const plotW = width - pad.left - pad.right;
2032
+ const groupW = plotW / data.length;
2033
+ const barGap = 3;
2034
+ const totalBarSpace = groupW * 0.7;
2035
+ const subBarW = (totalBarSpace - barGap * (keys.length - 1)) / keys.length;
2036
+ const groupPad = (groupW - totalBarSpace) / 2;
2037
+
2038
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
2039
+
2040
+ for (let i = 0; i < data.length; i++) {
2041
+ for (let k = 0; k < keys.length; k++) {
2042
+ if (this.#isSeriesHidden(keys[k])) continue;
2043
+ const v = +(data[i][keys[k]] ?? 0);
2044
+ const barH = maxVal ? (v / maxVal) * plotH : 0;
2045
+ const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
2046
+ const by = pad.top + plotH - barH;
2047
+ svg += `<path${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${topRoundedBarPath(bx, by, subBarW, barH, this.#resolveRadius())}"/>`;
2048
+
2049
+ if (!this.hideValues) {
2050
+ svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
2051
+ }
2052
+ }
2053
+ }
2054
+
2055
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
2056
+
2057
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2058
+ }
2059
+
2060
+ /* ── Multi-line ───────────────────────────────────────────────── */
2061
+
2062
+ #renderMultiLine() {
2063
+ const data = this.#data;
2064
+ const keys = this.#yKeys();
2065
+ const labels = data.map(d => d[this.x] ?? '');
2066
+
2067
+ const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
2068
+ const ticks = niceScale(0, Math.max(...allVals), 5);
2069
+ const maxVal = ticks[ticks.length - 1];
2070
+
2071
+ const dims = this.#dims();
2072
+ const { width, height, pad } = dims;
2073
+ const plotH = height - pad.top - pad.bottom;
2074
+ const plotW = width - pad.left - pad.right;
2075
+ const step = plotW / Math.max(data.length - 1, 1);
2076
+
2077
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
2078
+
2079
+ for (let k = 0; k < keys.length; k++) {
2080
+ if (this.#isSeriesHidden(keys[k])) continue;
2081
+ const vals = data.map(d => +(d[keys[k]] ?? 0));
2082
+ const points = vals.map((v, i) => {
2083
+ const px = pad.left + step * i;
2084
+ const py = pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0);
2085
+ return { x: px, y: py, v, label: labels[i] };
2086
+ });
2087
+
2088
+ const baseline = pad.top + plotH;
2089
+ const t = Math.max(0, Math.min(1, this.smooth));
2090
+
2091
+ /* Area fill */
2092
+ svg += `<path data-area${this.#seriesFill(k % 10, keys[k])} d="${smoothAreaPath(points, baseline, t)}"/>`;
2093
+
2094
+ /* Line */
2095
+ svg += `<path data-line${this.#seriesStroke(k % 10, keys[k])} d="${smoothPath(points, t)}"/>`;
2096
+
2097
+ /* Dots + hit targets. Hit circles deliberately omit data-slice so
2098
+ they aren't caught by the circle[data-slice] fill rule in CSS. */
2099
+ for (const p of points) {
2100
+ svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])} cx="${p.x}" cy="${p.y}" r="3"/>`;
2101
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: keys[k] })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
2102
+ }
2103
+ }
2104
+
2105
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
2106
+
2107
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2108
+ }
2109
+
2110
+ /* ── Legend ────────────────────────────────────────────────────── */
2111
+
2112
+ #legendData = null;
2113
+
2114
+ /* Public getter — external consumers (chart-legend-ui[for]) read this to
2115
+ mirror series data. Returns a defensive shallow copy so callers can't
2116
+ mutate our internals. Null when the chart has no legend-bearing type
2117
+ or hasn't rendered yet. */
2118
+ get legendData() {
2119
+ return this.#legendData ? this.#legendData.map(it => ({ ...it })) : null;
2120
+ }
2121
+
2122
+ #buildLegend() {
2123
+ if (!this.#legendData || !this.#legendData.length) return null;
2124
+
2125
+ const legend = document.createElement('div');
2126
+ legend.setAttribute('data-legend', '');
2127
+
2128
+ for (const item of this.#legendData) {
2129
+ const el = document.createElement('span');
2130
+ el.setAttribute('data-legend-item', '');
2131
+ if (item.key) el.setAttribute('data-series-key', item.key);
2132
+
2133
+ const dot = document.createElement('span');
2134
+ dot.setAttribute('data-legend-dot', '');
2135
+ dot.setAttribute('data-slice', String(item.slot));
2136
+ if (item.key) dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
2137
+ el.appendChild(dot);
2138
+
2139
+ const text = document.createElement('span');
2140
+ text.textContent = item.pct ? `${item.label} (${item.pct}%)` : item.label;
2141
+ el.appendChild(text);
2142
+
2143
+ legend.appendChild(el);
2144
+ }
2145
+
2146
+ /* Intentionally DO NOT null #legendData here — public `legendData`
2147
+ getter needs it to survive so chart-legend-ui[for] can mirror. */
2148
+ return legend;
2149
+ }
2150
+ }