@zumer/orbit-kit 0.1.0 → 2.0.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.
@@ -0,0 +1,569 @@
1
+ /**
2
+ * <o-sunburst>: a hierarchical chart. `data` is a tree of
3
+ * { label, value?, color?, children? }; every depth is a ring of <o-arc>s with
4
+ * a `value`, so Orbit stacks them in order and each child lands inside its
5
+ * parent. A node's value is its own `value` or the sum of its children (the
6
+ * larger of the two; the difference stays empty in the outer rings).
7
+ *
8
+ * .orbit-12.ok-sb-ring × depth one ring per depth; its radius is a
9
+ * > o-arc.ok-sb-node[value] × nodes fractional --o-orbit-number, so a zoom
10
+ * > o-arc.ok-sb-spacer[value] can move rings in and out
11
+ * .orbit-0 > .satellite.at-center hub: path, value and share
12
+ * ul.ok-sb-tree the same tree for assistive tech
13
+ *
14
+ * Hover or focus lights a branch and shows it in the hub. Click (or Enter)
15
+ * zooms into a branch, the hub zooms out. The element is a role="tree" with
16
+ * aria-activedescendant on the visually hidden list: Left/Right move between
17
+ * siblings, Up goes out to the first child, Down back in to the parent.
18
+ * Events: select { value, label, item, path, labels, depth } and
19
+ * navigate { path, depth }; both bubble.
20
+ */
21
+ import { OrbitKitElement, define, injectStyleOnce, universe, hub, fixed, finiteNumber, cssValue, formatNumber, escapeHTML as e, withShared, FORMAT_ATTRS } from './base.js';
22
+
23
+ const PALETTE = ['#4fd1c5', '#ffb547', '#ff7a6b', '#a78bfa', '#60a5fa', '#a3e635', '#f472b6', '#facc15'];
24
+ const MAX_DEPTH = 8;
25
+ const DURATION = 650;
26
+ const SMOOTH_LIMIT = 4000;
27
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
28
+ const ease = (t) => (t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
29
+ let uid = 0;
30
+
31
+ const SUNBURST_CSS = `
32
+ o-sunburst { display: inline-block; -webkit-tap-highlight-color: transparent; }
33
+ o-sunburst .ok-sb-ring { transition: opacity .2s; }
34
+ o-sunburst .ok-sb-ring.is-off { visibility: hidden; }
35
+ o-sunburst .ok-sb-node {
36
+ --o-stroke: var(--o-fill); --o-stroke-width: 0;
37
+ color: var(--ok-sunburst-label, #0c0f13); font-size: var(--ok-sunburst-label-size, clamp(10px, 3.3cqmin, 15px)); font-weight: 600;
38
+ transition: opacity .2s, color .25s, filter .2s;
39
+ }
40
+ o-sunburst .ok-sb-spacer { --o-fill: transparent; --o-stroke: transparent; }
41
+ o-sunburst.is-moving .ok-sb-node { color: transparent; transition: none; }
42
+ o-sunburst[data-lit] .ok-sb-node:not(.is-lit) { opacity: var(--ok-sunburst-dim, .3); }
43
+ o-sunburst .ok-sb-node.is-selected { filter: brightness(1.12); }
44
+ o-sunburst:focus-visible, o-sunburst:focus-visible > .bigbang { outline: none; }
45
+ o-sunburst:focus-visible .ok-sb-node.is-focus { --o-stroke: var(--ok-focus, #e8edf2); --o-stroke-width: 2; }
46
+ o-sunburst .ok-sb-hub {
47
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .15em;
48
+ box-sizing: border-box; width: 100%; aspect-ratio: 1; border-radius: 50%; padding: 12%; font-weight: 400; background: var(--ok-sb-hub-bg, none); transition: background .25s;
49
+ }
50
+ o-sunburst .ok-sb-hub.is-back { cursor: pointer; }
51
+ o-sunburst .ok-sb-hub.is-back:hover { background: color-mix(in srgb, var(--ok-sb-tint, transparent) 26%, transparent); }
52
+ o-sunburst:focus-visible .ok-sb-hub.is-focus { outline: 2px solid var(--ok-focus, #e8edf2); outline-offset: -2px; }
53
+ o-sunburst .ok-sb-path {
54
+ font-size: max(11px, 12cqmin); color: var(--ok-sunburst-muted, color-mix(in srgb, currentColor 62%, transparent));
55
+ max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
56
+ }
57
+ o-sunburst .ok-sb-value { font-size: var(--ok-sunburst-value-size, 23cqmin); font-weight: 650; letter-spacing: -.02em; line-height: 1.05; white-space: nowrap; }
58
+ o-sunburst .ok-sb-share { font-size: max(10px, 10cqmin); line-height: 1.2; color: var(--ok-sunburst-muted, color-mix(in srgb, currentColor 62%, transparent)); max-width: 100%; overflow-wrap: anywhere; }
59
+ o-sunburst .ok-sb-tree {
60
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
61
+ overflow: hidden; clip-path: inset(50%); white-space: nowrap; list-style: none;
62
+ }
63
+ @media (prefers-reduced-motion: reduce) { o-sunburst .ok-sb-node, o-sunburst .ok-sb-ring { transition: none; } }
64
+ `;
65
+
66
+ /** The tree, normalized: every node knows its value, depth, key, parent and fill. */
67
+ export function normalizeTree(raw, { maxDepth = MAX_DEPTH, palette = PALETTE } = {}) {
68
+ const source = Array.isArray(raw) ? { children: raw } : (raw && typeof raw === 'object' ? raw : { children: [] });
69
+ const walk = (item, depth, path, parent) => {
70
+ const node = { item, depth, path, key: path.join('.'), parent, label: '', value: 0, own: null, children: [] };
71
+ const list = Array.isArray(item.children) ? item.children.filter((c) => c && typeof c === 'object' && !Array.isArray(c)) : [];
72
+ // Past the deepest ring a branch counts as one slice, children included.
73
+ const kids = depth > 32 ? [] : list.map((child, i) => walk(child, depth + 1, [...path, i], node));
74
+ const sum = kids.reduce((n, c) => n + c.value, 0);
75
+ const own = item.value == null || item.value === '' ? null : Math.max(0, finiteNumber(item.value, 0));
76
+ node.own = own;
77
+ node.value = Math.max(own ?? 0, sum);
78
+ node.label = item.label != null ? String(item.label) : (depth ? `Item ${path[path.length - 1] + 1}` : '');
79
+ node.children = depth < maxDepth ? kids : [];
80
+ return node;
81
+ };
82
+ const root = walk(source, 0, [], null);
83
+ // Colors: the top level takes the palette, children lighter shades of it.
84
+ const paint = (node, base, steps) => {
85
+ node.children.forEach((child, i) => {
86
+ const own = cssValue(child.item.color, null);
87
+ const n = node.children.length;
88
+ if (own) { child.fill = own; child.base = own; child.steps = 0; }
89
+ else if (!node.depth) { child.base = palette[i % palette.length]; child.fill = child.base; child.steps = 0; }
90
+ else {
91
+ child.base = base; child.steps = steps + 1;
92
+ const share = Math.round(clamp(100 - child.steps * 17 - (n > 1 ? i / (n - 1) * 12 : 0), 30, 100));
93
+ child.fill = `color-mix(in oklab, ${base} ${share}%, var(--ok-sunburst-tint, #fff))`;
94
+ }
95
+ paint(child, child.base, child.steps);
96
+ });
97
+ };
98
+ paint(root, null, 0);
99
+ return root;
100
+ }
101
+
102
+ const descendantOf = (node, root) => { for (let n = node; n; n = n.parent) if (n === root) return true; return false; };
103
+
104
+ let measureContext;
105
+ function textWidth(text, font) {
106
+ if (!measureContext && typeof document !== 'undefined') measureContext = document.createElement('canvas').getContext?.('2d') || null;
107
+ if (!measureContext) return Infinity;
108
+ measureContext.font = font;
109
+ return measureContext.measureText(text).width;
110
+ }
111
+
112
+ export class OrbitSunburst extends OrbitKitElement {
113
+ static get observedAttributes() {
114
+ return withShared(['data', 'size', 'levels', 'hole', 'gap', 'from', 'label', 'labels'], FORMAT_ATTRS);
115
+ }
116
+
117
+ constructor() {
118
+ super();
119
+ this._uid = `ok-sb-${++uid}`;
120
+ for (const name of ['_onKey', '_onOver', '_onOut', '_onClick', '_onFocus', '_onBlur']) this[name] = this[name].bind(this);
121
+ }
122
+
123
+ readConfig() {
124
+ const root = normalizeTree(this._json('data', []));
125
+ let depth = 0;
126
+ const visit = (node) => { depth = Math.max(depth, node.depth); node.children.forEach(visit); };
127
+ visit(root);
128
+ const levels = clamp(Math.round(this._num('levels', 4)), 1, 6);
129
+ const shown = Math.max(1, Math.min(levels, depth));
130
+ const thickness = Math.min(3.2, 8.4 / shown);
131
+ const hole = this.hasAttribute('hole') ? clamp(this._num('hole', 3.6), 0, 10) : Math.max(3.6, 12 - shown * thickness);
132
+ return {
133
+ root, depth, shown, hole, thickness: (12 - hole) / shown,
134
+ gap: clamp(this._num('gap', 1), 0, 10), from: this._num('from', 0), size: this._size('size', '320px'),
135
+ label: this.getAttribute('label'), labels: this._str('labels', 'true') !== 'false', unit: this._unitText()
136
+ };
137
+ }
138
+
139
+ structuralSignature({ root, label, unit, labels, ...rest }) {
140
+ const shape = (node) => [node.key, node.value, node.fill, node.label, node.children.map(shape)];
141
+ return JSON.stringify({ ...rest, tree: shape(root) });
142
+ }
143
+
144
+ connectedCallback() {
145
+ // `data` set before the element was defined shadows the setter.
146
+ if (Object.hasOwn(this, 'data')) { const value = this.data; delete this.data; this.data = value; }
147
+ super.connectedCallback();
148
+ }
149
+
150
+ /** The data as an object; setting it writes the `data` attribute. */
151
+ get data() { return this._json('data', []); }
152
+ set data(value) { this.setAttribute('data', JSON.stringify(value ?? [])); }
153
+
154
+ build(cfg) {
155
+ injectStyleOnce('orbit-kit-sunburst', SUNBURST_CSS);
156
+ this._stopTween();
157
+ const nodes = [];
158
+ const visit = (node) => { nodes.push(node); node.children.forEach(visit); };
159
+ visit(cfg.root);
160
+ this._nodes = new Map(nodes.map((node) => [node.key, node]));
161
+ // Keep the zoom and focus when the same branch still exists.
162
+ this._zoom = this._nodes.get(this._zoom?.key) || cfg.root;
163
+ if (!this._zoom.children.length) this._zoom = this._zoom.parent || cfg.root;
164
+ this._focus = this._nodes.get(this._focus?.key) || null;
165
+ if (this._focus === cfg.root) this._focus = null;
166
+ this._selected = this._nodes.get(this._selected?.key) || null;
167
+
168
+ // Ring d holds the nodes at depth d in tree order. A node that ends early
169
+ // (or has room left over) leaves a transparent spacer in the outer rings.
170
+ const emit = (node, d) => {
171
+ if (node.depth === d) return [{ node, amount: node.value }];
172
+ const out = node.children.flatMap((child) => emit(child, d));
173
+ const rest = node.value - node.children.reduce((n, c) => n + c.value, 0);
174
+ if (rest > 1e-9 * node.value) out.push({ owner: node, amount: rest });
175
+ return out;
176
+ };
177
+ let rings = '';
178
+ for (let d = 1; d <= cfg.depth; d++) {
179
+ const arcs = emit(cfg.root, d).map(({ node, owner, amount }) => node
180
+ ? `<o-arc class="inner-orbit ok-sb-node" interactive data-key="${node.key}" value="0" style="--o-fill:${e(node.fill)}"></o-arc>`
181
+ : `<o-arc class="inner-orbit ok-sb-spacer" data-owner="${owner.key}" data-amount="${amount}" value="0"></o-arc>`).join('');
182
+ rings += `<div class="orbit-12 ok-sb-ring is-off" data-depth="${d}" style="--o-orbit-number:12;--o-size-ratio:${fixed(Math.max(.2, cfg.thickness - .14))};--o-gap:${fixed(cfg.gap)};opacity:0">${arcs}</div>`;
183
+ }
184
+ const inner = '<span class="ok-sb-path"></span><strong class="ok-sb-value"></strong><span class="ok-sb-share"></span>';
185
+ this.innerHTML = universe(cfg.size, `${rings}${hub(Math.max(1, 2 * cfg.hole - .5), inner, 'ok-hub ok-sb-hub', ' aria-hidden="true"')}`,
186
+ { spot: `--o-from:${fixed(cfg.from)}deg` }) + `<ul class="ok-sb-tree" role="none">${this._treeMarkup(cfg.root)}</ul>`;
187
+ this._box = this.querySelector('.bigbang');
188
+ this._box.setAttribute('aria-hidden', 'true');
189
+ this._rings = [...this.querySelectorAll('.ok-sb-ring')];
190
+ this._order = new Map(this._rings.map((ring) => [ring, [...ring.children]]));
191
+ this._arcs = [...this.querySelectorAll('.ok-sb-node')].map((el) => ({ el, node: this._nodes.get(el.dataset.key) }));
192
+ this._spacers = [...this.querySelectorAll('.ok-sb-spacer')].map((el) => ({ el, owner: this._nodes.get(el.dataset.owner), amount: Number(el.dataset.amount) }));
193
+ this._hub = this.querySelector('.ok-sb-hub');
194
+ this._hubPath = this.querySelector('.ok-sb-path');
195
+ this._hubValue = this.querySelector('.ok-sb-value');
196
+ this._hubShare = this.querySelector('.ok-sb-share');
197
+ this._items = new Map([...this.querySelectorAll('.ok-sb-tree [role="treeitem"]')].map((li) => [li.dataset.key, li]));
198
+ this._cfg = cfg;
199
+ this._root = cfg.root;
200
+ this._shown = null;
201
+ this._wire();
202
+ this._observeSize();
203
+ }
204
+
205
+ _treeMarkup(node) {
206
+ return node.children.map((child, i) => `<li role="treeitem" id="${this._uid}-${child.key}" data-key="${child.key}" aria-level="${child.depth}" aria-setsize="${node.children.length}" aria-posinset="${i + 1}"${child.children.length ? ' aria-expanded="true"' : ''}>${child.children.length ? `<ul role="group">${this._treeMarkup(child)}</ul>` : ''}</li>`).join('');
207
+ }
208
+
209
+ sync(cfg) {
210
+ // Same structure: keep the tree that the arcs were built from.
211
+ cfg.root = this._root;
212
+ this._cfg = cfg;
213
+ for (const [key, li] of this._items || []) {
214
+ const node = this._nodes.get(key);
215
+ li.setAttribute('aria-label', this._nodeText(node));
216
+ li.setAttribute('aria-selected', String(node === this._selected));
217
+ }
218
+ for (const { el, node } of this._arcs || []) el.title = this._nodeText(node);
219
+ this._describe(cfg.label ?? (cfg.root.label || 'Sunburst chart'), 'tree');
220
+ this.tabIndex = this._arcs?.length ? 0 : -1;
221
+ if (!this._tween) this._apply(this._layout(this._zoom), true);
222
+ this._showHub();
223
+ }
224
+
225
+ /* ---- numbers ----------------------------------------------------------- */
226
+
227
+ _valueText(value) {
228
+ const total = this._cfg.root.value;
229
+ return this._formatText(value, { min: 0, max: total, fallback: (v) => formatNumber(v, { locale: this._locale() }) });
230
+ }
231
+
232
+ /** Share of `of` (the zoomed branch by default), always as a percent. */
233
+ _shareText(value, of) {
234
+ return formatNumber(of > 0 ? value / of : 0, { format: 'percent', locale: this._locale(), min: 0, max: 1, decimals: this.getAttribute('decimals') ?? undefined });
235
+ }
236
+
237
+ _nodeText(node) {
238
+ const parent = node.parent;
239
+ const share = parent ? `, ${this._shareText(node.value, parent.value)} of ${parent.depth ? parent.label : (this._cfg.label ?? (parent.label || 'the total'))}` : '';
240
+ return `${node.label}: ${this._valueText(node.value)}${share}`;
241
+ }
242
+
243
+ /* ---- layout and zoom --------------------------------------------------- */
244
+
245
+ /** Where everything sits when `root` is in the middle: arc shares and ring radii. */
246
+ _layout(root) {
247
+ const cfg = this._cfg, total = root.value;
248
+ const shares = new Map();
249
+ for (const { el, node } of this._arcs) {
250
+ shares.set(el, descendantOf(node, root) ? (total > 0 ? node.value / total : 0) : (descendantOf(root, node) ? 1 : 0));
251
+ }
252
+ for (const { el, owner, amount } of this._spacers) shares.set(el, descendantOf(owner, root) && total > 0 ? amount / total : 0);
253
+ const rings = this._rings.map((ring) => {
254
+ const rel = Number(ring.dataset.depth) - root.depth;
255
+ const visible = rel >= 1 && rel <= cfg.shown;
256
+ // Rings inside the window step out from the hub; the others fold into
257
+ // the hub or wait at the edge, faded out.
258
+ const outer = rel < 1 ? cfg.hole : rel > cfg.shown ? 12 : cfg.hole + rel * cfg.thickness;
259
+ return { ring, outer, opacity: visible ? 1 : 0, visible };
260
+ });
261
+ return { root, shares, rings, ringOf: new Map(rings.map((r) => [r.ring, r])) };
262
+ }
263
+
264
+ /**
265
+ * Only arcs with a share stay in their ring: Orbit stacks every sibling, so
266
+ * slices outside the zoomed branch would cost layout time and draw nothing.
267
+ * During a transition the arcs of both ends stay. Returns the work left:
268
+ * stacking is quadratic in the arcs of a ring.
269
+ */
270
+ _keep(...states) {
271
+ let cost = 0;
272
+ for (const [ring, all] of this._order) {
273
+ const list = all.filter((el) => states.some((s) => s.shares.get(el) > 1e-6 && s.ringOf.get(ring).opacity > 0));
274
+ cost += list.length ** 2;
275
+ const current = ring.children;
276
+ if (list.length !== current.length || list.some((el, i) => current[i] !== el)) ring.replaceChildren(...list);
277
+ }
278
+ return cost;
279
+ }
280
+
281
+ _apply(state, final = false) {
282
+ if (final) this._keep(state);
283
+ for (const [el, share] of state.shares) {
284
+ const value = fixed(clamp(share * 100, 0, 100), 3);
285
+ if (el.getAttribute('value') !== value) el.setAttribute('value', value);
286
+ }
287
+ for (const { ring, outer, opacity, visible } of state.rings) {
288
+ ring.style.setProperty('--o-orbit-number', fixed(Math.max(.01, outer), 4));
289
+ ring.style.opacity = fixed(opacity, 3);
290
+ if (final) ring.classList.toggle('is-off', !visible);
291
+ else if (opacity > 0) ring.classList.remove('is-off');
292
+ }
293
+ this._shown = state;
294
+ if (final) {
295
+ this._labelArcs();
296
+ this._paintHub();
297
+ }
298
+ }
299
+
300
+ _mix(a, b, t) {
301
+ const shares = new Map();
302
+ for (const [el, to] of b.shares) shares.set(el, (a.shares.get(el) ?? 0) + (to - (a.shares.get(el) ?? 0)) * t);
303
+ const rings = b.rings.map((r, i) => ({
304
+ ring: r.ring, visible: r.visible,
305
+ outer: a.rings[i].outer + (r.outer - a.rings[i].outer) * t,
306
+ opacity: a.rings[i].opacity + (r.opacity - a.rings[i].opacity) * t
307
+ }));
308
+ return { root: b.root, shares, rings, ringOf: new Map(rings.map((r) => [r.ring, r])) };
309
+ }
310
+
311
+ _stopTween() {
312
+ if (this._tween && typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(this._tween);
313
+ this._tween = 0;
314
+ this.classList?.remove('is-moving');
315
+ }
316
+
317
+ /** Zoom so that `node` is in the middle, with a transition unless motion is reduced. */
318
+ _zoomTo(node, { focus } = {}) {
319
+ if (!node || !this._cfg) return;
320
+ if (!node.children.length) node = node.parent || this._cfg.root;
321
+ const from = this._shown || this._layout(this._zoom);
322
+ const changed = node !== this._zoom;
323
+ this._zoom = node;
324
+ if (focus !== undefined) this._setFocus(focus);
325
+ this._lit(null);
326
+ const target = this._layout(node);
327
+ this._stopTween();
328
+ this._paintHub();
329
+ const still = typeof matchMedia === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches || typeof requestAnimationFrame === 'undefined';
330
+ // Big trees swap with a short fade: a frame-by-frame zoom would stutter.
331
+ const heavy = !still && changed && this._keep(from, target) > SMOOTH_LIMIT;
332
+ if (still || !changed || heavy) {
333
+ this._apply(target, true);
334
+ if (heavy && this._box?.animate) this._box.animate([{ opacity: .35, scale: .94 }, { opacity: 1, scale: 1 }], { duration: 320, easing: 'ease-out' });
335
+ } else {
336
+ this.classList.add('is-moving');
337
+ for (const { el } of this._arcs) if (el.textContent) el.textContent = '';
338
+ const start = performance.now();
339
+ const frame = (now) => {
340
+ const t = Math.min(1, (now - start) / DURATION);
341
+ if (t < 1) { this._apply(this._mix(from, target, ease(t))); this._tween = requestAnimationFrame(frame); }
342
+ else { this._tween = 0; this.classList.remove('is-moving'); this._apply(target, true); }
343
+ };
344
+ this._tween = requestAnimationFrame(frame);
345
+ }
346
+ this._showHub();
347
+ if (changed) this.dispatchEvent(new CustomEvent('navigate', { detail: { path: this.path, depth: node.depth }, bubbles: true, composed: true }));
348
+ }
349
+
350
+ /** Labels run along the arcs that have room for them, upright on the lower half. */
351
+ _labelArcs() {
352
+ const cfg = this._cfg, state = this._shown;
353
+ if (!state || !this._arcs.length) return;
354
+ const width = this._box?.clientWidth || 0;
355
+ const style = width && typeof getComputedStyle !== 'undefined' ? getComputedStyle(this._arcs[0].el) : null;
356
+ const px = style ? parseFloat(style.fontSize) || 12 : 12;
357
+ const font = style ? `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}` : '';
358
+ const lane = width / 24;
359
+ for (const ring of state.rings) {
360
+ let before = 0;
361
+ for (const el of ring.ring.children) {
362
+ const share = state.shares.get(el) || 0;
363
+ const node = el.classList.contains('ok-sb-node') ? this._nodes.get(el.dataset.key) : null;
364
+ if (node) {
365
+ let text = '';
366
+ if (cfg.labels && width && ring.visible && share > 0) {
367
+ const radius = (ring.outer - cfg.thickness / 2) * lane;
368
+ const room = radius * (share * 360 - cfg.gap) * Math.PI / 180;
369
+ if (cfg.thickness * lane >= px * 1.35 && textWidth(node.label, font) + px * .8 <= room * .96) text = node.label;
370
+ }
371
+ // Text on the lower half runs the other way, so it reads upright.
372
+ const middle = ((cfg.from + (before + share / 2) * 360) % 360 + 360) % 360;
373
+ el.toggleAttribute('flip', !!text && middle > 90 && middle < 270);
374
+ if (el.textContent !== text) el.textContent = text;
375
+ }
376
+ before += share;
377
+ }
378
+ }
379
+ }
380
+
381
+ _observeSize() {
382
+ if (this._resize || typeof ResizeObserver === 'undefined') { this._resize?.disconnect(); this._resize?.observe(this._box); return; }
383
+ let width = 0;
384
+ this._resize = new ResizeObserver(() => {
385
+ const now = this._box?.clientWidth || 0;
386
+ if (now !== width && !this._tween) { width = now; this._labelArcs(); }
387
+ });
388
+ this._resize.observe(this._box);
389
+ }
390
+
391
+ /* ---- hub, highlight, focus --------------------------------------------- */
392
+
393
+ _paintHub() {
394
+ if (!this._hub) return;
395
+ const root = this._zoom, zoomed = root !== this._cfg.root;
396
+ this._hub.classList.toggle('is-back', zoomed);
397
+ this._hub.style.setProperty('--ok-sb-tint', zoomed ? root.fill : 'transparent');
398
+ this._hub.style.setProperty('--ok-sb-hub-bg', zoomed ? `color-mix(in srgb, ${root.fill} 16%, transparent)` : 'none');
399
+ }
400
+
401
+ /** The hub: the node under the pointer or in focus, else the branch in the middle. */
402
+ _showHub(node = this._hover || (this._focusShown() ? this._focus : null)) {
403
+ if (!this._hubValue || !this._cfg) return;
404
+ const root = this._zoom, cfg = this._cfg;
405
+ const rootName = root.depth ? root.label : (cfg.label ?? (cfg.root.label || 'Total'));
406
+ if (node && node !== root) {
407
+ const trail = [];
408
+ for (let n = node; n && n !== root; n = n.parent) trail.unshift(n.label);
409
+ this._hubPath.textContent = trail.length > 2 ? `… › ${trail.slice(-2).join(' › ')}` : trail.join(' › ');
410
+ this._hubValue.textContent = this._valueText(node.value);
411
+ this._hubShare.textContent = `${this._shareText(node.value, root.value)} of ${rootName}`;
412
+ } else {
413
+ this._hubPath.textContent = root.depth ? `‹ ${rootName}` : rootName;
414
+ this._hubValue.textContent = this._valueText(root.value);
415
+ this._hubShare.textContent = root.depth ? `${this._shareText(root.value, cfg.root.value)} of ${cfg.label ?? (cfg.root.label || 'the total')}` : '';
416
+ }
417
+ this._hubShare.hidden = !this._hubShare.textContent;
418
+ this._hub?.classList.toggle('is-focus', this._focus === root && root.depth > 0);
419
+ }
420
+
421
+ /** Light a branch: the node, its parents and everything under it. */
422
+ _lit(node) {
423
+ this._hover = node;
424
+ if (!this._arcs) return;
425
+ if (!node) delete this.dataset.lit;
426
+ else this.dataset.lit = node.key;
427
+ for (const { el, node: other } of this._arcs) el.classList.toggle('is-lit', !!node && (descendantOf(other, node) || descendantOf(node, other)));
428
+ this._showHub();
429
+ }
430
+
431
+ _setFocus(node) {
432
+ this._focus = node || null;
433
+ for (const { el, node: other } of this._arcs || []) el.classList.toggle('is-focus', other === this._focus);
434
+ const li = this._focus && this._items.get(this._focus.key);
435
+ if (li) this.setAttribute('aria-activedescendant', li.id);
436
+ else this.removeAttribute('aria-activedescendant');
437
+ if (this._focusShown()) this._lit(this._focus === this._zoom ? null : this._focus);
438
+ this._showHub();
439
+ }
440
+
441
+ /** Keyboard focus shows in the chart; a click that focuses the element does not. */
442
+ _focusShown() {
443
+ try { return !!this.matches?.(':focus-visible'); } catch { return !!this.matches?.(':focus'); }
444
+ }
445
+
446
+ _wire() {
447
+ if (this._wired) return;
448
+ this._wired = true;
449
+ this.addEventListener('keydown', this._onKey);
450
+ this.addEventListener('pointerover', this._onOver);
451
+ this.addEventListener('pointerout', this._onOut);
452
+ this.addEventListener('click', this._onClick);
453
+ this.addEventListener('focus', this._onFocus);
454
+ this.addEventListener('blur', this._onBlur);
455
+ }
456
+
457
+ onDisconnected() {
458
+ this._stopTween();
459
+ this._resize?.disconnect();
460
+ this._resize = null;
461
+ }
462
+
463
+ onConnected() { if (this._box && this._resize === null) this._observeSize(); }
464
+
465
+ _nodeAt(event) {
466
+ const arc = event.target?.closest?.('.ok-sb-node');
467
+ return arc && !this._tween ? this._nodes.get(arc.dataset.key) : null;
468
+ }
469
+
470
+ _onOver(event) {
471
+ const node = this._nodeAt(event);
472
+ if (node && node !== this._hover) this._lit(node);
473
+ }
474
+
475
+ _onOut(event) {
476
+ if (!this._hover || this._nodeAt({ target: event.relatedTarget })) return;
477
+ this._lit(this._focusShown() && this._focus !== this._zoom ? this._focus : null);
478
+ }
479
+
480
+ _onClick(event) {
481
+ if (event.target?.closest?.('.ok-sb-hub')) {
482
+ if (this._zoom.parent) this._zoomTo(this._zoom.parent, { focus: this._zoom });
483
+ return;
484
+ }
485
+ const node = this._nodeAt(event);
486
+ if (!node) return;
487
+ this._focus = node;
488
+ this._choose(node);
489
+ }
490
+
491
+ _onFocus() {
492
+ if (!this._focus || !this._isVisible(this._focus)) this._setFocus(this._zoom.children[0] || null);
493
+ else this._setFocus(this._focus);
494
+ }
495
+
496
+ _onBlur() { this._lit(null); }
497
+
498
+ _isVisible(node) {
499
+ const rel = node.depth - this._zoom.depth;
500
+ return node === this._zoom ? node.depth > 0 : descendantOf(node, this._zoom) && rel <= this._cfg.shown;
501
+ }
502
+
503
+ /** Select a node and zoom into it when it is a branch. */
504
+ _choose(node) {
505
+ this._selected = node;
506
+ for (const [key, li] of this._items) li.setAttribute('aria-selected', String(key === node.key));
507
+ for (const { el, node: other } of this._arcs) el.classList.toggle('is-selected', other === node);
508
+ const labels = [];
509
+ for (let n = node; n?.depth; n = n.parent) labels.unshift(n.label);
510
+ const detail = { value: node.value, label: node.label, item: node.item, path: [...node.path], labels, depth: node.depth };
511
+ this.dispatchEvent(new CustomEvent('select', { detail, bubbles: true, composed: true }));
512
+ if (node.children.length && node !== this._zoom) this._zoomTo(node, { focus: node.children[0] });
513
+ else this._setFocus(node);
514
+ }
515
+
516
+ _onKey(event) {
517
+ const node = this._focus || this._zoom.children[0];
518
+ if (!node || !this._cfg) return;
519
+ const siblings = node === this._zoom ? [node] : node.parent.children;
520
+ const i = siblings.indexOf(node);
521
+ let next = null;
522
+ switch (event.key) {
523
+ case 'ArrowRight': next = siblings[(i + 1) % siblings.length]; break;
524
+ case 'ArrowLeft': next = siblings[(i - 1 + siblings.length) % siblings.length]; break;
525
+ case 'Home': next = siblings[0]; break;
526
+ case 'End': next = siblings[siblings.length - 1]; break;
527
+ case 'ArrowUp':
528
+ if (!node.children.length) break;
529
+ // Out of the window: zoom in so the children show.
530
+ if (node !== this._zoom && node.depth - this._zoom.depth >= this._cfg.shown) { event.preventDefault(); this._zoomTo(node, { focus: node.children[0] }); return; }
531
+ next = node.children[0];
532
+ break;
533
+ case 'ArrowDown':
534
+ if (node === this._zoom || !node.parent?.depth) break;
535
+ next = node.parent;
536
+ break;
537
+ case 'Enter': case ' ':
538
+ event.preventDefault();
539
+ if (node === this._zoom) this._zoomTo(node.parent, { focus: node });
540
+ else this._choose(node);
541
+ return;
542
+ case 'Escape': case 'Backspace':
543
+ if (!this._zoom.parent) return;
544
+ event.preventDefault();
545
+ this._zoomTo(this._zoom.parent, { focus: this._zoom });
546
+ return;
547
+ default: return;
548
+ }
549
+ event.preventDefault();
550
+ if (next) this._setFocus(next);
551
+ }
552
+
553
+ /* ---- API ---------------------------------------------------------------- */
554
+
555
+ /** Index path of the branch in the middle ([] at the top). */
556
+ get path() { return [...(this._zoom?.path || [])]; }
557
+
558
+ /** Zoom into the branch at an index path, e.g. [0, 2]. */
559
+ zoom(path = []) {
560
+ if (!this._cfg) return;
561
+ const key = Array.isArray(path) ? path.join('.') : String(path);
562
+ this._zoomTo(key ? this._nodes.get(key) : this._cfg.root);
563
+ }
564
+
565
+ zoomOut() { if (this._zoom?.parent) this._zoomTo(this._zoom.parent); }
566
+ reset() { if (this._cfg) this._zoomTo(this._cfg.root); }
567
+ }
568
+
569
+ define('o-sunburst', OrbitSunburst);