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