@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.
package/src/o-wheel.js ADDED
@@ -0,0 +1,436 @@
1
+ /**
2
+ * <o-wheel>: a prize wheel. Segments are sized by their weight, the wheel
3
+ * spins with a long ease-out and lands on a result picked at random by weight,
4
+ * or on the one you ask for with spin(index).
5
+ *
6
+ * .orbit-11 > o-arc.ok-wheel-pointer… (outer system, fixed)
7
+ * .bigbang.ok-wheel-rotor a universe inside the universe: it turns
8
+ * .orbit-12 > o-arc rim
9
+ * .orbit-11 > o-arc[value] × n segments (stacked by Orbit)
10
+ * .orbit-7 > .satellite one ring per label, at the segment's angle
11
+ * .orbit-12 > .satellite one ring per peg, at each boundary
12
+ * .orbit-11 > .satellite the pointer, at 12 o'clock
13
+ * .orbit-0 > .satellite.at-center the Spin button
14
+ *
15
+ * The rotor turns with the Web Animations API, so Orbit has nothing to redraw
16
+ * while it spins. Methods: spin(index?) returns a Promise with the result.
17
+ * Properties: items, spinning, result. Events (bubbling): spin, tick, result.
18
+ */
19
+ import { OrbitKitElement, define, injectStyleOnce, universe, hub, fixed, finiteNumber, cssValue, escapeHTML as e } from './base.js';
20
+ import { contrastText } from './color.js';
21
+
22
+ /** The demos' accents: all light, so labels are dark and read well. */
23
+ export const WHEEL_PALETTE = ['#4fd1c5', '#ffb547', '#ff7a6b', '#a78bfa', '#60a5fa', '#a3e635'];
24
+ const MAX_ITEMS = 100;
25
+ const ROTOR = 0.92; // the wheel is 92% of the universe, so the pointer fits inside it
26
+
27
+ const WHEEL_CSS = `
28
+ o-wheel { -webkit-tap-highlight-color: transparent; }
29
+ o-wheel :where(.satellite) { border: none; background: transparent; }
30
+ o-wheel > .bigbang > .gravity-spot { font-size: var(--ok-font-size, 8cqmin); color: var(--ok-text, inherit); }
31
+ o-wheel .ok-wheel-rotor { --o-force-ratio: ${ROTOR}; }
32
+ o-wheel .ok-wheel-rotor > .gravity-spot { font-size: 8cqmin; }
33
+ o-wheel .ok-wheel-rim { --o-fill: var(--ok-wheel-rim, var(--ok-bezel, #232b35)); --o-stroke: var(--o-fill); }
34
+ o-wheel .ok-wheel-bed { --o-fill: var(--ok-wheel-rim, var(--ok-bezel, #232b35)); --o-stroke: var(--o-fill); }
35
+ o-wheel .ok-wheel-seg { --o-stroke: var(--o-fill); transition: opacity .3s; }
36
+ o-wheel .ok-wheel-text { --o-fill: transparent; --o-stroke: transparent; font-weight: 650; transition: opacity .3s; }
37
+ o-wheel .ok-wheel-label { --o-transform-custom: rotate(var(--ok-wheel-turn, 0deg)); }
38
+ o-wheel .ok-wheel-label.is-flipped { --o-transform-custom: rotate(calc(var(--ok-wheel-turn, 0deg) + 180deg)); }
39
+ o-wheel .ok-wheel-label.is-flipped span { text-align: start; }
40
+ o-wheel .ok-wheel-label span {
41
+ display: block; width: 100%; padding-inline: .2em; text-align: end; font-weight: 650; line-height: 1.1;
42
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .3s;
43
+ }
44
+ o-wheel .ok-wheel-peg { background: var(--ok-wheel-peg, var(--ok-hand, #e8edf2)); box-shadow: 0 0 0 .5px rgba(0,0,0,.35); }
45
+ o-wheel.has-result .ok-wheel-seg:not(.is-winner),
46
+ o-wheel.has-result .ok-wheel-text:not(.is-winner),
47
+ o-wheel.has-result .ok-wheel-label:not(.is-winner) span { opacity: .32; }
48
+ o-wheel .ok-wheel-pointer { width: 100%; height: 100%; overflow: visible; transform-origin: 50% 30%; filter: drop-shadow(0 .04em .08em rgba(0,0,0,.5)); }
49
+ o-wheel .ok-wheel-pointer path { fill: var(--ok-wheel-pointer, var(--ok-hand, #e8edf2)); }
50
+ o-wheel .ok-wheel-pointer circle { fill: var(--ok-wheel-rim, var(--ok-bezel, #232b35)); }
51
+ o-wheel .ok-wheel-hub { display: grid; place-items: center; }
52
+ o-wheel .ok-wheel-spin, o-wheel .ok-wheel-cap {
53
+ font: inherit; font-size: .62em; font-weight: 700; line-height: 1; letter-spacing: .01em;
54
+ width: 100%; aspect-ratio: 1; padding: 0; margin: 0; border-radius: 50%; display: grid; place-items: center;
55
+ color: var(--ok-wheel-hub-text, var(--ok-text, #e8edf2)); background: var(--ok-wheel-hub, var(--ok-surface, #161b22));
56
+ border: .09em solid var(--ok-wheel-rim, var(--ok-bezel, #232b35));
57
+ box-shadow: 0 .06em .3em rgba(0,0,0,.45);
58
+ }
59
+ o-wheel .ok-wheel-spin { cursor: pointer; transition: scale .15s, filter .15s; }
60
+ o-wheel .ok-wheel-spin:hover { filter: brightness(1.25); background: var(--ok-wheel-hub, var(--ok-surface, #161b22)); }
61
+ o-wheel .ok-wheel-spin:active { scale: .95; }
62
+ o-wheel .ok-wheel-spin[aria-disabled="true"] { cursor: default; filter: none; scale: 1; }
63
+ o-wheel .ok-wheel-spin:focus-visible { outline: 2px solid var(--ok-focus, #3da9fc); outline-offset: 2px; }
64
+ o-wheel.is-spinning .ok-wheel-spin { color: color-mix(in srgb, var(--ok-wheel-hub-text, var(--ok-text, #e8edf2)) 55%, transparent); }
65
+ o-wheel[data-disabled] > .bigbang { opacity: .5; }
66
+ o-wheel:not([data-disabled]):not(.is-spinning) > .bigbang { cursor: pointer; }
67
+ o-wheel .ok-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
68
+ @media (prefers-reduced-motion: reduce) { o-wheel .ok-wheel-seg, o-wheel .ok-wheel-label span, o-wheel .ok-wheel-spin { transition: none; } }
69
+ `;
70
+
71
+ const POINTER_SVG = '<svg class="ok-wheel-pointer" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 99 29 45a23 23 0 1 1 42 0z"/><circle cx="50" cy="31" r="8"/></svg>';
72
+
73
+ /** "5", "5s" or "4500ms" → seconds. */
74
+ export function parseSeconds(value, fallback) {
75
+ const text = String(value ?? '').trim().toLowerCase();
76
+ const m = text.match(/^(\d+(?:\.\d+)?)(ms|s)?$/);
77
+ if (!m) return fallback;
78
+ return m[2] === 'ms' ? Number(m[1]) / 1000 : Number(m[1]);
79
+ }
80
+
81
+ /** Items from JSON: strings or { label, color?, weight?, value? }; weight ≤ 0 leaves an item out. */
82
+ export function normalizeItems(list) {
83
+ if (!Array.isArray(list)) return [];
84
+ const items = [];
85
+ for (const raw of list) {
86
+ if (items.length >= MAX_ITEMS) break;
87
+ const item = typeof raw === 'string' || typeof raw === 'number' ? { label: String(raw) } : raw;
88
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
89
+ const weight = item.weight == null ? 1 : finiteNumber(item.weight, 1);
90
+ if (!(weight > 0) || !Number.isFinite(weight)) continue;
91
+ items.push({ label: item.label == null ? `Item ${items.length + 1}` : String(item.label), color: cssValue(item.color, null), weight, value: item.value, item: raw });
92
+ }
93
+ // Default colors never put the same color side by side, also across the seam.
94
+ items.forEach((item, i) => {
95
+ if (item.color) return;
96
+ const before = items[i - 1]?.color, first = i === items.length - 1 && i > 0 ? items[0].color : null;
97
+ let k = i % WHEEL_PALETTE.length;
98
+ while ((WHEEL_PALETTE[k] === before || WHEEL_PALETTE[k] === first) && items.length > 2) k = (k + 1) % WHEEL_PALETTE.length;
99
+ item.color = WHEEL_PALETTE[k];
100
+ item.auto = true;
101
+ });
102
+ return items;
103
+ }
104
+
105
+ /** Index of the segment that holds `angle` (degrees clockwise from the segment start, 0 to 360). */
106
+ export function segmentAt(angle, bounds) {
107
+ const a = ((angle % 360) + 360) % 360;
108
+ for (let i = 0; i < bounds.length; i++) if (a < bounds[i].end) return i;
109
+ return bounds.length - 1;
110
+ }
111
+
112
+ /** A fair pick by weight. `random` is a number in [0, 1). */
113
+ export function pickByWeight(items, random) {
114
+ const total = items.reduce((n, item) => n + item.weight, 0);
115
+ let r = random * total;
116
+ for (let i = 0; i < items.length; i++) { r -= items[i].weight; if (r < 0) return i; }
117
+ return items.length - 1;
118
+ }
119
+
120
+ /** Uniform in [0, 1), from crypto when there is one. */
121
+ function random() {
122
+ try {
123
+ const buf = new Uint32Array(1);
124
+ globalThis.crypto.getRandomValues(buf);
125
+ return buf[0] / 2 ** 32;
126
+ } catch { return Math.random(); }
127
+ }
128
+
129
+ /** Ease-out with a long tail: fast at first, a slow last turn. */
130
+ export const easeOut = (t) => 1 - (1 - t) ** 4;
131
+
132
+ /** Rough width of a label in ems, for fitting text along an arc without a browser. */
133
+ let measureCtx;
134
+ function textEms(text, font) {
135
+ try {
136
+ measureCtx ??= typeof document !== 'undefined' ? document.createElement('canvas').getContext('2d') : null;
137
+ if (measureCtx) { measureCtx.font = `650 100px ${font || 'system-ui, sans-serif'}`; return measureCtx.measureText(text).width / 100; }
138
+ } catch { /* fall through */ }
139
+ return text.length * 0.56;
140
+ }
141
+
142
+ export class OrbitWheel extends OrbitKitElement {
143
+ static get observedAttributes() {
144
+ return ['items', 'size', 'labels', 'duration', 'turns', 'gap', 'controls', 'label', 'remove-winner', 'disabled', 'pegs'];
145
+ }
146
+
147
+ constructor() {
148
+ super();
149
+ this._angle = null;
150
+ this._onClick = this._onClick.bind(this);
151
+ this._onKey = this._onKey.bind(this);
152
+ }
153
+
154
+ readConfig() {
155
+ const items = normalizeItems(this._json('items', []));
156
+ const total = items.reduce((n, item) => n + item.weight, 0) || 1;
157
+ let start = 0;
158
+ const bounds = items.map((item) => {
159
+ const span = item.weight / total * 360;
160
+ const b = { start, end: start + span, span, mid: start + span / 2 };
161
+ start += span;
162
+ return b;
163
+ });
164
+ if (bounds.length) bounds[bounds.length - 1].end = 360;
165
+ const labels = this._str('labels', 'across');
166
+ return {
167
+ items, bounds, size: this._size('size', '300px'),
168
+ labels: ['across', 'along', 'none'].includes(labels) ? labels : 'across',
169
+ duration: Math.min(30, Math.max(.2, parseSeconds(this.getAttribute('duration'), 5))),
170
+ turns: Math.min(40, Math.max(0, Math.round(this._num('turns', 5)))),
171
+ gap: this._gap(.4), controls: this._str('controls', 'true') !== 'false',
172
+ label: this._str('label', 'Spin'), removeWinner: this.hasAttribute('remove-winner') && this.getAttribute('remove-winner') !== 'false',
173
+ pegs: this.hasAttribute('pegs') ? this.getAttribute('pegs') !== 'false' : items.length <= 36,
174
+ disabled: this.hasAttribute('disabled') && this.getAttribute('disabled') !== 'false'
175
+ };
176
+ }
177
+
178
+ structuralSignature({ duration, turns, label, removeWinner, disabled, ...rest }) {
179
+ return JSON.stringify({ ...rest, items: rest.items.map(({ item, ...i }) => i) });
180
+ }
181
+
182
+ /** Label size in rotor lanes: as big as the narrowest point of the segment allows. */
183
+ _labelLanes(span, radius) {
184
+ const chord = 2 * radius * Math.sin(Math.min(180, span) * Math.PI / 360);
185
+ return Math.min(1.15, Math.max(.32, chord * .42));
186
+ }
187
+
188
+ build(cfg) {
189
+ injectStyleOnce('orbit-kit-wheel', WHEEL_CSS);
190
+ const gap = `--o-gap:${fixed(cfg.items.length > 1 ? cfg.gap * 6 : 0)}`;
191
+ const font = typeof getComputedStyle === 'function' && this.isConnected ? getComputedStyle(this).fontFamily : '';
192
+ // A rotor lane is a twelfth of its radius; 1em of the rotor is 8% of its width, 1.92 lanes.
193
+ const em = (lanes) => fixed(lanes / 1.92, 3);
194
+ const segments = cfg.items.map((item, i) =>
195
+ `<o-arc class="inner-orbit ok-wheel-seg" data-index="${i}" value="${fixed(cfg.bounds[i].span / 3.6, 4)}" shape="none" style="--o-fill:${e(item.color)}"></o-arc>`).join('');
196
+ let labels = '';
197
+ if (cfg.labels === 'across') {
198
+ labels = cfg.items.map((item, i) => {
199
+ const b = cfg.bounds[i];
200
+ // Shrink a long label (down to 60%) before it gets an ellipsis; 6.6 lanes of room.
201
+ const most = this._labelLanes(b.span, 6.5);
202
+ const size = Math.max(most * .6, Math.min(most, 6.6 / Math.max(.1, textEms(item.label, font))));
203
+ return `<div class="orbit-7" style="--o-from:${fixed(b.mid)}deg"><div class="satellite ok-wheel-label" data-index="${i}" data-mid="${fixed(b.mid)}" style="--o-size-ratio:7;--o-alignment:calc(var(--o-radius) / 7 * .25);--ok-wheel-turn:${fixed(b.mid - 90)}deg"><div class="capsule"><span style="font-size:${em(size)}em;color:${contrastText(item.color)}">${e(item.label)}</span></div></div></div>`;
204
+ }).join('');
205
+ } else if (cfg.labels === 'along') {
206
+ const arcs = cfg.items.map((item, i) => {
207
+ const b = cfg.bounds[i];
208
+ // Fit the text to 85% of its arc (radius 9 lanes): smaller type first (down
209
+ // to 65%), then an ellipsis, so nothing is cut.
210
+ const arc = 9 * b.span * Math.PI / 180 * .85;
211
+ const most = Math.min(1.1, this._labelLanes(b.span, 9) * 1.2);
212
+ const lanes = Math.max(most * .65, Math.min(most, arc / Math.max(.1, textEms(item.label, font))));
213
+ const room = arc / lanes;
214
+ let text = item.label;
215
+ if (textEms(text, font) > room) {
216
+ while (text.length > 1 && textEms(`${text}…`, font) > room) text = text.slice(0, -1);
217
+ text = text.length > 1 ? `${text.trimEnd()}…` : '';
218
+ }
219
+ return `<o-arc class="inner-orbit ok-wheel-text" data-index="${i}" data-mid="${fixed(b.mid)}" value="${fixed(b.span / 3.6, 4)}" style="font-size:${em(lanes)}em;--o-color:${contrastText(item.color)}">${e(text)}</o-arc>`;
220
+ }).join('');
221
+ labels = `<div class="orbit-10" style="--o-size-ratio:2.4;${gap}">${arcs}</div>`;
222
+ }
223
+ const pegs = cfg.pegs && cfg.items.length > 1 ? cfg.bounds.map((b) =>
224
+ `<div class="orbit-12" style="--o-from:${fixed(b.start)}deg"><div class="satellite ok-wheel-peg" style="--o-size-ratio:.42;--o-alignment:calc(var(--o-radius) / 12 * .58)"></div></div>`).join('') : '';
225
+ if (this._angle == null) this._angle = cfg.bounds.length ? -cfg.bounds[0].mid : 0;
226
+ const center = cfg.controls
227
+ ? `<button type="button" class="ok-wheel-spin ok-surface">${e(cfg.label)}</button>`
228
+ : '<span class="ok-wheel-cap ok-surface" aria-hidden="true"></span>';
229
+ this.innerHTML = universe(cfg.size, `
230
+ <div class="bigbang ok-wheel-rotor" aria-hidden="true" style="rotate:${fixed(this._angle)}deg">
231
+ <div class="gravity-spot">
232
+ <div class="orbit-12" style="--o-size-ratio:1.2;--o-gap:0"><o-arc class="inner-orbit ok-wheel-rim" value="100"></o-arc></div>
233
+ <div class="orbit-11" style="--o-size-ratio:8.7;--o-gap:0"><o-arc class="inner-orbit ok-wheel-bed" value="100"></o-arc></div>
234
+ <div class="orbit-11" style="--o-size-ratio:8.7;${gap}">${segments}</div>
235
+ ${labels}${pegs}
236
+ </div>
237
+ </div>
238
+ <div class="orbit-11" style="--o-size-ratio:2.5"><div class="satellite" style="--o-alignment:calc(var(--o-radius) / 11 * .3)"><div class="capsule">${POINTER_SVG}</div></div></div>
239
+ ${hub(5.3, center, 'ok-wheel-hub')}
240
+ <span class="ok-sr" aria-live="polite"></span>`);
241
+ this._rotor = this.querySelector('.ok-wheel-rotor');
242
+ this._pointer = this.querySelector('.ok-wheel-pointer');
243
+ this._button = this.querySelector('.ok-wheel-spin');
244
+ this._live = this.querySelector('.ok-sr');
245
+ this._built = true;
246
+ this._showWinner(null);
247
+ this._orient();
248
+ }
249
+
250
+ sync(cfg) {
251
+ this._cfg = cfg;
252
+ if (this._button) {
253
+ this._button.textContent = cfg.label;
254
+ this._button.setAttribute('aria-disabled', String(cfg.disabled || !!this._spinning || !cfg.items.length));
255
+ }
256
+ this.toggleAttribute?.('data-disabled', cfg.disabled);
257
+ const names = cfg.items.map((item) => item.label);
258
+ const list = names.length > 12 ? `${names.slice(0, 12).join(', ')} and ${names.length - 12} more` : names.join(', ');
259
+ if (cfg.controls) {
260
+ if (this._autoTab) { this.removeAttribute('tabindex'); this._autoTab = false; }
261
+ this._describe(names.length ? `Wheel: ${list}` : 'Empty wheel', 'group');
262
+ } else {
263
+ // Without the button the wheel itself is the button.
264
+ if (!this.hasAttribute('tabindex') || this._autoTab) { this.tabIndex = cfg.disabled ? -1 : 0; this._autoTab = true; }
265
+ this._describe(names.length ? `Spin the wheel: ${list}` : 'Empty wheel', 'button');
266
+ this.setAttribute('aria-disabled', String(cfg.disabled));
267
+ }
268
+ }
269
+
270
+ onConnected() {
271
+ this.addEventListener('click', this._onClick);
272
+ this.addEventListener('keydown', this._onKey);
273
+ }
274
+
275
+ onDisconnected() {
276
+ this.removeEventListener('click', this._onClick);
277
+ this.removeEventListener('keydown', this._onKey);
278
+ if (this._spinning) this._finish(true);
279
+ }
280
+
281
+ connectedCallback() {
282
+ // `items` set by a page script before the element was defined.
283
+ if (Object.hasOwn(this, 'items')) { const list = this.items; delete this.items; this.items = list; }
284
+ super.connectedCallback();
285
+ }
286
+
287
+ attributeChangedCallback(name, oldValue, newValue) {
288
+ // New items from outside cancel a winner waiting to be removed.
289
+ if (name === 'items' && !this._removing) this._pendingRemoval = null;
290
+ super.attributeChangedCallback(name, oldValue, newValue);
291
+ }
292
+
293
+ get items() { return this.readConfig().items.map((i) => i.item); }
294
+ set items(list) { this.setAttribute('items', JSON.stringify(Array.isArray(list) ? list : [])); }
295
+ get spinning() { return !!this._spinning; }
296
+ /** The last result: { index, label, value, item }, or null. */
297
+ get result() { return this._result ?? null; }
298
+
299
+ /** The button, or anywhere on the wheel itself (not the corners of its box). */
300
+ _onClick(event) {
301
+ if (event.target?.closest?.('.ok-wheel-spin')) { this.spin(); return; }
302
+ const box = this.querySelector('.bigbang')?.getBoundingClientRect?.();
303
+ if (!box || !box.width) return;
304
+ const dx = event.clientX - (box.left + box.width / 2), dy = event.clientY - (box.top + box.height / 2);
305
+ if (Math.hypot(dx, dy) <= Math.min(box.width, box.height) / 2 * ROTOR) this.spin();
306
+ }
307
+
308
+ _onKey(event) {
309
+ if (event.target !== this || (event.key !== 'Enter' && event.key !== ' ')) return;
310
+ event.preventDefault();
311
+ this.spin();
312
+ }
313
+
314
+ _reduced() {
315
+ try { return !!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
316
+ }
317
+
318
+ /**
319
+ * Spin. Without an index the result is picked at random, weighted; with one,
320
+ * the wheel lands on that item. Resolves with the result, or null when the
321
+ * wheel is empty or disabled. While spinning it returns the same promise.
322
+ */
323
+ spin(index) {
324
+ if (this._spinning) return this._spinning.promise;
325
+ if (this._pendingRemoval != null) {
326
+ const keep = this._pendingRemoval;
327
+ this._pendingRemoval = null;
328
+ const list = this.items;
329
+ list.splice(keep, 1);
330
+ this._removing = true;
331
+ try { this.items = list; } finally { this._removing = false; }
332
+ }
333
+ const cfg = this.readConfig();
334
+ if (cfg.disabled || !cfg.items.length) return Promise.resolve(null);
335
+ if (!this._built || this._sig !== this.structuralSignature(cfg)) this._render();
336
+ const forced = index != null && Number.isInteger(Number(index)) && Number(index) >= 0 && Number(index) < cfg.items.length;
337
+ const target = forced ? Number(index) : pickByWeight(cfg.items, random());
338
+ const b = cfg.bounds[target];
339
+ // Land somewhere inside the segment, never on a boundary.
340
+ const landing = b.start + b.span * (.15 + .7 * random());
341
+ const reduced = this._reduced();
342
+ const from = this._angle ?? 0;
343
+ let delta = (((-landing - from) % 360) + 360) % 360;
344
+ if (reduced && delta < 30) delta += 360;
345
+ delta += (reduced ? 0 : cfg.turns) * 360;
346
+ const duration = (reduced ? Math.min(cfg.duration, .7) : cfg.duration) * 1000;
347
+ this._showWinner(null);
348
+ if (this._live) this._live.textContent = '';
349
+ let resolve;
350
+ const promise = new Promise((r) => { resolve = r; });
351
+ this._spinning = { promise, resolve, target, from, delta, duration, reduced, cfg, last: segmentAt(-from, cfg.bounds), start: performance.now() };
352
+ this.classList.add('is-spinning');
353
+ this.setAttribute('aria-busy', 'true');
354
+ this.sync(cfg);
355
+ this.dispatchEvent(new CustomEvent('spin', { detail: { duration: duration / 1000 }, bubbles: true, composed: true }));
356
+ // The rotor turns through sampled keyframes of the same curve the ticks follow.
357
+ if (this._rotor?.animate) {
358
+ const frames = Array.from({ length: 61 }, (_, k) => ({ offset: k / 60, rotate: `${fixed(from + delta * easeOut(k / 60))}deg` }));
359
+ this._anim = this._rotor.animate(frames, { duration, easing: 'linear', fill: 'forwards' });
360
+ }
361
+ this._raf = requestAnimationFrame(() => this._frame());
362
+ return promise;
363
+ }
364
+
365
+ _frame() {
366
+ const s = this._spinning;
367
+ if (!s) return;
368
+ const elapsed = Number(this._anim?.currentTime ?? performance.now() - s.start);
369
+ const t = Math.min(1, elapsed / s.duration);
370
+ const angle = s.from + s.delta * easeOut(t);
371
+ if (!this._anim && this._rotor) this._rotor.style.rotate = `${fixed(angle)}deg`;
372
+ const under = segmentAt(-angle, s.cfg.bounds);
373
+ if (under !== s.last) {
374
+ s.last = under;
375
+ const speed = s.delta * 4 * (1 - t) ** 3 / s.duration; // degrees per ms
376
+ if (!s.reduced) this._wobble(speed);
377
+ this.dispatchEvent(new CustomEvent('tick', { detail: { index: under, label: s.cfg.items[under]?.label }, bubbles: true, composed: true }));
378
+ }
379
+ if (t >= 1) { this._finish(); return; }
380
+ this._raf = requestAnimationFrame(() => this._frame());
381
+ }
382
+
383
+ /** The pointer flicks back as a peg passes, and springs home. */
384
+ _wobble(speed) {
385
+ if (!this._pointer?.animate) return;
386
+ const now = performance.now();
387
+ if (now - (this._lastWobble || 0) < 28) return;
388
+ this._lastWobble = now;
389
+ this._wobbleAnim?.cancel();
390
+ const kick = 10 + 10 * Math.min(1, speed);
391
+ this._wobbleAnim = this._pointer.animate(
392
+ [{ rotate: '0deg' }, { rotate: `${fixed(-kick)}deg`, offset: .25 }, { rotate: `${fixed(kick * .18)}deg`, offset: .7 }, { rotate: '0deg' }],
393
+ { duration: 260, easing: 'ease-out' });
394
+ }
395
+
396
+ _finish(silent = false) {
397
+ const s = this._spinning;
398
+ if (!s) return;
399
+ cancelAnimationFrame(this._raf);
400
+ this._angle = ((s.from + s.delta) % 360 + 360) % 360;
401
+ // One style write at the end; the animation is dropped after it.
402
+ if (this._rotor) this._rotor.style.rotate = `${fixed(this._angle)}deg`;
403
+ this._anim?.cancel();
404
+ this._anim = null;
405
+ this._orient();
406
+ this._spinning = null;
407
+ this.classList.remove('is-spinning');
408
+ this.removeAttribute('aria-busy');
409
+ const item = s.cfg.items[s.target];
410
+ const detail = { index: s.target, label: item.label, value: item.value, item: item.item };
411
+ this._result = detail;
412
+ this.sync(this.readConfig());
413
+ if (silent) { s.resolve(detail); return; }
414
+ this._showWinner(s.target);
415
+ if (this._live) this._live.textContent = `Result: ${item.label}`;
416
+ if (s.cfg.removeWinner) this._pendingRemoval = s.target;
417
+ this.dispatchEvent(new CustomEvent('result', { detail, bubbles: true, composed: true }));
418
+ s.resolve(detail);
419
+ }
420
+
421
+ /** Labels on the left half of the wheel at rest turn over, so they read upright. */
422
+ _orient() {
423
+ for (const el of this.querySelectorAll('[data-mid]')) {
424
+ const at = ((Number(el.dataset.mid) + (this._angle ?? 0)) % 360 + 360) % 360;
425
+ if (el.localName === 'o-arc') el.toggleAttribute('flip', at > 90 && at < 270);
426
+ else el.classList.toggle('is-flipped', at > 180);
427
+ }
428
+ }
429
+
430
+ _showWinner(index) {
431
+ this.classList.toggle('has-result', index != null);
432
+ for (const el of this.querySelectorAll('[data-index]')) el.classList.toggle('is-winner', index != null && Number(el.dataset.index) === index);
433
+ }
434
+ }
435
+
436
+ define('o-wheel', OrbitWheel);
package/src/orbit-kit.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * @zumer/orbit-kit — high-level components on top of the Orbit primitives.
2
+ * @zumer/orbit-kit: ready-made components on top of Orbit 2.
3
3
  *
4
- * Importing this entry registers every kit component. The Orbit core
5
- * (CSS engine + base web components like <o-progress> / <o-arc>) must be
6
- * present on the page — it's declared as a peer dependency:
4
+ * Importing this entry registers every kit component. Orbit itself (its CSS
5
+ * and the <o-arc> / <o-progress> elements) must be on the page; it is a peer
6
+ * dependency:
7
7
  *
8
8
  * import '@zumer/orbit/style' // styles (or load orbit.css separately)
9
9
  * import '@zumer/orbit' // registers <o-progress> / <o-arc>
10
- * import '@zumer/orbit-kit' // registers <o-gauge>, <o-donut>, ...
10
+ * import '@zumer/orbit-kit' // registers <o-gauge>, <o-donut>, …
11
11
  */
12
12
 
13
13
  import './o-gauge.js';
@@ -20,6 +20,15 @@ import './o-compass.js';
20
20
  import './o-radar.js';
21
21
  import './o-clock.js';
22
22
  import './o-instrument.js';
23
+ import './o-timer.js';
24
+ import './o-status.js';
25
+ import './o-range.js';
26
+ import './o-time-picker.js';
27
+ import './o-sunburst.js';
28
+ import './o-wheel.js';
29
+ import './o-color-wheel.js';
30
+ import './o-spinner.js';
31
+ import './o-radar-chart.js';
23
32
 
24
33
  export { OrbitGauge } from './o-gauge.js';
25
34
  export { OrbitDonut } from './o-donut.js';
@@ -31,3 +40,12 @@ export { OrbitCompass } from './o-compass.js';
31
40
  export { OrbitRadar } from './o-radar.js';
32
41
  export { OrbitClock } from './o-clock.js';
33
42
  export { OrbitInstrument } from './o-instrument.js';
43
+ export { OrbitTimer } from './o-timer.js';
44
+ export { OrbitStatus } from './o-status.js';
45
+ export { OrbitSpinner } from './o-spinner.js';
46
+ export { OrbitRadarChart } from './o-radar-chart.js';
47
+ export { OrbitWheel } from './o-wheel.js';
48
+ export { OrbitColorWheel } from './o-color-wheel.js';
49
+ export { OrbitRange } from './o-range.js';
50
+ export { OrbitTimePicker } from './o-time-picker.js';
51
+ export { OrbitSunburst } from './o-sunburst.js';