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