@zumer/orbit-kit 0.1.0 → 2.0.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 CHANGED
@@ -1,135 +1,69 @@
1
+ import { OrbitKitElement, define, injectStyleOnce, universe, hub, fixed, escapeHTML as e, withShared, RANGE_ATTRS, FORMAT_ATTRS } from './base.js';
2
+
1
3
  /**
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>
4
+ * <o-gauge>: one value on an arc, with a readout in the middle.
13
5
  *
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.
6
+ * .bigbang (width = size)
7
+ * └── .gravity-spot
8
+ * ├── .orbit-12 > o-arc.inner-orbit track (thickness lanes, flush with the edge)
9
+ * ├── .orbit-12 > o-arc.inner-orbit value
10
+ * └── .orbit-0 > .satellite.at-center readout
20
11
  *
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.
12
+ * Value and label updates patch the existing arc; nothing is rebuilt.
23
13
  */
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 {
14
+ export class OrbitGauge extends OrbitKitElement {
29
15
  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();
16
+ return withShared(['value', 'range', 'from', 'orbit', 'thickness', 'size', 'label', 'fill', 'track', 'stroke-width', 'shape', 'glow'], RANGE_ATTRS, FORMAT_ATTRS);
38
17
  }
39
18
 
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);
19
+ readConfig() {
20
+ const range = Math.max(0, Math.min(360, this._num('range', 270)));
21
+ const { value, min, max, step, fraction } = this._range({ min: 0, max: 100, step: 0 });
54
22
  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
23
+ value, min, max, step, fraction, range,
24
+ from: this.hasAttribute('from') ? this._num('from', 0) : this._autoFrom(range),
25
+ thickness: this._thickness(2.4), size: this._size('size', '200px'),
26
+ fill: this._color('fill', null), track: this._color('track', null),
27
+ strokeWidth: Math.max(0, this._num('stroke-width', 0)), shape: this._str('shape', 'rounded'),
28
+ unit: this._unitText(), label: this.getAttribute('label'), glow: this.hasAttribute('glow') ? this._glowPx() : null
71
29
  };
72
30
  }
73
31
 
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>`;
32
+ structuralSignature({ value, fraction, min, max, step, label, unit, fill, track, ...structure }) { return JSON.stringify(structure); }
33
+ // The 0.x helper names keep working.
34
+ _readConfig() { return this.readConfig(); }
35
+ render() { this._render(); }
116
36
 
37
+ build(cfg) {
38
+ const ring = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;--o-size-ratio:${fixed(cfg.thickness)};--o-gap:0`;
39
+ // stroke-width (0.x): an outline in the band's own color, in px.
40
+ const stroke = cfg.strokeWidth ? `--o-stroke-width:${cfg.strokeWidth};` : '';
41
+ const glow = cfg.glow == null ? '' : `filter:drop-shadow(0 0 ${cfg.glow}px var(--ok-fill, #6366f1));`;
42
+ this.innerHTML = universe(cfg.size, `
43
+ <div class="orbit-12" style="${ring}"><o-arc class="inner-orbit ok-track" value="100" shape="${e(cfg.shape)}"${stroke ? ` style="${stroke}"` : ''}></o-arc></div>
44
+ <div class="orbit-12" style="${ring}"><o-arc class="inner-orbit ok-value o-gauge-fill" value="0" shape="${e(cfg.shape)}" ${stroke || glow ? ` style="${stroke}${glow}"` : ''}></o-arc></div>
45
+ ${hub(2 * (12 - cfg.thickness) - 1)}`);
117
46
  this._fill = this.querySelector('.o-gauge-fill');
118
- this._center = this.querySelector('.capsule');
47
+ this._center = this.querySelector('.ok-hub');
119
48
  }
120
49
 
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
- }
50
+ sync(cfg) {
51
+ this._applyTheme({ '--ok-fill': cfg.fill, '--ok-track': cfg.track });
52
+ const range = { min: cfg.min, max: cfg.max };
53
+ const reading = this._formatText(cfg.value, range);
54
+ const text = cfg.label ?? reading;
55
+ this._fill?.setAttribute('value', (cfg.fraction * 100).toFixed(2));
56
+ if (this._center) this._center.textContent = text;
57
+ this._describe(`${text}; ${this._format(cfg.value, range)} of ${this._formatText(cfg.max, range)}`, 'meter');
58
+ this._ariaRange(cfg);
130
59
  }
60
+ _build(cfg) { this.build(cfg); }
61
+ _sync(cfg) { this.sync(cfg); }
131
62
  }
132
63
 
133
- if (typeof customElements !== 'undefined' && !customElements.get('o-gauge')) {
134
- customElements.define('o-gauge', OrbitGauge);
135
- }
64
+ const GAUGE_CSS = `
65
+ o-gauge .ok-track { --o-fill: var(--ok-track, #262d38); --o-stroke: var(--o-fill); }
66
+ o-gauge .ok-value { --o-fill: var(--ok-fill, #6366f1); --o-stroke: var(--o-fill); transition: filter .2s; }
67
+ `;
68
+ injectStyleOnce('orbit-kit-gauge', GAUGE_CSS);
69
+ define('o-gauge', OrbitGauge);
@@ -3,16 +3,16 @@
3
3
  * preset-driven needle gauge for automotive, aviation and computer readouts.
4
4
  *
5
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):
6
+ * ticks, labels, color zones and the needle agree). The dial is the universe:
7
7
  *
8
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)
9
+ * ├── full sweep: .orbit-12 (filled) dial face + bezel
10
+ * │ partial(≤180): .orbit-11 > <o-arc> track band (no round case)
11
+ * ├── .orbit-11 fit-range > .vector × minor ticks
12
+ * ├── .orbit-11 fit-range > .vector × major ticks
13
+ * ├── .orbit-11 (per zone, own --o-from) > <o-arc> color/redline zones
14
+ * ├── .orbit-9 fit-range > .satellite × labels numeric scale
15
+ * ├── .orbit-0 > .vector needle (rotates to value)
16
16
  * └── .orbit-4 > readout (value + unit + caption)
17
17
  *
18
18
  * <o-instrument preset="tachometer" value="6.2"></o-instrument>
@@ -25,80 +25,84 @@
25
25
  * visible, even at zero) — pointed by `--o-angle-composite`. Only `value` is
26
26
  * cheap to change; the dial rebuilds when the scale, zones, case or theme change.
27
27
  */
28
- import { OrbitKitElement, define, injectStyleOnce } from './base.js';
28
+ import { OrbitKitElement, define, injectStyleOnce, finiteNumber, arrayData, cssValue, fixed, escapeHTML as e, withShared, RANGE_ATTRS, FORMAT_ATTRS } from './base.js';
29
29
 
30
- const RED = '#ef4444', YEL = '#eab308', GRN = '#22c55e', AMB = '#f59e0b', WHT = '#e5e7eb';
30
+ // WHT follows the theme's strong color (--ok-hand), so a white needle stays visible on a light face.
31
+ const RED = '#ef4444', YEL = '#eab308', GRN = '#22c55e', AMB = '#f59e0b', WHT = 'var(--ok-hand, #e5e7eb)';
31
32
 
32
33
  // Real instruments as preset bundles; any explicit attribute overrides them.
33
34
  const PRESETS = {
34
35
  tachometer: { min: 0, max: 8, range: 270, ticks: 9, minor: 5, unit: '', label: 'RPM x1000',
35
36
  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,
37
+ speedometer: { min: 0, max: 240, range: 270, ticks: 9, minor: 2, unit: 'km/h', label: 'Speed', value: 0, accent: WHT },
38
+ fuel: { min: 0, max: 1, range: 90, from: 270, ticks: 3, minor: 4, unit: '', label: 'Fuel', digital: false,
38
39
  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
+ temp: { min: 0, max: 1, range: 90, from: 270, ticks: 3, minor: 4, unit: '', label: 'Temp', digital: false,
40
41
  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
+ boost: { min: -1, max: 2, range: 240, ticks: 7, minor: 5, unit: 'bar', label: 'Boost',
42
43
  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
+ voltmeter: { min: 8, max: 16, range: 180, ticks: 5, minor: 5, unit: 'V', label: 'Volts',
44
45
  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
+ airspeed: { min: 0, max: 200, range: 300, ticks: 11, minor: 5, unit: 'kt', label: 'Airspeed',
46
47
  zones: [{ from: 40, to: 140, color: GRN }, { from: 140, to: 170, color: YEL }, { from: 170, to: 200, color: RED }],
47
48
  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
+ altimeter: { min: 0, max: 10, range: 360, ticks: 10, minor: 5, unit: 'x1000 ft', label: 'Altitude', value: 0, accent: WHT },
49
50
  cpu: { min: 0, max: 100, range: 270, ticks: 6, minor: 5, unit: '%', label: 'CPU',
50
51
  zones: [{ from: 85, to: 100, color: RED }], value: 0, accent: GRN },
51
52
  gpu: { min: 0, max: 100, range: 270, ticks: 6, minor: 5, unit: '%', label: 'GPU',
52
53
  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
+ vu: { min: -21, max: 3, range: 120, ticks: 5, minor: 5, unit: 'dB', label: 'VU',
54
55
  zones: [{ from: 0, to: 3, color: RED }], value: -8, accent: YEL }
55
56
  };
56
57
  const CASES = ['round', 'square', 'rounded', 'bare'];
57
58
 
58
59
  const INST_CSS = `
60
+ o-instrument { --ok-font-size: 7.2cqmin; }
59
61
  o-instrument .ok-inst-case { box-sizing: border-box; display: flex; align-items: center; justify-content: center; }
62
+ o-instrument .ok-inst-case--square, o-instrument .ok-inst-case--rounded { padding: 7%; }
60
63
  o-instrument .ok-inst-case--round { border-radius: 50%; }
61
64
  o-instrument .ok-inst-case--square { border-radius: 9%; }
62
65
  o-instrument .ok-inst-case--rounded { border-radius: 22%; }
63
66
  o-instrument .ok-inst-case--bare { background: transparent; }
64
67
  o-instrument .ok-inst-case--square,
65
68
  o-instrument .ok-inst-case--rounded {
66
- background: var(--ok-inst-case-bg, linear-gradient(160deg, #232a34, #11151b));
69
+ background: var(--ok-inst-case-bg, var(--ok-case, linear-gradient(160deg, #232a34, #11151b)));
67
70
  box-shadow: 0 10px 30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);
68
71
  }
69
- o-instrument .ok-inst-face { color: var(--ok-inst-label, #cbd5e1); }
72
+ o-instrument .ok-inst-face { color: var(--ok-inst-label, var(--ok-text, #cbd5e1)); }
70
73
  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);
74
+ background: var(--ok-inst-face, var(--ok-face, radial-gradient(circle at 50% 35%, #1b212b, #0a0d12)));
75
+ box-shadow: inset 0 0 0 4px var(--ok-inst-bezel, var(--ok-bezel, #2a3340)), inset 0 4px 18px rgba(0,0,0,.65);
73
76
  }
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; }
77
+ o-instrument .ok-inst-track { --o-fill: var(--ok-inst-track, var(--ok-track, #171c24)); --o-stroke: var(--o-fill); }
78
+ o-instrument .ok-inst-tick { background: var(--ok-inst-tick, var(--ok-tick, rgba(203,213,225,.45))); height: 1px; }
79
+ o-instrument .ok-inst-tick-M { background: var(--ok-inst-tick-M, var(--ok-hand, #e6edf3)); height: 2px; border-radius: 1px; }
80
+ o-instrument .ok-inst-num { font-weight: 700; font-variant-numeric: tabular-nums; font-size: .8em; white-space: nowrap; }
81
+ o-instrument .ok-inst-needle { background: var(--ok-inst-accent, var(--ok-accent, #ef4444)); height: max(2px, 1.2cqmin); border-radius: 0 3px 3px 0; transition: transform .25s cubic-bezier(.3,.7,.4,1); }
82
+ @media (prefers-reduced-motion: reduce) { o-instrument .ok-inst-needle { transition: none; } }
78
83
  o-instrument .ok-inst-readout {
79
84
  display: flex; flex-direction: column; align-items: center; line-height: 1.05;
80
85
  }
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
+ o-instrument .ok-inst-value { font-weight: 800; font-variant-numeric: tabular-nums; font-size: 1.5em; white-space: nowrap; color: var(--ok-inst-readout-color, var(--ok-hand, #f8fafc)); }
87
+ o-instrument .ok-inst-unit { font-size: .62em; opacity: .7; white-space: nowrap; }
88
+ o-instrument .ok-inst-cap-label { font-size: .6em; font-weight: 700; opacity: .65; margin-top: .15em; white-space: nowrap; }
89
+ o-instrument .ok-inst-cap { background: var(--ok-inst-accent, var(--ok-accent, #ef4444)); box-shadow: 0 0 0 3px var(--ok-inst-hub, var(--ok-surface, #0a0d12)); }
90
+ o-instrument.ok-glow .ok-inst-needle { filter: drop-shadow(0 0 var(--ok-inst-glow, 5px) var(--ok-inst-accent, #ef4444)); }
86
91
  `;
87
92
 
88
93
  export class OrbitInstrument extends OrbitKitElement {
89
94
  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'];
95
+ return withShared(['value', 'range', 'from', 'ticks', 'minor', 'zones',
96
+ 'tick-labels', 'label', 'redline', 'preset', 'case', 'size',
97
+ 'digital', 'face', 'bezel', 'accent', 'needle-color', 'tick-color', 'glow'], RANGE_ATTRS, FORMAT_ATTRS);
93
98
  }
94
99
 
95
100
  _preset() { return PRESETS[this.getAttribute('preset')] || {}; }
96
101
 
97
102
  _pNum(name, key, dflt) {
98
103
  const v = this.getAttribute(name);
99
- if (v != null && v !== '') return parseFloat(v);
100
104
  const p = this._preset();
101
- return p[key] != null ? p[key] : dflt;
105
+ return finiteNumber(v, p[key] != null ? p[key] : dflt);
102
106
  }
103
107
  _pStr(name, key, dflt) {
104
108
  const v = this.getAttribute(name);
@@ -108,22 +112,24 @@ export class OrbitInstrument extends OrbitKitElement {
108
112
  }
109
113
  _pArr(name, key, dflt) {
110
114
  const v = this.getAttribute(name);
111
- if (v != null && v.trim() !== '') { try { return JSON.parse(v); } catch { /* fall through */ } }
115
+ if (v != null && v.trim() !== '') { try { const parsed = JSON.parse(v); if (Array.isArray(parsed)) return parsed; } catch { /* fall through */ } }
112
116
  const p = this._preset();
113
117
  return p[key] != null ? p[key] : dflt;
114
118
  }
115
119
 
116
120
  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
- }));
121
+ const range = Math.max(0, Math.min(360, this._pNum('range', 'range', 270)));
122
+ const p = this._preset();
123
+ const r = this._range({ min: p.min ?? 0, max: p.max ?? 100, step: 0, value: p.value });
124
+ const { min, max } = r;
125
+ const span = r.span || 1;
126
+ const zones = arrayData(this._pArr('zones', 'zones', [])).map((z) => ({
127
+ from: Math.max(min, finiteNumber(z.from, min)), to: Math.min(max, finiteNumber(z.to, min)), color: cssValue(z.color, RED)
128
+ })).filter((z) => z.to > z.from);
124
129
  // `redline=` is sugar for a red zone from that value to max.
125
130
  const redline = this.getAttribute('redline');
126
- if (redline != null) zones.push({ from: parseFloat(redline), to: max, color: RED });
131
+ const redlineValue = finiteNumber(redline, max);
132
+ if (redline != null && redlineValue < max) zones.push({ from: Math.max(min, redlineValue), to: max, color: RED });
127
133
 
128
134
  // Partial-sweep gauges (fuel/temp/…) read as an arc band, not a round watch,
129
135
  // so they default to a "bare" case (no round bezel) unless told otherwise.
@@ -133,54 +139,50 @@ export class OrbitInstrument extends OrbitKitElement {
133
139
  if (!CASES.includes(caseShape)) caseShape = partial ? 'bare' : 'round';
134
140
 
135
141
  return {
136
- value: this._pNum('value', 'value', min),
142
+ value: r.value, fraction: r.fraction, step: r.step,
137
143
  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))),
144
+ from: this.hasAttribute('from') ? this._pNum('from', null, 0) : (this._preset().from ?? (range === 360 ? 0 : this._autoFrom(range))),
145
+ ticks: Math.max(2, Math.min(360, Math.round(this._pNum('ticks', 'ticks', 7)))),
146
+ minor: Math.max(0, Math.min(60, Math.round(this._pNum('minor', 'minor', 5)))),
141
147
  tickLabels: this._pArr('tick-labels', 'tickLabels', null),
142
- unit: this._pStr('unit', 'unit', ''),
148
+ unit: this.getAttribute('format') === 'unit' ? '' : this._pStr('unit', 'unit', ''),
143
149
  label: this._pStr('label', 'label', ''),
144
150
  digital: this.hasAttribute('digital')
145
151
  ? this.getAttribute('digital') !== 'false'
146
152
  : (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('|')
153
+ size: this._size('size', '240px'),
154
+ glow: this.getAttribute('glow'),
155
+ accent: cssValue(this._pStr('accent', 'accent', null), null),
156
+ theme: ['face', 'bezel', 'needle-color', 'tick-color'].map((a) => this.getAttribute(a)).join('|'),
157
+ // Tick labels are part of the dial, so the number format rebuilds it.
158
+ format: [...['format', 'locale', 'decimals', 'currency'].map((a) => this.getAttribute(a)), this._formatter ? 'fn' : '', this.getAttribute('format') === 'unit' ? this.getAttribute('unit') : ''].join('|')
151
159
  };
152
160
  }
153
161
 
154
162
  structuralSignature(cfg) {
155
- const { value, ...rest } = cfg;
163
+ const { value, fraction, step, unit, label, ...rest } = cfg;
156
164
  return JSON.stringify(rest);
157
165
  }
158
166
 
159
167
  build(cfg) {
160
168
  injectStyleOnce('orbit-kit-instrument', INST_CSS);
161
- this.classList.toggle('ok-glow', cfg.glow);
169
+ this.classList.toggle('ok-glow', cfg.glow != null);
162
170
 
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
- }
171
+ this._applyTheme({
172
+ '--ok-inst-accent': this.getAttribute('needle-color') ?? cfg.accent,
173
+ '--ok-inst-face': this.getAttribute('face'), '--ok-inst-bezel': this.getAttribute('bezel'),
174
+ '--ok-inst-tick': this.getAttribute('tick-color'),
175
+ '--ok-inst-glow': cfg.glow == null ? null : `${Math.max(0, this._num('glow', 5))}px`
176
+ });
170
177
 
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
178
  const fromVar = `--o-range:${cfg.range}deg;--o-from:${cfg.from}deg;`;
177
179
 
178
180
  // 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
+ // Center the content by moving the gravity-spot so the arc's bounding box
182
+ // (plus the center pivot) is centered in the square box.
181
183
  let centering = '';
182
- if (cfg.partial) {
183
- const R = dialPx * 5 / 11; // ≈ outer tick-ring radius
184
+ if (cfg.partial && cfg.caseShape === 'bare') {
185
+ const R = 50 * 11 / 12; // the tick ring, in cqmin of the universe
184
186
  let minX = 0, maxX = 0, minY = 0, maxY = 0; // seed with the center pivot
185
187
  for (let k = 0; k <= 24; k++) {
186
188
  const p = (cfg.from + (cfg.range * k) / 24) * Math.PI / 180;
@@ -188,64 +190,63 @@ export class OrbitInstrument extends OrbitKitElement {
188
190
  minX = Math.min(minX, x); maxX = Math.max(maxX, x);
189
191
  minY = Math.min(minY, y); maxY = Math.max(maxY, y);
190
192
  }
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);`;
193
+ centering = `translate:${fixed(-(minX + maxX) / 2 * R, 1)}cqmin ${fixed(-(minY + maxY) / 2 * R, 1)}cqmin;`;
194
194
  }
195
195
 
196
196
  // Face: a round dial for full sweeps, or a neutral track band for partial
197
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>';
198
+ const band = `--o-size-ratio:.55;--o-gap:0;`;
199
+ const face = cfg.caseShape === 'bare'
200
+ ? `<div class="orbit-11" style="${fromVar}${band}"><o-arc class="inner-orbit ok-inst-track" value="100"></o-arc></div>`
201
+ : '<div class="orbit-12 ok-inst-dial ok-surface"></div>';
201
202
 
202
203
  // --- ticks: major (count) + minor (between) over the range ---
203
204
  const majorN = cfg.ticks;
204
- const minorN = cfg.minor > 0 ? (majorN - 1) * cfg.minor + 1 : 0;
205
+ const closed = cfg.range === 360;
206
+ const fit = closed ? '' : 'fit-range';
207
+ const minorN = cfg.minor > 0 ? (closed ? majorN * (cfg.minor + 1) : (majorN - 1) * (cfg.minor + 1) + 1) : 0;
205
208
  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>`
209
+ ? `<div class="orbit-11 ${fit}" style="${fromVar}--o-size-ratio:.3">${Array.from({ length: minorN }, () =>
210
+ '<div class="vector inner-orbit ok-inst-tick"></div>').join('')}</div>`
208
211
  : '';
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>`;
212
+ const majorTicks = `<div class="orbit-11 ${fit}" style="${fromVar}--o-size-ratio:.6">${Array.from({ length: majorN }, () =>
213
+ '<div class="vector inner-orbit ok-inst-tick-M"></div>').join('')}</div>`;
211
214
 
212
215
  // --- numeric / custom scale labels ---
213
216
  const labels = Array.from({ length: majorN }, (_, i) => {
214
217
  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>`;
218
+ : this._format(cfg.min + (i * cfg.span) / (closed ? majorN : majorN - 1), { kind: 'tick', min: cfg.min, max: cfg.max, fallback: (v) => this._fmtTick(v) });
219
+ return `<div class="satellite"><div class="capsule ok-inst-num">${e(txt)}</div></div>`;
217
220
  }).join('');
218
- const labelRing = `<div class="orbit-8 fit-range" style="${fromVar}">${labels}</div>`;
221
+ const labelRing = `<div class="orbit-9 ${fit}" style="${fromVar}">${labels}</div>`;
219
222
 
220
- // --- color zones: each in its own orbit so non-contiguous zones land right ---
223
+ // --- color zones: each on its own ring so non-contiguous zones land right ---
221
224
  const zones = cfg.zones.map((z) => {
222
225
  const startA = cfg.from + ((z.from - cfg.min) / cfg.span) * cfg.range;
223
226
  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
+ const style = `--o-range:${cfg.range}deg;--o-from:${fixed(startA)}deg;${band}`;
228
+ const arc = `--o-fill:${z.color};--o-stroke:${z.color}${cfg.glow != null ? `;filter:drop-shadow(0 0 ${Math.max(0, this._num('glow', 4))}px ${z.color})` : ''}`;
229
+ return `<div class="orbit-11" style="${style}"><o-arc class="inner-orbit ok-inst-zone" value="${pct.toFixed(3)}" style="${e(arc)}"></o-arc></div>`;
227
230
  }).join('');
228
231
 
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
- : '';
232
+ const readout = `<div class="orbit-4"><div class="satellite angle-180"><div class="capsule ok-inst-readout">
233
+ ${cfg.digital ? '<span class="ok-inst-value"></span>' : ''}
234
+ <span class="ok-inst-unit">${e(cfg.unit)}</span>
235
+ <span class="ok-inst-cap-label">${e(cfg.label)}</span>
236
+ </div></div></div>`;
236
237
 
237
238
  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}">
239
+ <div class="ok-inst-case ok-inst-case--${cfg.caseShape}${cfg.caseShape === 'square' || cfg.caseShape === 'rounded' ? ' ok-surface' : ''}" style="width:${e(cfg.size)};aspect-ratio:1">
240
+ <div class="bigbang" style="width:100%">
241
+ <div class="gravity-spot ok-inst-face"${centering ? ` style="${centering}"` : ''}>
241
242
  ${face}
242
243
  ${minorTicks}${majorTicks}
243
244
  ${zones}
244
245
  ${labelRing}
245
246
  ${readout}
246
247
  <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>
248
+ <div class="vector outer-orbit ok-inst-needle" style="--o-size-ratio:9.6"></div>
249
+ <div class="satellite at-center ok-inst-cap" style="--o-size-ratio:.8"></div>
249
250
  </div>
250
251
  </div>
251
252
  </div>
@@ -253,16 +254,26 @@ export class OrbitInstrument extends OrbitKitElement {
253
254
 
254
255
  this._needle = this.querySelector('.ok-inst-needle');
255
256
  this._value = this.querySelector('.ok-inst-value');
257
+ this._unit = this.querySelector('.ok-inst-unit');
258
+ this._caption = this.querySelector('.ok-inst-cap-label');
259
+ this._readout = this.querySelector('.ok-inst-readout');
256
260
  }
257
261
 
258
262
  sync(cfg) {
259
- const v = Math.min(cfg.max, Math.max(cfg.min, cfg.value));
260
- const frac = (v - cfg.min) / cfg.span;
263
+ const frac = cfg.fraction;
261
264
  // The arc start (value 0) sits at screen angle `from + 270`; the needle is
262
265
  // a full-length vector rotated to the value's screen angle (always visible).
263
266
  const deg = (((cfg.from + 270 + frac * cfg.range) % 360) + 360) % 360;
264
267
  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);
268
+ const range = { min: cfg.min, max: cfg.max };
269
+ const reading = this._format(cfg.value, { ...range, fallback: (v) => this._fmtValue(v, cfg.span) });
270
+ if (this._value) this._value.textContent = reading;
271
+ if (this._unit) { this._unit.textContent = cfg.unit; this._unit.hidden = !cfg.unit; }
272
+ if (this._caption) { this._caption.textContent = cfg.label; this._caption.hidden = !cfg.label; }
273
+ if (this._readout) this._readout.hidden = !cfg.digital && !cfg.label && !cfg.unit;
274
+ const tick = (v) => this._format(v, { ...range, kind: 'tick', fallback: String });
275
+ this._describe(`${cfg.label ? cfg.label + ': ' : ''}${reading}${cfg.unit}; range ${tick(cfg.min)} to ${tick(cfg.max)}`, 'meter');
276
+ this._ariaRange(cfg, cfg.value, this._formatText(cfg.value, { ...range, kind: 'aria', fallback: (v) => this._fmtValue(v, cfg.span) }, cfg.unit));
266
277
  }
267
278
 
268
279
  /** Tick-scale label: integers bare, otherwise one decimal. */