@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.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * <o-color-wheel>: a circular color picker. A hue ring around a
3
+ * saturation / brightness square, with an optional ring of swatches.
4
+ *
5
+ * .orbit-0 > .satellite.at-center > .ok-cw-ring the hue ring (a conic gradient)
6
+ * .orbit-12 > .satellite hue handle, at the hue's angle
7
+ * .orbit-11 > .satellite > button × n swatches, spread by Orbit
8
+ * .orbit-0 > .satellite.at-center > .ok-cw-area saturation (x) and brightness (y)
9
+ *
10
+ * The hue handle is one satellite on a ring whose --o-from is the hue, so
11
+ * Orbit places it. It is a form control (FormControl): `name`, `disabled`,
12
+ * form reset/restore; it submits the color as hex, rgb() or hsl() (`format`).
13
+ * Emits `input` while changing and `change` when a gesture commits.
14
+ */
15
+ import { OrbitKitElement, FormControl, define, injectStyleOnce, universe, hub, fixed, escapeHTML as e, FORM_ATTRS, withShared } from './base.js';
16
+ import { parseColor, rgbToHex, hsvToRgb, rgbToHsv, colorName, formatColor } from './color.js';
17
+
18
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
19
+ const FORMATS = ['hex', 'rgb', 'hsl'];
20
+
21
+ const CW_CSS = `
22
+ o-color-wheel { -webkit-tap-highlight-color: transparent; }
23
+ o-color-wheel :where(.satellite) { border: none; background: transparent; }
24
+ o-color-wheel > .bigbang > .gravity-spot { font-size: var(--ok-font-size, 8cqmin); color: var(--ok-text, inherit); }
25
+ o-color-wheel .ok-cw-ring {
26
+ width: 100%; aspect-ratio: 1; border-radius: 50%;
27
+ background: conic-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
28
+ -webkit-mask: radial-gradient(closest-side, #0000 calc(var(--ok-cw-hole) - .5px), #000 var(--ok-cw-hole), #000 calc(100% - .75px), #0000 100%);
29
+ mask: radial-gradient(closest-side, #0000 calc(var(--ok-cw-hole) - .5px), #000 var(--ok-cw-hole), #000 calc(100% - .75px), #0000 100%);
30
+ }
31
+ o-color-wheel .ok-cw-area {
32
+ position: relative; width: 100%; aspect-ratio: 1; border-radius: var(--ok-cw-radius, .3em); touch-action: none;
33
+ background: linear-gradient(to top, #000, #0000), linear-gradient(to right, #fff, hsl(var(--ok-cw-hue, 0) 100% 50%));
34
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
35
+ }
36
+ o-color-wheel .ok-cw-thumb {
37
+ box-sizing: border-box; display: block; border-radius: 50%; background: var(--ok-cw-color, #fff);
38
+ border: .1em solid var(--ok-cw-thumb-border, #fff); box-shadow: 0 0 0 1px rgba(0,0,0,.35), 0 .05em .25em rgba(0,0,0,.45);
39
+ outline: none; cursor: grab; touch-action: none;
40
+ }
41
+ o-color-wheel .ok-cw-hue-thumb { width: 100%; aspect-ratio: 1; }
42
+ o-color-wheel .ok-cw-area-thumb { position: absolute; width: .82em; aspect-ratio: 1; translate: -50% -50%; }
43
+ o-color-wheel .ok-cw-thumb:focus-visible,
44
+ o-color-wheel .ok-cw-swatch:focus-visible { outline: 2px solid var(--ok-focus, #3da9fc); outline-offset: 2px; }
45
+ o-color-wheel .ok-cw-swatch {
46
+ font: inherit; display: block; width: 100%; aspect-ratio: 1; padding: 0; margin: 0; border-radius: 50%; cursor: pointer;
47
+ background: var(--ok-cw-swatch); border: .07em solid var(--ok-cw-swatch-border, rgba(255,255,255,.14));
48
+ transition: scale .15s;
49
+ }
50
+ o-color-wheel .ok-cw-swatch:hover { scale: 1.12; background: var(--ok-cw-swatch); }
51
+ o-color-wheel .ok-cw-swatch[aria-pressed="true"] { border-color: var(--ok-cw-thumb-border, #fff); box-shadow: 0 0 0 .07em rgba(0,0,0,.4); }
52
+ o-color-wheel[data-disabled] > .bigbang { opacity: .5; }
53
+ o-color-wheel[data-disabled] :is(.ok-cw-thumb, .ok-cw-swatch) { cursor: default; }
54
+ @media (prefers-reduced-motion: reduce) { o-color-wheel .ok-cw-swatch { transition: none; } }
55
+ `;
56
+
57
+ /** "#1e90ff" for any parsable color, else null. */
58
+ export const toHex = (value) => { const rgb = parseColor(value); return rgb ? rgbToHex(rgb) : null; };
59
+
60
+ export class OrbitColorWheel extends FormControl(OrbitKitElement) {
61
+ static get observedAttributes() {
62
+ return withShared(['value', 'size', 'format', 'swatches', 'label', 'thickness'], FORM_ATTRS);
63
+ }
64
+
65
+ constructor() {
66
+ super();
67
+ for (const name of ['_onDown', '_onMove', '_onUp', '_onKey']) this[name] = this[name].bind(this);
68
+ }
69
+
70
+ readConfig() {
71
+ const swatches = (Array.isArray(this._json('swatches', [])) ? this._json('swatches', []) : []).slice(0, 36).map((s) => {
72
+ const item = typeof s === 'string' ? { color: s } : (s && typeof s === 'object' ? s : {});
73
+ const hex = toHex(item.color);
74
+ return hex ? { hex, label: item.label == null ? null : String(item.label) } : null;
75
+ }).filter(Boolean);
76
+ const format = this._str('format', 'hex');
77
+ const band = clamp(this._num('thickness', swatches.length ? 1.7 : 1.9), .8, 3);
78
+ const outer = swatches.length ? 9.6 : 11.6;
79
+ return {
80
+ size: this._size('size', '240px'), swatches, format: FORMATS.includes(format) ? format : 'hex',
81
+ label: this._str('label', 'Color picker'), disabled: this._isDisabled(),
82
+ outer, band, center: outer - band / 2,
83
+ // The square fits inside the hole with a little air.
84
+ half: (outer - band - .6) / Math.SQRT2,
85
+ value: toHex(this.getAttribute('value')) || '#4fd1c5'
86
+ };
87
+ }
88
+
89
+ structuralSignature({ value, label, disabled, format, ...rest }) { return JSON.stringify(rest); }
90
+
91
+ build(cfg) {
92
+ injectStyleOnce('orbit-kit-color-wheel', CW_CSS);
93
+ const hole = fixed((cfg.outer - cfg.band) / cfg.outer * 100, 2);
94
+ const sw = cfg.swatches.length ? Math.min(1.7, 2 * Math.PI * 11 / cfg.swatches.length * .62) : 0;
95
+ const swatches = cfg.swatches.length ? `<div class="orbit-11 ok-cw-swatches" role="group" aria-label="Swatches" style="--o-size-ratio:${fixed(sw)}">${cfg.swatches.map((s, i) => {
96
+ const name = s.label ?? colorName(s.hex);
97
+ return `<div class="satellite"><div class="capsule"><button type="button" class="ok-cw-swatch" data-index="${i}" data-hex="${s.hex}" tabindex="-1" aria-pressed="false" aria-label="${e(`${name}, ${s.hex}`)}" title="${e(name)}" style="--ok-cw-swatch:${s.hex}"></button></div></div>`;
98
+ }).join('')}</div>` : '';
99
+ this.innerHTML = universe(cfg.size, `
100
+ ${hub(2 * cfg.outer, `<div class="ok-cw-ring" aria-hidden="true" style="--ok-cw-hole:${hole}%"></div>`, 'ok-cw-ring-cap')}
101
+ <div class="orbit-12 ok-cw-hue-ring"><div class="satellite" style="--o-size-ratio:${fixed(cfg.band + .45)};--o-alignment:calc(var(--o-radius) / 12 * ${fixed(12 - cfg.center)})"><div class="capsule"><span class="ok-cw-thumb ok-cw-hue-thumb" role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="359" data-part="hue"></span></div></div></div>
102
+ ${hub(2 * cfg.half, '<div class="ok-cw-area"><span class="ok-cw-thumb ok-cw-area-thumb" role="slider" aria-label="Saturation and brightness" aria-roledescription="2D slider" aria-valuemin="0" aria-valuemax="100" data-part="area"></span></div>', 'ok-cw-area-cap')}
103
+ ${swatches}`);
104
+ this._box = this.querySelector('.bigbang');
105
+ this._hueRing = this.querySelector('.ok-cw-hue-ring');
106
+ this._hueThumb = this.querySelector('.ok-cw-hue-thumb');
107
+ this._area = this.querySelector('.ok-cw-area');
108
+ this._areaThumb = this.querySelector('.ok-cw-area-thumb');
109
+ this._swatches = [...this.querySelectorAll('.ok-cw-swatch')];
110
+ this._painted = null;
111
+ }
112
+
113
+ sync(cfg) {
114
+ this._cfg = cfg;
115
+ // A new value attribute moves the picker; the hue survives grays and black.
116
+ if (!this._hsv || cfg.value !== rgbToHex(hsvToRgb(this._hsv))) this._hsv = this._adopt(cfg.value);
117
+ this._describe(cfg.label, 'group');
118
+ for (const thumb of [this._hueThumb, this._areaThumb]) if (thumb) thumb.tabIndex = cfg.disabled ? -1 : 0;
119
+ this._roving(this._swatches.find((s) => s.tabIndex === 0) || this._swatches[0], cfg.disabled);
120
+ this.style.touchAction = cfg.disabled ? 'auto' : 'none';
121
+ if (cfg.disabled) this._stopDrag();
122
+ this._paint();
123
+ }
124
+
125
+ /** HSV for a hex, keeping the current hue (and saturation) where the hex has none. */
126
+ _adopt(hex) {
127
+ const next = rgbToHsv(parseColor(hex) || { r: 0, g: 0, b: 0 });
128
+ const prev = this._hsv;
129
+ if (prev && (next.s === 0 || next.v === 0)) next.h = prev.h;
130
+ if (prev && next.v === 0) next.s = prev.s;
131
+ return next;
132
+ }
133
+
134
+ _paint() {
135
+ const { h, s, v } = this._hsv;
136
+ const rgb = hsvToRgb(this._hsv);
137
+ const hex = rgbToHex(rgb);
138
+ const key = `${fixed(h, 2)}|${fixed(s, 4)}|${fixed(v, 4)}`;
139
+ if (key === this._painted) return;
140
+ this._painted = key;
141
+ const name = colorName(rgb);
142
+ if (this._hueRing) this._hueRing.style.setProperty('--o-from', `${fixed(h, 2)}deg`);
143
+ if (this._hueThumb) {
144
+ this._hueThumb.style.setProperty('--ok-cw-color', `hsl(${fixed(h, 2)} 100% 50%)`);
145
+ this._hueThumb.setAttribute('aria-valuenow', String(Math.round(h) % 360));
146
+ this._hueThumb.setAttribute('aria-valuetext', `${Math.round(h) % 360} degrees, ${colorName(hsvToRgb({ h, s: 1, v: 1 }))}`);
147
+ }
148
+ if (this._area) this._area.style.setProperty('--ok-cw-hue', fixed(h, 2));
149
+ if (this._areaThumb) {
150
+ this._areaThumb.style.left = `${fixed(s * 100, 2)}%`;
151
+ this._areaThumb.style.top = `${fixed((1 - v) * 100, 2)}%`;
152
+ this._areaThumb.style.setProperty('--ok-cw-color', hex);
153
+ this._areaThumb.setAttribute('aria-valuenow', String(Math.round(s * 100)));
154
+ this._areaThumb.setAttribute('aria-valuetext', `Saturation ${Math.round(s * 100)}%, brightness ${Math.round(v * 100)}%, ${name}, ${hex}`);
155
+ }
156
+ for (const sw of this._swatches) sw.setAttribute('aria-pressed', String(sw.dataset.hex === hex));
157
+ }
158
+
159
+ get value() { return this._hsv ? rgbToHex(hsvToRgb(this._hsv)) : this.readConfig().value; }
160
+ /** Any CSS color; it is stored as hex. Setting it fires no event. */
161
+ set value(color) {
162
+ const hex = toHex(color);
163
+ if (!hex) return;
164
+ if (!this.isConnected) { this.setAttribute('value', hex); return; }
165
+ this._hsv = this._adopt(hex);
166
+ this._reflectValue(hex);
167
+ this._paint();
168
+ this._updateForm();
169
+ }
170
+ get rgb() { return formatColor(parseColor(this.value), 'rgb'); }
171
+ get hsl() { return formatColor(parseColor(this.value), 'hsl'); }
172
+ /** A plain-English name of the color, like "dark blue". */
173
+ get colorName() { return colorName(this.value); }
174
+
175
+ _formValue() {
176
+ const format = this.getAttribute('format');
177
+ return formatColor(parseColor(this.value), FORMATS.includes(format) ? format : 'hex');
178
+ }
179
+
180
+ _formAnchor() { return this._areaThumb || undefined; }
181
+
182
+ onConnected() {
183
+ this.addEventListener('pointerdown', this._onDown);
184
+ this.addEventListener('lostpointercapture', this._onUp);
185
+ this.addEventListener('keydown', this._onKey);
186
+ this.addEventListener('click', this._onSwatch);
187
+ }
188
+
189
+ onDisconnected() {
190
+ this.removeEventListener('pointerdown', this._onDown);
191
+ this.removeEventListener('lostpointercapture', this._onUp);
192
+ this.removeEventListener('keydown', this._onKey);
193
+ this.removeEventListener('click', this._onSwatch);
194
+ this._stopDrag();
195
+ }
196
+
197
+ /** Set the color from a gesture: `input` on each change, `change` with { change: true }. */
198
+ _commit(hsv, { change = false } = {}) {
199
+ const before = this.value;
200
+ this._hsv = { h: ((hsv.h % 360) + 360) % 360, s: clamp(hsv.s, 0, 1), v: clamp(hsv.v, 0, 1) };
201
+ const hex = this.value;
202
+ this._reflectValue(hex);
203
+ this._paint();
204
+ this._updateForm();
205
+ if (hex !== before) {
206
+ this._emit('input');
207
+ if (change) this._emit('change');
208
+ }
209
+ }
210
+
211
+ _emit(type) {
212
+ const rgb = parseColor(this.value);
213
+ this._emitValue(type, this.value, { rgb: formatColor(rgb, 'rgb'), hsl: formatColor(rgb, 'hsl'), name: colorName(rgb) });
214
+ }
215
+
216
+ /** Where a pointer is, in lanes from the center (a lane is a twelfth of the radius). */
217
+ _lanes(event) {
218
+ const r = this._box?.getBoundingClientRect?.();
219
+ if (!r?.width) return null;
220
+ const lane = Math.min(r.width, r.height) / 24;
221
+ return { x: (event.clientX - (r.left + r.width / 2)) / lane, y: (event.clientY - (r.top + r.height / 2)) / lane };
222
+ }
223
+
224
+ _onDown(event) {
225
+ if (this._isDisabled() || event.button !== 0 || this._pointerId != null || event.target?.closest?.('.ok-cw-swatch')) return;
226
+ const p = this._lanes(event);
227
+ const cfg = this._cfg;
228
+ if (!p || !cfg) return;
229
+ const d = Math.hypot(p.x, p.y);
230
+ if (d >= cfg.outer - cfg.band - .5 && d <= cfg.outer + .5) this._mode = 'hue';
231
+ else if (Math.abs(p.x) <= cfg.half + .5 && Math.abs(p.y) <= cfg.half + .5) this._mode = 'area';
232
+ else return;
233
+ event.preventDefault();
234
+ (this._mode === 'hue' ? this._hueThumb : this._areaThumb)?.focus({ preventScroll: true });
235
+ this._pointerId = event.pointerId;
236
+ this._startValue = this.value;
237
+ this.setPointerCapture?.(event.pointerId);
238
+ window.addEventListener('pointermove', this._onMove);
239
+ window.addEventListener('pointerup', this._onUp);
240
+ window.addEventListener('pointercancel', this._onUp);
241
+ this._move(p);
242
+ }
243
+
244
+ _onMove(event) {
245
+ if (event.pointerId !== this._pointerId || this._isDisabled()) return;
246
+ const p = this._lanes(event);
247
+ if (p) this._move(p);
248
+ }
249
+
250
+ _move(p) {
251
+ const { half } = this._cfg;
252
+ if (this._mode === 'hue') this._commit({ ...this._hsv, h: Math.atan2(p.x, -p.y) * 180 / Math.PI });
253
+ else this._commit({ ...this._hsv, s: (p.x + half) / (2 * half), v: (half - p.y) / (2 * half) });
254
+ }
255
+
256
+ _stopDrag() {
257
+ if (typeof window === 'undefined') return;
258
+ if (this._pointerId != null && this.hasPointerCapture?.(this._pointerId)) this.releasePointerCapture(this._pointerId);
259
+ this._pointerId = null;
260
+ window.removeEventListener('pointermove', this._onMove);
261
+ window.removeEventListener('pointerup', this._onUp);
262
+ window.removeEventListener('pointercancel', this._onUp);
263
+ }
264
+
265
+ _onUp(event) {
266
+ if (event.pointerId !== this._pointerId) return;
267
+ const changed = this.value !== this._startValue;
268
+ this._stopDrag();
269
+ if (changed && !this._isDisabled()) this._emit('change');
270
+ }
271
+
272
+ _onSwatch = (event) => {
273
+ const sw = event.target?.closest?.('.ok-cw-swatch');
274
+ if (!sw || this._isDisabled()) return;
275
+ this._roving(sw);
276
+ this._commit(rgbToHsv(parseColor(sw.dataset.hex)), { change: true });
277
+ };
278
+
279
+ _roving(active, disabled = this._isDisabled()) {
280
+ for (const sw of this._swatches || []) sw.tabIndex = !disabled && sw === active ? 0 : -1;
281
+ }
282
+
283
+ _onKey(event) {
284
+ if (this._isDisabled()) return;
285
+ const part = event.target?.dataset?.part;
286
+ const big = event.shiftKey ? 10 : 1;
287
+ const { h, s, v } = this._hsv;
288
+ let next = null;
289
+ if (event.target?.classList?.contains('ok-cw-swatch')) {
290
+ const i = this._swatches.indexOf(event.target), n = this._swatches.length;
291
+ const to = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1, ArrowUp: i - 1, Home: 0, End: n - 1 }[event.key];
292
+ if (to == null) return;
293
+ event.preventDefault();
294
+ const sw = this._swatches[(to + n) % n];
295
+ this._roving(sw);
296
+ sw.focus();
297
+ return;
298
+ }
299
+ if (part === 'hue') {
300
+ const moves = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1, PageUp: 10, PageDown: -10 };
301
+ if (Object.hasOwn(moves, event.key)) next = { h: (Math.round(h) + moves[event.key] * (event.key.startsWith('Page') ? 1 : big) + 360) % 360, s, v };
302
+ else if (event.key === 'Home') next = { h: 0, s, v };
303
+ else if (event.key === 'End') next = { h: 359, s, v };
304
+ } else if (part === 'area') {
305
+ const step = big / 100;
306
+ const r = (n) => Math.round(n * 100) / 100;
307
+ if (event.key === 'ArrowRight') next = { h, s: r(s) + step, v };
308
+ else if (event.key === 'ArrowLeft') next = { h, s: r(s) - step, v };
309
+ else if (event.key === 'ArrowUp') next = { h, s, v: r(v) + step };
310
+ else if (event.key === 'ArrowDown') next = { h, s, v: r(v) - step };
311
+ else if (event.key === 'PageUp') next = { h, s, v: r(v) + .1 };
312
+ else if (event.key === 'PageDown') next = { h, s, v: r(v) - .1 };
313
+ else if (event.key === 'Home') next = { h, s: 0, v };
314
+ else if (event.key === 'End') next = { h, s: 1, v };
315
+ }
316
+ if (!next) return;
317
+ event.preventDefault();
318
+ this._commit(next, { change: true });
319
+ }
320
+ }
321
+
322
+ define('o-color-wheel', OrbitColorWheel);
package/src/o-compass.js CHANGED
@@ -1,41 +1,42 @@
1
1
  /**
2
2
  * <o-compass> — heading indicator.
3
3
  *
4
- * A fixed dial with a red lubber index at the top; the compass card (36 ticks
5
- * + cardinal letters) rotates so the current `heading` sits under the index —
6
- * the way an aircraft heading indicator reads. Cardinal letters stay upright
7
- * (`spin-lock`). Rotating the card is done through Orbit's own `--o-from`, so
8
- * changing `heading` only re-rotates (no rebuild).
4
+ * A fixed dial with a lubber index at the top; the compass card (36 ticks and
5
+ * the cardinal letters) turns so the current `heading` sits under the index,
6
+ * the way an aircraft heading indicator reads. Turning the card is Orbit's own
7
+ * `--o-from` on its rings, so changing `heading` only re-rotates. Capsules
8
+ * stay upright on their own.
9
9
  *
10
10
  * .bigbang > .gravity-spot
11
- * ├── .orbit-6 (filled) — dial face + bezel
12
- * ├── .orbit-6 (rotates) > .vector × 36 — tick scale
13
- * ├── .orbit-5 (rotates) > .satellite × cardinals (spin-lock)
14
- * ├── .orbit-6 > .satellite.angle-0 > lubber index (fixed)
15
- * └── .orbit-0 > heading readout
11
+ * ├── .orbit-12 (filled) dial face + bezel
12
+ * ├── .orbit-12 (turns) > .vector × 36 tick scale
13
+ * ├── .orbit-10 (turns) > .satellite × n cardinals
14
+ * ├── .orbit-12 > .satellite.angle-0 lubber index (fixed)
15
+ * └── .orbit-0 > heading readout
16
16
  *
17
17
  * <o-compass heading="45"></o-compass>
18
18
  * <o-compass heading="120" labels="N,NE,E,SE,S,SW,W,NW"></o-compass>
19
19
  */
20
- import { OrbitKitElement, define, injectStyleOnce } from './base.js';
20
+ import { OrbitKitElement, define, injectStyleOnce, universe, escapeHTML as e, withShared, FORMAT_ATTRS } from './base.js';
21
21
 
22
22
  const CMP_CSS = `
23
+ o-compass { --ok-font-size: 7cqmin; }
23
24
  o-compass .ok-cmp-dial {
24
- background: var(--ok-cmp-face, radial-gradient(circle at 50% 35%, #1b212b, #0a0d12));
25
- box-shadow: inset 0 0 0 4px var(--ok-cmp-bezel, #2a3340), inset 0 4px 18px rgba(0,0,0,.65);
25
+ background: var(--ok-cmp-face, var(--ok-face, radial-gradient(circle at 50% 35%, #1b212b, #0a0d12)));
26
+ box-shadow: inset 0 0 0 4px var(--ok-cmp-bezel, var(--ok-bezel, #2a3340)), inset 0 4px 18px rgba(0,0,0,.65);
26
27
  }
27
- o-compass .ok-cmp-rotor { transition: transform .3s ease; }
28
- o-compass .ok-cmp-tick { background: var(--ok-cmp-tick, rgba(203,213,225,.4)); height: 1px; }
29
- o-compass .ok-cmp-tick-M { background: #e6edf3; height: 2px; border-radius: 1px; }
30
- o-compass .ok-cmp-card { font-weight: 800; color: #e6edf3; }
31
- o-compass .ok-cmp-card.north { color: var(--ok-cmp-accent, #ef4444); }
28
+ o-compass .ok-cmp-tick { background: var(--ok-cmp-tick, var(--ok-tick, rgba(203,213,225,.4))); height: 1px; }
29
+ o-compass .ok-cmp-tick-M { background: var(--ok-cmp-tick-M, var(--ok-hand, #e6edf3)); height: max(2px, .9cqmin); border-radius: 1px; }
30
+ o-compass .ok-cmp-card { font-weight: 800; color: var(--ok-cmp-text, var(--ok-text, #e6edf3)); }
31
+ o-compass .ok-cmp-card.north { color: var(--ok-cmp-accent, var(--ok-accent, #ef4444)); }
32
32
  o-compass .ok-cmp-lubber {
33
- width: 0; height: 0; border-left: 7px solid transparent; border-right: 7px solid transparent;
34
- border-top: 12px solid var(--ok-cmp-accent, #ef4444);
33
+ filter: drop-shadow(0 0 var(--ok-cmp-glow, 0px) var(--ok-cmp-accent, var(--ok-accent, #ef4444)));
34
+ width: 0; height: 0; border-left: .45em solid transparent; border-right: .45em solid transparent;
35
+ border-top: .8em solid var(--ok-cmp-accent, var(--ok-accent, #ef4444)); translate: 0 40%;
35
36
  }
36
37
  o-compass .ok-cmp-readout { display: flex; flex-direction: column; align-items: center; line-height: 1.05; }
37
- o-compass .ok-cmp-deg { font-weight: 800; font-size: 1.5em; font-variant-numeric: tabular-nums; color: #f8fafc; }
38
- o-compass .ok-cmp-sub { font-size: .6em; letter-spacing: .15em; opacity: .6; }
38
+ o-compass .ok-cmp-deg { font-weight: 800; font-size: 1.5em; font-variant-numeric: tabular-nums; color: var(--ok-cmp-text, var(--ok-text, #f8fafc)); }
39
+ o-compass .ok-cmp-sub { font-size: .65em; opacity: .6; }
39
40
  `;
40
41
 
41
42
  // Compass point abbreviations for the heading readout.
@@ -43,7 +44,7 @@ const POINTS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
43
44
 
44
45
  export class OrbitCompass extends OrbitKitElement {
45
46
  static get observedAttributes() {
46
- return ['heading', 'size', 'labels', 'fill', 'unit', 'glow'];
47
+ return withShared(['heading', 'size', 'labels', 'fill', 'accent', 'glow'], FORMAT_ATTRS);
47
48
  }
48
49
 
49
50
  readConfig() {
@@ -51,50 +52,41 @@ export class OrbitCompass extends OrbitKitElement {
51
52
  return {
52
53
  heading: ((this._num('heading', 0) % 360) + 360) % 360,
53
54
  labels,
54
- size: this._str('size', '220px'),
55
- fill: this._str('fill', '#ef4444'),
56
- unit: this._str('unit', '°'),
57
- glow: this.hasAttribute('glow')
55
+ size: this._size('size', '220px'),
56
+ fill: this._color('accent', this._color('fill', null)),
57
+ unit: this._unitText('°'),
58
+ glow: this.getAttribute('glow')
58
59
  };
59
60
  }
60
61
 
61
62
  structuralSignature(cfg) {
62
- const { heading, ...rest } = cfg;
63
+ const { heading, unit, fill, ...rest } = cfg;
63
64
  return JSON.stringify(rest);
64
65
  }
65
66
 
66
67
  build(cfg) {
67
68
  injectStyleOnce('orbit-kit-compass', CMP_CSS);
68
- this.style.setProperty('--ok-cmp-accent', cfg.fill);
69
-
70
- // orbit-6 is the outermost ring; scale --o-force so it fills `size`.
71
- const dialPx = parseFloat(cfg.size) || 220;
72
- const force = (dialPx * 12 / 6).toFixed(1);
73
- const baseFont = (dialPx * 0.07).toFixed(1);
69
+ this._applyTheme({ '--ok-cmp-glow': cfg.glow == null ? null : `${Math.max(0, this._num('glow', 6))}px` });
74
70
 
75
71
  // 36 ticks every 10°; every 9th (90°) is a long cardinal tick.
76
72
  const ticks = Array.from({ length: 36 }, (_, i) =>
77
- `<div class="vector inner-orbit ${i % 9 === 0 ? 'shrink-30 ok-cmp-tick-M' : 'shrink-60 ok-cmp-tick'}"></div>`).join('');
73
+ `<div class="vector inner-orbit ${i % 9 === 0 ? 'ok-cmp-tick-M" style="--o-size-ratio:1.5' : 'ok-cmp-tick" style="--o-size-ratio:.8'}"></div>`).join('');
78
74
 
79
75
  // Cardinals distributed by count (NO fit-range — that overlaps on a 360 loop).
80
76
  // Plain satellites (NOT spin-lock): the capsule stays upright by default
81
77
  // while the ring's --o-from rotates only their POSITION. spin-lock would
82
78
  // over-rotate the letters once we drive --o-from for the heading.
83
79
  const cards = cfg.labels.map((l, i) =>
84
- `<div class="satellite"><div class="capsule ok-cmp-card${i === 0 ? ' north' : ''}">${l}</div></div>`).join('');
80
+ `<div class="satellite"><div class="capsule ok-cmp-card${i === 0 ? ' north' : ''}">${e(l)}</div></div>`).join('');
85
81
 
86
- this.innerHTML = `
87
- <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;font-size:${baseFont}px;">
88
- <div class="gravity-spot" style="--o-force:${force}px;">
89
- <div class="orbit-6 ok-cmp-dial"></div>
90
- <div class="orbit-6 ok-cmp-rotor o-cmp-ticks" style="--o-from:0deg;">${ticks}</div>
91
- <div class="orbit-5 ok-cmp-rotor o-cmp-cards" style="--o-from:0deg;">${cards}</div>
92
- <div class="orbit-6"><div class="satellite angle-0"><div class="capsule ok-cmp-lubber"></div></div></div>
93
- <div class="orbit-0"><div class="satellite at-center"><div class="capsule ok-cmp-readout">
94
- <span class="ok-cmp-deg"></span><span class="ok-cmp-sub"></span>
95
- </div></div></div>
96
- </div>
97
- </div>`;
82
+ this.innerHTML = universe(cfg.size, `
83
+ <div class="orbit-12 ok-cmp-dial ok-surface"></div>
84
+ <div class="orbit-12 ok-cmp-rotor o-cmp-ticks" style="--o-from:0deg">${ticks}</div>
85
+ <div class="orbit-10 ok-cmp-rotor o-cmp-cards" style="--o-from:0deg">${cards}</div>
86
+ <div class="orbit-12"><div class="satellite angle-0"><div class="capsule ok-cmp-lubber"></div></div></div>
87
+ <div class="orbit-0"><div class="satellite at-center"><div class="capsule ok-cmp-readout">
88
+ <span class="ok-cmp-deg"></span><span class="ok-cmp-sub"></span>
89
+ </div></div></div>`);
98
90
 
99
91
  this._ticks = this.querySelector('.o-cmp-ticks');
100
92
  this._cards = this.querySelector('.o-cmp-cards');
@@ -107,8 +99,12 @@ export class OrbitCompass extends OrbitKitElement {
107
99
  const rot = `${-cfg.heading}deg`;
108
100
  if (this._ticks) this._ticks.style.setProperty('--o-from', rot);
109
101
  if (this._cards) this._cards.style.setProperty('--o-from', rot);
110
- if (this._deg) this._deg.textContent = `${String(Math.round(cfg.heading)).padStart(3, '0')}${cfg.unit}`;
102
+ const range = { min: 0, max: 360 };
103
+ const degrees = (h) => String(Math.round(h) % 360).padStart(3, '0');
104
+ if (this._deg) this._deg.textContent = this._formatText(cfg.heading, { ...range, fallback: degrees }, cfg.unit);
111
105
  if (this._sub) this._sub.textContent = POINTS[Math.round(cfg.heading / 45) % 8];
106
+ this._describe(`Heading ${this._formatText(cfg.heading, { ...range, kind: 'aria', fallback: (h) => String(Math.round(h) % 360) }, cfg.unit)}, ${POINTS[Math.round(cfg.heading / 45) % 8]}`);
107
+ this._applyTheme({ '--ok-cmp-accent': cfg.fill });
112
108
  }
113
109
  }
114
110
 
package/src/o-donut.js CHANGED
@@ -1,100 +1,88 @@
1
+ import { OrbitKitElement, define, injectStyleOnce, universe, hub, fixed, finiteNumber, cssValue, escapeHTML as e, withShared, FORMAT_ATTRS } from './base.js';
2
+
3
+ const PALETTE = ['#6366f1', '#22c55e', '#eab308', '#f97316', '#ef4444', '#06b6d4', '#a855f7', '#ec4899'];
4
+
1
5
  /**
2
- * <o-donut> — data-driven donut / pie chart.
3
- *
4
- * Collapses the Orbit recipe:
5
- *
6
- * .bigbang > .gravity-spot
7
- * ├── .orbit-N > <o-arc> × segments (each arc stacks onto the previous)
8
- * └── .orbit-0 > .satellite.at-center > .capsule (center label)
6
+ * <o-donut>: a donut chart from a JSON array. Segments are sibling <o-arc>s
7
+ * with a `value`, so Orbit stacks them one after another around one ring.
9
8
  *
10
- * ...into one tag fed by a JSON array:
11
- *
12
- * <o-donut data='[{"value":52,"color":"#f97316","label":"Rent"},
13
- * {"value":28,"color":"#eab308"},
14
- * {"value":20,"color":"#22c55e"}]'
15
- * label="$2.4k" unit=""></o-donut>
16
- *
17
- * Segments are normalized to the sum of their values (so they fill the ring),
18
- * unless `total` is given — then values are taken as a fraction of `total`,
19
- * leaving an empty remainder (partial donut).
9
+ * .orbit-12 > o-arc track
10
+ * .orbit-12 > o-arc[value] × n segments (stacked by Orbit)
11
+ * .orbit-0 > .satellite.at-center label
20
12
  */
21
- import { OrbitKitElement, define } from './base.js';
22
-
23
13
  export class OrbitDonut extends OrbitKitElement {
24
14
  static get observedAttributes() {
25
- return ['data', 'size', 'orbit', 'stroke-width', 'gap', 'track',
26
- 'range', 'from', 'label', 'unit', 'total', 'glow'];
15
+ return withShared(['data', 'size', 'orbit', 'thickness', 'stroke-width', 'gap', 'track', 'range', 'from', 'label', 'total', 'shape', 'glow'], FORMAT_ATTRS);
27
16
  }
28
17
 
29
18
  readConfig() {
30
- const range = this._num('range', 360);
31
- const data = (this._json('data', []) || []).map((d) => ({
32
- value: Number(d.value) || 0,
33
- color: d.color || null,
34
- label: d.label != null ? String(d.label) : null
19
+ const data = this._array('data').map((d, i) => ({
20
+ value: Math.max(0, finiteNumber(d.value)), color: cssValue(d.color, PALETTE[i % PALETTE.length]),
21
+ label: d.label == null ? null : String(d.label)
35
22
  }));
36
- const sum = data.reduce((a, d) => a + d.value, 0) || 1;
37
- const total = this._num('total', sum);
23
+ // Normalize through a scaled sum to avoid overflow on large finite values.
24
+ const largest = data.reduce((n, d) => Math.max(n, d.value), 1);
25
+ const sum = data.reduce((n, d) => n + d.value / largest, 0) * largest;
26
+ const requestedTotal = this._num('total', sum);
27
+ const total = requestedTotal > 0 ? requestedTotal : (sum || 1);
28
+ let remaining = 100;
29
+ const weightSum = data.reduce((n, d) => n + d.value / largest, 0) || 1;
30
+ for (const d of data) {
31
+ const pct = Number.isFinite(total) ? d.value / total * 100 : (d.value / largest) / weightSum * 100;
32
+ d.pct = Math.max(0, Math.min(remaining, pct));
33
+ remaining = Math.max(0, remaining - d.pct);
34
+ }
38
35
  return {
39
- data,
40
- total,
41
- range,
42
- from: this.hasAttribute('from') ? this._num('from', 0) : 0,
43
- orbit: Math.round(this._num('orbit', 3)),
44
- size: this._str('size', '200px'),
45
- strokeWidth: this._num('stroke-width', 14),
46
- gap: this._num('gap', 2),
47
- shape: this._str('shape', 'none'),
48
- track: this._str('track', 'rgba(255,255,255,.05)'),
49
- unit: this._str('unit', ''),
50
- glow: this.getAttribute('glow'),
51
- label: this.hasAttribute('label') ? this.getAttribute('label') : null
36
+ data, total, range: Math.max(0, Math.min(360, this._num('range', 360))), from: this._num('from', 0),
37
+ thickness: this._thickness(3), size: this._size('size', '200px'),
38
+ strokeWidth: Math.max(0, this._num('stroke-width', 0)), gap: this._gap(1.5),
39
+ shape: this._str('shape', 'none'), track: this._color('track', null),
40
+ unit: this._unitText(), glow: this.hasAttribute('glow') ? this._glowPx() : null, label: this.getAttribute('label')
52
41
  };
53
42
  }
54
43
 
55
- structuralSignature(cfg) {
56
- // Re-render the whole subtree whenever the data or geometry changes;
57
- // segment count and arc values are all structural here.
58
- return JSON.stringify(cfg);
44
+ /** A segment value as text; "percent" is its share of the total. */
45
+ _segmentText(cfg, d, i) {
46
+ return `${d.label ?? `Segment ${i + 1}`}: ${this._formatText(d.value, { min: 0, max: cfg.total })}`;
59
47
  }
60
48
 
61
- build(cfg) {
62
- const palette = ['#6366f1', '#22c55e', '#eab308', '#f97316', '#ef4444',
63
- '#06b6d4', '#a855f7', '#ec4899'];
64
-
65
- const segments = cfg.data.map((d, i) => {
66
- const pct = (d.value / cfg.total) * 100;
67
- const color = d.color || palette[i % palette.length];
68
- const style = [
69
- `--o-fill:${color}`,
70
- `--o-stroke:${color}`,
71
- `--o-stroke-width:${cfg.strokeWidth}`,
72
- `--o-gap:${cfg.gap}`,
73
- this._glow(color)
74
- ].join(';');
75
- const title = d.label ? ` title="${d.label}: ${d.value}"` : '';
76
- return `<o-arc value="${pct.toFixed(4)}" shape="${cfg.shape}" style="${style}"${title}></o-arc>`;
77
- }).join('');
78
-
79
- // Faint full-circle track underneath the segments (rounded to match).
80
- const track = cfg.track && cfg.track !== 'transparent'
81
- ? `<o-arc value="100" style="--o-fill:${cfg.track};--o-stroke:${cfg.track};--o-stroke-width:${cfg.strokeWidth}"></o-arc>`
82
- : '';
49
+ structuralSignature({ data, total, label, unit, track, ...structure }) {
50
+ return JSON.stringify({ ...structure, count: data.length });
51
+ }
83
52
 
84
- const center = cfg.label !== null
85
- ? `<div class="orbit-0"><div class="satellite at-center"><div class="capsule ok-hub">${cfg.label}${cfg.unit}</div></div></div>`
86
- : '';
53
+ build(cfg) {
54
+ const ring = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;--o-size-ratio:${fixed(cfg.thickness)};`;
55
+ const stroke = cfg.strokeWidth ? `--o-stroke-width:${cfg.strokeWidth};` : '';
56
+ const segments = cfg.data.map((d, i) =>
57
+ `<o-arc class="inner-orbit ok-donut-segment" value="${d.pct.toFixed(4)}" shape="${e(cfg.shape)}" style="${e(`--o-fill:${d.color};${stroke}${cfg.glow == null ? '' : `filter:drop-shadow(0 0 ${cfg.glow}px ${d.color});`}`)}" title="${e(this._segmentText(cfg, d, i))}"></o-arc>`).join('');
58
+ this.innerHTML = universe(cfg.size, `
59
+ <div class="orbit-12" style="${ring}--o-gap:0"><o-arc class="inner-orbit ok-track" value="100"></o-arc></div>
60
+ <div class="orbit-12" style="${ring}--o-gap:${fixed(cfg.gap * 6)}">${segments}</div>
61
+ ${hub(2 * (12 - cfg.thickness) - 1, `${e(cfg.label ?? '')}${cfg.label == null ? '' : e(cfg.unit)}`)}`);
62
+ this._segments = [...this.querySelectorAll('.ok-donut-segment')];
63
+ this._center = this.querySelector('.ok-hub');
64
+ }
87
65
 
88
- this.innerHTML = `
89
- <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
90
- <div class="gravity-spot">
91
- <div class="orbit-${cfg.orbit}" style="--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;">
92
- ${track}${segments}
93
- </div>
94
- ${center}
95
- </div>
96
- </div>`;
66
+ sync(cfg) {
67
+ this._applyTheme({ '--ok-track': cfg.track });
68
+ this._segments?.forEach((arc, i) => {
69
+ const d = cfg.data[i];
70
+ arc.setAttribute('value', d.pct.toFixed(4));
71
+ arc.setAttribute('title', this._segmentText(cfg, d, i));
72
+ arc.style.setProperty('--o-fill', d.color);
73
+ if (cfg.glow != null) arc.style.setProperty('filter', `drop-shadow(0 0 ${cfg.glow}px ${d.color})`);
74
+ else arc.style.removeProperty?.('filter');
75
+ });
76
+ if (this._center) {
77
+ this._center.textContent = cfg.label == null ? '' : `${cfg.label}${cfg.unit}`;
78
+ this._center.hidden = cfg.label == null;
79
+ }
80
+ this._describe([cfg.label == null ? null : `${cfg.label}${cfg.unit}`, ...cfg.data.map((d, i) => this._segmentText(cfg, d, i))].filter(Boolean).join('; ') || 'Empty donut chart');
97
81
  }
98
82
  }
99
83
 
84
+ injectStyleOnce('orbit-kit-donut', `
85
+ o-donut .ok-track { --o-fill: var(--ok-track, rgba(127,127,127,.12)); --o-stroke: var(--o-fill); }
86
+ o-donut .ok-donut-segment { --o-stroke: var(--o-fill); }
87
+ `);
100
88
  define('o-donut', OrbitDonut);