@adia-ai/web-components 0.0.12 → 0.0.14
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 +10 -10
- package/components/card/card.css +29 -0
- package/components/chart/chart.a2ui.json +43 -6
- package/components/chart/chart.css +224 -0
- package/components/chart/chart.js +1049 -27
- package/components/chart/chart.yaml +62 -6
- package/components/chart-legend/chart-legend.a2ui.json +139 -0
- package/components/chart-legend/chart-legend.css +124 -0
- package/components/chart-legend/chart-legend.js +185 -0
- package/components/chart-legend/chart-legend.yaml +133 -0
- package/components/code/code-editor.js +161 -0
- package/components/code/code.a2ui.json +59 -0
- package/components/code/code.css +78 -2
- package/components/code/code.js +147 -9
- package/components/code/code.yaml +42 -0
- package/components/heatmap/heatmap.js +62 -13
- package/components/index.js +1 -0
- package/components/select/select.css +1 -1
- package/components/slider/slider.js +8 -3
- package/components/stat/stat.a2ui.json +3 -0
- package/components/stat/stat.css +32 -0
- package/components/stat/stat.yaml +6 -0
- package/components/tooltip/tooltip.a2ui.json +29 -4
- package/components/tooltip/tooltip.css +111 -0
- package/components/tooltip/tooltip.js +200 -12
- package/components/tooltip/tooltip.yaml +38 -4
- package/core/icons.js +35 -1
- package/core/index.js +25 -0
- package/core/provider.js +1 -1
- package/index.css +26 -0
- package/index.js +18 -0
- package/package.json +14 -6
- package/patterns/adia-chat/adia-chat.js +1 -1
- package/styles/colors/semantics.css +6 -5
- package/styles/{styles.css → components.css} +9 -111
- package/styles/resets.css +116 -0
- package/styles/tokens.css +8 -2
- package/core/_cm-core.js +0 -38
- package/core/_cm-theme.js +0 -58
- package/core/_lang-css.js +0 -2
- package/core/_lang-html.js +0 -2
- package/core/_lang-javascript.js +0 -2
- package/core/_lang-json.js +0 -2
- package/core/_lang-markdown.js +0 -2
- package/core/_lang-yaml.js +0 -2
- package/core/code-editor-bundle.js +0 -63
|
@@ -196,8 +196,9 @@ class AdiaChart extends AdiaElement {
|
|
|
196
196
|
hideValues: { type: Boolean, default: false, reflect: true, attribute: 'hide-values' },
|
|
197
197
|
radius: { type: Number, default: null, reflect: true },
|
|
198
198
|
smooth: { type: Number, default: 0.4, reflect: true },
|
|
199
|
-
aspect: { type: String, default: 'std', reflect: true }, // std | wide | square | tall
|
|
199
|
+
aspect: { type: String, default: 'std', reflect: true }, // std | wide | square | tall (deprecated — OD-CHART-02)
|
|
200
200
|
size: { type: String, default: '', reflect: true }, // sm | md | lg
|
|
201
|
+
format: { type: String, default: 'abbr', reflect: true }, // abbr | decimal | currency | percent (OD-CHART-03)
|
|
201
202
|
};
|
|
202
203
|
|
|
203
204
|
static template = () => null;
|
|
@@ -207,6 +208,9 @@ class AdiaChart extends AdiaElement {
|
|
|
207
208
|
#resizeRaf = null;
|
|
208
209
|
#lastW = 0;
|
|
209
210
|
#lastH = 0;
|
|
211
|
+
/* Set of series keys hidden by external chart-legend-ui[for=self] toggles.
|
|
212
|
+
Kept at render time; repopulated lookups rebuild with the new set. */
|
|
213
|
+
#hiddenSeriesKeys = new Set();
|
|
210
214
|
|
|
211
215
|
/** Resolves the corner radius: the `radius` prop if explicitly set,
|
|
212
216
|
* otherwise `--a-radius` from the host's computed style. Falls back
|
|
@@ -220,6 +224,7 @@ class AdiaChart extends AdiaElement {
|
|
|
220
224
|
|
|
221
225
|
set data(arr) {
|
|
222
226
|
this.#data = Array.isArray(arr) ? arr : [];
|
|
227
|
+
this.#warnIfOverBudget();
|
|
223
228
|
this.#renderChart();
|
|
224
229
|
}
|
|
225
230
|
|
|
@@ -227,7 +232,55 @@ class AdiaChart extends AdiaElement {
|
|
|
227
232
|
return this.#data;
|
|
228
233
|
}
|
|
229
234
|
|
|
235
|
+
/* OD-CHART-11 — one-shot perf-budget warning. Chart-ui re-renders the
|
|
236
|
+
full SVG string on every data change; perf drops noticeably past
|
|
237
|
+
~5,000 datums (the tipping point varies by type — scatter and
|
|
238
|
+
multi-line are the worst offenders). Authors over the budget should
|
|
239
|
+
downsample at the data layer (LTTB, uniform sampling, aggregation
|
|
240
|
+
by bucket) before setting .data. Override the threshold via the
|
|
241
|
+
`--chart-perf-budget` CSS token for ad-hoc testing. */
|
|
242
|
+
#perfBudgetWarned = false;
|
|
243
|
+
#warnIfOverBudget() {
|
|
244
|
+
if (this.#perfBudgetWarned) return;
|
|
245
|
+
const budget = this.#readPerfBudget();
|
|
246
|
+
if (this.#data.length > budget) {
|
|
247
|
+
console.warn(
|
|
248
|
+
`[chart-ui] .data has ${this.#data.length} rows which exceeds the ` +
|
|
249
|
+
`recommended perf budget of ${budget}. Consider downsampling at the ` +
|
|
250
|
+
`data layer (e.g., LTTB, bucket aggregation). Override the budget ` +
|
|
251
|
+
`via CSS: chart-ui { --chart-perf-budget: 10000 }.`
|
|
252
|
+
);
|
|
253
|
+
this.#perfBudgetWarned = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
#readPerfBudget() {
|
|
257
|
+
const raw = getComputedStyle(this).getPropertyValue('--chart-perf-budget').trim();
|
|
258
|
+
const n = parseInt(raw, 10);
|
|
259
|
+
return Number.isFinite(n) && n > 0 ? n : 5000;
|
|
260
|
+
}
|
|
261
|
+
|
|
230
262
|
connected() {
|
|
263
|
+
if (!this.hasAttribute('role')) this.setAttribute('role', 'img');
|
|
264
|
+
if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', this.heading || `${this.type} chart`);
|
|
265
|
+
|
|
266
|
+
/* Phase 1b — listen for legend-toggle events from external
|
|
267
|
+
chart-legend-ui[for=self] and hide the matching series. Events
|
|
268
|
+
bubble so document-level listening catches descendant legends. */
|
|
269
|
+
document.addEventListener('legend-toggle', this.#onLegendToggle);
|
|
270
|
+
|
|
271
|
+
/* OD-CHART-06 — keyboard a11y. Chart becomes focusable; arrow keys
|
|
272
|
+
move a virtual focus across datums in DOM order, Enter/Space fires
|
|
273
|
+
chart-select, Escape clears focus. Per-datum focus dispatches the
|
|
274
|
+
same chart-hover event the pointer path uses, so tooltip-ui[for]
|
|
275
|
+
tracks keyboard focus transparently.
|
|
276
|
+
Deprecation warnings for `aspect=` and `heading=` also fire here
|
|
277
|
+
per OD-CHART-02 — one-shot per instance to keep the console clean. */
|
|
278
|
+
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
|
|
279
|
+
this.addEventListener('keydown', this.#onKeydown);
|
|
280
|
+
this.addEventListener('focus', this.#onFocus);
|
|
281
|
+
this.addEventListener('blur', this.#onBlur);
|
|
282
|
+
this.#warnDeprecatedAttrs();
|
|
283
|
+
|
|
231
284
|
this.#resizeObs = new ResizeObserver((entries) => {
|
|
232
285
|
const { inlineSize: w, blockSize: h } = entries[0].contentBoxSize[0];
|
|
233
286
|
if (!this.#data.length) return;
|
|
@@ -306,11 +359,31 @@ class AdiaChart extends AdiaElement {
|
|
|
306
359
|
|
|
307
360
|
/* ── Main render ──────────────────────────────────────────────── */
|
|
308
361
|
|
|
362
|
+
#emptySlotTemplate = null;
|
|
363
|
+
|
|
309
364
|
#renderChart() {
|
|
310
365
|
if (!this.isConnected) return;
|
|
366
|
+
|
|
367
|
+
/* Preserve the empty slot across re-renders. First render captures the
|
|
368
|
+
author-provided <* slot="empty"> into a template; subsequent renders
|
|
369
|
+
restore it so data-empty → data-present → data-empty transitions
|
|
370
|
+
don't lose the slotted empty-state-ui. Visibility is CSS-toggled via
|
|
371
|
+
[data-has-data] on the host. */
|
|
372
|
+
const existingEmpty = this.querySelector(':scope > [slot="empty"]');
|
|
373
|
+
if (existingEmpty && !this.#emptySlotTemplate) {
|
|
374
|
+
this.#emptySlotTemplate = existingEmpty.cloneNode(true);
|
|
375
|
+
}
|
|
376
|
+
|
|
311
377
|
this.innerHTML = '';
|
|
378
|
+
if (this.#emptySlotTemplate) this.appendChild(this.#emptySlotTemplate.cloneNode(true));
|
|
312
379
|
|
|
313
|
-
if (!this.#data.length)
|
|
380
|
+
if (!this.#data.length) {
|
|
381
|
+
this.removeAttribute('data-has-data');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.setAttribute('data-has-data', '');
|
|
385
|
+
|
|
386
|
+
this.#injectSeriesColors();
|
|
314
387
|
|
|
315
388
|
if (this.heading) {
|
|
316
389
|
const headingEl = document.createElement('div');
|
|
@@ -337,6 +410,14 @@ class AdiaChart extends AdiaElement {
|
|
|
337
410
|
case 'radar': ({ svg: svgContent, viewBox: vb } = this.#renderRadar()); break;
|
|
338
411
|
case 'sparkline': ({ svg: svgContent, viewBox: vb } = this.#renderSparkline()); break;
|
|
339
412
|
case 'segments': ({ svg: svgContent, viewBox: vb } = this.#renderSegments()); break;
|
|
413
|
+
case 'area': ({ svg: svgContent, viewBox: vb } = this.#renderArea()); break;
|
|
414
|
+
case 'scatter': ({ svg: svgContent, viewBox: vb } = this.#renderScatter()); break;
|
|
415
|
+
case 'radial-bar': ({ svg: svgContent, viewBox: vb } = this.#renderRadialBar()); break;
|
|
416
|
+
case 'gauge': ({ svg: svgContent, viewBox: vb } = this.#renderGauge()); break;
|
|
417
|
+
case 'funnel': ({ svg: svgContent, viewBox: vb } = this.#renderFunnel()); break;
|
|
418
|
+
case 'treemap': ({ svg: svgContent, viewBox: vb } = this.#renderTreemap()); break;
|
|
419
|
+
case 'sankey': ({ svg: svgContent, viewBox: vb } = this.#renderSankey()); break;
|
|
420
|
+
case 'composed': ({ svg: svgContent, viewBox: vb } = this.#renderComposed()); break;
|
|
340
421
|
case 'stacked-bar': ({ svg: svgContent, viewBox: vb } = this.#renderStackedBar()); break;
|
|
341
422
|
case 'grouped-bar': ({ svg: svgContent, viewBox: vb } = this.#renderGroupedBar()); break;
|
|
342
423
|
case 'multi-line': ({ svg: svgContent, viewBox: vb } = this.#renderMultiLine()); break;
|
|
@@ -347,18 +428,59 @@ class AdiaChart extends AdiaElement {
|
|
|
347
428
|
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
348
429
|
svgEl.innerHTML = svgContent;
|
|
349
430
|
|
|
350
|
-
/* Append legend for types that need it
|
|
351
|
-
|
|
431
|
+
/* Append legend for types that need it. Legend data is also exposed via
|
|
432
|
+
the public `legendData` getter so a sibling chart-legend-ui[for] can
|
|
433
|
+
mirror the same series. When an external legend is present (Phase 1b:
|
|
434
|
+
chart-legend-ui[for=self]), the internal one is suppressed so we
|
|
435
|
+
don't double-render — the #legendData remains populated for the
|
|
436
|
+
external to read. */
|
|
437
|
+
if (['pie', 'donut', 'stacked-bar', 'grouped-bar', 'multi-line', 'radial-bar'].includes(this.type)
|
|
438
|
+
&& !this.#hasExternalLegend()) {
|
|
352
439
|
const legend = this.#buildLegend();
|
|
353
440
|
if (legend) this.appendChild(legend);
|
|
354
441
|
}
|
|
355
442
|
|
|
356
|
-
/*
|
|
357
|
-
|
|
358
|
-
|
|
443
|
+
/* Notify external legend/tooltip consumers that legendData has refreshed. */
|
|
444
|
+
this.dispatchEvent(new CustomEvent('legend-update', { bubbles: true }));
|
|
445
|
+
|
|
446
|
+
/* Wire hover tooltip + custom events. Internal tooltip (#tipEl) remains
|
|
447
|
+
for back-compat; Phase 2 retires it when tooltip-ui[follows=pointer]
|
|
448
|
+
lands. Custom events are additive — fire regardless of tooltip state.
|
|
449
|
+
OD-CHART-07: pointerdown handles touch (tap-to-pin); pointerover /
|
|
450
|
+
move still handle mouse + pen (`pointerType` gating inside each). */
|
|
359
451
|
svgEl.addEventListener('pointerover', this.#onPointerOver);
|
|
360
452
|
svgEl.addEventListener('pointermove', this.#onPointerMove);
|
|
361
453
|
svgEl.addEventListener('pointerleave', this.#onPointerLeave);
|
|
454
|
+
svgEl.addEventListener('pointerdown', this.#onPointerDown);
|
|
455
|
+
svgEl.addEventListener('click', this.#onClick);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* ── Per-series --color-{key} injection ──
|
|
459
|
+
For each declared series key (`y="revenue,users"`), emit an inline CSS
|
|
460
|
+
custom property on the host mapping the key to the categorical palette
|
|
461
|
+
slot it occupies. Consumers override `--color-revenue` at any ancestor
|
|
462
|
+
to recolor that series across chart + legend + (Phase 2) tooltip. */
|
|
463
|
+
#injectSeriesColors() {
|
|
464
|
+
const keys = this.#yKeys();
|
|
465
|
+
for (let i = 0; i < keys.length; i++) {
|
|
466
|
+
const slot = i % 10;
|
|
467
|
+
this.style.setProperty(`--color-${keys[i]}`, `var(--chart-${slot})`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* Render-time helpers — emit data-slice + series-key + inline style that
|
|
472
|
+
references `--color-{key}` with fallback to `--chart-{slot}`. Inline
|
|
473
|
+
style beats the stylesheet's data-slice rule, so consumer overrides of
|
|
474
|
+
--color-{key} at any ancestor recolor that series. Non-series-keyed
|
|
475
|
+
elements (pie/donut/segments categorical slots) keep data-slice only
|
|
476
|
+
and are coloured by the stylesheet. */
|
|
477
|
+
#seriesFill(slotIdx, seriesKey) {
|
|
478
|
+
if (!seriesKey) return ` data-slice="${slotIdx}"`;
|
|
479
|
+
return ` data-slice="${slotIdx}" data-series-key="${esc(seriesKey)}" style="fill: var(--color-${seriesKey}, var(--chart-${slotIdx}))"`;
|
|
480
|
+
}
|
|
481
|
+
#seriesStroke(slotIdx, seriesKey) {
|
|
482
|
+
if (!seriesKey) return ` data-slice="${slotIdx}"`;
|
|
483
|
+
return ` data-slice="${slotIdx}" data-series-key="${esc(seriesKey)}" style="stroke: var(--color-${seriesKey}, var(--chart-${slotIdx}))"`;
|
|
362
484
|
}
|
|
363
485
|
|
|
364
486
|
/* ── Tooltip ───────────────────────────────────────────────────── */
|
|
@@ -369,23 +491,378 @@ class AdiaChart extends AdiaElement {
|
|
|
369
491
|
this.#resizeObs?.disconnect();
|
|
370
492
|
this.#resizeObs = null;
|
|
371
493
|
if (this.#resizeRaf) { cancelAnimationFrame(this.#resizeRaf); this.#resizeRaf = null; }
|
|
494
|
+
document.removeEventListener('legend-toggle', this.#onLegendToggle);
|
|
495
|
+
document.removeEventListener('pointerdown', this.#pinnedTouchDismiss);
|
|
496
|
+
this.removeEventListener('keydown', this.#onKeydown);
|
|
497
|
+
this.removeEventListener('focus', this.#onFocus);
|
|
498
|
+
this.removeEventListener('blur', this.#onBlur);
|
|
372
499
|
this.#hideTooltip();
|
|
373
500
|
}
|
|
374
501
|
|
|
502
|
+
/* Detect whether an external chart-legend-ui or tooltip-ui is acting as
|
|
503
|
+
this chart's legend / tooltip via [for=self.id]. When present, the
|
|
504
|
+
internal counterpart is suppressed so we don't double-render. */
|
|
505
|
+
#hasExternalLegend() {
|
|
506
|
+
if (!this.id) return false;
|
|
507
|
+
return !!document.querySelector(`chart-legend-ui[for="${CSS.escape(this.id)}"]`);
|
|
508
|
+
}
|
|
509
|
+
#hasExternalTooltip() {
|
|
510
|
+
if (!this.id) return false;
|
|
511
|
+
return !!document.querySelector(`tooltip-ui[follows="pointer"][for="${CSS.escape(this.id)}"]`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#onLegendToggle = (event) => {
|
|
515
|
+
const legend = event.target?.closest?.('chart-legend-ui[for]');
|
|
516
|
+
if (!legend || legend.getAttribute('for') !== this.id) return;
|
|
517
|
+
const { key, active } = event.detail || {};
|
|
518
|
+
if (!key) return;
|
|
519
|
+
if (active) this.#hiddenSeriesKeys.delete(key);
|
|
520
|
+
else this.#hiddenSeriesKeys.add(key);
|
|
521
|
+
this.#renderChart();
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
#isSeriesHidden(key) {
|
|
525
|
+
return !!key && this.#hiddenSeriesKeys.has(key);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/* ── Deprecation warnings (OD-CHART-02) ─────────────────────────
|
|
529
|
+
`aspect=` and `heading=` were part of the pre-composition model —
|
|
530
|
+
`aspect` is stale because parents now size the chart; `heading`
|
|
531
|
+
because card headers are the semantic location for chart titles.
|
|
532
|
+
Warn once per instance so the console stays readable. Attrs still
|
|
533
|
+
honored; removal planned for the next major. */
|
|
534
|
+
#deprecationWarned = false;
|
|
535
|
+
#warnDeprecatedAttrs() {
|
|
536
|
+
if (this.#deprecationWarned) return;
|
|
537
|
+
if (this.aspect && this.aspect !== 'std') {
|
|
538
|
+
console.warn(
|
|
539
|
+
`[chart-ui] aspect="${this.aspect}" is deprecated. ` +
|
|
540
|
+
`Parents should size the chart directly (width/height on the card ` +
|
|
541
|
+
`or container). The attribute will be removed in a future major.`
|
|
542
|
+
);
|
|
543
|
+
this.#deprecationWarned = true;
|
|
544
|
+
}
|
|
545
|
+
if (this.heading) {
|
|
546
|
+
console.warn(
|
|
547
|
+
`[chart-ui] heading="${this.heading}" is deprecated. ` +
|
|
548
|
+
`Place the title in an enclosing card-ui <header><span slot="heading"> ` +
|
|
549
|
+
`instead. The attribute will be removed in a future major.`
|
|
550
|
+
);
|
|
551
|
+
this.#deprecationWarned = true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* ── Number formatting (OD-CHART-03) ────────────────────────────
|
|
556
|
+
`format` attribute selects how datum values are displayed on axis
|
|
557
|
+
labels, bar/line value overlays, donut centers, etc. Falls back to
|
|
558
|
+
the local `fmt()` helper for `abbr` (existing behavior). Currency
|
|
559
|
+
prefix reads --chart-currency-prefix (default "$") so consumers
|
|
560
|
+
retune per locale without touching the format attr itself. */
|
|
561
|
+
#fmtValue(v) {
|
|
562
|
+
if (v == null || v === '') return '';
|
|
563
|
+
const n = +v;
|
|
564
|
+
if (!Number.isFinite(n)) return String(v);
|
|
565
|
+
const fmtAttr = this.format || 'abbr';
|
|
566
|
+
switch (fmtAttr) {
|
|
567
|
+
case 'decimal': return n.toFixed(2);
|
|
568
|
+
case 'percent': return `${(n * 100).toFixed(1)}%`;
|
|
569
|
+
case 'currency': {
|
|
570
|
+
/* --chart-currency-prefix is a CSS custom property storing a string.
|
|
571
|
+
getComputedStyle returns it with the surrounding quotes ('"$"'),
|
|
572
|
+
which must be stripped before concatenation. Empty/unset falls
|
|
573
|
+
back to '$'. */
|
|
574
|
+
let prefix = getComputedStyle(this).getPropertyValue('--chart-currency-prefix').trim();
|
|
575
|
+
if ((prefix.startsWith('"') && prefix.endsWith('"')) ||
|
|
576
|
+
(prefix.startsWith("'") && prefix.endsWith("'"))) {
|
|
577
|
+
prefix = prefix.slice(1, -1);
|
|
578
|
+
}
|
|
579
|
+
return `${prefix || '$'}${fmt(n)}`;
|
|
580
|
+
}
|
|
581
|
+
case 'abbr':
|
|
582
|
+
default: return fmt(n);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/* ── Keyboard nav (OD-CHART-06) ─────────────────────────────────
|
|
587
|
+
Virtual focus across data points in DOM order — ArrowLeft/Right or
|
|
588
|
+
ArrowUp/Down step by one; Home/End jump to first/last; Enter/Space
|
|
589
|
+
fires chart-select; Escape clears focus and fires chart-leave. On
|
|
590
|
+
focus change, the chart emits the same chart-hover event shape as
|
|
591
|
+
the pointer path so tooltip-ui[follows=pointer][for] tracks keyboard
|
|
592
|
+
focus without needing its own code path.
|
|
593
|
+
|
|
594
|
+
Per-datum focus indicator is a data-a11y-focus attribute on the
|
|
595
|
+
focused element + a data-a11y-focused attribute on the host; CSS
|
|
596
|
+
paints the outline. */
|
|
597
|
+
#focusedDatumIdx = -1;
|
|
598
|
+
|
|
599
|
+
#datums() {
|
|
600
|
+
/* `[data-tip-label]` OR `[data-tip-value]` — every datum emits at
|
|
601
|
+
least one, so this catches every renderer. `circle[data-hit]` is
|
|
602
|
+
included intentionally (line/scatter uses a hit overlay as its
|
|
603
|
+
hover target). */
|
|
604
|
+
return Array.from(this.querySelectorAll('[data-tip-label], [data-tip-value]'));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
#setFocusedDatum(idx) {
|
|
608
|
+
const datums = this.#datums();
|
|
609
|
+
if (!datums.length) { this.#clearFocusedDatum(); return; }
|
|
610
|
+
this.#focusedDatumIdx = Math.max(0, Math.min(idx, datums.length - 1));
|
|
611
|
+
this.#paintFocusIndicator();
|
|
612
|
+
this.#emitHoverForFocused();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
#paintFocusIndicator() {
|
|
616
|
+
const datums = this.#datums();
|
|
617
|
+
/* Clear previous focus marker. */
|
|
618
|
+
for (const d of datums) d.removeAttribute('data-a11y-focus');
|
|
619
|
+
const el = datums[this.#focusedDatumIdx];
|
|
620
|
+
if (!el) return this.removeAttribute('data-a11y-focused');
|
|
621
|
+
el.setAttribute('data-a11y-focus', '');
|
|
622
|
+
this.setAttribute('data-a11y-focused', '');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
#clearFocusedDatum() {
|
|
626
|
+
this.#focusedDatumIdx = -1;
|
|
627
|
+
for (const d of this.#datums()) d.removeAttribute('data-a11y-focus');
|
|
628
|
+
this.removeAttribute('data-a11y-focused');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#emitHoverForFocused() {
|
|
632
|
+
const el = this.#datums()[this.#focusedDatumIdx];
|
|
633
|
+
if (!el) return;
|
|
634
|
+
const rect = el.getBoundingClientRect();
|
|
635
|
+
const synthEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
|
|
636
|
+
this.#emitHover(el, synthEvent);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
#emitSelectForFocused() {
|
|
640
|
+
const el = this.#datums()[this.#focusedDatumIdx];
|
|
641
|
+
if (!el) return;
|
|
642
|
+
const rect = el.getBoundingClientRect();
|
|
643
|
+
const synthEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
|
|
644
|
+
this.dispatchEvent(new CustomEvent('chart-select', {
|
|
645
|
+
bubbles: true,
|
|
646
|
+
detail: this.#tipPayload(el, synthEvent),
|
|
647
|
+
}));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
#onFocus = () => {
|
|
651
|
+
/* On first focus, select the first datum. Subsequent focuses keep
|
|
652
|
+
the prior position (common keyboard-nav convention). */
|
|
653
|
+
if (this.#focusedDatumIdx === -1) this.#setFocusedDatum(0);
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
#onBlur = () => {
|
|
657
|
+
this.#clearFocusedDatum();
|
|
658
|
+
if (this.#hoveredTarget) this.#emitLeave();
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
#onKeydown = (e) => {
|
|
662
|
+
const datums = this.#datums();
|
|
663
|
+
if (!datums.length) return;
|
|
664
|
+
switch (e.key) {
|
|
665
|
+
case 'ArrowRight':
|
|
666
|
+
case 'ArrowDown':
|
|
667
|
+
e.preventDefault();
|
|
668
|
+
this.#setFocusedDatum(this.#focusedDatumIdx + 1);
|
|
669
|
+
break;
|
|
670
|
+
case 'ArrowLeft':
|
|
671
|
+
case 'ArrowUp':
|
|
672
|
+
e.preventDefault();
|
|
673
|
+
this.#setFocusedDatum(Math.max(0, this.#focusedDatumIdx - 1));
|
|
674
|
+
break;
|
|
675
|
+
case 'Home':
|
|
676
|
+
e.preventDefault();
|
|
677
|
+
this.#setFocusedDatum(0);
|
|
678
|
+
break;
|
|
679
|
+
case 'End':
|
|
680
|
+
e.preventDefault();
|
|
681
|
+
this.#setFocusedDatum(datums.length - 1);
|
|
682
|
+
break;
|
|
683
|
+
case 'Enter':
|
|
684
|
+
case ' ':
|
|
685
|
+
e.preventDefault();
|
|
686
|
+
this.#emitSelectForFocused();
|
|
687
|
+
break;
|
|
688
|
+
case 'Escape':
|
|
689
|
+
e.preventDefault();
|
|
690
|
+
this.#clearFocusedDatum();
|
|
691
|
+
if (this.#hoveredTarget) this.#emitLeave();
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
#hoveredTarget = null;
|
|
697
|
+
|
|
375
698
|
#onPointerOver = (e) => {
|
|
699
|
+
/* OD-CHART-07 — touch uses pointerdown to tap-to-pin; pointerover
|
|
700
|
+
is too noisy on touch devices (fires redundantly with down). */
|
|
701
|
+
if (e.pointerType === 'touch') return;
|
|
376
702
|
const t = e.target.closest('[data-tip-label], [data-tip-value]');
|
|
377
|
-
if (t)
|
|
703
|
+
if (t) {
|
|
704
|
+
this.#showTooltip(t, e);
|
|
705
|
+
this.#emitHover(t, e);
|
|
706
|
+
}
|
|
378
707
|
};
|
|
379
708
|
|
|
380
709
|
#onPointerMove = (e) => {
|
|
710
|
+
if (e.pointerType === 'touch') return; /* touch handled via pointerdown */
|
|
381
711
|
const t = e.target.closest('[data-tip-label], [data-tip-value]');
|
|
382
|
-
if (!t)
|
|
712
|
+
if (!t) {
|
|
713
|
+
if (this.#hoveredTarget) this.#emitLeave();
|
|
714
|
+
return this.#hideTooltip();
|
|
715
|
+
}
|
|
383
716
|
this.#showTooltip(t, e);
|
|
717
|
+
if (t !== this.#hoveredTarget) this.#emitHover(t, e);
|
|
384
718
|
};
|
|
385
719
|
|
|
386
|
-
#onPointerLeave = () =>
|
|
720
|
+
#onPointerLeave = (e) => {
|
|
721
|
+
if (e && e.pointerType === 'touch') return;
|
|
722
|
+
this.#hideTooltip();
|
|
723
|
+
if (this.#hoveredTarget) this.#emitLeave();
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
/* OD-CHART-07 — tap-to-pin on touch devices. Tapping a datum shows
|
|
727
|
+
the tooltip and emits chart-hover; tapping elsewhere (including
|
|
728
|
+
outside the chart via the document listener below) dismisses it.
|
|
729
|
+
Mouse + pen go through the existing pointerover path; pointerdown
|
|
730
|
+
here only processes touch so desktop behavior is unchanged. */
|
|
731
|
+
#onPointerDown = (e) => {
|
|
732
|
+
if (e.pointerType !== 'touch') return;
|
|
733
|
+
const t = e.target.closest('[data-tip-label], [data-tip-value]');
|
|
734
|
+
if (!t) {
|
|
735
|
+
/* Tap on empty plot area → dismiss any pinned tooltip. */
|
|
736
|
+
if (this.#hoveredTarget) this.#emitLeave();
|
|
737
|
+
return this.#hideTooltip();
|
|
738
|
+
}
|
|
739
|
+
this.#showTooltip(t, e);
|
|
740
|
+
if (t !== this.#hoveredTarget) this.#emitHover(t, e);
|
|
741
|
+
/* Attach document-level dismiss — removed when tap lands outside
|
|
742
|
+
the chart. Idempotent: re-attaching replaces the same listener
|
|
743
|
+
by reference. */
|
|
744
|
+
document.addEventListener('pointerdown', this.#pinnedTouchDismiss);
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
/* Document-level touch listener — dismiss pinned tooltip when the
|
|
748
|
+
user taps outside the chart. Only attached when a tooltip is
|
|
749
|
+
currently pinned via touch. Lazily attached/detached to avoid
|
|
750
|
+
perpetual document listeners on every page. */
|
|
751
|
+
#pinnedTouchDismiss = (e) => {
|
|
752
|
+
if (e.pointerType !== 'touch') return;
|
|
753
|
+
if (!this.contains(e.target)) {
|
|
754
|
+
this.#hideTooltip();
|
|
755
|
+
if (this.#hoveredTarget) this.#emitLeave();
|
|
756
|
+
document.removeEventListener('pointerdown', this.#pinnedTouchDismiss);
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
#onClick = (e) => {
|
|
761
|
+
const t = e.target.closest('[data-tip-label], [data-tip-value]');
|
|
762
|
+
if (!t) return;
|
|
763
|
+
this.dispatchEvent(new CustomEvent('chart-select', {
|
|
764
|
+
bubbles: true,
|
|
765
|
+
detail: this.#tipPayload(t, e),
|
|
766
|
+
}));
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
#emitHover(target, event) {
|
|
770
|
+
this.#hoveredTarget = target;
|
|
771
|
+
this.dispatchEvent(new CustomEvent('chart-hover', {
|
|
772
|
+
bubbles: true,
|
|
773
|
+
detail: this.#tipPayload(target, event),
|
|
774
|
+
}));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
#emitLeave() {
|
|
778
|
+
this.#hoveredTarget = null;
|
|
779
|
+
this.dispatchEvent(new CustomEvent('chart-leave', { bubbles: true }));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/* Build the standard event payload from a hovered/clicked datum element.
|
|
783
|
+
OD-CHART-05 — per-X-column granularity. For multi-series renderers
|
|
784
|
+
(stacked-bar, grouped-bar, multi-line, composed), the pointer is
|
|
785
|
+
logically over "one X-axis column" and all series at that X are
|
|
786
|
+
hover-relevant. The detail therefore carries:
|
|
787
|
+
- top-level fields describing the specific datum the pointer
|
|
788
|
+
actually entered (label, value, pct, series, slot) — back-compat
|
|
789
|
+
with Phase 1-2 consumers.
|
|
790
|
+
- `payload` array with every series at this X column, each row
|
|
791
|
+
shaped { series, value, pct?, slot }. For single-series types,
|
|
792
|
+
payload has one entry containing the top-level datum.
|
|
793
|
+
Tooltip-ui[follows=pointer] renders one row per payload entry so
|
|
794
|
+
the card shows all series at that X, highlighting the hovered
|
|
795
|
+
series slightly. */
|
|
796
|
+
#tipPayload(target, event) {
|
|
797
|
+
const { tipLabel, tipValue, tipPct, tipSeries } = target.dataset;
|
|
798
|
+
const slot = target.dataset.slice != null ? Number(target.dataset.slice) : null;
|
|
799
|
+
const value = tipValue != null ? Number(tipValue) : null;
|
|
800
|
+
const pct = tipPct != null ? Number(tipPct) : null;
|
|
801
|
+
|
|
802
|
+
const hoveredLabel = tipLabel ?? null;
|
|
803
|
+
const hoveredSeries = tipSeries ?? null;
|
|
804
|
+
|
|
805
|
+
/* Build the per-X-column payload. Look up the data row whose x-key
|
|
806
|
+
matches the hovered label; enumerate declared y-keys; project a
|
|
807
|
+
row per (visible) series. For types without an x-key (pie/donut/
|
|
808
|
+
segments/radar — categorical) or with no y-keys, payload falls
|
|
809
|
+
back to a single entry matching the hovered datum. */
|
|
810
|
+
const payload = this.#buildXColumnPayload(hoveredLabel, hoveredSeries) ?? [{
|
|
811
|
+
series: hoveredSeries,
|
|
812
|
+
label: hoveredLabel,
|
|
813
|
+
value: Number.isFinite(value) ? value : (tipValue ?? null),
|
|
814
|
+
pct: Number.isFinite(pct) ? pct : null,
|
|
815
|
+
slot,
|
|
816
|
+
}];
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
label: hoveredLabel,
|
|
820
|
+
value: Number.isFinite(value) ? value : (tipValue ?? null),
|
|
821
|
+
pct: Number.isFinite(pct) ? pct : null,
|
|
822
|
+
series: hoveredSeries,
|
|
823
|
+
slot,
|
|
824
|
+
payload,
|
|
825
|
+
pointerX: event?.clientX ?? null,
|
|
826
|
+
pointerY: event?.clientY ?? null,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
#buildXColumnPayload(xLabel, hoveredSeries) {
|
|
831
|
+
if (xLabel == null) return null;
|
|
832
|
+
const xKey = this.x;
|
|
833
|
+
const yKeys = this.#yKeys();
|
|
834
|
+
if (!xKey || yKeys.length === 0) return null;
|
|
835
|
+
/* Find the row whose x-key value matches the hovered label. Raw
|
|
836
|
+
label string match — both sides came from the same source so no
|
|
837
|
+
type coercion needed. */
|
|
838
|
+
const row = this.#data.find(d => String(d[xKey] ?? '') === String(xLabel));
|
|
839
|
+
if (!row) return null;
|
|
840
|
+
const visible = yKeys.filter(k => !this.#isSeriesHidden(k));
|
|
841
|
+
/* Single-series types don't emit `data-tip-series` — the hovered
|
|
842
|
+
datum is implicitly the one and only series. Promote it so the
|
|
843
|
+
tooltip can still emphasize the row. */
|
|
844
|
+
const effectiveHovered = hoveredSeries || (visible.length === 1 ? visible[0] : null);
|
|
845
|
+
return visible.map((k, i) => {
|
|
846
|
+
const v = +(row[k] ?? 0);
|
|
847
|
+
return {
|
|
848
|
+
series: k,
|
|
849
|
+
label: xLabel,
|
|
850
|
+
value: Number.isFinite(v) ? v : null,
|
|
851
|
+
pct: null,
|
|
852
|
+
slot: i % 10,
|
|
853
|
+
hovered: k === effectiveHovered,
|
|
854
|
+
};
|
|
855
|
+
});
|
|
856
|
+
}
|
|
387
857
|
|
|
388
858
|
#showTooltip(target, event) {
|
|
859
|
+
/* Phase 2 follow-up — when an external tooltip-ui[follows=pointer][for=self]
|
|
860
|
+
is present on the page, it owns the tooltip affordance. Skip the
|
|
861
|
+
internal #tipEl entirely to avoid double-render / fighting for the
|
|
862
|
+
top-layer. The external tooltip subscribes to chart-hover events
|
|
863
|
+
emitted in #emitHover below. */
|
|
864
|
+
if (this.#hasExternalTooltip()) return;
|
|
865
|
+
|
|
389
866
|
const { tipLabel, tipValue, tipPct, tipSeries } = target.dataset;
|
|
390
867
|
|
|
391
868
|
if (!this.#tipEl) {
|
|
@@ -402,7 +879,7 @@ class AdiaChart extends AdiaElement {
|
|
|
402
879
|
if (tipLabel) lines.push(`<span data-tip-role="label">${esc(tipLabel)}</span>`);
|
|
403
880
|
if (tipValue !== undefined) {
|
|
404
881
|
const pct = tipPct !== undefined ? ` <span data-tip-role="pct">(${tipPct}%)</span>` : '';
|
|
405
|
-
lines.push(`<span data-tip-role="value">${
|
|
882
|
+
lines.push(`<span data-tip-role="value">${this.#fmtValue(tipValue)}${pct}</span>`);
|
|
406
883
|
}
|
|
407
884
|
this.#tipEl.innerHTML = lines.join('');
|
|
408
885
|
|
|
@@ -463,7 +940,7 @@ class AdiaChart extends AdiaElement {
|
|
|
463
940
|
/* Y-axis labels */
|
|
464
941
|
for (const t of displayTicks) {
|
|
465
942
|
const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
|
|
466
|
-
s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${
|
|
943
|
+
s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
|
|
467
944
|
}
|
|
468
945
|
|
|
469
946
|
/* X-axis labels — stride based on label width so they never overlap */
|
|
@@ -519,7 +996,7 @@ class AdiaChart extends AdiaElement {
|
|
|
519
996
|
svg += `<rect data-bar${tip({ label: labels[i], value: v })} x="${bx}" y="${by}" width="${barInner}" height="${barH}" rx="${this.#resolveRadius()}"/>`;
|
|
520
997
|
|
|
521
998
|
if (showValues) {
|
|
522
|
-
svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${
|
|
999
|
+
svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
|
|
523
1000
|
}
|
|
524
1001
|
}
|
|
525
1002
|
|
|
@@ -527,7 +1004,7 @@ class AdiaChart extends AdiaElement {
|
|
|
527
1004
|
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
528
1005
|
const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
|
|
529
1006
|
svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
|
|
530
|
-
svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${
|
|
1007
|
+
svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
|
|
531
1008
|
/* Wider invisible hit target so the thin dashed line is hoverable */
|
|
532
1009
|
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"/>`;
|
|
533
1010
|
}
|
|
@@ -575,7 +1052,7 @@ class AdiaChart extends AdiaElement {
|
|
|
575
1052
|
svg += `<circle data-dot cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
|
|
576
1053
|
svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
|
|
577
1054
|
if (showValues) {
|
|
578
|
-
svg += `<text data-value x="${p.x}" y="${p.y - 8}" text-anchor="middle" font-size="${dims.valueSize}">${
|
|
1055
|
+
svg += `<text data-value x="${p.x}" y="${p.y - 8}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(p.v)}</text>`;
|
|
579
1056
|
}
|
|
580
1057
|
}
|
|
581
1058
|
|
|
@@ -583,7 +1060,7 @@ class AdiaChart extends AdiaElement {
|
|
|
583
1060
|
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
584
1061
|
const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
|
|
585
1062
|
svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
|
|
586
|
-
svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${
|
|
1063
|
+
svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
|
|
587
1064
|
/* Wider invisible hit target so the thin dashed line is hoverable */
|
|
588
1065
|
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"/>`;
|
|
589
1066
|
}
|
|
@@ -678,7 +1155,7 @@ class AdiaChart extends AdiaElement {
|
|
|
678
1155
|
/* Center total — font size tied to donut radius so it scales with the chart */
|
|
679
1156
|
const totalFs = Math.max(14, Math.round(outer * 0.32));
|
|
680
1157
|
const labelFs = Math.max(9, Math.round(outer * 0.16));
|
|
681
|
-
svg += `<text data-donut-total x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${
|
|
1158
|
+
svg += `<text data-donut-total x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(total)}</text>`;
|
|
682
1159
|
svg += `<text data-donut-label x="${cx}" y="${cy + totalFs}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">Total</text>`;
|
|
683
1160
|
|
|
684
1161
|
this.#legendData = data.map((d, i) => ({
|
|
@@ -815,6 +1292,537 @@ class AdiaChart extends AdiaElement {
|
|
|
815
1292
|
return { svg, viewBox: `0 0 ${w} ${h}` };
|
|
816
1293
|
}
|
|
817
1294
|
|
|
1295
|
+
/* ── Area chart (filled line) ────────────────────────────────────
|
|
1296
|
+
Single-series axis chart emphasizing the filled region under the
|
|
1297
|
+
curve. Shares all infrastructure with #renderLine() — CSS rules
|
|
1298
|
+
scoped to :scope[type="area"] override the area-fill opacity and
|
|
1299
|
+
the line treatment to make the region dominant. Output is DOM-
|
|
1300
|
+
compatible with #renderLine() so legend / tooltip / events work
|
|
1301
|
+
identically. */
|
|
1302
|
+
#renderArea() {
|
|
1303
|
+
return this.#renderLine();
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/* ── Scatter (points only, no connecting line) ──────────────────
|
|
1307
|
+
Two-dimensional distribution — each datum is a dot positioned by
|
|
1308
|
+
(x, y). Unlike #renderLine(), no connecting path is emitted. The
|
|
1309
|
+
x-key is typically also numeric; when it's a category label, the
|
|
1310
|
+
dots land at category-indexed column centers. */
|
|
1311
|
+
#renderScatter() {
|
|
1312
|
+
const dims = this.#dims();
|
|
1313
|
+
const data = this.#data;
|
|
1314
|
+
const yKey = this.#yKeys()[0] || this.y;
|
|
1315
|
+
const vals = data.map(v => +(v[yKey] ?? 0));
|
|
1316
|
+
const labels = data.map(v => v[this.x] ?? '');
|
|
1317
|
+
const ticks = niceScale(0, Math.max(...vals), 5);
|
|
1318
|
+
const maxVal = ticks[ticks.length - 1];
|
|
1319
|
+
|
|
1320
|
+
const { width, height, pad } = dims;
|
|
1321
|
+
const plotH = height - pad.top - pad.bottom;
|
|
1322
|
+
const plotW = width - pad.left - pad.right;
|
|
1323
|
+
const step = plotW / Math.max(data.length - 1, 1);
|
|
1324
|
+
|
|
1325
|
+
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
|
|
1326
|
+
|
|
1327
|
+
const dotR = dims.sizeClass === 'sm' ? 2.5 : 4;
|
|
1328
|
+
const hitR = Math.max(dotR * 2, 10);
|
|
1329
|
+
|
|
1330
|
+
for (let i = 0; i < vals.length; i++) {
|
|
1331
|
+
const px = pad.left + step * i;
|
|
1332
|
+
const py = pad.top + plotH - (maxVal ? (vals[i] / maxVal) * plotH : 0);
|
|
1333
|
+
svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
|
|
1334
|
+
svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/* ── Radial bar (concentric rings, each ring one datum) ─────────
|
|
1341
|
+
Each datum gets its own ring in a concentric stack. The ring's
|
|
1342
|
+
sweep angle is proportional to value/max — e.g., v=max is a full
|
|
1343
|
+
ring, v=0 is nothing. Rings are clipped to a common max-radius
|
|
1344
|
+
track shown as a faint backing arc so empty values read visually.
|
|
1345
|
+
Center is empty — authors who want a value overlay use the
|
|
1346
|
+
`[slot=empty]` trick or compose in a sibling element. */
|
|
1347
|
+
#renderRadialBar() {
|
|
1348
|
+
const data = this.#data;
|
|
1349
|
+
const yKey = this.#yKeys()[0] || this.y;
|
|
1350
|
+
const vals = data.map(d => +(d[yKey] ?? 0));
|
|
1351
|
+
const labels = data.map(d => d[this.x] ?? '');
|
|
1352
|
+
const maxVal = Math.max(...vals) || 1;
|
|
1353
|
+
|
|
1354
|
+
const dims = this.#dims();
|
|
1355
|
+
const { width, height } = dims;
|
|
1356
|
+
const cx = width / 2;
|
|
1357
|
+
const cy = height / 2;
|
|
1358
|
+
const outerR = Math.max(30, Math.min(width, height) * 0.45);
|
|
1359
|
+
const innerR = outerR * 0.3;
|
|
1360
|
+
const ringCount = vals.length || 1;
|
|
1361
|
+
const bandW = (outerR - innerR) / ringCount;
|
|
1362
|
+
const gap = Math.min(2, bandW * 0.15);
|
|
1363
|
+
|
|
1364
|
+
let svg = '';
|
|
1365
|
+
|
|
1366
|
+
/* Backing tracks + filled arcs per datum, from inner to outer.
|
|
1367
|
+
Uses the stroke-dasharray technique: each ring is a single <circle>,
|
|
1368
|
+
rotated so the dash starts at 12 o'clock, with dasharray sized to
|
|
1369
|
+
(filled, remainder). Avoids the "full circle" vs "arc path" branch
|
|
1370
|
+
that produced rendering artifacts at 100% fills. */
|
|
1371
|
+
for (let i = 0; i < vals.length; i++) {
|
|
1372
|
+
const r0 = innerR + bandW * i + gap / 2;
|
|
1373
|
+
const r1 = innerR + bandW * (i + 1) - gap / 2;
|
|
1374
|
+
const mid = (r0 + r1) / 2;
|
|
1375
|
+
const thickness = r1 - r0;
|
|
1376
|
+
const circumference = 2 * Math.PI * mid;
|
|
1377
|
+
const filled = Math.max(0, Math.min(1, vals[i] / maxVal)) * circumference;
|
|
1378
|
+
|
|
1379
|
+
/* Backing ring — full circle, faint stroke */
|
|
1380
|
+
svg += `<circle data-radial-track cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}"/>`;
|
|
1381
|
+
|
|
1382
|
+
if (filled <= 0) continue;
|
|
1383
|
+
|
|
1384
|
+
const pct = ((vals[i] / maxVal) * 100).toFixed(1);
|
|
1385
|
+
const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
|
|
1386
|
+
|
|
1387
|
+
/* Filled arc — stroke-dasharray splits the circumference into
|
|
1388
|
+
(drawn, gap). Rotated -90° around the center so the dash begins
|
|
1389
|
+
at 12 o'clock and sweeps clockwise. stroke-linecap="butt" for
|
|
1390
|
+
full rings (so the ends don't overlap into a wedge artifact);
|
|
1391
|
+
"round" for partial arcs so ends read as bar caps. */
|
|
1392
|
+
const isFull = Math.abs(filled - circumference) < 0.5;
|
|
1393
|
+
const linecap = isFull ? 'butt' : 'round';
|
|
1394
|
+
const dashArray = isFull
|
|
1395
|
+
? `${circumference} 0`
|
|
1396
|
+
: `${filled} ${circumference - filled}`;
|
|
1397
|
+
|
|
1398
|
+
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})"/>`;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
this.#legendData = data.map((d, i) => ({
|
|
1402
|
+
label: labels[i],
|
|
1403
|
+
key: labels[i],
|
|
1404
|
+
value: vals[i],
|
|
1405
|
+
pct: ((vals[i] / maxVal) * 100).toFixed(1),
|
|
1406
|
+
slot: i % 10,
|
|
1407
|
+
}));
|
|
1408
|
+
|
|
1409
|
+
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/* ── Gauge (half-donut with center value) ───────────────────────
|
|
1413
|
+
Common KPI visualization: a 180° arc filled from the leftmost
|
|
1414
|
+
point (9 o'clock) clockwise to the current value. Center shows
|
|
1415
|
+
the value as a large number. Uses the same data-slice fill path
|
|
1416
|
+
as pie/donut for theme coherence. Accepts a single datum OR
|
|
1417
|
+
sums all data values — max is `maxVal` prop if set, otherwise
|
|
1418
|
+
taken as the sum of all values (treats the total as 100%). */
|
|
1419
|
+
#renderGauge() {
|
|
1420
|
+
const data = this.#data;
|
|
1421
|
+
const yKey = this.#yKeys()[0] || this.y;
|
|
1422
|
+
const vals = data.map(d => +(d[yKey] ?? 0));
|
|
1423
|
+
const sum = vals.reduce((a, b) => a + b, 0) || 1;
|
|
1424
|
+
|
|
1425
|
+
/* Gauge reads a single value + optional max from the first datum
|
|
1426
|
+
(v, max?) OR treats the first value as the numerator and the
|
|
1427
|
+
sum as the denominator for "X of Y" style metrics. */
|
|
1428
|
+
const primary = vals[0] ?? 0;
|
|
1429
|
+
const maxVal = data[0]?.max != null ? +data[0].max : (vals.length === 1 ? Math.max(primary, 1) : sum);
|
|
1430
|
+
const pct = Math.max(0, Math.min(1, primary / maxVal));
|
|
1431
|
+
|
|
1432
|
+
const dims = this.#dims();
|
|
1433
|
+
const { width, height } = dims;
|
|
1434
|
+
|
|
1435
|
+
/* Place the arc's visual center so the half-circle uses the full
|
|
1436
|
+
width below the value label. cy sits lower so the half-arc has
|
|
1437
|
+
room for the big center label above it. */
|
|
1438
|
+
const cx = width / 2;
|
|
1439
|
+
const cy = height * 0.68;
|
|
1440
|
+
const outerR = Math.max(40, Math.min(width * 0.45, height * 0.6));
|
|
1441
|
+
const innerR = outerR * 0.72;
|
|
1442
|
+
|
|
1443
|
+
let svg = '';
|
|
1444
|
+
|
|
1445
|
+
/* Backing arc — full 180° upper semicircle, from 9 o'clock (angle π)
|
|
1446
|
+
clockwise through 12 o'clock (3π/2) to 3 o'clock (2π). donutArcPath
|
|
1447
|
+
produces a filled ring wedge; with start=π, end=2π the sliceAngle
|
|
1448
|
+
equals π so large-arc-flag=0 and the arc passes over the top. */
|
|
1449
|
+
svg += `<path data-radial-track d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, 2 * Math.PI, 0)}"/>`;
|
|
1450
|
+
|
|
1451
|
+
/* Filled portion — 0..180° proportional to pct. pct=0 draws nothing;
|
|
1452
|
+
pct=1 overlays the full backing arc. */
|
|
1453
|
+
if (pct > 0) {
|
|
1454
|
+
const fillEnd = Math.PI + Math.PI * pct;
|
|
1455
|
+
const tipAttrs = tip({ label: data[0]?.[this.x] ?? 'Value', value: primary, pct: (pct * 100).toFixed(1) });
|
|
1456
|
+
svg += `<path data-slice="0"${tipAttrs} data-gauge-fill d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, fillEnd, 0)}"/>`;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/* Center value label */
|
|
1460
|
+
const totalFs = Math.max(18, Math.round(outerR * 0.42));
|
|
1461
|
+
const labelFs = Math.max(10, Math.round(outerR * 0.2));
|
|
1462
|
+
const labelY = cy - outerR * 0.15;
|
|
1463
|
+
svg += `<text data-gauge-value x="${cx}" y="${labelY}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
|
|
1464
|
+
if (maxVal !== primary) {
|
|
1465
|
+
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>`;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/* ── Funnel (stage drop-off) ───────────────────────────────────
|
|
1472
|
+
Classic conversion funnel: stages listed top-to-bottom, each
|
|
1473
|
+
rendered as a trapezoid whose width shrinks proportional to its
|
|
1474
|
+
value. Stage labels live to the left, value + pct vs first stage
|
|
1475
|
+
to the right. Typical use: sales pipeline, signup funnel.
|
|
1476
|
+
|
|
1477
|
+
Trapezoid geometry: for stage i with value v_i and max value v_0:
|
|
1478
|
+
halfWidthTop = (v_i / v_max) * plotW / 2
|
|
1479
|
+
halfWidthBot = (v_{i+1} / v_max) * plotW / 2
|
|
1480
|
+
Last stage uses halfWidthBot = halfWidthTop (degrades to rect). */
|
|
1481
|
+
#renderFunnel() {
|
|
1482
|
+
const data = this.#data;
|
|
1483
|
+
const yKey = this.#yKeys()[0] || this.y;
|
|
1484
|
+
const vals = data.map(d => +(d[yKey] ?? 0));
|
|
1485
|
+
const labels = data.map(d => d[this.x] ?? '');
|
|
1486
|
+
const n = vals.length;
|
|
1487
|
+
if (n === 0) return { svg: '', viewBox: '0 0 100 100' };
|
|
1488
|
+
|
|
1489
|
+
const maxVal = Math.max(...vals) || 1;
|
|
1490
|
+
|
|
1491
|
+
const dims = this.#dims();
|
|
1492
|
+
const { width, height } = dims;
|
|
1493
|
+
const fs = dims.fontSize;
|
|
1494
|
+
const padX = Math.max(width * 0.18, 80); /* room for stage labels */
|
|
1495
|
+
const padY = fs * 0.8;
|
|
1496
|
+
const plotW = width - padX * 2;
|
|
1497
|
+
const plotH = height - padY * 2;
|
|
1498
|
+
const stageH = plotH / n;
|
|
1499
|
+
const gap = Math.max(2, stageH * 0.08);
|
|
1500
|
+
|
|
1501
|
+
const cx = width / 2;
|
|
1502
|
+
|
|
1503
|
+
let svg = '';
|
|
1504
|
+
for (let i = 0; i < n; i++) {
|
|
1505
|
+
const vTop = vals[i];
|
|
1506
|
+
const vBot = (i < n - 1) ? vals[i + 1] : vals[i];
|
|
1507
|
+
const halfTop = (vTop / maxVal) * (plotW / 2);
|
|
1508
|
+
const halfBot = (vBot / maxVal) * (plotW / 2);
|
|
1509
|
+
const y0 = padY + stageH * i + gap / 2;
|
|
1510
|
+
const y1 = padY + stageH * (i + 1) - gap / 2;
|
|
1511
|
+
|
|
1512
|
+
const pct = ((vTop / maxVal) * 100).toFixed(1);
|
|
1513
|
+
const tipAttrs = tip({ label: labels[i], value: vTop, pct });
|
|
1514
|
+
|
|
1515
|
+
/* Trapezoid path: top-left, top-right, bottom-right, bottom-left. */
|
|
1516
|
+
const d = `M ${cx - halfTop} ${y0} L ${cx + halfTop} ${y0} L ${cx + halfBot} ${y1} L ${cx - halfBot} ${y1} Z`;
|
|
1517
|
+
svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
|
|
1518
|
+
|
|
1519
|
+
/* Stage label — left-aligned outside the funnel */
|
|
1520
|
+
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>`;
|
|
1521
|
+
|
|
1522
|
+
/* Value + pct — right-aligned outside the funnel */
|
|
1523
|
+
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>`;
|
|
1524
|
+
if (i > 0) {
|
|
1525
|
+
const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
|
|
1526
|
+
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>`;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
this.#legendData = data.map((d, i) => ({
|
|
1531
|
+
label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
|
|
1532
|
+
}));
|
|
1533
|
+
|
|
1534
|
+
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/* ── Treemap (hierarchical rect tiling) ────────────────────────
|
|
1538
|
+
Flat squarified treemap: each datum is a rect whose area is
|
|
1539
|
+
proportional to its value. Not nested — Phase 5 ships flat
|
|
1540
|
+
rectangles; a nested variant (children arrays) is a natural
|
|
1541
|
+
future extension.
|
|
1542
|
+
|
|
1543
|
+
Uses the Bruls/Huijbregts/Van Wijk squarified algorithm adapted
|
|
1544
|
+
for single-level data: sorts values desc, packs rows that hold
|
|
1545
|
+
aspect ratios close to 1, alternating row direction based on
|
|
1546
|
+
remaining container aspect. */
|
|
1547
|
+
#renderTreemap() {
|
|
1548
|
+
const data = this.#data;
|
|
1549
|
+
const yKey = this.#yKeys()[0] || this.y;
|
|
1550
|
+
const vals = data.map(d => +(d[yKey] ?? 0));
|
|
1551
|
+
const labels = data.map(d => d[this.x] ?? '');
|
|
1552
|
+
const n = vals.length;
|
|
1553
|
+
if (n === 0) return { svg: '', viewBox: '0 0 100 100' };
|
|
1554
|
+
|
|
1555
|
+
const dims = this.#dims();
|
|
1556
|
+
const { width, height, fontSize } = dims;
|
|
1557
|
+
const total = vals.reduce((a, b) => a + b, 0) || 1;
|
|
1558
|
+
|
|
1559
|
+
/* Sort indices by value desc — squarified needs sorted input but
|
|
1560
|
+
we keep original indices for color/label/hit mapping. */
|
|
1561
|
+
const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => vals[b] - vals[a]);
|
|
1562
|
+
|
|
1563
|
+
const rects = []; /* {i, x, y, w, h} in plot coords */
|
|
1564
|
+
|
|
1565
|
+
/* Scale areas so they fill the container. */
|
|
1566
|
+
const area = width * height;
|
|
1567
|
+
const scaled = order.map(i => (vals[i] / total) * area);
|
|
1568
|
+
|
|
1569
|
+
/* Squarified packer — iterative rows. */
|
|
1570
|
+
let x = 0, y = 0, w = width, h = height;
|
|
1571
|
+
let row = [];
|
|
1572
|
+
let rowStart = 0;
|
|
1573
|
+
|
|
1574
|
+
const worst = (row, width) => {
|
|
1575
|
+
if (row.length === 0) return Infinity;
|
|
1576
|
+
const sum = row.reduce((a, b) => a + b, 0);
|
|
1577
|
+
const max = Math.max(...row);
|
|
1578
|
+
const min = Math.min(...row);
|
|
1579
|
+
const wsq = width * width;
|
|
1580
|
+
const ssq = sum * sum;
|
|
1581
|
+
return Math.max((wsq * max) / ssq, ssq / (wsq * min));
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
const layoutRow = (row, rowStart, x, y, w, h) => {
|
|
1585
|
+
const sum = row.reduce((a, b) => a + b, 0);
|
|
1586
|
+
const horizontal = w >= h;
|
|
1587
|
+
const rowH = horizontal ? sum / w : h;
|
|
1588
|
+
const rowW = horizontal ? w : sum / h;
|
|
1589
|
+
|
|
1590
|
+
let offset = 0;
|
|
1591
|
+
for (let k = 0; k < row.length; k++) {
|
|
1592
|
+
const frac = row[k] / sum;
|
|
1593
|
+
const idx = order[rowStart + k];
|
|
1594
|
+
if (horizontal) {
|
|
1595
|
+
const segW = frac * w;
|
|
1596
|
+
rects.push({ i: idx, x: x + offset, y, w: segW, h: rowH });
|
|
1597
|
+
offset += segW;
|
|
1598
|
+
} else {
|
|
1599
|
+
const segH = frac * h;
|
|
1600
|
+
rects.push({ i: idx, x, y: y + offset, w: rowW, h: segH });
|
|
1601
|
+
offset += segH;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (horizontal) return { x, y: y + rowH, w, h: h - rowH };
|
|
1605
|
+
return { x: x + rowW, y, w: w - rowW, h };
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
let remaining = scaled.slice();
|
|
1609
|
+
while (remaining.length > 0) {
|
|
1610
|
+
row = [remaining[0]];
|
|
1611
|
+
rowStart = scaled.length - remaining.length;
|
|
1612
|
+
const shortSide = Math.min(w, h);
|
|
1613
|
+
let i = 1;
|
|
1614
|
+
while (i < remaining.length) {
|
|
1615
|
+
const next = row.concat(remaining[i]);
|
|
1616
|
+
if (worst(next, shortSide) > worst(row, shortSide)) break;
|
|
1617
|
+
row = next;
|
|
1618
|
+
i++;
|
|
1619
|
+
}
|
|
1620
|
+
const newRect = layoutRow(row, rowStart, x, y, w, h);
|
|
1621
|
+
x = newRect.x; y = newRect.y; w = newRect.w; h = newRect.h;
|
|
1622
|
+
remaining = remaining.slice(row.length);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/* Emit rects + labels. */
|
|
1626
|
+
let svg = '';
|
|
1627
|
+
const pad = 2;
|
|
1628
|
+
for (const r of rects) {
|
|
1629
|
+
const pct = ((vals[r.i] / total) * 100).toFixed(1);
|
|
1630
|
+
const tipAttrs = tip({ label: labels[r.i], value: vals[r.i], pct });
|
|
1631
|
+
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()}"/>`;
|
|
1632
|
+
|
|
1633
|
+
/* Three text-placement branches keyed on available tile size:
|
|
1634
|
+
- `tall` (h > ~2.5 line heights): label + value stacked, top-aligned
|
|
1635
|
+
- `short` (h ≥ ~1.2 line heights but < tall): label only, vertically
|
|
1636
|
+
centered — avoids the clipped-text look when tiles are squat
|
|
1637
|
+
- `tiny` (h too small for even one line): no text
|
|
1638
|
+
All branches gate on width > ~4× fontSize so labels don't overflow. */
|
|
1639
|
+
const labelX = r.x + 8;
|
|
1640
|
+
const canShowAny = r.w > fontSize * 4;
|
|
1641
|
+
const tall = canShowAny && r.h > fontSize * 2.5;
|
|
1642
|
+
const short = canShowAny && !tall && r.h > fontSize * 1.2;
|
|
1643
|
+
|
|
1644
|
+
if (tall) {
|
|
1645
|
+
svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(labels[r.i])}</text>`;
|
|
1646
|
+
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>`;
|
|
1647
|
+
} else if (short) {
|
|
1648
|
+
const cy = r.y + r.h / 2;
|
|
1649
|
+
svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(labels[r.i])}</text>`;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
this.#legendData = data.map((d, i) => ({
|
|
1654
|
+
label: labels[i], value: vals[i], pct: ((vals[i] / total) * 100).toFixed(1), slot: i % 10,
|
|
1655
|
+
}));
|
|
1656
|
+
|
|
1657
|
+
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/* ── Sankey (flow between source and target nodes) ─────────────
|
|
1661
|
+
Flow diagram with columns of nodes and curved bands connecting
|
|
1662
|
+
them. Data shape for Sankey differs from other chart types:
|
|
1663
|
+
data = [{ source: 'A', target: 'B', value: 10 }, ...]
|
|
1664
|
+
Nodes are auto-derived from unique source/target values.
|
|
1665
|
+
Lays out two columns (source-left, target-right) with node
|
|
1666
|
+
heights proportional to throughput. */
|
|
1667
|
+
#renderSankey() {
|
|
1668
|
+
const data = this.#data;
|
|
1669
|
+
if (!data.length) return { svg: '', viewBox: '0 0 100 100' };
|
|
1670
|
+
|
|
1671
|
+
/* Derive source + target node sets from the link data. */
|
|
1672
|
+
const sourceSet = new Map(); /* name → { outflow, y0, y1 } */
|
|
1673
|
+
const targetSet = new Map(); /* name → { inflow, y0, y1 } */
|
|
1674
|
+
for (const link of data) {
|
|
1675
|
+
const s = link.source ?? link.from ?? '';
|
|
1676
|
+
const t = link.target ?? link.to ?? '';
|
|
1677
|
+
const v = +(link.value ?? link.v ?? 0);
|
|
1678
|
+
if (!sourceSet.has(s)) sourceSet.set(s, { name: s, flow: 0 });
|
|
1679
|
+
if (!targetSet.has(t)) targetSet.set(t, { name: t, flow: 0 });
|
|
1680
|
+
sourceSet.get(s).flow += v;
|
|
1681
|
+
targetSet.get(t).flow += v;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const dims = this.#dims();
|
|
1685
|
+
const { width, height, fontSize } = dims;
|
|
1686
|
+
const nodeW = Math.max(8, width * 0.03);
|
|
1687
|
+
const colPad = nodeW + fontSize * 5;
|
|
1688
|
+
|
|
1689
|
+
const sourceTotal = [...sourceSet.values()].reduce((a, s) => a + s.flow, 0) || 1;
|
|
1690
|
+
const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;
|
|
1691
|
+
|
|
1692
|
+
const nodeGap = fontSize * 0.6;
|
|
1693
|
+
|
|
1694
|
+
/* Assign y0/y1 to each source node, stacked top-to-bottom. */
|
|
1695
|
+
const sources = [...sourceSet.values()];
|
|
1696
|
+
const targets = [...targetSet.values()];
|
|
1697
|
+
const sourceTotalH = height - nodeGap * (sources.length - 1);
|
|
1698
|
+
const targetTotalH = height - nodeGap * (targets.length - 1);
|
|
1699
|
+
|
|
1700
|
+
let y = 0;
|
|
1701
|
+
for (const s of sources) {
|
|
1702
|
+
const h = (s.flow / sourceTotal) * sourceTotalH;
|
|
1703
|
+
s.y0 = y;
|
|
1704
|
+
s.y1 = y + h;
|
|
1705
|
+
s.cursor = y; /* running y for outgoing links */
|
|
1706
|
+
y += h + nodeGap;
|
|
1707
|
+
}
|
|
1708
|
+
y = 0;
|
|
1709
|
+
for (const t of targets) {
|
|
1710
|
+
const h = (t.flow / targetTotal) * targetTotalH;
|
|
1711
|
+
t.y0 = y;
|
|
1712
|
+
t.y1 = y + h;
|
|
1713
|
+
t.cursor = y;
|
|
1714
|
+
y += h + nodeGap;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
let svg = '';
|
|
1718
|
+
|
|
1719
|
+
const nodeRx = Math.min(this.#resolveRadius(), nodeW / 2);
|
|
1720
|
+
|
|
1721
|
+
/* Source nodes — left column */
|
|
1722
|
+
sources.forEach((s, i) => {
|
|
1723
|
+
svg += `<rect data-sankey-node data-slice="${i % 10}" x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
|
|
1724
|
+
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>`;
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
/* Target nodes — right column */
|
|
1728
|
+
targets.forEach((t, i) => {
|
|
1729
|
+
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}"/>`;
|
|
1730
|
+
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>`;
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
/* Links — bezier bands */
|
|
1734
|
+
for (const link of data) {
|
|
1735
|
+
const s = sourceSet.get(link.source ?? link.from ?? '');
|
|
1736
|
+
const t = targetSet.get(link.target ?? link.to ?? '');
|
|
1737
|
+
if (!s || !t) continue;
|
|
1738
|
+
const v = +(link.value ?? link.v ?? 0);
|
|
1739
|
+
if (v <= 0) continue;
|
|
1740
|
+
|
|
1741
|
+
const sH = (v / sourceTotal) * sourceTotalH;
|
|
1742
|
+
const tH = (v / targetTotal) * targetTotalH;
|
|
1743
|
+
const sTop = s.cursor;
|
|
1744
|
+
const sBot = sTop + sH;
|
|
1745
|
+
const tTop = t.cursor;
|
|
1746
|
+
const tBot = tTop + tH;
|
|
1747
|
+
s.cursor += sH;
|
|
1748
|
+
t.cursor += tH;
|
|
1749
|
+
|
|
1750
|
+
const x0 = colPad;
|
|
1751
|
+
const x1 = width - colPad;
|
|
1752
|
+
const mx = (x0 + x1) / 2;
|
|
1753
|
+
const tipAttrs = tip({ label: `${s.name} → ${t.name}`, value: v });
|
|
1754
|
+
|
|
1755
|
+
const path = `M ${x0} ${sTop} C ${mx} ${sTop}, ${mx} ${tTop}, ${x1} ${tTop} L ${x1} ${tBot} C ${mx} ${tBot}, ${mx} ${sBot}, ${x0} ${sBot} Z`;
|
|
1756
|
+
svg += `<path data-sankey-link${tipAttrs} d="${path}"/>`;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/* ── Composed (bar + line overlay) ─────────────────────────────
|
|
1763
|
+
Multi-series combining a bar chart for primary values with a line
|
|
1764
|
+
overlay for secondary values. Data shape: each row has the x-key,
|
|
1765
|
+
a `bar` key, and a `line` key. Both are axis-aligned against the
|
|
1766
|
+
same Y scale (single axis for v1; dual-axis future extension).
|
|
1767
|
+
|
|
1768
|
+
Uses y="bar,line" as the series keys by convention. */
|
|
1769
|
+
#renderComposed() {
|
|
1770
|
+
const dims = this.#dims();
|
|
1771
|
+
const data = this.#data;
|
|
1772
|
+
const keys = this.#yKeys();
|
|
1773
|
+
const barKey = keys[0] || 'bar';
|
|
1774
|
+
const lineKey = keys[1] || 'line';
|
|
1775
|
+
const labels = data.map(d => d[this.x] ?? '');
|
|
1776
|
+
const barVals = data.map(d => +(d[barKey] ?? 0));
|
|
1777
|
+
const lineVals = data.map(d => +(d[lineKey] ?? 0));
|
|
1778
|
+
const allVals = [...barVals, ...lineVals];
|
|
1779
|
+
const ticks = niceScale(0, Math.max(...allVals), 5);
|
|
1780
|
+
const maxVal = ticks[ticks.length - 1];
|
|
1781
|
+
|
|
1782
|
+
const { width, height, pad } = dims;
|
|
1783
|
+
const plotH = height - pad.top - pad.bottom;
|
|
1784
|
+
const plotW = width - pad.left - pad.right;
|
|
1785
|
+
const barW = plotW / data.length;
|
|
1786
|
+
const barInner = barW * 0.6;
|
|
1787
|
+
const barGap = (barW - barInner) / 2;
|
|
1788
|
+
const step = plotW / Math.max(data.length - 1, 1);
|
|
1789
|
+
|
|
1790
|
+
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
|
|
1791
|
+
|
|
1792
|
+
/* Bar series (slot 0) */
|
|
1793
|
+
if (!this.#isSeriesHidden(barKey)) {
|
|
1794
|
+
for (let i = 0; i < data.length; i++) {
|
|
1795
|
+
const v = barVals[i];
|
|
1796
|
+
const barH = maxVal ? (v / maxVal) * plotH : 0;
|
|
1797
|
+
const bx = pad.left + barW * i + barGap;
|
|
1798
|
+
const by = pad.top + plotH - barH;
|
|
1799
|
+
svg += `<rect${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} x="${bx}" y="${by}" width="${barInner}" height="${barH}" rx="${this.#resolveRadius()}"/>`;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/* Line series (slot 1) — anchored at bar center X positions */
|
|
1804
|
+
if (!this.#isSeriesHidden(lineKey)) {
|
|
1805
|
+
const points = lineVals.map((v, i) => ({
|
|
1806
|
+
x: pad.left + barW * i + barW / 2,
|
|
1807
|
+
y: pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0),
|
|
1808
|
+
v, label: labels[i],
|
|
1809
|
+
}));
|
|
1810
|
+
const t = Math.max(0, Math.min(1, this.smooth));
|
|
1811
|
+
svg += `<path data-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(points, t)}"/>`;
|
|
1812
|
+
for (const p of points) {
|
|
1813
|
+
svg += `<circle data-dot${this.#seriesFill(1, lineKey)} cx="${p.x}" cy="${p.y}" r="3"/>`;
|
|
1814
|
+
svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
this.#legendData = [
|
|
1819
|
+
{ label: barKey, key: barKey, slot: 0 },
|
|
1820
|
+
{ label: lineKey, key: lineKey, slot: 1 },
|
|
1821
|
+
];
|
|
1822
|
+
|
|
1823
|
+
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1824
|
+
}
|
|
1825
|
+
|
|
818
1826
|
/* ── Segments (single horizontal stacked bar) ──────────────────
|
|
819
1827
|
Categorical data rendered as one horizontal bar split into
|
|
820
1828
|
proportional colored slices. Good for compact in-card
|
|
@@ -915,6 +1923,7 @@ class AdiaChart extends AdiaElement {
|
|
|
915
1923
|
let stackY = pad.top + plotH;
|
|
916
1924
|
const segCount = keys.length;
|
|
917
1925
|
for (let k = 0; k < segCount; k++) {
|
|
1926
|
+
if (this.#isSeriesHidden(keys[k])) continue;
|
|
918
1927
|
const v = +(data[i][keys[k]] ?? 0);
|
|
919
1928
|
const segH = maxVal ? (v / maxVal) * plotH : 0;
|
|
920
1929
|
if (segH <= 0) { stackY -= segH; continue; }
|
|
@@ -926,7 +1935,7 @@ class AdiaChart extends AdiaElement {
|
|
|
926
1935
|
const isBottom = k === 0;
|
|
927
1936
|
const r = Math.min(this.#resolveRadius(), barInner / 2, bh / 2);
|
|
928
1937
|
|
|
929
|
-
const attrs =
|
|
1938
|
+
const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
|
|
930
1939
|
|
|
931
1940
|
if (isTop && isBottom) {
|
|
932
1941
|
// Single segment — round top + bottom
|
|
@@ -944,7 +1953,7 @@ class AdiaChart extends AdiaElement {
|
|
|
944
1953
|
}
|
|
945
1954
|
}
|
|
946
1955
|
|
|
947
|
-
this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
|
|
1956
|
+
this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
|
|
948
1957
|
|
|
949
1958
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
950
1959
|
}
|
|
@@ -974,19 +1983,20 @@ class AdiaChart extends AdiaElement {
|
|
|
974
1983
|
|
|
975
1984
|
for (let i = 0; i < data.length; i++) {
|
|
976
1985
|
for (let k = 0; k < keys.length; k++) {
|
|
1986
|
+
if (this.#isSeriesHidden(keys[k])) continue;
|
|
977
1987
|
const v = +(data[i][keys[k]] ?? 0);
|
|
978
1988
|
const barH = maxVal ? (v / maxVal) * plotH : 0;
|
|
979
1989
|
const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
|
|
980
1990
|
const by = pad.top + plotH - barH;
|
|
981
|
-
svg += `<rect
|
|
1991
|
+
svg += `<rect${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} x="${bx}" y="${by}" width="${subBarW}" height="${barH}" rx="${this.#resolveRadius()}"/>`;
|
|
982
1992
|
|
|
983
1993
|
if (!this.hideValues) {
|
|
984
|
-
svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${
|
|
1994
|
+
svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
|
|
985
1995
|
}
|
|
986
1996
|
}
|
|
987
1997
|
}
|
|
988
1998
|
|
|
989
|
-
this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
|
|
1999
|
+
this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
|
|
990
2000
|
|
|
991
2001
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
992
2002
|
}
|
|
@@ -1011,6 +2021,7 @@ class AdiaChart extends AdiaElement {
|
|
|
1011
2021
|
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
|
|
1012
2022
|
|
|
1013
2023
|
for (let k = 0; k < keys.length; k++) {
|
|
2024
|
+
if (this.#isSeriesHidden(keys[k])) continue;
|
|
1014
2025
|
const vals = data.map(d => +(d[keys[k]] ?? 0));
|
|
1015
2026
|
const points = vals.map((v, i) => {
|
|
1016
2027
|
const px = pad.left + step * i;
|
|
@@ -1022,20 +2033,20 @@ class AdiaChart extends AdiaElement {
|
|
|
1022
2033
|
const t = Math.max(0, Math.min(1, this.smooth));
|
|
1023
2034
|
|
|
1024
2035
|
/* Area fill */
|
|
1025
|
-
svg += `<path data-area
|
|
2036
|
+
svg += `<path data-area${this.#seriesFill(k % 10, keys[k])} d="${smoothAreaPath(points, baseline, t)}"/>`;
|
|
1026
2037
|
|
|
1027
2038
|
/* Line */
|
|
1028
|
-
svg += `<path data-line
|
|
2039
|
+
svg += `<path data-line${this.#seriesStroke(k % 10, keys[k])} d="${smoothPath(points, t)}"/>`;
|
|
1029
2040
|
|
|
1030
2041
|
/* Dots + hit targets. Hit circles deliberately omit data-slice so
|
|
1031
2042
|
they aren't caught by the circle[data-slice] fill rule in CSS. */
|
|
1032
2043
|
for (const p of points) {
|
|
1033
|
-
svg += `<circle data-dot
|
|
2044
|
+
svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])} cx="${p.x}" cy="${p.y}" r="3"/>`;
|
|
1034
2045
|
svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: keys[k] })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
|
|
1035
2046
|
}
|
|
1036
2047
|
}
|
|
1037
2048
|
|
|
1038
|
-
this.#legendData = keys.map((k, i) => ({ label: k, slot: i % 10 }));
|
|
2049
|
+
this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
|
|
1039
2050
|
|
|
1040
2051
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1041
2052
|
}
|
|
@@ -1044,6 +2055,14 @@ class AdiaChart extends AdiaElement {
|
|
|
1044
2055
|
|
|
1045
2056
|
#legendData = null;
|
|
1046
2057
|
|
|
2058
|
+
/* Public getter — external consumers (chart-legend-ui[for]) read this to
|
|
2059
|
+
mirror series data. Returns a defensive shallow copy so callers can't
|
|
2060
|
+
mutate our internals. Null when the chart has no legend-bearing type
|
|
2061
|
+
or hasn't rendered yet. */
|
|
2062
|
+
get legendData() {
|
|
2063
|
+
return this.#legendData ? this.#legendData.map(it => ({ ...it })) : null;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1047
2066
|
#buildLegend() {
|
|
1048
2067
|
if (!this.#legendData || !this.#legendData.length) return null;
|
|
1049
2068
|
|
|
@@ -1053,10 +2072,12 @@ class AdiaChart extends AdiaElement {
|
|
|
1053
2072
|
for (const item of this.#legendData) {
|
|
1054
2073
|
const el = document.createElement('span');
|
|
1055
2074
|
el.setAttribute('data-legend-item', '');
|
|
2075
|
+
if (item.key) el.setAttribute('data-series-key', item.key);
|
|
1056
2076
|
|
|
1057
2077
|
const dot = document.createElement('span');
|
|
1058
2078
|
dot.setAttribute('data-legend-dot', '');
|
|
1059
2079
|
dot.setAttribute('data-slice', String(item.slot));
|
|
2080
|
+
if (item.key) dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
|
|
1060
2081
|
el.appendChild(dot);
|
|
1061
2082
|
|
|
1062
2083
|
const text = document.createElement('span');
|
|
@@ -1066,7 +2087,8 @@ class AdiaChart extends AdiaElement {
|
|
|
1066
2087
|
legend.appendChild(el);
|
|
1067
2088
|
}
|
|
1068
2089
|
|
|
1069
|
-
|
|
2090
|
+
/* Intentionally DO NOT null #legendData here — public `legendData`
|
|
2091
|
+
getter needs it to survive so chart-legend-ui[for] can mirror. */
|
|
1070
2092
|
return legend;
|
|
1071
2093
|
}
|
|
1072
2094
|
}
|