@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/LICENSE +21 -0
- package/README.md +395 -0
- package/package.json +57 -0
- package/src/base.js +133 -0
- package/src/o-clock.js +383 -0
- package/src/o-compass.js +115 -0
- package/src/o-donut.js +100 -0
- package/src/o-gauge.js +135 -0
- package/src/o-instrument.js +280 -0
- package/src/o-knob.js +191 -0
- package/src/o-menu.js +280 -0
- package/src/o-meter.js +109 -0
- package/src/o-radar.js +145 -0
- package/src/o-rings.js +93 -0
- package/src/orbit-kit.js +33 -0
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);
|
package/src/o-compass.js
ADDED
|
@@ -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);
|