@zumer/orbit-kit 0.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-clock.js ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * <o-clock> — analog clock / watch face. The kit's flagship.
3
+ *
4
+ * The idiomatic Orbit clock: every part is a plain element on a nested orbit,
5
+ * and the rotation math is the framework's job. Hands are `.vector`s at the
6
+ * center orbit, pointed by `--o-angle-composite`; ticks are vectors
7
+ * auto-distributed around a ring; numerals are satellites on an outer ring;
8
+ * sub-dials are nested gravity-spots with their own hands.
9
+ *
10
+ * Variants:
11
+ * - case: round | square | rounded | rectangle (the body/bezel)
12
+ * - dial: round | oval (oval uses Orbit ellipses,
13
+ * oriented to match the case so a rectangle gives a true tonneau)
14
+ * - numerals:arabic | roman | none
15
+ * - complications: digital readout, date window, brand label
16
+ * - subdials: chronograph tri-compax (small seconds / 24h / date), live
17
+ * - presets: classic | roman | minimal | chrono | smartwatch
18
+ *
19
+ * <o-clock live seconds></o-clock>
20
+ * <o-clock preset="chrono" live brand="ORBIT"></o-clock>
21
+ * <o-clock case="rectangle" dial="oval" live smooth></o-clock> <!-- tonneau -->
22
+ *
23
+ * Hands/readouts are patched in place every frame/second; the structure is
24
+ * rebuilt only when the face itself changes.
25
+ */
26
+ import { OrbitKitElement, define, injectStyleOnce } from './base.js';
27
+
28
+ const CLOCK_CSS = `
29
+ o-clock .ok-clock-case {
30
+ box-sizing: border-box; display: flex; align-items: center; justify-content: center;
31
+ }
32
+ o-clock .ok-clock-case--round { border-radius: 50%; }
33
+ o-clock .ok-clock-case--square { border-radius: 9%; }
34
+ o-clock .ok-clock-case--rounded { border-radius: 26%; }
35
+ o-clock .ok-clock-case--rectangle { border-radius: 24%; }
36
+ o-clock .ok-clock-case--square,
37
+ o-clock .ok-clock-case--rounded,
38
+ o-clock .ok-clock-case--rectangle {
39
+ background: var(--ok-clock-case-bg, linear-gradient(160deg, #232a34, #11151b));
40
+ box-shadow: 0 10px 30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);
41
+ }
42
+ o-clock .ok-clock-face { color: var(--ok-clock-numeral, #e6edf3); }
43
+ o-clock .ok-clock-dial {
44
+ background: var(--ok-clock-face, radial-gradient(circle at 50% 35%, #1b212b, #0c0f14));
45
+ box-shadow: inset 0 0 0 4px var(--ok-clock-bezel, #2a3340), inset 0 4px 18px rgba(0,0,0,.6);
46
+ }
47
+ o-clock .ok-clock-tick { background: var(--ok-clock-tick, rgba(230,237,243,.35)); height: 2px; border-radius: 2px; }
48
+ o-clock .ok-clock-tick-h { background: var(--ok-clock-tick-h, #e6edf3); height: 3px; border-radius: 2px; }
49
+ o-clock .ok-clock-num { font-weight: 700; font-variant-numeric: tabular-nums; }
50
+ o-clock .ok-clock-brand { font-size: .62em; letter-spacing: .18em; font-weight: 800; text-transform: uppercase; color: var(--ok-clock-accent, #ef4565); }
51
+ o-clock .ok-clock-digital { font-size: .7em; font-weight: 700; letter-spacing: .04em; font-variant-numeric: tabular-nums; opacity: .85; }
52
+ o-clock .ok-clock-date { font-size: .62em; font-weight: 800; font-variant-numeric: tabular-nums; padding: .12em .4em; border-radius: 4px; background: rgba(255,255,255,.1); }
53
+ o-clock .ok-clock-hour { background: var(--ok-clock-hand, #e6edf3); height: 7px; border-radius: 4px; }
54
+ o-clock .ok-clock-min { background: var(--ok-clock-hand, #e6edf3); height: 5px; border-radius: 3px; }
55
+ o-clock .ok-clock-sec { background: var(--ok-clock-accent, #ef4565); height: 2px; border-radius: 2px; }
56
+ o-clock .ok-clock-cap { background: var(--ok-clock-accent, #ef4565); box-shadow: 0 0 0 3px var(--ok-clock-face-cap, #0c0f14); }
57
+ o-clock .ok-clock-ringtime { font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: .02em; color: var(--ok-clock-numeral, #e6edf3); text-align: center; }
58
+ /* sub-dials */
59
+ o-clock .ok-subdial-ring { border: 1px solid var(--ok-clock-tick, rgba(230,237,243,.3)); box-shadow: inset 0 0 8px rgba(0,0,0,.4); }
60
+ o-clock .ok-sub-tick { background: var(--ok-clock-tick, rgba(230,237,243,.3)); height: 1px; }
61
+ o-clock .ok-subdial-hand { background: var(--ok-clock-accent, #ef4565); height: 2px; border-radius: 2px; }
62
+ o-clock .ok-subdial-cap { background: var(--ok-clock-tick-h, #e6edf3); }
63
+ o-clock .ok-subdial-label{ font-size: .4em; opacity: .55; letter-spacing: .1em; text-transform: uppercase; }
64
+ o-clock.ok-glow .ok-clock-hour,
65
+ o-clock.ok-glow .ok-clock-min { filter: drop-shadow(0 0 4px var(--ok-clock-hand, #e6edf3)); }
66
+ o-clock.ok-glow .ok-clock-sec { filter: drop-shadow(0 0 5px var(--ok-clock-accent, #ef4565)); }
67
+ `;
68
+
69
+ const ROMAN = ['XII', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI'];
70
+ const ARABIC = ['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'];
71
+
72
+ // Presets are just default bundles; any explicit attribute overrides them.
73
+ const PRESETS = {
74
+ classic: { case: 'round', numerals: 'arabic' },
75
+ roman: { case: 'round', numerals: 'roman' },
76
+ minimal: { case: 'round', numerals: 'none' },
77
+ chrono: { case: 'round', numerals: 'arabic', subdials: true, brand: 'ORBIT' },
78
+ smartwatch: { case: 'rounded', numerals: 'arabic', seconds: true, smooth: true,
79
+ digital: true, date: true, accent: '#3da9fc' }
80
+ };
81
+ const CASES = ['round', 'square', 'rounded', 'rectangle'];
82
+
83
+ export class OrbitClock extends OrbitKitElement {
84
+ static get observedAttributes() {
85
+ return ['time', 'live', 'smooth', 'seconds', 'numerals', 'size', 'preset',
86
+ 'case', 'dial', 'digital', 'date', 'brand', 'subdials',
87
+ 'face', 'bezel', 'case-bg', 'hand-color', 'accent', 'tick-color', 'glow'];
88
+ }
89
+
90
+ /** Resolve a string attribute against the active preset, then a hard default. */
91
+ _opt(name, dflt) {
92
+ const v = this.getAttribute(name);
93
+ if (v != null) return v;
94
+ const p = PRESETS[this.getAttribute('preset')];
95
+ return (p && p[name] != null) ? p[name] : dflt;
96
+ }
97
+
98
+ /** Resolve a boolean attribute (supports `attr="false"`) against the preset. */
99
+ _flag(name, dflt = false) {
100
+ if (this.hasAttribute(name)) return this.getAttribute(name) !== 'false';
101
+ const p = PRESETS[this.getAttribute('preset')];
102
+ return (p && p[name] != null) ? !!p[name] : dflt;
103
+ }
104
+
105
+ readConfig() {
106
+ const caseShape = CASES.includes(this._opt('case', 'round')) ? this._opt('case', 'round') : 'round';
107
+ const smooth = this._flag('smooth');
108
+ return {
109
+ time: this._str('time', null),
110
+ live: this.hasAttribute('live'),
111
+ smooth,
112
+ seconds: this._flag('seconds') || smooth,
113
+ numerals: this._opt('numerals', 'arabic'),
114
+ caseShape,
115
+ oval: this._opt('dial', 'round') === 'oval',
116
+ ringsFace: this._opt('dial', 'round') === 'rings',
117
+ digital: this._flag('digital'),
118
+ date: this._flag('date'),
119
+ subdials: this._flag('subdials'),
120
+ brand: this._opt('brand', null),
121
+ size: this._str('size', '300px'),
122
+ glow: this.hasAttribute('glow'),
123
+ theme: ['face', 'bezel', 'case-bg', 'hand-color', 'accent', 'tick-color']
124
+ .map((a) => this.getAttribute(a)).join('|')
125
+ };
126
+ }
127
+
128
+ structuralSignature(cfg) {
129
+ // Hands + digital/date/sub-dial readouts are patched live; only the face rebuilds.
130
+ const { time, ...rest } = cfg;
131
+ return JSON.stringify(rest);
132
+ }
133
+
134
+ build(cfg) {
135
+ injectStyleOnce('orbit-kit-clock', CLOCK_CSS);
136
+ this.classList.toggle('ok-glow', cfg.glow);
137
+ this._ringTime = null; // reset face mode each build
138
+
139
+ // Convenience attributes mirror the CSS custom properties.
140
+ const map = {
141
+ face: '--ok-clock-face', bezel: '--ok-clock-bezel', 'case-bg': '--ok-clock-case-bg',
142
+ 'hand-color': '--ok-clock-hand', accent: '--ok-clock-accent', 'tick-color': '--ok-clock-tick'
143
+ };
144
+ for (const [attr, prop] of Object.entries(map)) {
145
+ const v = this.getAttribute(attr);
146
+ if (v != null) this.style.setProperty(prop, v);
147
+ }
148
+ if (this.getAttribute('accent') == null) {
149
+ const p = PRESETS[this.getAttribute('preset')];
150
+ if (p && p.accent) this.style.setProperty('--ok-clock-accent', p.accent);
151
+ }
152
+
153
+ if (cfg.ringsFace) { this._buildRingsFace(cfg); return; }
154
+
155
+ // Orbit sizes track --o-force (orbit-N diameter = N/12 * force), NOT the
156
+ // box. The dial is orbit-11, so scale force to make it equal the requested
157
+ // size. The case wraps the dial with extra frame for non-round shapes.
158
+ const dialPx = parseFloat(cfg.size) || 300;
159
+ const force = (dialPx * 12 / 11).toFixed(1);
160
+ const frame = cfg.caseShape === 'round' ? 0 : Math.round(dialPx * 0.08);
161
+ const caseW = dialPx + frame * 2;
162
+ const caseH = caseW + (cfg.caseShape === 'rectangle' ? Math.round(dialPx * 0.26) : 0);
163
+ const baseFont = (dialPx * 0.075).toFixed(1);
164
+
165
+ // Oval dial: stretch the orbits to match the CASE orientation so a tall
166
+ // (rectangle) case gives a tall dial (true tonneau), not a sideways oval.
167
+ let ellipse = '';
168
+ if (cfg.oval) {
169
+ if (caseH > caseW) ellipse = `--o-ellipse-x:1;--o-ellipse-y:${(caseW / caseH).toFixed(3)};`;
170
+ else if (caseW > caseH) ellipse = `--o-ellipse-x:${(caseH / caseW).toFixed(3)};--o-ellipse-y:1;`;
171
+ else ellipse = '--o-ellipse-x:1;--o-ellipse-y:.82;'; // square box → gentle vertical oval
172
+ }
173
+
174
+ // --- ticks: 60 minute, 12 hour (overdraw every 5th) ---
175
+ const minuteTicks = Array.from({ length: 60 }, () =>
176
+ '<div class="vector inner-orbit shrink-55 ok-clock-tick"></div>').join('');
177
+ const hourTicks = Array.from({ length: 12 }, () =>
178
+ '<div class="vector inner-orbit shrink-15 ok-clock-tick-h"></div>').join('');
179
+
180
+ // --- numerals ---
181
+ const labels = cfg.numerals === 'roman' ? ROMAN
182
+ : cfg.numerals === 'none' ? null : ARABIC;
183
+ const numerals = labels
184
+ ? `<div class="orbit-8">${labels.map((n) =>
185
+ `<div class="satellite"><div class="capsule ok-clock-num">${n}</div></div>`).join('')}</div>`
186
+ : '';
187
+
188
+ // --- complications (suppressed when sub-dials own the lower face) ---
189
+ const brand = cfg.brand
190
+ ? `<div class="orbit-4"><div class="satellite angle-0"><div class="capsule ok-clock-brand">${cfg.brand}</div></div></div>`
191
+ : '';
192
+ const digital = (cfg.digital && !cfg.subdials)
193
+ ? `<div class="orbit-4"><div class="satellite angle-180"><div class="capsule ok-clock-digital"></div></div></div>`
194
+ : '';
195
+ const date = (cfg.date && !cfg.subdials)
196
+ ? `<div class="orbit-6"><div class="satellite angle-90"><div class="capsule ok-clock-date"></div></div></div>`
197
+ : '';
198
+
199
+ // --- sub-dials: chronograph tri-compax at 3/6/9 ---
200
+ const subForce = (dialPx * 0.96).toFixed(1);
201
+ const subdials = cfg.subdials
202
+ ? this._subdial('sec', 'angle-270', subForce, 'S') // 9 o'clock — small seconds
203
+ + this._subdial('h24', 'angle-90', subForce, '24') // 3 o'clock — 24-hour
204
+ + this._subdial('date', 'angle-180', subForce, '31') // 6 o'clock — date
205
+ : '';
206
+
207
+ // --- hands ---
208
+ const secondHand = cfg.seconds
209
+ ? '<div class="vector outer-orbit grow-4x ok-clock-hand ok-clock-sec"></div>'
210
+ : '';
211
+
212
+ this.innerHTML = `
213
+ <div class="ok-clock-case ok-clock-case--${cfg.caseShape}" style="width:${caseW}px;height:${caseH}px;font-size:${baseFont}px;">
214
+ <div class="bigbang" style="width:100%;height:100%;">
215
+ <div class="gravity-spot ok-clock-face" style="--o-force:${force}px;${ellipse}">
216
+ <div class="orbit-11 ok-clock-dial"></div>
217
+ <div class="orbit-10">${minuteTicks}</div>
218
+ <div class="orbit-10">${hourTicks}</div>
219
+ ${numerals}
220
+ ${brand}${digital}${date}${subdials}
221
+ <div class="orbit-0">
222
+ <div class="vector outer-orbit grow-2x ok-clock-hand ok-clock-hour"></div>
223
+ <div class="vector outer-orbit grow-3x ok-clock-hand ok-clock-min"></div>
224
+ ${secondHand}
225
+ <div class="satellite at-center shrink-80 ok-clock-cap"></div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>`;
230
+
231
+ this._hourHand = this.querySelector('.ok-clock-hour');
232
+ this._minHand = this.querySelector('.ok-clock-min');
233
+ this._secHand = this.querySelector('.ok-clock-sec');
234
+ this._digital = this.querySelector('.ok-clock-digital');
235
+ this._dateEl = this.querySelector('.ok-clock-date');
236
+ this._subSec = this.querySelector('.ok-sub-sec');
237
+ this._sub24 = this.querySelector('.ok-sub-h24');
238
+ this._subDate = this.querySelector('.ok-sub-date');
239
+ }
240
+
241
+ /**
242
+ * One chronograph sub-dial: a nested gravity-spot (its own --o-force keeps it
243
+ * small) with a tick ring, a labeled counter and a single live hand.
244
+ */
245
+ _subdial(kind, angleClass, subForce, label) {
246
+ const ticks = Array.from({ length: 12 }, () =>
247
+ '<div class="vector inner-orbit shrink-60 ok-sub-tick"></div>').join('');
248
+ return `
249
+ <div class="orbit-5">
250
+ <div class="satellite ${angleClass}">
251
+ <div class="gravity-spot" style="--o-force:${subForce}px;">
252
+ <div class="orbit-3 ok-subdial-ring"></div>
253
+ <div class="orbit-3">${ticks}</div>
254
+ <div class="orbit-1"><div class="satellite angle-180"><div class="capsule ok-subdial-label">${label}</div></div></div>
255
+ <div class="orbit-0">
256
+ <div class="vector outer-orbit grow-1x ok-subdial-hand ok-sub-${kind}"></div>
257
+ <div class="satellite at-center shrink-90 ok-subdial-cap"></div>
258
+ </div>
259
+ </div>
260
+ </div>
261
+ </div>`;
262
+ }
263
+
264
+ /** iOS-style face: concentric activity rings (hours / minutes / seconds). */
265
+ _buildRingsFace(cfg) {
266
+ // Clear analog refs so _paint takes the rings branch.
267
+ this._hourHand = this._minHand = this._secHand = null;
268
+ this._subSec = this._sub24 = this._subDate = this._digital = this._dateEl = null;
269
+
270
+ const defs = [
271
+ { key: 'H', color: this.getAttribute('hour-color') || '#ff453a' },
272
+ { key: 'M', color: this.getAttribute('minute-color') || '#32d74b' }
273
+ ];
274
+ if (cfg.seconds) defs.push({ key: 'S', color: this.getAttribute('second-color') || this.getAttribute('accent') || '#0a84ff' });
275
+
276
+ const outer = 1 + defs.length;
277
+ const sizeNum = parseFloat(cfg.size) || 300;
278
+ const force = (sizeNum * 12 / (outer + 1)).toFixed(1);
279
+ const sw = Math.max(6, Math.round(sizeNum * 0.05));
280
+
281
+ const rings = defs.map((r, i) => {
282
+ const orbit = outer - i;
283
+ const track = `color-mix(in srgb, ${r.color} 14%, #0c0f14)`;
284
+ const glow = cfg.glow ? `filter:drop-shadow(0 0 5px ${r.color});` : '';
285
+ const fill = `--o-fill:${r.color};--o-stroke:${r.color};--o-stroke-width:${sw};${glow}`;
286
+ // Solid track o-arc behind + o-progress value (o-progress back is hollow).
287
+ return `<div class="orbit-${orbit}"><o-arc value="100" shape="rounded" style="--o-fill:${track};--o-stroke:${track};--o-stroke-width:${sw}"></o-arc></div>`
288
+ + `<div class="orbit-${orbit}"><o-progress class="ok-ring-${r.key}" value="0" max="100" shape="rounded" style="${fill}"></o-progress></div>`;
289
+ }).join('');
290
+
291
+ this.innerHTML = `
292
+ <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
293
+ <div class="gravity-spot" style="--o-force:${force}px;">
294
+ ${rings}
295
+ <div class="orbit-0"><div class="satellite at-center">
296
+ <div class="capsule ok-clock-ringtime" style="font-size:${(sizeNum * 0.13).toFixed(1)}px;"></div>
297
+ </div></div>
298
+ </div>
299
+ </div>`;
300
+
301
+ this._ringH = this.querySelector('.ok-ring-H');
302
+ this._ringM = this.querySelector('.ok-ring-M');
303
+ this._ringS = this.querySelector('.ok-ring-S');
304
+ this._ringTime = this.querySelector('.ok-clock-ringtime');
305
+ }
306
+
307
+ sync(cfg) {
308
+ this._paint(this._resolveDate(cfg), cfg.smooth);
309
+ if (cfg.live) this._startLoop(cfg.smooth);
310
+ else this._stopLoop();
311
+ }
312
+
313
+ onDisconnected() {
314
+ this._stopLoop();
315
+ }
316
+
317
+ /** Point all hands and update digital/date readouts for a given Date. */
318
+ _paint(date, smooth) {
319
+ const h = date.getHours(), m = date.getMinutes();
320
+ const s = smooth ? date.getSeconds() + date.getMilliseconds() / 1000 : date.getSeconds();
321
+
322
+ // Rings face (iOS-style): fill concentric progress rings by time.
323
+ if (this._ringTime) {
324
+ const setV = (el, frac) => el && el.setAttribute('value', (frac * 100).toFixed(2));
325
+ setV(this._ringH, ((h % 12) + m / 60) / 12);
326
+ setV(this._ringM, (m + s / 60) / 60);
327
+ setV(this._ringS, (s % 60) / 60);
328
+ this._ringTime.textContent = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
329
+ return;
330
+ }
331
+
332
+ const set = (el, deg) => el && el.style.setProperty('--o-angle-composite', `${deg}deg`);
333
+ // Main hands (composite folds in -90° so 12 o'clock points up).
334
+ set(this._hourHand, (h % 12) * 30 + m / 2 + s / 120 - 90);
335
+ set(this._minHand, m * 6 + s / 10 - 90);
336
+ set(this._secHand, s * 6 - 90);
337
+ // Sub-dials.
338
+ set(this._subSec, s * 6 - 90);
339
+ set(this._sub24, ((h + m / 60) / 24) * 360 - 90);
340
+ set(this._subDate, ((date.getDate() - 1) / 31) * 360 - 90);
341
+ // Digital / date readouts.
342
+ if (this._digital) {
343
+ this._digital.textContent = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
344
+ }
345
+ if (this._dateEl) this._dateEl.textContent = String(date.getDate());
346
+ }
347
+
348
+ /** Resolve the configured time: explicit `time="HH:MM[:SS]"` or now. */
349
+ _resolveDate(cfg) {
350
+ if (cfg.time) {
351
+ const [hh, mm, ss] = cfg.time.split(':').map((n) => parseInt(n, 10));
352
+ const d = new Date();
353
+ d.setHours(hh || 0, mm || 0, ss || 0, 0);
354
+ return d;
355
+ }
356
+ return new Date();
357
+ }
358
+
359
+ _startLoop(smooth) {
360
+ if (this._running && this._loopSmooth === smooth) return;
361
+ this._stopLoop();
362
+ this._running = true;
363
+ this._loopSmooth = smooth;
364
+ if (smooth) {
365
+ const frame = () => {
366
+ this._paint(new Date(), true);
367
+ this._raf = requestAnimationFrame(frame);
368
+ };
369
+ this._raf = requestAnimationFrame(frame);
370
+ } else {
371
+ this._timer = setInterval(() => this._paint(new Date(), false), 1000);
372
+ }
373
+ }
374
+
375
+ _stopLoop() {
376
+ this._running = false;
377
+ if (this._raf) cancelAnimationFrame(this._raf);
378
+ if (this._timer) clearInterval(this._timer);
379
+ this._raf = this._timer = null;
380
+ }
381
+ }
382
+
383
+ define('o-clock', OrbitClock);
@@ -0,0 +1,115 @@
1
+ /**
2
+ * <o-compass> — heading indicator.
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).
9
+ *
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
16
+ *
17
+ * <o-compass heading="45"></o-compass>
18
+ * <o-compass heading="120" labels="N,NE,E,SE,S,SW,W,NW"></o-compass>
19
+ */
20
+ import { OrbitKitElement, define, injectStyleOnce } from './base.js';
21
+
22
+ const CMP_CSS = `
23
+ 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);
26
+ }
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); }
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);
35
+ }
36
+ 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; }
39
+ `;
40
+
41
+ // Compass point abbreviations for the heading readout.
42
+ const POINTS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
43
+
44
+ export class OrbitCompass extends OrbitKitElement {
45
+ static get observedAttributes() {
46
+ return ['heading', 'size', 'labels', 'fill', 'unit', 'glow'];
47
+ }
48
+
49
+ readConfig() {
50
+ const labels = this._str('labels', 'N,E,S,W').split(',').map((s) => s.trim()).filter(Boolean);
51
+ return {
52
+ heading: ((this._num('heading', 0) % 360) + 360) % 360,
53
+ labels,
54
+ size: this._str('size', '220px'),
55
+ fill: this._str('fill', '#ef4444'),
56
+ unit: this._str('unit', '°'),
57
+ glow: this.hasAttribute('glow')
58
+ };
59
+ }
60
+
61
+ structuralSignature(cfg) {
62
+ const { heading, ...rest } = cfg;
63
+ return JSON.stringify(rest);
64
+ }
65
+
66
+ build(cfg) {
67
+ 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);
74
+
75
+ // 36 ticks every 10°; every 9th (90°) is a long cardinal tick.
76
+ 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('');
78
+
79
+ // Cardinals distributed by count (NO fit-range — that overlaps on a 360 loop).
80
+ // Plain satellites (NOT spin-lock): the capsule stays upright by default
81
+ // while the ring's --o-from rotates only their POSITION. spin-lock would
82
+ // over-rotate the letters once we drive --o-from for the heading.
83
+ const cards = cfg.labels.map((l, i) =>
84
+ `<div class="satellite"><div class="capsule ok-cmp-card${i === 0 ? ' north' : ''}">${l}</div></div>`).join('');
85
+
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>`;
98
+
99
+ this._ticks = this.querySelector('.o-cmp-ticks');
100
+ this._cards = this.querySelector('.o-cmp-cards');
101
+ this._deg = this.querySelector('.ok-cmp-deg');
102
+ this._sub = this.querySelector('.ok-cmp-sub');
103
+ }
104
+
105
+ sync(cfg) {
106
+ // Rotate the card so the heading lands under the fixed top index.
107
+ const rot = `${-cfg.heading}deg`;
108
+ if (this._ticks) this._ticks.style.setProperty('--o-from', rot);
109
+ 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}`;
111
+ if (this._sub) this._sub.textContent = POINTS[Math.round(cfg.heading / 45) % 8];
112
+ }
113
+ }
114
+
115
+ define('o-compass', OrbitCompass);
package/src/o-donut.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
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)
9
+ *
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).
20
+ */
21
+ import { OrbitKitElement, define } from './base.js';
22
+
23
+ export class OrbitDonut extends OrbitKitElement {
24
+ static get observedAttributes() {
25
+ return ['data', 'size', 'orbit', 'stroke-width', 'gap', 'track',
26
+ 'range', 'from', 'label', 'unit', 'total', 'glow'];
27
+ }
28
+
29
+ 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
35
+ }));
36
+ const sum = data.reduce((a, d) => a + d.value, 0) || 1;
37
+ const total = this._num('total', sum);
38
+ 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
52
+ };
53
+ }
54
+
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);
59
+ }
60
+
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
+ : '';
83
+
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
+ : '';
87
+
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>`;
97
+ }
98
+ }
99
+
100
+ define('o-donut', OrbitDonut);