@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-gauge.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * <o-gauge> — high-level gauge component for Orbit.
3
+ *
4
+ * Collapses the canonical Orbit gauge recipe:
5
+ *
6
+ * .bigbang > .gravity-spot
7
+ * ├── .orbit-N (range/from) > <o-progress>
8
+ * └── .orbit-0 > .satellite.at-center > .capsule (center label)
9
+ *
10
+ * ...into a single declarative tag:
11
+ *
12
+ * <o-gauge value="72" unit="%"></o-gauge>
13
+ *
14
+ * Design note — LIGHT DOM on purpose:
15
+ * Orbit positions elements through global CSS (cos()/sin()/:has()) and reads
16
+ * inherited --o-* custom properties. A layout-composing component must therefore
17
+ * render into the light DOM so the global orbit.css applies and the inner
18
+ * <o-progress> participates in the cascade normally. This also keeps "view
19
+ * source" honest: the output is exactly the documented Orbit structure.
20
+ *
21
+ * Requires @zumer/orbit (CSS + JS) to be loaded on the page so that
22
+ * <o-progress> is registered. orbit-kit declares it as a peer dependency.
23
+ */
24
+ // Side-effect import: injects the kit baseline (strips the default satellite
25
+ // outline) so a standalone <o-gauge> looks right without the rest of the kit.
26
+ import './base.js';
27
+
28
+ export class OrbitGauge extends HTMLElement {
29
+ static get observedAttributes() {
30
+ return [
31
+ 'value', 'max', 'range', 'from', 'orbit', 'size',
32
+ 'label', 'unit', 'fill', 'track', 'stroke-width', 'shape', 'glow'
33
+ ];
34
+ }
35
+
36
+ connectedCallback() {
37
+ this.render();
38
+ }
39
+
40
+ attributeChangedCallback() {
41
+ if (this.isConnected) this.render();
42
+ }
43
+
44
+ /** Parse a numeric attribute with a fallback. */
45
+ _num(name, fallback) {
46
+ const raw = this.getAttribute(name);
47
+ if (raw === null || raw === '') return fallback;
48
+ const n = parseFloat(raw);
49
+ return Number.isFinite(n) ? n : fallback;
50
+ }
51
+
52
+ _readConfig() {
53
+ const range = this._num('range', 270);
54
+ return {
55
+ value: this._num('value', 0),
56
+ max: this._num('max', 100),
57
+ range,
58
+ // Default `from` centers the arc's gap at the bottom (6 o'clock),
59
+ // matching the classic 3/4 gauge: range-270 -> from-225.
60
+ from: this.hasAttribute('from')
61
+ ? this._num('from', 0)
62
+ : ((360 - range / 2) % 360 + 360) % 360,
63
+ orbit: Math.round(this._num('orbit', 4)),
64
+ size: this.getAttribute('size') || '200px',
65
+ fill: this.getAttribute('fill') || '#6366f1',
66
+ track: this.getAttribute('track') || '#262d38',
67
+ strokeWidth: this._num('stroke-width', 8),
68
+ shape: this.getAttribute('shape') || 'rounded',
69
+ unit: this.getAttribute('unit') || '',
70
+ label: this.hasAttribute('label') ? this.getAttribute('label') : null
71
+ };
72
+ }
73
+
74
+ render() {
75
+ const cfg = this._readConfig();
76
+ // Only structural attributes require rebuilding the subtree; value/label
77
+ // changes (e.g. during animation) just patch the existing nodes, so the
78
+ // inner <o-progress> isn't torn down and recreated on every frame.
79
+ const sig = [
80
+ cfg.range, cfg.from, cfg.orbit, cfg.size,
81
+ cfg.fill, cfg.track, cfg.strokeWidth, cfg.shape,
82
+ this.getAttribute('glow')
83
+ ].join('|');
84
+
85
+ if (!this._built || this._sig !== sig) {
86
+ this._build(cfg);
87
+ this._sig = sig;
88
+ this._built = true;
89
+ }
90
+ this._sync(cfg);
91
+ }
92
+
93
+ _build(cfg) {
94
+ const glow = this.hasAttribute('glow')
95
+ ? `filter:drop-shadow(0 0 ${this._num('glow', 6) || 6}px ${cfg.fill});`
96
+ : '';
97
+ // Track + value as two stacked <o-arc>s (each in its own orbit so they
98
+ // don't chain): o-arc FILLS solid, unlike o-progress's back which only
99
+ // strokes a hollow outline (the ugly border + center seam).
100
+ const ring = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;`;
101
+ const trackStyle = `--o-fill:${cfg.track};--o-stroke:${cfg.track};--o-stroke-width:${cfg.strokeWidth}`;
102
+ const fillStyle = `--o-fill:${cfg.fill};--o-stroke:${cfg.fill};--o-stroke-width:${cfg.strokeWidth};${glow}`;
103
+
104
+ this.innerHTML = `
105
+ <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
106
+ <div class="gravity-spot">
107
+ <div class="orbit-${cfg.orbit}" style="${ring}"><o-arc value="100" shape="${cfg.shape}" style="${trackStyle}"></o-arc></div>
108
+ <div class="orbit-${cfg.orbit}" style="${ring}"><o-arc class="o-gauge-fill" value="0" shape="${cfg.shape}" style="${fillStyle}"></o-arc></div>
109
+ <div class="orbit-0">
110
+ <div class="satellite at-center">
111
+ <div class="capsule ok-hub"></div>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </div>`;
116
+
117
+ this._fill = this.querySelector('.o-gauge-fill');
118
+ this._center = this.querySelector('.capsule');
119
+ }
120
+
121
+ _sync(cfg) {
122
+ if (this._fill) {
123
+ const pct = Math.max(0, Math.min(100, (cfg.value / cfg.max) * 100));
124
+ this._fill.setAttribute('value', pct.toFixed(2));
125
+ }
126
+ if (this._center) {
127
+ this._center.textContent =
128
+ cfg.label !== null ? cfg.label : `${cfg.value}${cfg.unit}`;
129
+ }
130
+ }
131
+ }
132
+
133
+ if (typeof customElements !== 'undefined' && !customElements.get('o-gauge')) {
134
+ customElements.define('o-gauge', OrbitGauge);
135
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * <o-instrument> — cockpit / dashboard instrument gauge. A flagship-grade,
3
+ * preset-driven needle gauge for automotive, aviation and computer readouts.
4
+ *
5
+ * Anatomy (all in the light DOM, aligned in one --o-from/--o-range frame so
6
+ * ticks, labels, color zones and the needle agree):
7
+ *
8
+ * case > .bigbang > .gravity-spot
9
+ * ├── full sweep: .orbit-11 (filled) — round dial face + bezel
10
+ * │ partial(≤180): .orbit-9 > <o-arc> — track band (no round case)
11
+ * ├── .orbit-10 fit-range > .vector × minor ticks
12
+ * ├── .orbit-10 fit-range > .vector × major ticks
13
+ * ├── .orbit-9 (per zone, own --o-from) > <o-arc>— color/redline zones
14
+ * ├── .orbit-8 fit-range > .satellite × labels — numeric scale
15
+ * ├── .orbit-0 > .vector needle (full length, rotates to value)
16
+ * └── .orbit-4 > readout (value + unit + caption)
17
+ *
18
+ * <o-instrument preset="tachometer" value="6.2"></o-instrument>
19
+ * <o-instrument preset="airspeed" value="120"></o-instrument>
20
+ * <o-instrument preset="fuel"></o-instrument> <!-- quarter arc, no round case -->
21
+ * <o-instrument min="0" max="100" value="40" unit="%" label="LOAD"
22
+ * zones='[{"from":85,"to":100,"color":"#ef4444"}]'></o-instrument>
23
+ *
24
+ * The needle is a fixed-length vector that ROTATES to the value (so it's always
25
+ * visible, even at zero) — pointed by `--o-angle-composite`. Only `value` is
26
+ * cheap to change; the dial rebuilds when the scale, zones, case or theme change.
27
+ */
28
+ import { OrbitKitElement, define, injectStyleOnce } from './base.js';
29
+
30
+ const RED = '#ef4444', YEL = '#eab308', GRN = '#22c55e', AMB = '#f59e0b', WHT = '#e5e7eb';
31
+
32
+ // Real instruments as preset bundles; any explicit attribute overrides them.
33
+ const PRESETS = {
34
+ tachometer: { min: 0, max: 8, range: 270, ticks: 9, minor: 5, unit: '', label: 'RPM x1000',
35
+ zones: [{ from: 6.5, to: 8, color: RED }], value: 0.8, accent: RED },
36
+ speedometer: { min: 0, max: 240, range: 270, ticks: 9, minor: 2, unit: 'km/h', label: 'SPEED', value: 0, accent: WHT },
37
+ fuel: { min: 0, max: 1, range: 90, from: 270, ticks: 3, minor: 4, unit: '', label: 'FUEL', digital: false,
38
+ tickLabels: ['E', '½', 'F'], zones: [{ from: 0, to: 0.12, color: RED }], value: 0.66, accent: AMB },
39
+ temp: { min: 0, max: 1, range: 90, from: 270, ticks: 3, minor: 4, unit: '', label: 'TEMP', digital: false,
40
+ tickLabels: ['C', '', 'H'], zones: [{ from: 0.82, to: 1, color: RED }], value: 0.45, accent: RED },
41
+ boost: { min: -1, max: 2, range: 240, ticks: 7, minor: 5, unit: 'bar', label: 'BOOST',
42
+ zones: [{ from: 1.2, to: 2, color: RED }], value: 0, accent: '#06b6d4' },
43
+ voltmeter: { min: 8, max: 16, range: 180, ticks: 5, minor: 5, unit: 'V', label: 'VOLTS',
44
+ zones: [{ from: 8, to: 11, color: RED }, { from: 11, to: 12, color: YEL }], value: 13.8, accent: GRN },
45
+ airspeed: { min: 0, max: 200, range: 300, ticks: 11, minor: 5, unit: 'kt', label: 'AIRSPEED',
46
+ zones: [{ from: 40, to: 140, color: GRN }, { from: 140, to: 170, color: YEL }, { from: 170, to: 200, color: RED }],
47
+ value: 0, accent: WHT },
48
+ altimeter: { min: 0, max: 10, range: 360, ticks: 10, minor: 5, unit: 'x1000 ft', label: 'ALT', value: 0, accent: WHT },
49
+ cpu: { min: 0, max: 100, range: 270, ticks: 6, minor: 5, unit: '%', label: 'CPU',
50
+ zones: [{ from: 85, to: 100, color: RED }], value: 0, accent: GRN },
51
+ gpu: { min: 0, max: 100, range: 270, ticks: 6, minor: 5, unit: '%', label: 'GPU',
52
+ zones: [{ from: 85, to: 100, color: RED }], value: 0, accent: '#a855f7' },
53
+ vu: { min: -20, max: 3, range: 120, ticks: 6, minor: 5, unit: 'dB', label: 'VU',
54
+ zones: [{ from: 0, to: 3, color: RED }], value: -8, accent: YEL }
55
+ };
56
+ const CASES = ['round', 'square', 'rounded', 'bare'];
57
+
58
+ const INST_CSS = `
59
+ o-instrument .ok-inst-case { box-sizing: border-box; display: flex; align-items: center; justify-content: center; }
60
+ o-instrument .ok-inst-case--round { border-radius: 50%; }
61
+ o-instrument .ok-inst-case--square { border-radius: 9%; }
62
+ o-instrument .ok-inst-case--rounded { border-radius: 22%; }
63
+ o-instrument .ok-inst-case--bare { background: transparent; }
64
+ o-instrument .ok-inst-case--square,
65
+ o-instrument .ok-inst-case--rounded {
66
+ background: var(--ok-inst-case-bg, linear-gradient(160deg, #232a34, #11151b));
67
+ box-shadow: 0 10px 30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);
68
+ }
69
+ o-instrument .ok-inst-face { color: var(--ok-inst-label, #cbd5e1); }
70
+ o-instrument .ok-inst-dial {
71
+ background: var(--ok-inst-face, radial-gradient(circle at 50% 35%, #1b212b, #0a0d12));
72
+ box-shadow: inset 0 0 0 4px var(--ok-inst-bezel, #2a3340), inset 0 4px 18px rgba(0,0,0,.65);
73
+ }
74
+ o-instrument .ok-inst-tick { background: var(--ok-inst-tick, rgba(203,213,225,.45)); height: 1px; }
75
+ o-instrument .ok-inst-tick-M { background: var(--ok-inst-tick-M, #e6edf3); height: 2px; border-radius: 1px; }
76
+ o-instrument .ok-inst-num { font-weight: 700; font-variant-numeric: tabular-nums; font-size: .8em; }
77
+ o-instrument .ok-inst-needle { background: var(--ok-inst-accent, #ef4444); height: 3px; border-radius: 0 3px 3px 0; }
78
+ o-instrument .ok-inst-readout {
79
+ display: flex; flex-direction: column; align-items: center; line-height: 1.05;
80
+ }
81
+ o-instrument .ok-inst-value { font-weight: 800; font-variant-numeric: tabular-nums; font-size: 1.5em; color: var(--ok-inst-readout-color, #f8fafc); }
82
+ o-instrument .ok-inst-unit { font-size: .62em; opacity: .7; letter-spacing: .05em; }
83
+ o-instrument .ok-inst-cap-label { font-size: .58em; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; opacity: .65; margin-top: .15em; }
84
+ o-instrument .ok-inst-cap { background: var(--ok-inst-accent, #e6edf3); box-shadow: 0 0 0 3px var(--ok-inst-hub, #0a0d12); }
85
+ o-instrument.ok-glow .ok-inst-needle { filter: drop-shadow(0 0 5px var(--ok-inst-accent, #ef4444)); }
86
+ `;
87
+
88
+ export class OrbitInstrument extends OrbitKitElement {
89
+ static get observedAttributes() {
90
+ return ['value', 'min', 'max', 'range', 'from', 'ticks', 'minor', 'zones',
91
+ 'tick-labels', 'unit', 'label', 'redline', 'preset', 'case', 'size',
92
+ 'digital', 'face', 'bezel', 'accent', 'needle-color', 'tick-color', 'glow'];
93
+ }
94
+
95
+ _preset() { return PRESETS[this.getAttribute('preset')] || {}; }
96
+
97
+ _pNum(name, key, dflt) {
98
+ const v = this.getAttribute(name);
99
+ if (v != null && v !== '') return parseFloat(v);
100
+ const p = this._preset();
101
+ return p[key] != null ? p[key] : dflt;
102
+ }
103
+ _pStr(name, key, dflt) {
104
+ const v = this.getAttribute(name);
105
+ if (v != null) return v;
106
+ const p = this._preset();
107
+ return p[key] != null ? p[key] : dflt;
108
+ }
109
+ _pArr(name, key, dflt) {
110
+ const v = this.getAttribute(name);
111
+ if (v != null && v.trim() !== '') { try { return JSON.parse(v); } catch { /* fall through */ } }
112
+ const p = this._preset();
113
+ return p[key] != null ? p[key] : dflt;
114
+ }
115
+
116
+ readConfig() {
117
+ const range = this._pNum('range', 'range', 270);
118
+ const min = this._pNum('min', 'min', 0);
119
+ const max = this._pNum('max', 'max', 100);
120
+ const span = (max - min) || 1;
121
+ const zones = (this._pArr('zones', 'zones', []) || []).map((z) => ({
122
+ from: Number(z.from), to: Number(z.to), color: z.color || RED
123
+ }));
124
+ // `redline=` is sugar for a red zone from that value to max.
125
+ const redline = this.getAttribute('redline');
126
+ if (redline != null) zones.push({ from: parseFloat(redline), to: max, color: RED });
127
+
128
+ // Partial-sweep gauges (fuel/temp/…) read as an arc band, not a round watch,
129
+ // so they default to a "bare" case (no round bezel) unless told otherwise.
130
+ const partial = range <= 180;
131
+ const userCase = this.getAttribute('case') ?? this._preset().case ?? null;
132
+ let caseShape = userCase || (partial ? 'bare' : 'round');
133
+ if (!CASES.includes(caseShape)) caseShape = partial ? 'bare' : 'round';
134
+
135
+ return {
136
+ value: this._pNum('value', 'value', min),
137
+ min, max, span, range, zones, caseShape, partial,
138
+ from: this.hasAttribute('from') ? this._pNum('from', null, 0) : (this._preset().from ?? this._autoFrom(range)),
139
+ ticks: Math.max(2, Math.round(this._pNum('ticks', 'ticks', 7))),
140
+ minor: Math.max(0, Math.round(this._pNum('minor', 'minor', 5))),
141
+ tickLabels: this._pArr('tick-labels', 'tickLabels', null),
142
+ unit: this._pStr('unit', 'unit', ''),
143
+ label: this._pStr('label', 'label', ''),
144
+ digital: this.hasAttribute('digital')
145
+ ? this.getAttribute('digital') !== 'false'
146
+ : (this._preset().digital !== false),
147
+ size: this._str('size', '240px'),
148
+ glow: this.hasAttribute('glow'),
149
+ accent: this._pStr('accent', 'accent', '#ef4444'),
150
+ theme: ['face', 'bezel', 'needle-color', 'tick-color'].map((a) => this.getAttribute(a)).join('|')
151
+ };
152
+ }
153
+
154
+ structuralSignature(cfg) {
155
+ const { value, ...rest } = cfg;
156
+ return JSON.stringify(rest);
157
+ }
158
+
159
+ build(cfg) {
160
+ injectStyleOnce('orbit-kit-instrument', INST_CSS);
161
+ this.classList.toggle('ok-glow', cfg.glow);
162
+
163
+ this.style.setProperty('--ok-inst-accent', cfg.accent);
164
+ const map = { face: '--ok-inst-face', bezel: '--ok-inst-bezel',
165
+ 'needle-color': '--ok-inst-accent', 'tick-color': '--ok-inst-tick' };
166
+ for (const [attr, prop] of Object.entries(map)) {
167
+ const v = this.getAttribute(attr);
168
+ if (v != null) this.style.setProperty(prop, v);
169
+ }
170
+
171
+ const dialPx = parseFloat(cfg.size) || 240;
172
+ const force = (dialPx * 12 / 11).toFixed(1);
173
+ const frame = (cfg.caseShape === 'round' || cfg.caseShape === 'bare') ? 0 : Math.round(dialPx * 0.07);
174
+ const box = dialPx + frame * 2;
175
+ const baseFont = (dialPx * 0.072).toFixed(1);
176
+ const fromVar = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;`;
177
+
178
+ // Partial gauges (a 90° fuel arc) otherwise sit crammed in one quadrant.
179
+ // Center the content by translating the gravity-spot so the arc's bounding
180
+ // box (plus the center pivot) is centered in the square box.
181
+ let centering = '';
182
+ if (cfg.partial) {
183
+ const R = dialPx * 5 / 11; // ≈ outer tick-ring radius
184
+ let minX = 0, maxX = 0, minY = 0, maxY = 0; // seed with the center pivot
185
+ for (let k = 0; k <= 24; k++) {
186
+ const p = (cfg.from + (cfg.range * k) / 24) * Math.PI / 180;
187
+ const x = Math.sin(p), y = -Math.cos(p);
188
+ minX = Math.min(minX, x); maxX = Math.max(maxX, x);
189
+ minY = Math.min(minY, y); maxY = Math.max(maxY, y);
190
+ }
191
+ const dx = (-(minX + maxX) / 2 * R).toFixed(1);
192
+ const dy = (-(minY + maxY) / 2 * R).toFixed(1);
193
+ centering = `transform:translate(${dx}px,${dy}px);`;
194
+ }
195
+
196
+ // Face: a round dial for full sweeps, or a neutral track band for partial
197
+ // gauges (so a quarter-arc fuel gauge isn't trapped in a round bezel).
198
+ const face = cfg.partial
199
+ ? `<div class="orbit-9" style="${fromVar}"><o-arc value="100" style="--o-fill:var(--ok-inst-track,#171c24);--o-stroke:var(--ok-inst-track,#171c24);--o-stroke-width:8"></o-arc></div>`
200
+ : '<div class="orbit-11 ok-inst-dial"></div>';
201
+
202
+ // --- ticks: major (count) + minor (between) over the range ---
203
+ const majorN = cfg.ticks;
204
+ const minorN = cfg.minor > 0 ? (majorN - 1) * cfg.minor + 1 : 0;
205
+ const minorTicks = minorN
206
+ ? `<div class="orbit-10 fit-range" style="${fromVar}">${Array.from({ length: minorN }, () =>
207
+ '<div class="vector inner-orbit shrink-80 ok-inst-tick"></div>').join('')}</div>`
208
+ : '';
209
+ const majorTicks = `<div class="orbit-10 fit-range" style="${fromVar}">${Array.from({ length: majorN }, () =>
210
+ '<div class="vector inner-orbit shrink-55 ok-inst-tick-M"></div>').join('')}</div>`;
211
+
212
+ // --- numeric / custom scale labels ---
213
+ const labels = Array.from({ length: majorN }, (_, i) => {
214
+ const txt = cfg.tickLabels ? (cfg.tickLabels[i] ?? '')
215
+ : this._fmtTick(cfg.min + (i * cfg.span) / (majorN - 1));
216
+ return `<div class="satellite"><div class="capsule ok-inst-num">${txt}</div></div>`;
217
+ }).join('');
218
+ const labelRing = `<div class="orbit-8 fit-range" style="${fromVar}">${labels}</div>`;
219
+
220
+ // --- color zones: each in its own orbit so non-contiguous zones land right ---
221
+ const zones = cfg.zones.map((z) => {
222
+ const startA = cfg.from + ((z.from - cfg.min) / cfg.span) * cfg.range;
223
+ const pct = ((z.to - z.from) / cfg.span) * 100;
224
+ const style = `--o-range:${cfg.range}deg;--o-from:${startA}deg`;
225
+ const arc = `--o-fill:${z.color};--o-stroke:${z.color};--o-stroke-width:5${cfg.glow ? `;filter:drop-shadow(0 0 4px ${z.color})` : ''}`;
226
+ return `<div class="orbit-9" style="${style}"><o-arc value="${pct.toFixed(3)}" style="${arc}"></o-arc></div>`;
227
+ }).join('');
228
+
229
+ const readout = cfg.digital || cfg.label
230
+ ? `<div class="orbit-4"><div class="satellite angle-180"><div class="capsule ok-inst-readout">
231
+ ${cfg.digital ? `<span class="ok-inst-value"></span>` : ''}
232
+ ${cfg.unit ? `<span class="ok-inst-unit">${cfg.unit}</span>` : ''}
233
+ ${cfg.label ? `<span class="ok-inst-cap-label">${cfg.label}</span>` : ''}
234
+ </div></div></div>`
235
+ : '';
236
+
237
+ this.innerHTML = `
238
+ <div class="ok-inst-case ok-inst-case--${cfg.caseShape}" style="width:${box}px;height:${box}px;font-size:${baseFont}px;">
239
+ <div class="bigbang" style="width:100%;height:100%;">
240
+ <div class="gravity-spot ok-inst-face" style="--o-force:${force}px;${centering}">
241
+ ${face}
242
+ ${minorTicks}${majorTicks}
243
+ ${zones}
244
+ ${labelRing}
245
+ ${readout}
246
+ <div class="orbit-0">
247
+ <div class="vector outer-orbit grow-4x ok-inst-needle"></div>
248
+ <div class="satellite at-center shrink-75 ok-inst-cap"></div>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </div>`;
253
+
254
+ this._needle = this.querySelector('.ok-inst-needle');
255
+ this._value = this.querySelector('.ok-inst-value');
256
+ }
257
+
258
+ sync(cfg) {
259
+ const v = Math.min(cfg.max, Math.max(cfg.min, cfg.value));
260
+ const frac = (v - cfg.min) / cfg.span;
261
+ // The arc start (value 0) sits at screen angle `from + 270`; the needle is
262
+ // a full-length vector rotated to the value's screen angle (always visible).
263
+ const deg = (((cfg.from + 270 + frac * cfg.range) % 360) + 360) % 360;
264
+ if (this._needle) this._needle.style.setProperty('--o-angle-composite', `${deg.toFixed(2)}deg`);
265
+ if (this._value) this._value.textContent = this._fmtValue(cfg.value, cfg.span);
266
+ }
267
+
268
+ /** Tick-scale label: integers bare, otherwise one decimal. */
269
+ _fmtTick(v) {
270
+ return Number.isInteger(v) ? String(v) : v.toFixed(1).replace(/\.0$/, '');
271
+ }
272
+ /** Readout value: more precision for small spans (bar, dB), integer for big. */
273
+ _fmtValue(v, span) {
274
+ if (span <= 3) return v.toFixed(1);
275
+ if (span <= 20) return v.toFixed(1).replace(/\.0$/, '');
276
+ return String(Math.round(v));
277
+ }
278
+ }
279
+
280
+ define('o-instrument', OrbitInstrument);
package/src/o-knob.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * <o-knob> — interactive rotary control.
3
+ *
4
+ * Collapses the Orbit recipe:
5
+ *
6
+ * .bigbang > .gravity-spot
7
+ * ├── .orbit-N > <o-arc shape="circle-a"> (the dial)
8
+ * └── .orbit-0 > .satellite.at-center > .capsule (value readout)
9
+ *
10
+ * ...into one tag that behaves like a form control:
11
+ *
12
+ * <o-knob value="65" min="0" max="100" unit="%" fill="#a1a1aa"></o-knob>
13
+ *
14
+ * Interaction: drag vertically (or use the mouse wheel / arrow keys when
15
+ * focused) to change the value. Emits a bubbling `input` event on every change
16
+ * and `change` when the gesture ends. `el.value` reads/writes the number.
17
+ */
18
+ import { OrbitKitElement, define } from './base.js';
19
+
20
+ export class OrbitKnob extends OrbitKitElement {
21
+ static get observedAttributes() {
22
+ return ['value', 'min', 'max', 'step', 'size', 'orbit', 'fill', 'track',
23
+ 'stroke-width', 'shape', 'unit', 'label', 'disabled', 'range', 'from', 'glow'];
24
+ }
25
+
26
+ readConfig() {
27
+ const range = this._num('range', 270);
28
+ const min = this._num('min', 0);
29
+ const max = this._num('max', 100);
30
+ return {
31
+ value: this._num('value', min),
32
+ min, max,
33
+ span: (max - min) || 1,
34
+ step: this._num('step', 1),
35
+ range,
36
+ from: this.hasAttribute('from') ? this._num('from', 0) : this._autoFrom(range),
37
+ orbit: Math.round(this._num('orbit', 4)),
38
+ size: this._str('size', '160px'),
39
+ fill: this._str('fill', '#a1a1aa'),
40
+ track: this._str('track', '#27272a'),
41
+ strokeWidth: this._num('stroke-width', 14),
42
+ shape: this._str('shape', 'rounded'),
43
+ unit: this._str('unit', ''),
44
+ disabled: this.hasAttribute('disabled'),
45
+ glow: this.getAttribute('glow'),
46
+ label: this.hasAttribute('label') ? this.getAttribute('label') : null
47
+ };
48
+ }
49
+
50
+ structuralSignature(cfg) {
51
+ const { value, label, disabled, ...rest } = cfg;
52
+ return JSON.stringify(rest);
53
+ }
54
+
55
+ build(cfg) {
56
+ // Scale --o-force so the dial (orbit-N) fits the box with a margin.
57
+ const dialPx = parseFloat(cfg.size) || 160;
58
+ const force = (dialPx * 12 / (cfg.orbit + 1)).toFixed(1);
59
+ const ring = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;`;
60
+ const trackStyle = `--o-fill:${cfg.track};--o-stroke:${cfg.track};--o-stroke-width:${cfg.strokeWidth}`;
61
+ const valStyle = `--o-fill:${cfg.fill};--o-stroke:${cfg.fill};--o-stroke-width:${cfg.strokeWidth};${this._glow(cfg.fill)}`;
62
+
63
+ this.innerHTML = `
64
+ <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;${cfg.disabled ? 'opacity:.5;' : ''}">
65
+ <div class="gravity-spot" style="--o-force:${force}px;">
66
+ <div class="orbit-${cfg.orbit}" style="${ring}"><o-arc value="100" shape="${cfg.shape}" style="${trackStyle}"></o-arc></div>
67
+ <div class="orbit-${cfg.orbit}" style="${ring}"><o-arc class="o-knob-dial" value="0" shape="${cfg.shape}" style="${valStyle}"></o-arc></div>
68
+ <div class="orbit-0">
69
+ <div class="satellite at-center">
70
+ <div class="capsule ok-hub o-knob-readout"></div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </div>`;
75
+
76
+ this._dial = this.querySelector('.o-knob-dial');
77
+ this._readout = this.querySelector('.o-knob-readout');
78
+
79
+ // Accessibility: act as a slider.
80
+ this.setAttribute('role', 'slider');
81
+ this.tabIndex = cfg.disabled ? -1 : 0;
82
+ this.style.touchAction = 'none';
83
+ this.style.cursor = cfg.disabled ? 'default' : 'ns-resize';
84
+ }
85
+
86
+ sync(cfg) {
87
+ const v = Math.min(cfg.max, Math.max(cfg.min, cfg.value));
88
+ const pct = ((v - cfg.min) / cfg.span) * 100;
89
+ if (this._dial) this._dial.setAttribute('value', pct.toFixed(2));
90
+ if (this._readout) {
91
+ this._readout.textContent = cfg.label !== null ? cfg.label : `${Math.round(v)}${cfg.unit}`;
92
+ }
93
+ this.setAttribute('aria-valuenow', String(v));
94
+ this.setAttribute('aria-valuemin', String(cfg.min));
95
+ this.setAttribute('aria-valuemax', String(cfg.max));
96
+ }
97
+
98
+ /** Numeric value as a property (framework-friendly). */
99
+ get value() { return this._num('value', this._num('min', 0)); }
100
+ set value(v) { this._commit(v); }
101
+
102
+ onConnected() {
103
+ this._onDown = this._onDown.bind(this);
104
+ this._onMove = this._onMove.bind(this);
105
+ this._onUp = this._onUp.bind(this);
106
+ this._onWheel = this._onWheel.bind(this);
107
+ this._onKey = this._onKey.bind(this);
108
+ this.addEventListener('pointerdown', this._onDown);
109
+ this.addEventListener('wheel', this._onWheel, { passive: false });
110
+ this.addEventListener('keydown', this._onKey);
111
+ }
112
+
113
+ onDisconnected() {
114
+ this.removeEventListener('pointerdown', this._onDown);
115
+ this.removeEventListener('wheel', this._onWheel);
116
+ this.removeEventListener('keydown', this._onKey);
117
+ window.removeEventListener('pointermove', this._onMove);
118
+ window.removeEventListener('pointerup', this._onUp);
119
+ }
120
+
121
+ _cfg() { return this.readConfig(); }
122
+
123
+ _commit(raw, emitChange = false) {
124
+ const cfg = this._cfg();
125
+ if (cfg.disabled) return;
126
+ let v = Math.min(cfg.max, Math.max(cfg.min, raw));
127
+ if (cfg.step > 0) v = Math.round(v / cfg.step) * cfg.step;
128
+ v = Number(v.toFixed(6));
129
+ const prev = this._num('value', cfg.min);
130
+ if (v !== prev) {
131
+ this.setAttribute('value', String(v)); // triggers re-render via attributeChangedCallback
132
+ this.dispatchEvent(new CustomEvent('input', { detail: { value: v }, bubbles: true }));
133
+ }
134
+ if (emitChange) {
135
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: v }, bubbles: true }));
136
+ }
137
+ }
138
+
139
+ _onDown(e) {
140
+ const cfg = this._cfg();
141
+ if (cfg.disabled) return;
142
+ e.preventDefault();
143
+ this.focus();
144
+ this._dragStartY = e.clientY;
145
+ this._dragStartVal = this._num('value', cfg.min);
146
+ window.addEventListener('pointermove', this._onMove);
147
+ window.addEventListener('pointerup', this._onUp);
148
+ }
149
+
150
+ _onMove(e) {
151
+ const cfg = this._cfg();
152
+ // Full sweep ~= 200px of vertical travel; up increases.
153
+ const dy = this._dragStartY - e.clientY;
154
+ const delta = (dy / 200) * cfg.span;
155
+ this._commit(this._dragStartVal + delta);
156
+ }
157
+
158
+ _onUp() {
159
+ window.removeEventListener('pointermove', this._onMove);
160
+ window.removeEventListener('pointerup', this._onUp);
161
+ this._commit(this._num('value', this._cfg().min), true);
162
+ }
163
+
164
+ _onWheel(e) {
165
+ const cfg = this._cfg();
166
+ if (cfg.disabled) return;
167
+ e.preventDefault();
168
+ const dir = e.deltaY < 0 ? 1 : -1;
169
+ this._commit(this._num('value', cfg.min) + dir * (cfg.step || 1), true);
170
+ }
171
+
172
+ _onKey(e) {
173
+ const cfg = this._cfg();
174
+ if (cfg.disabled) return;
175
+ const step = cfg.step || 1;
176
+ const map = {
177
+ ArrowUp: step, ArrowRight: step, ArrowDown: -step, ArrowLeft: -step,
178
+ PageUp: step * 10, PageDown: -step * 10
179
+ };
180
+ if (e.key in map) {
181
+ e.preventDefault();
182
+ this._commit(this._num('value', cfg.min) + map[e.key], true);
183
+ } else if (e.key === 'Home') {
184
+ e.preventDefault(); this._commit(cfg.min, true);
185
+ } else if (e.key === 'End') {
186
+ e.preventDefault(); this._commit(cfg.max, true);
187
+ }
188
+ }
189
+ }
190
+
191
+ define('o-knob', OrbitKnob);