@zumer/orbit-kit 0.1.0 → 2.1.0

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/src/o-meter.js CHANGED
@@ -1,109 +1,55 @@
1
+ import { OrbitKitElement, define, injectStyleOnce, universe, hub, finiteNumber, cssValue, fixed, escapeHTML as e, withShared, RANGE_ATTRS, FORMAT_ATTRS } from './base.js';
2
+
1
3
  /**
2
- * <o-meter> — speedometer / tachometer gauge with ticks, colored zones and a needle.
3
- *
4
- * Collapses the Orbit recipe:
5
- *
6
- * .bigbang > .gravity-spot
7
- * ├── .orbit-{outer} (range/from, fit-range) > .vector × ticks
8
- * ├── .orbit-{mid} (range/from) > <o-arc> × zones
9
- * ├── .orbit-{inner} (range/from) > <o-arc shape="arrow"> (needle)
10
- * └── .orbit-0 > .satellite.at-center > .capsule (value + unit)
11
- *
12
- * ...into one tag:
13
- *
14
- * <o-meter value="125" min="0" max="240" unit="km/h" ticks="9"
15
- * zones='[{"from":0,"to":140,"color":"#22c55e"},
16
- * {"from":140,"to":200,"color":"#eab308"},
17
- * {"from":200,"to":240,"color":"#ef4444"}]'></o-meter>
18
- *
19
- * Zones are given in value units (min..max). The needle and tick layers are
20
- * cheap to patch, so changing `value` only re-points the needle (no rebuild).
4
+ * <o-meter>: a basic needle gauge, kept from 0.x. <o-instrument> does
5
+ * everything it does and more.
21
6
  */
22
- import { OrbitKitElement, define } from './base.js';
23
-
24
7
  export class OrbitMeter extends OrbitKitElement {
25
8
  static get observedAttributes() {
26
- return ['value', 'min', 'max', 'range', 'from', 'size', 'ticks',
27
- 'zones', 'fill', 'unit', 'label', 'needle-color', 'glow'];
9
+ return withShared(['value', 'range', 'from', 'size', 'ticks', 'zones', 'fill', 'label', 'needle-color', 'glow'], RANGE_ATTRS, FORMAT_ATTRS);
28
10
  }
29
-
30
11
  readConfig() {
31
- const range = this._num('range', 270);
32
- const min = this._num('min', 0);
33
- const max = this._num('max', 100);
34
- const span = (max - min) || 1;
35
- const zones = (this._json('zones', []) || []).map((z) => ({
36
- from: Number(z.from) || 0,
37
- to: Number(z.to) || 0,
38
- color: z.color || '#6366f1'
39
- }));
12
+ const { value, min, max, span, step, fraction } = this._range({ min: 0, max: 100, step: 0 });
13
+ const range = Math.max(0, Math.min(360, this._num('range', 270)));
40
14
  return {
41
- value: this._num('value', min),
42
- min, max, span, range, zones,
15
+ value, min, max, span: span || 1, step, fraction, range,
43
16
  from: this.hasAttribute('from') ? this._num('from', 0) : this._autoFrom(range),
44
- size: this._str('size', '220px'),
45
- ticks: Math.max(0, Math.round(this._num('ticks', 9))),
46
- needleColor: this._str('needle-color', '#e5e7eb'),
47
- unit: this._str('unit', ''),
48
- glow: this.getAttribute('glow'),
49
- label: this.hasAttribute('label') ? this.getAttribute('label') : null
17
+ zones: this._array('zones').map((z) => ({ from: Math.max(min, finiteNumber(z.from, min)), to: Math.min(max, finiteNumber(z.to, min)), color: cssValue(z.color, this._color('fill', '#6366f1')) })).filter((z) => z.to > z.from),
18
+ size: this._size('size', '220px'), ticks: Math.max(0, Math.min(360, Math.round(this._num('ticks', 9)))),
19
+ needleColor: this._color('needle-color', null), unit: this._unitText(),
20
+ glow: this.getAttribute('glow'), label: this.getAttribute('label')
50
21
  };
51
22
  }
52
-
53
- structuralSignature(cfg) {
54
- // Everything except `value` and `label` is structural; the needle and the
55
- // center readout are patched in sync() so the arc isn't rebuilt per frame.
56
- const { value, label, ...rest } = cfg;
57
- return JSON.stringify(rest);
58
- }
59
-
23
+ structuralSignature({ value, fraction, step, label, unit, needleColor, ...structure }) { return JSON.stringify(structure); }
60
24
  build(cfg) {
61
- const ticks = Array.from({ length: cfg.ticks }, () => '<div class="vector"></div>').join('');
62
-
25
+ const frame = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;`;
26
+ const ticks = cfg.ticks ? `<div class="orbit-12 ${cfg.range < 360 ? 'fit-range' : ''}" style="${frame}--o-size-ratio:.7">${'<div class="vector inner-orbit"></div>'.repeat(cfg.ticks)}</div>` : '';
63
27
  const zones = cfg.zones.map((z) => {
64
- const pct = ((z.to - z.from) / cfg.span) * 100;
65
- const style = `--o-fill:${z.color};--o-stroke:${z.color};--o-stroke-width:6;--o-gap:0;${this._glow(z.color)}`;
66
- return `<o-arc value="${pct.toFixed(4)}" style="${style}"></o-arc>`;
28
+ const from = cfg.from + (z.from - cfg.min) / cfg.span * cfg.range;
29
+ const style = `--o-fill:${z.color};--o-stroke:${z.color};--o-gap:0;${this._glow(z.color)}`;
30
+ return `<div class="orbit-11" style="--o-range:${cfg.range}deg;--o-from:${fixed(from)}deg;--o-size-ratio:.8"><o-arc class="inner-orbit" value="${((z.to - z.from) / cfg.span * 100).toFixed(4)}" style="${e(style)}"></o-arc></div>`;
67
31
  }).join('');
68
-
69
- const zoneLayer = zones
70
- ? `<div class="orbit-5" style="--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;">${zones}</div>`
71
- : '';
72
-
73
- const tickLayer = cfg.ticks
74
- ? `<div class="orbit-6 fit-range" style="--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;">${ticks}</div>`
75
- : '';
76
-
77
- this.innerHTML = `
78
- <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
79
- <div class="gravity-spot">
80
- ${tickLayer}
81
- ${zoneLayer}
82
- <div class="orbit-4" style="--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;">
83
- <o-arc class="o-meter-needle" value="0" shape="arrow" style="--o-fill:${cfg.needleColor};--o-stroke:${cfg.needleColor}"></o-arc>
84
- </div>
85
- <div class="orbit-0">
86
- <div class="satellite at-center">
87
- <div class="capsule ok-hub o-meter-readout"></div>
88
- </div>
89
- </div>
90
- </div>
91
- </div>`;
92
-
32
+ this.innerHTML = universe(cfg.size, `
33
+ ${ticks}${zones}
34
+ <div class="orbit-0"><div class="vector outer-orbit o-meter-needle" style="--o-size-ratio:9.5;${e(this._glow('var(--ok-meter-needle, var(--ok-hand, #e5e7eb))'))}"></div></div>
35
+ ${hub(6, '', 'ok-hub o-meter-readout')}`);
93
36
  this._needle = this.querySelector('.o-meter-needle');
94
37
  this._readout = this.querySelector('.o-meter-readout');
95
38
  }
96
-
97
39
  sync(cfg) {
98
- const clamped = Math.min(cfg.max, Math.max(cfg.min, cfg.value));
99
- const pct = ((clamped - cfg.min) / cfg.span) * 100;
100
- if (this._needle) this._needle.setAttribute('value', pct.toFixed(2));
101
- if (this._readout) {
102
- this._readout.textContent = cfg.label !== null
103
- ? cfg.label
104
- : `${Math.round(clamped)}${cfg.unit}`;
105
- }
40
+ this._applyTheme({ '--ok-meter-needle': cfg.needleColor });
41
+ const angle = cfg.from - 90 + cfg.fraction * cfg.range;
42
+ this._needle?.style.setProperty('--o-angle-composite', `${fixed(angle)}deg`);
43
+ const range = { min: cfg.min, max: cfg.max };
44
+ const text = cfg.label ?? this._formatText(cfg.value, { ...range, fallback: (v) => String(Math.round(v)) });
45
+ if (this._readout) this._readout.textContent = text;
46
+ this._describe(`${text}; ${this._formatText(cfg.value, range)}, range ${this._format(cfg.min, range)} to ${this._format(cfg.max, range)}`, 'meter');
47
+ this._ariaRange(cfg);
106
48
  }
107
49
  }
108
-
50
+ injectStyleOnce('orbit-kit-meter', `
51
+ o-meter .vector.inner-orbit { background: var(--ok-tick, currentColor); opacity: .7; height: max(1px, .6cqmin); }
52
+ o-meter .o-meter-needle { background: var(--ok-meter-needle, var(--ok-hand, #e5e7eb)); height: max(2px, 1.2cqmin); border-radius: 0 3px 3px 0; }
53
+ o-meter .o-meter-readout { --ok-hub-size: 1.1em; }
54
+ `);
109
55
  define('o-meter', OrbitMeter);
@@ -0,0 +1,391 @@
1
+ /**
2
+ * <o-radar-chart>: a radar (spider) chart. One spoke per axis, one polygon
3
+ * per series. Not to be confused with <o-radar>, the scope with a sweep.
4
+ *
5
+ * .orbit-0 > .vector × axes spokes, one web radius long
6
+ * .orbit-N > .side × axes (× levels) the web: each level is a ring of
7
+ * chords, a regular polygon
8
+ * .orbit-0 > .satellite.at-center the plot: an SVG as wide as the
9
+ * > .capsule > svg universe, in lanes (viewBox ±12)
10
+ * .orbit-N > .satellite × axes axis labels, pushed outward with
11
+ * --o-dir-x / --o-dir-y
12
+ *
13
+ * Values are drawn in SVG because every vertex sits at its own distance;
14
+ * the web, spokes and labels are Orbit, so they follow the axes' angles.
15
+ * New data glides from the old shape to the new one.
16
+ *
17
+ * Events (bubbling): select { axis, series, value, axisIndex, seriesIndex },
18
+ * toggle { series, seriesIndex, hidden }.
19
+ */
20
+ import { OrbitKitElement, define, injectStyleOnce, universe, fixed, finiteNumber, cssValue, normalizeRange, escapeHTML as e, withShared, FORMAT_ATTRS } from './base.js';
21
+
22
+ export const RADAR_CHART_PALETTE = ['#4fd1c5', '#ffb547', '#ff7a6b', '#a78bfa', '#60a5fa', '#a3e635'];
23
+ /** The web's radius, in lanes; labels sit just outside it. */
24
+ const WEB = 7.8;
25
+ const LABEL_LANE = 8.7;
26
+
27
+ /** The smallest of 1, 2, 2.5, 5 × 10^n at or above `v`, for an automatic max. */
28
+ export function niceMax(v) {
29
+ if (!(v > 0) || !Number.isFinite(v)) return 1;
30
+ const p = 10 ** Math.floor(Math.log10(v));
31
+ return [1, 2, 2.5, 5, 10].map((f) => f * p).find((n) => n >= v * (1 - 1e-9));
32
+ }
33
+
34
+ /** An x/y point (in lanes) on axis i of n, at distance r. Axis 0 points up. */
35
+ const at = (i, n, r) => {
36
+ const a = (i / n) * 2 * Math.PI;
37
+ return [r * Math.sin(a), -r * Math.cos(a)];
38
+ };
39
+
40
+ /** A ring `lanes` out: the lane above it, shrunk back toward the one below. */
41
+ const ring = (lanes, cls, style = '', inner = '') => {
42
+ const n = Math.max(1, Math.ceil(lanes - 1e-6));
43
+ return `<div class="orbit-${n} ${cls}" style="--o-orbit-ratio:${fixed(n - lanes, 4)}${style}">${inner}</div>`;
44
+ };
45
+
46
+ const RADAR_CHART_CSS = `
47
+ :where(o-radar-chart) { display: inline-flex; flex-direction: column; align-items: center; gap: .75em; position: relative; vertical-align: top; }
48
+ o-radar-chart { --ok-font-size: 4.1cqmin; --_grid: var(--ok-radar-chart-grid, var(--ok-track, rgba(138,151,165,.3))); }
49
+ o-radar-chart .ok-rc-spoke, o-radar-chart .ok-rc-level > .side { background: var(--_grid); }
50
+ o-radar-chart .ok-rc-level.is-outer > .side, o-radar-chart .ok-rc-ring.is-outer { --_grid: var(--ok-radar-chart-edge, var(--ok-radar-chart-grid, var(--ok-tick, rgba(138,151,165,.5)))); }
51
+ o-radar-chart .ok-rc-ring { box-shadow: inset 0 0 0 1px var(--_grid); }
52
+ o-radar-chart .ok-rc-plot { width: 100%; aspect-ratio: 1; pointer-events: none; }
53
+ o-radar-chart .ok-rc-plot > svg { display: block; width: 100%; height: 100%; overflow: visible; }
54
+ o-radar-chart .ok-rc-scale { font-size: .7px; fill: currentColor; opacity: .55; font-variant-numeric: tabular-nums; }
55
+ o-radar-chart .ok-rc-series { color: var(--c); transition: opacity .25s; }
56
+ o-radar-chart .ok-rc-area {
57
+ fill: currentColor; fill-opacity: var(--ok-radar-chart-fill-opacity, .18);
58
+ stroke: currentColor; stroke-width: var(--ok-radar-chart-stroke, 2px); stroke-linejoin: round; vector-effect: non-scaling-stroke;
59
+ }
60
+ o-radar-chart .ok-rc-series.is-dim { opacity: .22; }
61
+ o-radar-chart .ok-rc-series.is-hidden { opacity: 0; visibility: hidden; }
62
+ o-radar-chart .ok-rc-point { pointer-events: auto; cursor: pointer; outline: none; }
63
+ o-radar-chart .ok-rc-hit { fill: transparent; }
64
+ o-radar-chart .ok-rc-dot { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: scale .15s; }
65
+ o-radar-chart .ok-rc-halo { fill: none; stroke: currentColor; stroke-width: 2px; vector-effect: non-scaling-stroke; opacity: 0; transform-box: fill-box; transform-origin: center; scale: .6; transition: opacity .15s, scale .15s; }
66
+ o-radar-chart .ok-rc-point.is-active .ok-rc-dot { scale: 1.35; }
67
+ o-radar-chart .ok-rc-point.is-active .ok-rc-halo { opacity: .45; scale: 1; }
68
+ o-radar-chart .ok-rc-point:focus-visible .ok-rc-halo { stroke: var(--ok-focus, currentColor); opacity: 1; scale: 1; }
69
+ o-radar-chart .ok-rc-label {
70
+ position: absolute; width: max-content; max-width: 7em; line-height: 1.2; font-weight: 600;
71
+ color: var(--ok-radar-chart-label, inherit); opacity: .82;
72
+ translate: calc(var(--o-dir-x) * 50% - 50%) calc(var(--o-dir-y) * 50% - 50%);
73
+ left: 50%; top: 50%; text-align: var(--_align, center);
74
+ }
75
+ o-radar-chart .ok-rc-tip {
76
+ position: absolute; z-index: 2; pointer-events: none; width: max-content; max-width: 14em;
77
+ translate: -50% calc(-100% - 1.3em); padding: .45em .7em; border-radius: .6em;
78
+ font-size: 4.1cqmin; line-height: 1.25; text-align: left; white-space: nowrap;
79
+ color: var(--ok-radar-chart-tip-text, var(--ok-text, #e8edf2));
80
+ background: var(--ok-radar-chart-tip, var(--ok-surface, rgba(18,22,28,.94)));
81
+ box-shadow: 0 0 0 1px var(--_grid), 0 6px 18px rgba(0,0,0,.28);
82
+ }
83
+ o-radar-chart .ok-rc-tip strong { display: block; font-size: 1.25em; font-variant-numeric: tabular-nums; }
84
+ o-radar-chart .ok-rc-tip span { display: flex; align-items: center; gap: .4em; opacity: .75; }
85
+ o-radar-chart .ok-rc-tip i, o-radar-chart .ok-rc-swatch { width: .7em; height: .7em; border-radius: 50%; background: var(--c); flex: none; }
86
+ o-radar-chart .ok-rc-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .3em .5em; font-size: var(--ok-radar-chart-legend-size, .875em); max-width: 100%; }
87
+ o-radar-chart .ok-rc-key {
88
+ font: inherit; color: inherit; display: inline-flex; align-items: center; gap: .45em;
89
+ padding: .3em .7em; border-radius: 99px; border: 1px solid transparent; background: none; cursor: pointer;
90
+ transition: opacity .2s, background-color .2s;
91
+ }
92
+ o-radar-chart .ok-rc-key:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
93
+ o-radar-chart .ok-rc-key[aria-pressed="false"] { opacity: .45; }
94
+ o-radar-chart .ok-rc-key[aria-pressed="false"] .ok-rc-swatch { background: none; box-shadow: inset 0 0 0 1.5px var(--c); }
95
+ o-radar-chart .ok-rc-key:focus-visible { outline: 2px solid var(--ok-focus, #3da9fc); outline-offset: 2px; }
96
+ o-radar-chart .ok-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
97
+ @media (prefers-reduced-motion: reduce) { o-radar-chart .ok-rc-series, o-radar-chart .ok-rc-dot, o-radar-chart .ok-rc-halo, o-radar-chart .ok-rc-key { transition: none; } }
98
+ `;
99
+
100
+ export class OrbitRadarChart extends OrbitKitElement {
101
+ static get observedAttributes() {
102
+ return withShared(['data', 'series', 'min', 'max', 'levels', 'grid', 'size', 'label', 'legend', 'scale', 'points', 'duration'], FORMAT_ATTRS);
103
+ }
104
+
105
+ constructor() {
106
+ super();
107
+ this._hidden = new Set();
108
+ this._shown = null;
109
+ this._frame = 0;
110
+ }
111
+
112
+ readConfig() {
113
+ const raw = this._json('data', []);
114
+ const items = Array.isArray(raw) ? raw : [];
115
+ let axes = items.map((item, i) => {
116
+ const o = item && typeof item === 'object' ? item : { label: item };
117
+ return { label: o.label == null ? `Axis ${i + 1}` : String(o.label), min: o.min, max: o.max, value: o.value };
118
+ });
119
+ let series = this._array('series').map((s, i) => ({
120
+ label: s.label == null ? `Series ${i + 1}` : String(s.label),
121
+ values: Array.isArray(s.values) ? s.values : [],
122
+ color: cssValue(s.color, ''), hidden: s.hidden === true
123
+ }));
124
+ // A single series can live in `data` itself: [{ label, value }].
125
+ if (!series.length && axes.some((a) => a.value != null)) {
126
+ series = [{ label: this.getAttribute('label') || 'Value', values: axes.map((a) => a.value), color: '', hidden: false }];
127
+ }
128
+ if (!axes.length) {
129
+ const n = series.reduce((m, s) => Math.max(m, s.values.length), 0);
130
+ axes = Array.from({ length: n }, (_, i) => ({ label: `Axis ${i + 1}` }));
131
+ }
132
+ const sharedMin = this._num('min', 0);
133
+ const largest = series.reduce((m, s) => s.values.reduce((n, v) => Math.max(n, finiteNumber(v, -Infinity)), m), -Infinity);
134
+ const sharedMax = this._num('max', niceMax(largest - Math.min(0, sharedMin)) + Math.min(0, sharedMin));
135
+ let shared = true;
136
+ axes = axes.map((a) => {
137
+ if (a.min != null || a.max != null) shared = false;
138
+ const r = normalizeRange({ min: finiteNumber(a.min, sharedMin), max: finiteNumber(a.max, sharedMax) });
139
+ return { label: a.label, min: r.min, max: r.max };
140
+ });
141
+ series = series.map((s, i) => {
142
+ const values = axes.map((a, j) => {
143
+ const v = finiteNumber(s.values[j], a.min);
144
+ return Math.min(a.max, Math.max(a.min, v));
145
+ });
146
+ return {
147
+ ...s, values,
148
+ fractions: values.map((v, j) => (axes[j].max > axes[j].min ? (v - axes[j].min) / (axes[j].max - axes[j].min) : 0)),
149
+ color: s.color || (i === 0 ? `var(--ok-fill, ${RADAR_CHART_PALETTE[0]})` : RADAR_CHART_PALETTE[i % RADAR_CHART_PALETTE.length])
150
+ };
151
+ });
152
+ const legend = this.getAttribute('legend');
153
+ return {
154
+ axes, series, shared,
155
+ levels: Math.max(1, Math.min(12, Math.round(this._num('levels', 4)))),
156
+ grid: this.getAttribute('grid') === 'circle' || axes.length < 3 ? 'circle' : 'polygon',
157
+ size: this._size('size', '320px'), label: this.getAttribute('label'),
158
+ legend: legend === 'false' ? false : (legend != null && legend !== 'auto') || series.length > 1,
159
+ scale: this.getAttribute('scale') !== 'false' && shared,
160
+ points: this.getAttribute('points') !== 'false',
161
+ duration: Math.max(0, this._num('duration', 600))
162
+ };
163
+ }
164
+
165
+ structuralSignature({ axes, series, levels, grid, size, legend, scale, points }) {
166
+ return JSON.stringify({ axes: axes.length, series: series.length, levels, grid, size, legend, scale, points });
167
+ }
168
+
169
+ build(cfg) {
170
+ const n = cfg.axes.length;
171
+ const levels = Array.from({ length: cfg.levels }, (_, k) => WEB * (k + 1) / cfg.levels);
172
+ const outer = (k) => (k === cfg.levels - 1 ? ' is-outer' : '');
173
+ // The web: a ring of chords per level (a regular polygon), or plain rings.
174
+ // Chords are laid out from their midpoints; this --o-from puts their
175
+ // corners on the spokes, whatever the number of axes.
176
+ const web = levels.map((r, k) => (cfg.grid === 'polygon'
177
+ ? ring(r, `ok-rc-level${outer(k)}`, `;--o-from:${fixed(180 / n - 90)}deg`, '<div class="side"></div>'.repeat(n))
178
+ : ring(r, `ok-rc-ring${outer(k)}`))).join('');
179
+ const spokes = n ? `<div class="orbit-0">${`<div class="vector outer-orbit ok-rc-spoke" style="--o-size-ratio:${WEB}"></div>`.repeat(n)}</div>` : '';
180
+ const scale = cfg.scale ? levels.map((r, k) => `<text class="ok-rc-scale" x=".35" y="${fixed(-r + .62)}" data-level="${k}"></text>`).join('') : '';
181
+ const series = cfg.series.map((s, i) => `<g class="ok-rc-series" data-series="${i}"><polygon class="ok-rc-area"></polygon>${cfg.points ? cfg.axes.map((_, j) =>
182
+ `<g class="ok-rc-point" tabindex="-1" role="img" data-series="${i}" data-axis="${j}"><circle class="ok-rc-hit" r="1.1"></circle><circle class="ok-rc-halo" r=".85"></circle><circle class="ok-rc-dot" r=".34"></circle></g>`).join('') : ''}</g>`).join('');
183
+ const plot = `<div class="orbit-0" style="--o-size-ratio:24"><div class="satellite at-center"><div class="capsule ok-rc-plot">` +
184
+ `<svg viewBox="-12 -12 24 24" role="presentation" focusable="false">${scale}${series}</svg><div class="ok-rc-tip" hidden></div></div></div></div>`;
185
+ const labels = n ? ring(LABEL_LANE, 'ok-rc-labels', '', '<div class="satellite"><div class="capsule"><span class="ok-rc-label"></span></div></div>'.repeat(n)) : '';
186
+ const legend = cfg.legend ? `<div class="ok-rc-legend">${cfg.series.map((_, i) => `<button type="button" class="ok-rc-key" data-series="${i}" aria-pressed="true"><span class="ok-rc-swatch"></span><span class="ok-rc-key-text"></span></button>`).join('')}</div>` : '';
187
+ this.innerHTML = universe(cfg.size, `${web}${spokes}${plot}${labels}`) + legend +
188
+ '<div class="ok-sr"><table><caption></caption><thead><tr></tr></thead><tbody></tbody></table></div>';
189
+
190
+ this._groups = [...this.querySelectorAll('.ok-rc-series')];
191
+ this._areas = this._groups.map((g) => g.querySelector('.ok-rc-area'));
192
+ this._points = this._groups.map((g) => [...g.querySelectorAll('.ok-rc-point')]);
193
+ this._labels = [...this.querySelectorAll('.ok-rc-label')];
194
+ this._scaleText = [...this.querySelectorAll('.ok-rc-scale')];
195
+ this._keys = [...this.querySelectorAll('.ok-rc-key')];
196
+ this._tip = this.querySelector('.ok-rc-tip');
197
+ this._table = this.querySelector('.ok-sr > table');
198
+ this._active = null;
199
+
200
+ for (const point of this._points.flat()) {
201
+ point.addEventListener('pointerenter', () => this._activate(point, false));
202
+ point.addEventListener('pointerleave', () => this._activate(this.contains(this.ownerDocument.activeElement) && this.ownerDocument.activeElement.classList?.contains('ok-rc-point') ? this.ownerDocument.activeElement : null, false));
203
+ point.addEventListener('focus', () => this._activate(point, true));
204
+ point.addEventListener('blur', () => this._activate(null, false));
205
+ point.addEventListener('click', () => { point.focus?.(); this._select(point); });
206
+ }
207
+ for (const key of this._keys) {
208
+ key.addEventListener('click', () => this._toggle(Number(key.dataset.series)));
209
+ key.addEventListener('pointerenter', () => this._highlight(Number(key.dataset.series)));
210
+ key.addEventListener('pointerleave', () => this._highlight(null));
211
+ key.addEventListener('focus', () => this._highlight(Number(key.dataset.series)));
212
+ key.addEventListener('blur', () => this._highlight(null));
213
+ }
214
+ if (!this._listening) {
215
+ this._listening = true;
216
+ this.addEventListener('keydown', (event) => this._onKey(event));
217
+ }
218
+ }
219
+
220
+ sync(cfg) {
221
+ this._cfg = cfg;
222
+ const n = cfg.axes.length;
223
+ cfg.series.forEach((s, i) => {
224
+ if (s.hidden && !this._seeded?.has(i)) this._hidden.add(i);
225
+ });
226
+ this._seeded = new Set(cfg.series.map((_, i) => i));
227
+ this._groups.forEach((g, i) => {
228
+ const s = cfg.series[i];
229
+ g.style.setProperty('--c', s.color);
230
+ g.classList.toggle('is-hidden', this._hidden.has(i));
231
+ this._points[i].forEach((p, j) => p.setAttribute('aria-label', `${s.label}, ${cfg.axes[j].label}: ${this._valueText(cfg, i, j, 'aria')}`));
232
+ });
233
+ this._keys.forEach((key, i) => {
234
+ key.style.setProperty('--c', cfg.series[i].color);
235
+ key.querySelector('.ok-rc-key-text').textContent = cfg.series[i].label;
236
+ key.setAttribute('aria-pressed', String(!this._hidden.has(i)));
237
+ });
238
+ // Labels: centered at the top and bottom, flush toward the chart elsewhere.
239
+ this._labels.forEach((label, j) => {
240
+ label.textContent = cfg.axes[j].label;
241
+ const x = Math.sin(j / n * 2 * Math.PI);
242
+ label.style.setProperty('--_align', Math.abs(x) < .2 ? 'center' : x > 0 ? 'left' : 'right');
243
+ });
244
+ const axis0 = cfg.axes[0];
245
+ this._scaleText.forEach((text, k) => {
246
+ const v = axis0.min + (axis0.max - axis0.min) * (k + 1) / cfg.levels;
247
+ text.textContent = this._format(Number(v.toPrecision(12)), { kind: 'tick', min: axis0.min, max: axis0.max, axis: axis0.label, axisIndex: 0, fallback: (x) => String(x) });
248
+ });
249
+ this._fillTable(cfg);
250
+ this._roving();
251
+ const name = cfg.label || 'Radar chart';
252
+ this._describe(`${name}: ${n} axes, ${cfg.series.length === 1 ? '1 series' : `${cfg.series.length} series`}`, 'figure');
253
+ this._animate(cfg.series.map((s) => s.fractions));
254
+ if (this._active) this._activate(this._active, this._activeFocus);
255
+ }
256
+
257
+ _valueText(cfg, i, j, kind = 'value') {
258
+ const a = cfg.axes[j];
259
+ // A formatter also gets the axis and series, for a unit per axis.
260
+ return this._formatText(cfg.series[i].values[j], { kind, min: a.min, max: a.max, axis: a.label, axisIndex: j, series: cfg.series[i].label, seriesIndex: i });
261
+ }
262
+
263
+ _fillTable(cfg) {
264
+ if (!this._table) return;
265
+ this._table.caption.textContent = cfg.label || 'Radar chart';
266
+ const head = this._table.tHead.rows[0];
267
+ head.innerHTML = `<th scope="col">Axis</th>${cfg.series.map((s) => `<th scope="col">${e(s.label)}</th>`).join('')}`;
268
+ this._table.tBodies[0].innerHTML = cfg.axes.map((a, j) =>
269
+ `<tr><th scope="row">${e(a.label)}</th>${cfg.series.map((_, i) => `<td>${e(this._valueText(cfg, i, j))}</td>`).join('')}</tr>`).join('');
270
+ }
271
+
272
+ /** Glide every vertex from what is drawn now to `target` (fractions 0..1). */
273
+ _animate(target) {
274
+ const n = this._cfg.axes.length;
275
+ const from = target.map((row, i) => row.map((_, j) => this._shown?.[i]?.[j] ?? 0));
276
+ if (this._frame && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this._frame);
277
+ const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
278
+ const duration = this._cfg.duration;
279
+ if (reduce || !duration || typeof requestAnimationFrame !== 'function' || !n) { this._draw(target); return; }
280
+ const start = performance.now();
281
+ const step = (now) => {
282
+ const t = Math.min(1, (now - start) / duration), k = 1 - (1 - t) ** 3;
283
+ this._draw(target.map((row, i) => row.map((v, j) => from[i][j] + (v - from[i][j]) * k)));
284
+ this._frame = t < 1 ? requestAnimationFrame(step) : 0;
285
+ };
286
+ this._draw(from);
287
+ this._frame = requestAnimationFrame(step);
288
+ }
289
+
290
+ _draw(fractions) {
291
+ this._shown = fractions;
292
+ const n = this._cfg.axes.length;
293
+ fractions.forEach((row, i) => {
294
+ const pts = row.map((f, j) => at(j, n, f * WEB));
295
+ this._areas[i]?.setAttribute('points', pts.map(([x, y]) => `${fixed(x)},${fixed(y)}`).join(' '));
296
+ this._points[i]?.forEach((p, j) => p.setAttribute('transform', `translate(${fixed(pts[j][0])} ${fixed(pts[j][1])})`));
297
+ });
298
+ if (this._active) this._placeTip(this._active);
299
+ }
300
+
301
+ /** Show a point's value in the tooltip and bring its series forward. */
302
+ _activate(point, focus) {
303
+ this._active = point;
304
+ this._activeFocus = focus;
305
+ for (const p of this._points.flat()) p.classList.toggle('is-active', p === point);
306
+ if (!point || !this._tip || !this._cfg) {
307
+ if (this._tip) this._tip.hidden = true;
308
+ this._highlight(null);
309
+ return;
310
+ }
311
+ const i = Number(point.dataset.series), j = Number(point.dataset.axis), cfg = this._cfg;
312
+ const s = cfg.series[i];
313
+ if (!s) return;
314
+ this._tip.style.setProperty('--c', s.color);
315
+ this._tip.innerHTML = `<span>${cfg.series.length > 1 ? `<i></i>${e(s.label)} · ` : ''}${e(cfg.axes[j].label)}</span><strong>${e(this._valueText(cfg, i, j))}</strong>`;
316
+ this._tip.hidden = false;
317
+ this._placeTip(point);
318
+ this._highlight(i);
319
+ if (focus) this._roving(point);
320
+ }
321
+
322
+ _placeTip(point) {
323
+ const i = Number(point.dataset.series), j = Number(point.dataset.axis);
324
+ const f = this._shown?.[i]?.[j] ?? 0;
325
+ const [x, y] = at(j, this._cfg.axes.length, f * WEB);
326
+ this._tip.style.left = `${fixed(50 + x / 24 * 100)}%`;
327
+ this._tip.style.top = `${fixed(50 + y / 24 * 100)}%`;
328
+ }
329
+
330
+ _highlight(index) {
331
+ this._groups?.forEach((g, i) => g.classList.toggle('is-dim', index != null && i !== index && this._cfg.series.length > 1));
332
+ }
333
+
334
+ /** One tab stop for all the points: the last focused one, else the first visible. */
335
+ _roving(point) {
336
+ const visible = this._points.filter((_, i) => !this._hidden.has(i)).flat();
337
+ const keep = point || (visible.includes(this._focusPoint) ? this._focusPoint : visible[0]);
338
+ this._focusPoint = keep;
339
+ for (const p of this._points.flat()) p.setAttribute('tabindex', p === keep ? '0' : '-1');
340
+ }
341
+
342
+ _select(point) {
343
+ const i = Number(point.dataset.series), j = Number(point.dataset.axis), cfg = this._cfg;
344
+ this.dispatchEvent(new CustomEvent('select', {
345
+ detail: { axis: cfg.axes[j].label, series: cfg.series[i].label, value: cfg.series[i].values[j], axisIndex: j, seriesIndex: i },
346
+ bubbles: true, composed: true
347
+ }));
348
+ }
349
+
350
+ _toggle(i) {
351
+ if (this._hidden.has(i)) this._hidden.delete(i);
352
+ else if (this._hidden.size < this._cfg.series.length - 1) this._hidden.add(i);
353
+ else return;
354
+ const hidden = this._hidden.has(i);
355
+ this._groups[i]?.classList.toggle('is-hidden', hidden);
356
+ this._keys[i]?.setAttribute('aria-pressed', String(!hidden));
357
+ if (this._active && Number(this._active.dataset.series) === i) this._activate(null, false);
358
+ this._roving();
359
+ this.dispatchEvent(new CustomEvent('toggle', { detail: { series: this._cfg.series[i].label, seriesIndex: i, hidden }, bubbles: true, composed: true }));
360
+ }
361
+
362
+ /** Left/Right walk the axes, Up/Down switch series, Home/End jump, Enter/Space select. */
363
+ _onKey(event) {
364
+ const point = event.target?.closest?.('.ok-rc-point');
365
+ if (!point || !this._cfg) return;
366
+ const n = this._cfg.axes.length;
367
+ const visible = this._cfg.series.map((_, i) => i).filter((i) => !this._hidden.has(i));
368
+ let i = Number(point.dataset.series), j = Number(point.dataset.axis);
369
+ if (event.key === 'ArrowRight') j = (j + 1) % n;
370
+ else if (event.key === 'ArrowLeft') j = (j - 1 + n) % n;
371
+ else if (event.key === 'Home') j = 0;
372
+ else if (event.key === 'End') j = n - 1;
373
+ else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
374
+ const at = visible.indexOf(i);
375
+ i = visible[(at + (event.key === 'ArrowUp' ? 1 : -1) + visible.length) % visible.length];
376
+ } else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this._select(point); return; }
377
+ else if (event.key === 'Escape') { this._activate(null, false); return; }
378
+ else return;
379
+ event.preventDefault();
380
+ const next = this._points[i]?.[j];
381
+ if (next) { this._roving(next); next.focus(); }
382
+ }
383
+
384
+ onDisconnected() { if (this._frame && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this._frame); this._frame = 0; }
385
+
386
+ /** Series that the legend has hidden, by index. */
387
+ get hiddenSeries() { return [...this._hidden].sort((a, b) => a - b); }
388
+ }
389
+
390
+ injectStyleOnce('orbit-kit-radar-chart', RADAR_CHART_CSS);
391
+ define('o-radar-chart', OrbitRadarChart);