@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-menu.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <o-menu> — radial / pie menu, single- or multi-level, two expansion modes.
|
|
3
|
+
*
|
|
4
|
+
* Each sector is an <o-arc> whose curved label rides the arc itself (Orbit
|
|
5
|
+
* renders textContent along the sector path, auto-centered — no angle math).
|
|
6
|
+
* Items may nest via `children`. Two ways to reveal a branch's children:
|
|
7
|
+
*
|
|
8
|
+
* expand="drill" (default) — the ring REPLACES itself with the
|
|
9
|
+
* sub-level; the center becomes a "back" button.
|
|
10
|
+
* expand="concentric" — the parent ring STAYS and the children fan out in an
|
|
11
|
+
* outer arc aligned to the chosen sector's wedge
|
|
12
|
+
* (the radial-editor look). Click an open branch to
|
|
13
|
+
* collapse it; the center resets to the top.
|
|
14
|
+
*
|
|
15
|
+
* Items may carry an `icon` (emoji or raw SVG string), placed dead-center on
|
|
16
|
+
* the sector via `--o-angle-composite` and kept upright with `spin-lock`.
|
|
17
|
+
*
|
|
18
|
+
* Events (bubbling):
|
|
19
|
+
* - `select` detail { value, label, item, path:[indices] }
|
|
20
|
+
* - `navigate` detail { path:[indices], depth }
|
|
21
|
+
*
|
|
22
|
+
* `el.path` reads the open path; `el.back()` / `el.reset()` navigate.
|
|
23
|
+
*/
|
|
24
|
+
import { OrbitKitElement, define, injectStyleOnce } from './base.js';
|
|
25
|
+
|
|
26
|
+
const MENU_CSS = `
|
|
27
|
+
o-menu { --o-menu-hub: rgba(20,22,30,.92); }
|
|
28
|
+
o-menu o-arc.o-menu-item { cursor: pointer; transition: filter .18s ease; }
|
|
29
|
+
o-menu o-arc.o-menu-item:hover { filter: brightness(1.25); }
|
|
30
|
+
o-menu o-arc.o-menu-item.is-active { filter: brightness(1.4) drop-shadow(0 0 6px currentColor); }
|
|
31
|
+
o-menu .o-menu-icon { pointer-events: none; }
|
|
32
|
+
o-menu .o-menu-icon .capsule { display: flex; align-items: center; justify-content: center; }
|
|
33
|
+
o-menu .ok-menu-ring { animation: ok-menu-in .22s cubic-bezier(.2,.7,.3,1); transform-origin: center; }
|
|
34
|
+
@keyframes ok-menu-in { from { opacity: .25; transform: scale(.9); } to { opacity: 1; transform: scale(1); } }
|
|
35
|
+
o-menu .o-menu-hub {
|
|
36
|
+
border-radius: 50%;
|
|
37
|
+
background: var(--o-menu-hub);
|
|
38
|
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
|
|
39
|
+
padding: 16%;
|
|
40
|
+
font-weight: 800; text-align: center; line-height: 1.12;
|
|
41
|
+
display: flex; align-items: center; justify-content: center;
|
|
42
|
+
transition: color .18s ease, background .18s ease;
|
|
43
|
+
}
|
|
44
|
+
o-menu .o-menu-hub.is-back { cursor: pointer; }
|
|
45
|
+
o-menu .o-menu-hub.is-back:hover { background: var(--o-menu-hub-hover, rgba(36,40,54,.96)); }
|
|
46
|
+
/* Monochrome theme: slate sectors that tint to the accent on hover / active. */
|
|
47
|
+
o-menu.o-menu--mono o-arc.o-menu-item { --o-fill: var(--ok-menu-slate, #2b333f); --o-stroke: var(--ok-menu-slate, #2b333f); transition: filter .18s, --o-fill .18s; }
|
|
48
|
+
o-menu.o-menu--mono o-arc.o-menu-item:hover { --o-fill: var(--ok-menu-accent, #3da9fc); --o-stroke: var(--ok-menu-accent, #3da9fc); filter: none; }
|
|
49
|
+
o-menu.o-menu--mono o-arc.o-menu-item.is-active { --o-fill: var(--ok-menu-accent, #3da9fc); --o-stroke: var(--ok-menu-accent, #3da9fc); filter: none; }
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
// Refined, slightly desaturated palette (not the saturated rainbow).
|
|
53
|
+
const PALETTE = ['#6b72c9', '#3ea58f', '#cf9a52', '#cf6b6b', '#5e9fd0', '#9b80c9', '#c77ba2', '#85ab5c'];
|
|
54
|
+
|
|
55
|
+
export class OrbitMenu extends OrbitKitElement {
|
|
56
|
+
static get observedAttributes() {
|
|
57
|
+
return ['items', 'size', 'orbit', 'range', 'from', 'gap', 'fill', 'label', 'label-color', 'expand', 'theme', 'accent'];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Recursively normalize the items tree. */
|
|
61
|
+
_norm(items) {
|
|
62
|
+
return (items || []).map((it, i) => ({
|
|
63
|
+
label: it.label != null ? String(it.label) : `Item ${i + 1}`,
|
|
64
|
+
value: it.value !== undefined ? it.value : (it.label ?? i),
|
|
65
|
+
color: it.color || null,
|
|
66
|
+
icon: it.icon != null ? String(it.icon) : null,
|
|
67
|
+
children: it.children ? this._norm(it.children) : null
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
readConfig() {
|
|
72
|
+
const range = this._num('range', 360);
|
|
73
|
+
return {
|
|
74
|
+
items: this._norm(this._json('items', []) || []),
|
|
75
|
+
range,
|
|
76
|
+
from: this.hasAttribute('from') ? this._num('from', 0)
|
|
77
|
+
: (range === 360 ? 0 : this._autoFrom(range)),
|
|
78
|
+
orbit: Math.round(this._num('orbit', 4)),
|
|
79
|
+
size: this._str('size', '280px'),
|
|
80
|
+
gap: this._num('gap', 6),
|
|
81
|
+
labelColor: this._str('label-color', '#f8fafc'),
|
|
82
|
+
theme: this._str('theme', 'mono') === 'color' ? 'color' : 'mono',
|
|
83
|
+
accent: this._str('accent', '#3da9fc'),
|
|
84
|
+
expand: this._str('expand', 'drill') === 'concentric' ? 'concentric' : 'drill',
|
|
85
|
+
label: this.hasAttribute('label') ? this.getAttribute('label') : 'MENU'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
structuralSignature(cfg) {
|
|
90
|
+
return JSON.stringify(cfg);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
build(cfg) {
|
|
94
|
+
injectStyleOnce('orbit-kit-menu', MENU_CSS);
|
|
95
|
+
this._cfg = cfg;
|
|
96
|
+
this.classList.toggle('o-menu--mono', cfg.theme === 'mono');
|
|
97
|
+
this.style.setProperty('--ok-menu-accent', cfg.accent);
|
|
98
|
+
if (!this._path) this._path = [];
|
|
99
|
+
if (cfg.expand === 'concentric') this._buildConcentric(cfg);
|
|
100
|
+
else this._buildDrill(cfg);
|
|
101
|
+
this._wireKeys();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Render one ring of sectors (+ centered icons) at a given orbit/wedge. */
|
|
105
|
+
_ring(items, from, range, orbit, level, openIndex, cfg) {
|
|
106
|
+
const N = items.length || 1;
|
|
107
|
+
const sliceW = range / N;
|
|
108
|
+
const arcs = items.map((it, i) => {
|
|
109
|
+
const color = it.color || PALETTE[i % PALETTE.length];
|
|
110
|
+
const isBranch = !!(it.children && it.children.length);
|
|
111
|
+
const text = it.icon ? '' : it.label + (isBranch ? ' ›' : '');
|
|
112
|
+
const cls = `o-menu-item${isBranch ? ' is-branch' : ''}${openIndex === i ? ' is-active' : ''}`;
|
|
113
|
+
// Mono theme paints fills via CSS (so :hover/:active can tint them);
|
|
114
|
+
// color theme sets the per-item color inline.
|
|
115
|
+
const style = cfg.theme === 'mono'
|
|
116
|
+
? `--o-gap:${cfg.gap};--o-color:${cfg.labelColor};font-weight:700;`
|
|
117
|
+
: `--o-fill:${color};--o-stroke:${color};--o-gap:${cfg.gap};--o-color:${cfg.labelColor};font-weight:700;`;
|
|
118
|
+
// Flip labels whose sector sits in the lower half so text stays upright.
|
|
119
|
+
const phi = (((from + (i + 0.5) * sliceW) % 360) + 360) % 360;
|
|
120
|
+
const flip = phi > 90 && phi < 270 ? ' flip' : '';
|
|
121
|
+
return `<o-arc class="${cls}" data-level="${level}" data-index="${i}" shape="rounded"${flip} style="${style}">${text}</o-arc>`;
|
|
122
|
+
}).join('');
|
|
123
|
+
const arcRing = `<div class="orbit-${orbit} ok-menu-ring" style="--o-range:${range}deg;--o-from:${from}deg;">${arcs}</div>`;
|
|
124
|
+
// Icons live in their OWN orbit so they don't pollute the arcs' count-based
|
|
125
|
+
// distribution. Each is pinned to its sector center via an explicit
|
|
126
|
+
// composite that MATCHES the count-based arc center (no fit-range, which
|
|
127
|
+
// would shift the arcs and misalign the icons).
|
|
128
|
+
const icons = items.map((it, i) => it.icon
|
|
129
|
+
? `<div class="satellite spin-lock o-menu-icon" style="--o-angle-composite:calc(${((i + 0.5) * sliceW - 90).toFixed(2)}deg);"><div class="capsule">${it.icon}</div></div>`
|
|
130
|
+
: '').join('');
|
|
131
|
+
const iconRing = icons
|
|
132
|
+
? `<div class="orbit-${orbit}" style="--o-from:${from}deg;">${icons}</div>`
|
|
133
|
+
: '';
|
|
134
|
+
return arcRing + iconRing;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_buildConcentric(cfg) {
|
|
138
|
+
let rings = '', from = cfg.from, range = cfg.range, items = cfg.items, orbit = cfg.orbit;
|
|
139
|
+
for (let d = 0; ; d++) {
|
|
140
|
+
const openIndex = this._path[d];
|
|
141
|
+
rings += this._ring(items, from, range, orbit, d, openIndex ?? null, cfg);
|
|
142
|
+
if (openIndex == null) break;
|
|
143
|
+
const branch = items[openIndex];
|
|
144
|
+
if (!branch || !branch.children || !branch.children.length) { this._path = this._path.slice(0, d); break; }
|
|
145
|
+
const sliceW = range / (items.length || 1);
|
|
146
|
+
from = from + openIndex * sliceW;
|
|
147
|
+
range = sliceW;
|
|
148
|
+
items = branch.children;
|
|
149
|
+
orbit += 2;
|
|
150
|
+
}
|
|
151
|
+
const open = this._path.length > 0;
|
|
152
|
+
this.innerHTML = `
|
|
153
|
+
<div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
|
|
154
|
+
<div class="gravity-spot">
|
|
155
|
+
${rings}
|
|
156
|
+
<div class="orbit-0"><div class="satellite at-center"><div class="capsule o-menu-hub${open ? ' is-back' : ''}">${cfg.label}</div></div></div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>`;
|
|
159
|
+
this._hub = this.querySelector('.o-menu-hub');
|
|
160
|
+
this._arcs = [...this.querySelectorAll('.o-menu-item')];
|
|
161
|
+
this._bindArcs((lvl, i) => this._activateAt(lvl, i));
|
|
162
|
+
if (this._hub && open) this._hub.addEventListener('click', () => this.reset());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_buildDrill(cfg) {
|
|
166
|
+
const { level, trail } = this._resolveLevel(cfg.items);
|
|
167
|
+
this._level = level;
|
|
168
|
+
const atRoot = this._path.length === 0;
|
|
169
|
+
const hubText = atRoot ? cfg.label : `‹ ${trail[trail.length - 1].label}`;
|
|
170
|
+
const ring = this._ring(level, cfg.from, cfg.range, cfg.orbit, this._path.length, null, cfg);
|
|
171
|
+
this.innerHTML = `
|
|
172
|
+
<div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
|
|
173
|
+
<div class="gravity-spot">
|
|
174
|
+
${ring}
|
|
175
|
+
<div class="orbit-0"><div class="satellite at-center"><div class="capsule o-menu-hub${atRoot ? '' : ' is-back'}">${hubText}</div></div></div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>`;
|
|
178
|
+
this._hub = this.querySelector('.o-menu-hub');
|
|
179
|
+
this._arcs = [...this.querySelectorAll('.o-menu-item')];
|
|
180
|
+
this._bindArcs((lvl, i) => this._activateAt(this._path.length, i));
|
|
181
|
+
if (this._hub && !atRoot) this._hub.addEventListener('click', () => this.back());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_bindArcs(onActivate) {
|
|
185
|
+
this._arcs.forEach((arc) => {
|
|
186
|
+
const lvl = Number(arc.dataset.level), i = Number(arc.dataset.index);
|
|
187
|
+
arc.addEventListener('click', () => onActivate(lvl, i));
|
|
188
|
+
arc.addEventListener('pointerenter', () => this._setHub(this._itemsAtLevel(lvl)[i]?.label));
|
|
189
|
+
arc.addEventListener('pointerleave', () => this._setHub());
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_wireKeys() {
|
|
194
|
+
this.tabIndex = 0;
|
|
195
|
+
if (this._keyBound) return;
|
|
196
|
+
this._keyBound = true;
|
|
197
|
+
this.addEventListener('keydown', (e) => {
|
|
198
|
+
if ((e.key === 'Escape' || e.key === 'Backspace') && this._path.length) {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
this._cfg.expand === 'concentric' ? (this._path = this._path.slice(0, -1), this.build(this._cfg)) : this.back();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Items array at a given depth, walking the open path. */
|
|
206
|
+
_itemsAtLevel(level) {
|
|
207
|
+
let items = this._cfg.items;
|
|
208
|
+
for (let d = 0; d < level; d++) {
|
|
209
|
+
const node = items[this._path[d]];
|
|
210
|
+
if (!node || !node.children) return items;
|
|
211
|
+
items = node.children;
|
|
212
|
+
}
|
|
213
|
+
return items;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Drill-mode: resolve items at the current path; reset if invalid. */
|
|
217
|
+
_resolveLevel(root) {
|
|
218
|
+
let level = root; const trail = [];
|
|
219
|
+
for (const idx of this._path) {
|
|
220
|
+
const node = level[idx];
|
|
221
|
+
if (!node || !node.children || !node.children.length) { this._path = []; return { level: root, trail: [] }; }
|
|
222
|
+
trail.push(node); level = node.children;
|
|
223
|
+
}
|
|
224
|
+
return { level, trail };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_setHub(text) {
|
|
228
|
+
if (!this._hub) return;
|
|
229
|
+
this._hub.textContent = text != null ? text
|
|
230
|
+
: (this._path.length && this._cfg.expand === 'drill'
|
|
231
|
+
? `‹ ${this._itemsAtLevel(this._path.length - 1)[this._path[this._path.length - 1]]?.label ?? this._cfg.label}`
|
|
232
|
+
: (this._selectedLabel ?? this._cfg.label));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Activate sector `i` at depth `level`: drill/expand a branch, or select a leaf. */
|
|
236
|
+
_activateAt(level, i) {
|
|
237
|
+
const items = this._itemsAtLevel(level);
|
|
238
|
+
const it = items[i];
|
|
239
|
+
if (!it) return;
|
|
240
|
+
const isBranch = it.children && it.children.length;
|
|
241
|
+
if (isBranch) {
|
|
242
|
+
// Concentric toggles; drill always opens the next level.
|
|
243
|
+
if (this._cfg.expand === 'concentric' && this._path[level] === i) this._path = this._path.slice(0, level);
|
|
244
|
+
else this._path = [...this._path.slice(0, level), i];
|
|
245
|
+
this.build(this._cfg);
|
|
246
|
+
this.dispatchEvent(new CustomEvent('navigate', { detail: { path: [...this._path], depth: this._path.length }, bubbles: true }));
|
|
247
|
+
} else {
|
|
248
|
+
this._value = it.value;
|
|
249
|
+
this._selectedLabel = it.label;
|
|
250
|
+
this._path = this._path.slice(0, level);
|
|
251
|
+
this.build(this._cfg);
|
|
252
|
+
this.dispatchEvent(new CustomEvent('select', {
|
|
253
|
+
detail: { value: it.value, label: it.label, item: it, path: [...this._path, i] }, bubbles: true
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- public API ---
|
|
259
|
+
get path() { return [...(this._path || [])]; }
|
|
260
|
+
get value() { return this._value; }
|
|
261
|
+
back() {
|
|
262
|
+
if (!this._path || !this._path.length) return;
|
|
263
|
+
this._path = this._path.slice(0, -1);
|
|
264
|
+
this.build(this._cfg);
|
|
265
|
+
this.dispatchEvent(new CustomEvent('navigate', { detail: { path: [...this._path], depth: this._path.length }, bubbles: true }));
|
|
266
|
+
}
|
|
267
|
+
reset() {
|
|
268
|
+
if (!this._path.length) return;
|
|
269
|
+
this._path = [];
|
|
270
|
+
this.build(this._cfg);
|
|
271
|
+
this.dispatchEvent(new CustomEvent('navigate', { detail: { path: [], depth: 0 }, bubbles: true }));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Back-compat with the former flat menu.
|
|
275
|
+
_activate(i) { this._activateAt(this._path.length, i); }
|
|
276
|
+
_select(i) { this._activateAt(this._path.length, i); }
|
|
277
|
+
get selectedIndex() { return this._value != null && this._path.length === 0 ? this._arcs.findIndex((a) => a.classList.contains('is-active')) : -1; }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
define('o-menu', OrbitMenu);
|
package/src/o-meter.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <o-meter> — speedometer / tachometer gauge with ticks, colored zones and a needle.
|
|
3
|
+
*
|
|
4
|
+
* Collapses the Orbit recipe:
|
|
5
|
+
*
|
|
6
|
+
* .bigbang > .gravity-spot
|
|
7
|
+
* ├── .orbit-{outer} (range/from, fit-range) > .vector × ticks
|
|
8
|
+
* ├── .orbit-{mid} (range/from) > <o-arc> × zones
|
|
9
|
+
* ├── .orbit-{inner} (range/from) > <o-arc shape="arrow"> (needle)
|
|
10
|
+
* └── .orbit-0 > .satellite.at-center > .capsule (value + unit)
|
|
11
|
+
*
|
|
12
|
+
* ...into one tag:
|
|
13
|
+
*
|
|
14
|
+
* <o-meter value="125" min="0" max="240" unit="km/h" ticks="9"
|
|
15
|
+
* zones='[{"from":0,"to":140,"color":"#22c55e"},
|
|
16
|
+
* {"from":140,"to":200,"color":"#eab308"},
|
|
17
|
+
* {"from":200,"to":240,"color":"#ef4444"}]'></o-meter>
|
|
18
|
+
*
|
|
19
|
+
* Zones are given in value units (min..max). The needle and tick layers are
|
|
20
|
+
* cheap to patch, so changing `value` only re-points the needle (no rebuild).
|
|
21
|
+
*/
|
|
22
|
+
import { OrbitKitElement, define } from './base.js';
|
|
23
|
+
|
|
24
|
+
export class OrbitMeter extends OrbitKitElement {
|
|
25
|
+
static get observedAttributes() {
|
|
26
|
+
return ['value', 'min', 'max', 'range', 'from', 'size', 'ticks',
|
|
27
|
+
'zones', 'fill', 'unit', 'label', 'needle-color', 'glow'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
readConfig() {
|
|
31
|
+
const range = this._num('range', 270);
|
|
32
|
+
const min = this._num('min', 0);
|
|
33
|
+
const max = this._num('max', 100);
|
|
34
|
+
const span = (max - min) || 1;
|
|
35
|
+
const zones = (this._json('zones', []) || []).map((z) => ({
|
|
36
|
+
from: Number(z.from) || 0,
|
|
37
|
+
to: Number(z.to) || 0,
|
|
38
|
+
color: z.color || '#6366f1'
|
|
39
|
+
}));
|
|
40
|
+
return {
|
|
41
|
+
value: this._num('value', min),
|
|
42
|
+
min, max, span, range, zones,
|
|
43
|
+
from: this.hasAttribute('from') ? this._num('from', 0) : this._autoFrom(range),
|
|
44
|
+
size: this._str('size', '220px'),
|
|
45
|
+
ticks: Math.max(0, Math.round(this._num('ticks', 9))),
|
|
46
|
+
needleColor: this._str('needle-color', '#e5e7eb'),
|
|
47
|
+
unit: this._str('unit', ''),
|
|
48
|
+
glow: this.getAttribute('glow'),
|
|
49
|
+
label: this.hasAttribute('label') ? this.getAttribute('label') : null
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
structuralSignature(cfg) {
|
|
54
|
+
// Everything except `value` and `label` is structural; the needle and the
|
|
55
|
+
// center readout are patched in sync() so the arc isn't rebuilt per frame.
|
|
56
|
+
const { value, label, ...rest } = cfg;
|
|
57
|
+
return JSON.stringify(rest);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
build(cfg) {
|
|
61
|
+
const ticks = Array.from({ length: cfg.ticks }, () => '<div class="vector"></div>').join('');
|
|
62
|
+
|
|
63
|
+
const zones = cfg.zones.map((z) => {
|
|
64
|
+
const pct = ((z.to - z.from) / cfg.span) * 100;
|
|
65
|
+
const style = `--o-fill:${z.color};--o-stroke:${z.color};--o-stroke-width:6;--o-gap:0;${this._glow(z.color)}`;
|
|
66
|
+
return `<o-arc value="${pct.toFixed(4)}" style="${style}"></o-arc>`;
|
|
67
|
+
}).join('');
|
|
68
|
+
|
|
69
|
+
const zoneLayer = zones
|
|
70
|
+
? `<div class="orbit-5" style="--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;">${zones}</div>`
|
|
71
|
+
: '';
|
|
72
|
+
|
|
73
|
+
const tickLayer = cfg.ticks
|
|
74
|
+
? `<div class="orbit-6 fit-range" style="--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;">${ticks}</div>`
|
|
75
|
+
: '';
|
|
76
|
+
|
|
77
|
+
this.innerHTML = `
|
|
78
|
+
<div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
|
|
79
|
+
<div class="gravity-spot">
|
|
80
|
+
${tickLayer}
|
|
81
|
+
${zoneLayer}
|
|
82
|
+
<div class="orbit-4" style="--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;">
|
|
83
|
+
<o-arc class="o-meter-needle" value="0" shape="arrow" style="--o-fill:${cfg.needleColor};--o-stroke:${cfg.needleColor}"></o-arc>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="orbit-0">
|
|
86
|
+
<div class="satellite at-center">
|
|
87
|
+
<div class="capsule ok-hub o-meter-readout"></div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>`;
|
|
92
|
+
|
|
93
|
+
this._needle = this.querySelector('.o-meter-needle');
|
|
94
|
+
this._readout = this.querySelector('.o-meter-readout');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
sync(cfg) {
|
|
98
|
+
const clamped = Math.min(cfg.max, Math.max(cfg.min, cfg.value));
|
|
99
|
+
const pct = ((clamped - cfg.min) / cfg.span) * 100;
|
|
100
|
+
if (this._needle) this._needle.setAttribute('value', pct.toFixed(2));
|
|
101
|
+
if (this._readout) {
|
|
102
|
+
this._readout.textContent = cfg.label !== null
|
|
103
|
+
? cfg.label
|
|
104
|
+
: `${Math.round(clamped)}${cfg.unit}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
define('o-meter', OrbitMeter);
|
package/src/o-radar.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <o-radar> — radar / scope display.
|
|
3
|
+
*
|
|
4
|
+
* Built the idiomatic Orbit way: the range rings ARE orbits — each `.orbit-N`
|
|
5
|
+
* is a concentric circle, so we just style its border instead of stacking
|
|
6
|
+
* full-circle arcs. On top of that grid: radial cross-hair spokes (`.vector`),
|
|
7
|
+
* a rotating sweep wedge with a bright leading edge, and glowing blips placed
|
|
8
|
+
* on the orbit matching their distance and aimed with `--o-angle-composite`.
|
|
9
|
+
*
|
|
10
|
+
* .bigbang > .gravity-spot
|
|
11
|
+
* ├── .orbit-{2..outer} (styled border) — range rings
|
|
12
|
+
* ├── .orbit-{outer} fit-range range-360 > .vector × spokes
|
|
13
|
+
* ├── .orbit-{outer} (rotating) > <o-arc> wedge — the sweep
|
|
14
|
+
* ├── .orbit-{level} > .satellite (angled) — one per blip
|
|
15
|
+
* └── .orbit-0 > .satellite.at-center — hub + label
|
|
16
|
+
*
|
|
17
|
+
* <o-radar rings="4" color="#22d3ee" spokes="8"
|
|
18
|
+
* targets='[{"angle":60,"dist":0.8},{"angle":200,"dist":0.45}]'></o-radar>
|
|
19
|
+
*
|
|
20
|
+
* `angle` is degrees clockwise from North (top); `dist` is 0–1 from center.
|
|
21
|
+
*/
|
|
22
|
+
import { OrbitKitElement, define, injectStyleOnce } from './base.js';
|
|
23
|
+
|
|
24
|
+
const RADAR_CSS = `
|
|
25
|
+
@keyframes o-radar-sweep { to { transform: rotate(360deg); } }
|
|
26
|
+
@keyframes o-radar-ping {
|
|
27
|
+
0% { transform: scale(.5); opacity: 1; }
|
|
28
|
+
70% { opacity: .45; }
|
|
29
|
+
100% { transform: scale(1.7); opacity: 0; }
|
|
30
|
+
}
|
|
31
|
+
o-radar .o-radar-sweep { animation: o-radar-sweep var(--o-radar-speed, 4s) linear infinite; transform-origin: center; }
|
|
32
|
+
o-radar .o-radar-blip { position: relative; border-radius: 50%; }
|
|
33
|
+
o-radar .o-radar-blip::after {
|
|
34
|
+
content: ""; position: absolute; inset: -4px; border-radius: 50%;
|
|
35
|
+
border: 1px solid currentColor; animation: o-radar-ping 1.8s ease-out infinite;
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
export class OrbitRadar extends OrbitKitElement {
|
|
40
|
+
static get observedAttributes() {
|
|
41
|
+
return ['targets', 'rings', 'spokes', 'size', 'color', 'sweep', 'sweep-width', 'speed', 'label'];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
readConfig() {
|
|
45
|
+
const targets = (this._json('targets', []) || []).map((t) => ({
|
|
46
|
+
angle: Number(t.angle) || 0,
|
|
47
|
+
dist: Math.min(1, Math.max(0, t.dist == null ? 0.5 : Number(t.dist))),
|
|
48
|
+
color: t.color || null
|
|
49
|
+
}));
|
|
50
|
+
return {
|
|
51
|
+
targets,
|
|
52
|
+
rings: Math.max(1, Math.round(this._num('rings', 4))),
|
|
53
|
+
spokes: Math.max(0, Math.round(this._num('spokes', 8))),
|
|
54
|
+
size: this._str('size', '240px'),
|
|
55
|
+
color: this._str('color', '#22d3ee'),
|
|
56
|
+
sweep: this._str('sweep', 'true') !== 'false',
|
|
57
|
+
sweepWidth: Math.min(300, Math.max(10, this._num('sweep-width', 110))),
|
|
58
|
+
speed: this._num('speed', 4),
|
|
59
|
+
label: this.hasAttribute('label') ? this.getAttribute('label') : null
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
structuralSignature(cfg) {
|
|
64
|
+
return JSON.stringify(cfg);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
build(cfg) {
|
|
68
|
+
injectStyleOnce('orbit-kit-radar', RADAR_CSS);
|
|
69
|
+
|
|
70
|
+
const C = cfg.color;
|
|
71
|
+
// Two orbit steps per ring so the grid breathes; outer ring hosts sweep/spokes.
|
|
72
|
+
const outer = cfg.rings * 2;
|
|
73
|
+
|
|
74
|
+
// Range rings — native orbit borders, outermost a touch brighter.
|
|
75
|
+
const rings = Array.from({ length: cfg.rings }, (_, i) => {
|
|
76
|
+
const orbit = (i + 1) * 2;
|
|
77
|
+
const alpha = 0.1 + (i / cfg.rings) * 0.22;
|
|
78
|
+
return `<div class="orbit-${orbit}" style="border:1px solid ${this._rgba(C, alpha)};"></div>`;
|
|
79
|
+
}).join('');
|
|
80
|
+
|
|
81
|
+
// Cross-hair spokes — radial vectors, evenly distributed.
|
|
82
|
+
const spokes = cfg.spokes
|
|
83
|
+
? `<div class="orbit-${outer} fit-range range-360 from-0">
|
|
84
|
+
${Array.from({ length: cfg.spokes }, () =>
|
|
85
|
+
`<div class="vector" style="background:${this._rgba(C, 0.14)};height:1px;"></div>`).join('')}
|
|
86
|
+
</div>`
|
|
87
|
+
: '';
|
|
88
|
+
|
|
89
|
+
// Sweep — a real rotating beam: a conic-gradient disc painted on the outer
|
|
90
|
+
// orbit circle, fading from a transparent tail to a crisp bright leading
|
|
91
|
+
// edge over `sweepWidth` degrees. Smooth, not a blocky fan of arcs.
|
|
92
|
+
const w = cfg.sweepWidth;
|
|
93
|
+
const beam = `conic-gradient(from 0deg,`
|
|
94
|
+
+ ` ${this._rgba(C, 0)} 0deg,`
|
|
95
|
+
+ ` ${this._rgba(C, 0.05)} ${(w * 0.5).toFixed(1)}deg,`
|
|
96
|
+
+ ` ${this._rgba(C, 0.30)} ${(w - 3).toFixed(1)}deg,`
|
|
97
|
+
+ ` ${this._rgba(C, 0.85)} ${w}deg,`
|
|
98
|
+
+ ` transparent ${w}deg, transparent 360deg)`;
|
|
99
|
+
const sweep = cfg.sweep
|
|
100
|
+
? `<div class="orbit-${outer} o-radar-sweep" style="--o-radar-speed:${cfg.speed}s;background:${beam};"></div>`
|
|
101
|
+
: '';
|
|
102
|
+
|
|
103
|
+
// Blips — each on the orbit matching its distance, aimed by angle.
|
|
104
|
+
const blips = cfg.targets.map((t) => {
|
|
105
|
+
const color = t.color || C;
|
|
106
|
+
const level = Math.min(outer, Math.max(1, Math.round(t.dist * outer)));
|
|
107
|
+
return `<div class="orbit-${level}">
|
|
108
|
+
<div class="satellite" style="--o-angle-composite:calc(${t.angle}deg - 90deg);color:${color};">
|
|
109
|
+
<div class="capsule o-radar-blip" style="width:9px;height:9px;background:${color};box-shadow:0 0 10px ${color};"></div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>`;
|
|
112
|
+
}).join('');
|
|
113
|
+
|
|
114
|
+
const center = `
|
|
115
|
+
<div class="orbit-0">
|
|
116
|
+
<div class="satellite at-center">
|
|
117
|
+
<div class="capsule" style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
|
118
|
+
<span style="width:6px;height:6px;border-radius:50%;background:${C};box-shadow:0 0 8px ${C};"></span>
|
|
119
|
+
${cfg.label !== null ? `<span style="font-weight:800;font-size:.7em;letter-spacing:.15em;color:${C};">${cfg.label}</span>` : ''}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>`;
|
|
123
|
+
|
|
124
|
+
this.innerHTML = `
|
|
125
|
+
<div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
|
|
126
|
+
<div class="gravity-spot">
|
|
127
|
+
${rings}
|
|
128
|
+
${spokes}
|
|
129
|
+
${sweep}
|
|
130
|
+
${blips}
|
|
131
|
+
${center}
|
|
132
|
+
</div>
|
|
133
|
+
</div>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Convert #rrggbb (or pass-through) to rgba() at the given alpha. */
|
|
137
|
+
_rgba(color, alpha) {
|
|
138
|
+
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
|
139
|
+
if (!m) return color;
|
|
140
|
+
const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16));
|
|
141
|
+
return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
define('o-radar', OrbitRadar);
|
package/src/o-rings.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <o-rings> — Apple-style concentric activity rings.
|
|
3
|
+
*
|
|
4
|
+
* Collapses the Orbit recipe:
|
|
5
|
+
*
|
|
6
|
+
* .bigbang > .gravity-spot
|
|
7
|
+
* ├── .orbit-{outer} > <o-progress> (ring 1, outermost)
|
|
8
|
+
* ├── .orbit-{outer-1} > <o-progress> (ring 2)
|
|
9
|
+
* ├── ...
|
|
10
|
+
* └── .orbit-0 > .satellite.at-center > .capsule (optional center label)
|
|
11
|
+
*
|
|
12
|
+
* ...into one tag fed by a JSON array (outer ring first):
|
|
13
|
+
*
|
|
14
|
+
* <o-rings data='[{"value":80,"color":"#fa114f"},
|
|
15
|
+
* {"value":55,"color":"#a3ff00"},
|
|
16
|
+
* {"value":92,"color":"#00fff0"}]'></o-rings>
|
|
17
|
+
*
|
|
18
|
+
* Each datum may set its own `max` (default 100), `color`, and `label`.
|
|
19
|
+
*/
|
|
20
|
+
import { OrbitKitElement, define } from './base.js';
|
|
21
|
+
|
|
22
|
+
export class OrbitRings extends OrbitKitElement {
|
|
23
|
+
static get observedAttributes() {
|
|
24
|
+
return ['data', 'size', 'stroke-width', 'track', 'shape',
|
|
25
|
+
'range', 'from', 'step', 'label', 'unit', 'glow'];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
readConfig() {
|
|
29
|
+
const range = this._num('range', 360);
|
|
30
|
+
const data = (this._json('data', []) || []).map((d) => ({
|
|
31
|
+
value: Number(d.value) || 0,
|
|
32
|
+
max: Number(d.max) || 100,
|
|
33
|
+
color: d.color || null,
|
|
34
|
+
label: d.label != null ? String(d.label) : null
|
|
35
|
+
}));
|
|
36
|
+
return {
|
|
37
|
+
data,
|
|
38
|
+
range,
|
|
39
|
+
from: this.hasAttribute('from') ? this._num('from', 0)
|
|
40
|
+
: (range === 360 ? 0 : this._autoFrom(range)),
|
|
41
|
+
size: this._str('size', '200px'),
|
|
42
|
+
strokeWidth: this._num('stroke-width', 12),
|
|
43
|
+
track: this.hasAttribute('track') ? this.getAttribute('track') : null,
|
|
44
|
+
shape: this._str('shape', 'rounded'),
|
|
45
|
+
step: Math.round(this._num('step', 1)),
|
|
46
|
+
unit: this._str('unit', ''),
|
|
47
|
+
glow: this.getAttribute('glow'),
|
|
48
|
+
label: this.hasAttribute('label') ? this.getAttribute('label') : null
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
structuralSignature(cfg) {
|
|
53
|
+
return JSON.stringify(cfg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
build(cfg) {
|
|
57
|
+
const palette = ['#fa114f', '#a3ff00', '#00fff0', '#ffd60a', '#ff9500'];
|
|
58
|
+
// Outermost ring gets the highest orbit number so rings nest inward.
|
|
59
|
+
const outer = 1 + cfg.data.length * cfg.step;
|
|
60
|
+
// Scale --o-force so the outer ring fits within `size` (orbit-N = N/12 *
|
|
61
|
+
// force) with a margin for the rounded caps + glow bloom, instead of the
|
|
62
|
+
// default 500px that overflows the box.
|
|
63
|
+
const sizeNum = parseFloat(cfg.size) || 200;
|
|
64
|
+
const force = (sizeNum * 12 / (outer + 1)).toFixed(1);
|
|
65
|
+
|
|
66
|
+
const rings = cfg.data.map((d, i) => {
|
|
67
|
+
const orbit = outer - i * cfg.step;
|
|
68
|
+
const color = d.color || palette[i % palette.length];
|
|
69
|
+
// iOS-style track: a dim, OPAQUE wash of the ring's own color, drawn as a
|
|
70
|
+
// separate solid o-arc (o-progress's back only strokes a hollow outline).
|
|
71
|
+
const track = cfg.track || `color-mix(in srgb, ${color} 14%, #0c0f14)`;
|
|
72
|
+
const ring = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;`;
|
|
73
|
+
const fill = `--o-fill:${color};--o-stroke:${color};--o-stroke-width:${cfg.strokeWidth};${this._glow(color)}`;
|
|
74
|
+
return `
|
|
75
|
+
<div class="orbit-${orbit}" style="${ring}"><o-arc value="100" shape="${cfg.shape}" style="--o-fill:${track};--o-stroke:${track};--o-stroke-width:${cfg.strokeWidth}"></o-arc></div>
|
|
76
|
+
<div class="orbit-${orbit}" style="${ring}"><o-progress value="${d.value}" max="${d.max}" shape="${cfg.shape}" style="${fill}"></o-progress></div>`;
|
|
77
|
+
}).join('');
|
|
78
|
+
|
|
79
|
+
const center = cfg.label !== null
|
|
80
|
+
? `<div class="orbit-0"><div class="satellite at-center"><div class="capsule ok-hub">${cfg.label}${cfg.unit}</div></div></div>`
|
|
81
|
+
: '';
|
|
82
|
+
|
|
83
|
+
this.innerHTML = `
|
|
84
|
+
<div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
|
|
85
|
+
<div class="gravity-spot" style="--o-force:${force}px;">
|
|
86
|
+
${rings}
|
|
87
|
+
${center}
|
|
88
|
+
</div>
|
|
89
|
+
</div>`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
define('o-rings', OrbitRings);
|
package/src/orbit-kit.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zumer/orbit-kit — high-level components on top of the Orbit primitives.
|
|
3
|
+
*
|
|
4
|
+
* Importing this entry registers every kit component. The Orbit core
|
|
5
|
+
* (CSS engine + base web components like <o-progress> / <o-arc>) must be
|
|
6
|
+
* present on the page — it's declared as a peer dependency:
|
|
7
|
+
*
|
|
8
|
+
* import '@zumer/orbit/style' // styles (or load orbit.css separately)
|
|
9
|
+
* import '@zumer/orbit' // registers <o-progress> / <o-arc>
|
|
10
|
+
* import '@zumer/orbit-kit' // registers <o-gauge>, <o-donut>, ...
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import './o-gauge.js';
|
|
14
|
+
import './o-donut.js';
|
|
15
|
+
import './o-rings.js';
|
|
16
|
+
import './o-meter.js';
|
|
17
|
+
import './o-knob.js';
|
|
18
|
+
import './o-menu.js';
|
|
19
|
+
import './o-compass.js';
|
|
20
|
+
import './o-radar.js';
|
|
21
|
+
import './o-clock.js';
|
|
22
|
+
import './o-instrument.js';
|
|
23
|
+
|
|
24
|
+
export { OrbitGauge } from './o-gauge.js';
|
|
25
|
+
export { OrbitDonut } from './o-donut.js';
|
|
26
|
+
export { OrbitRings } from './o-rings.js';
|
|
27
|
+
export { OrbitMeter } from './o-meter.js';
|
|
28
|
+
export { OrbitKnob } from './o-knob.js';
|
|
29
|
+
export { OrbitMenu } from './o-menu.js';
|
|
30
|
+
export { OrbitCompass } from './o-compass.js';
|
|
31
|
+
export { OrbitRadar } from './o-radar.js';
|
|
32
|
+
export { OrbitClock } from './o-clock.js';
|
|
33
|
+
export { OrbitInstrument } from './o-instrument.js';
|