/* widgets.js — retro-instrument widget library (GW namespace).
Classic script: load after the token :root block (tokens.json → gen_tokens.py),
before any GW.* call. Works from file:// — no modules, no build step.
Contract: each gallery card in panel-widget-gallery.html is the visual +
behavioral spec its builder is judged against.
Spec: docs/specs/2026-07-12-component-generation-spec.org */
(function () {
'use strict';
const GW = {};
/* ================= shared engine ================= */
const SVGNS = 'http://www.w3.org/2000/svg';
function svgEl(parent, tag, attrs) {
const e = document.createElementNS(SVGNS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
parent.appendChild(e); return e;
}
function polar(cx, cy, r, deg) {
const a = deg * Math.PI / 180;
return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
}
/* ---- pointer-drag helpers (rect-ratio based, so CSS zoom is transparent) ---- */
function dragX(el, onPct) {
el.style.touchAction = 'none';
const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100))); };
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId); at(e);
const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
function dragY(el, onPct) {
el.style.touchAction = 'none';
const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (r.bottom - e.clientY) / r.height * 100))); };
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId); at(e);
const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
function dragDelta(el, get, set, opts) {
opts = opts || {}; el.style.touchAction = 'none';
const min = opts.min == null ? 0 : opts.min, max = opts.max == null ? 100 : opts.max, sens = opts.sens || ((max - min) / 160);
el.addEventListener('pointerdown', e => {
if (opts.stop) e.stopPropagation(); el.setPointerCapture(e.pointerId);
const y0 = e.clientY, v0 = get();
const mv = ev => { let v = v0 + (y0 - ev.clientY) * sens; set(Math.max(min, Math.min(max, v))); };
const up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); };
el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault();
});
}
/* ---- seven-segment digit builder ---- */
const SEG = { '0': 'abcdef', '1': 'bc', '2': 'abged', '3': 'abgcd', '4': 'fbgc', '5': 'afgcd', '6': 'afgedc', '7': 'abc', '8': 'abcdefg', '9': 'abcdfg', '-': 'g', ' ': '' };
function hseg(cy) { return `5,${cy} 7,${cy - 2} 17,${cy - 2} 19,${cy} 17,${cy + 2} 7,${cy + 2}`; }
function vseg(cx, ty, by) { return `${cx},${ty} ${cx - 2},${ty + 2} ${cx - 2},${by - 2} ${cx},${by} ${cx + 2},${by - 2} ${cx + 2},${ty + 2}`; }
const SEGPTS = { a: hseg(3), g: hseg(22), d: hseg(41), f: vseg(5, 4, 21), b: vseg(19, 4, 21), e: vseg(5, 23, 40), c: vseg(19, 23, 40) };
function seg7(ch, cls) {
const lit = SEG[ch] || ''; let s = `';
}
function buildBars(el, n) { el.innerHTML = ''; for (let k = 0; k < n; k++) el.appendChild(document.createElement('i')); }
/* ---- VU law: authentic nonlinear dB scale, dB → 0..1 sweep position ---- */
const VUDB = [[-20, 0], [-10, .20], [-7, .31], [-5, .40], [-3, .50], [-2, .58], [-1, .66], [0, .75], [1, .84], [2, .91], [3, 1]];
/* interpolate a dB reading from a 0..1 sweep position */
function vuDb(t) {
let i = 0; while (i < VUDB.length - 2 && VUDB[i + 1][1] < t) i++;
const [d1, t1] = VUDB[i], [d2, t2] = VUDB[i + 1];
return d1 + (d2 - d1) * Math.max(0, Math.min(1, (t - t1) / (t2 - t1)));
}
/* ---- screen-color families: period phosphor/LCD palettes as scoped CSS vars.
Shipped literals are the fallbacks, so defaults are pixel-identical until applied. ---- */
const SCREEN_FAMS = {
green: { '--scr-hi': '#7fe0a0', '--scr-ink': '#58b87e', '--scr-dim': '#3d5c46',
'--scr-bg1': '#0a120c', '--scr-bg2': '#050c07', '--scr-bgc': '#04140a', '--scr-bge': '#020a05', '--scr-brd': '#123018',
'--scr-grat': 'rgba(111,206,51,.18)', '--scr-gratc': 'rgba(111,206,51,.35)', '--scr-glow': 'rgba(127,224,160,.8)',
'--crt-face1': '#b9d8c0', '--crt-face2': '#8bb296', '--crt-glow': '#eef7ee' },
amber: { '--scr-hi': '#ffbe54', '--scr-ink': '#e2a038', '--scr-dim': '#7d5c16',
'--scr-bg1': '#140d06', '--scr-bg2': '#0a0705', '--scr-bgc': '#140d06', '--scr-bge': '#0a0603', '--scr-brd': '#33230e',
'--scr-grat': 'rgba(226,160,56,.18)', '--scr-gratc': 'rgba(226,160,56,.35)', '--scr-glow': 'rgba(255,190,84,.8)',
'--crt-face1': '#d8cba6', '--crt-face2': '#b3a276', '--crt-glow': '#ffe7c0' },
red: { '--scr-hi': '#ff9a4c', '--scr-ink': '#e0742e', '--scr-dim': '#5c3416',
'--scr-bg1': '#140a05', '--scr-bg2': '#0c0603', '--scr-bgc': '#140a05', '--scr-bge': '#0a0502', '--scr-brd': '#331c0c',
'--scr-grat': 'rgba(224,116,46,.18)', '--scr-gratc': 'rgba(224,116,46,.35)', '--scr-glow': 'rgba(255,154,76,.8)',
'--crt-face1': '#d8b9a6', '--crt-face2': '#b39276', '--crt-glow': '#ffdcc0' },
blue: { '--scr-hi': '#cfe4ff', '--scr-ink': '#94b8d8', '--scr-dim': '#3a4c60',
'--scr-bg1': '#0a0e14', '--scr-bg2': '#05080c', '--scr-bgc': '#0a1018', '--scr-bge': '#04070c', '--scr-brd': '#16283a',
'--scr-grat': 'rgba(148,184,216,.18)', '--scr-gratc': 'rgba(148,184,216,.35)', '--scr-glow': 'rgba(207,228,255,.8)',
'--crt-face1': '#c2d2dd', '--crt-face2': '#93a8b6', '--crt-glow': '#eaf2ff' },
vfd: { '--scr-hi': '#63e6c8', '--scr-ink': '#46b89e', '--scr-dim': '#1e4a40',
'--scr-bg1': '#06100d', '--scr-bg2': '#040b09', '--scr-bgc': '#041410', '--scr-bge': '#020a08', '--scr-brd': '#0e3028',
'--scr-grat': 'rgba(70,184,158,.18)', '--scr-gratc': 'rgba(70,184,158,.35)', '--scr-glow': 'rgba(99,230,200,.8)',
'--crt-face1': '#b0d8cd', '--crt-face2': '#84ada2', '--crt-glow': '#dcfff5' },
white: { '--scr-hi': '#f2f4f2', '--scr-ink': '#c8cac8', '--scr-dim': '#5a5c5a',
'--scr-bg1': '#0b0c0b', '--scr-bg2': '#070807', '--scr-bgc': '#0d0e0d', '--scr-bge': '#060706', '--scr-brd': '#2a2c2a',
'--scr-grat': 'rgba(200,202,200,.18)', '--scr-gratc': 'rgba(200,202,200,.35)', '--scr-glow': 'rgba(242,244,242,.7)',
'--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' },
};
/* ================= widget builders =================
Every builder: GW.name(host, opts) → handle. host is an empty element the
widget renders into. opts.onChange(value, text) fires on every state change,
including the initial paint; text is the widget's canonical readout string. */
const noop = () => {};
/* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*). */
GW.slideToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw);
let on;
const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
sw.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: sw, get: () => on, set };
};
/* 02 console keys — mutually exclusive push buttons; {label, red} per key */
GW.consoleKeys = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const keys = opts.keys || [{ label: 'LIVE' }, { label: 'SCAN' }, { label: 'MUTED', red: true }];
const wrap = document.createElement('span'); host.appendChild(wrap);
const btns = keys.map(k => {
const b = document.createElement('button'); b.className = 'key'; b.textContent = k.label;
wrap.appendChild(b); return b;
});
let idx;
const set = i => {
idx = i;
btns.forEach((b, j) => { b.classList.remove('on', 'red'); if (j === i) b.classList.add(keys[i].red ? 'red' : 'on'); });
onChange(i, keys[i].label);
};
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
set(opts.active || 0);
return { el: wrap, get: () => idx, set };
};
/* 03 horizontal fader — continuous 0-100 */
GW.faderH = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const f = document.createElement('div'); f.className = 'fader';
f.innerHTML = '
';
host.appendChild(f);
let val;
const set = p => {
val = Math.max(0, Math.min(100, p));
f.querySelector('.fill').style.width = val + '%';
f.querySelector('.cap').style.left = val + '%';
onChange(val, 'level ' + Math.round(val));
};
dragX(f, set);
set(opts.value !== undefined ? opts.value : 68);
return { el: f, get: () => val, set };
};
/* 04 vertical fader — one channel-strip fader; compose per channel */
GW.faderV = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const f = document.createElement('div'); f.className = 'vfader';
f.innerHTML = '';
host.appendChild(f);
let val;
const set = p => {
val = Math.max(0, Math.min(100, p));
f.querySelector('.fill').style.height = val + '%';
f.querySelector('.cap').style.bottom = val + '%';
onChange(val, 'level ' + Math.round(val));
};
dragY(f, set);
set(opts.value !== undefined ? opts.value : 60);
return { el: f, get: () => val, set };
};
/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep */
GW.knob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100;
const k = document.createElement('span'); k.className = 'knob';
k.innerHTML = '';
host.appendChild(k);
let val;
const set = v => {
val = Math.max(min, Math.min(max, v));
k.querySelector('.ind').style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`;
onChange(val, String(Math.round(val)));
};
dragDelta(k, () => val, set, { min, max });
set(opts.value !== undefined ? opts.value : 53);
return { el: k, get: () => val, set };
};
/* 06 segmented selector — pick one of a few */
GW.segmented = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['TIMER', 'ALARM', 'POMO'];
const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg);
const btns = items.map(t => { const b = document.createElement('button'); b.textContent = t; seg.appendChild(b); return b; });
let idx;
const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); };
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
set(opts.active || 0);
return { el: seg, get: () => idx, set };
};
/* 07 chip toggle — inline binary inside a line of text */
GW.chipToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const chip = document.createElement('span'); chip.className = 'chip';
chip.textContent = opts.label || 'discoverable on';
host.appendChild(chip);
let on;
const set = v => { on = !!v; chip.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
chip.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: chip, get: () => on, set };
};
/* 08 arm-to-fire — two-stage confirm for destructive actions */
GW.armButton = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again';
const a = document.createElement('button'); a.className = 'arm'; a.textContent = label;
host.appendChild(a);
let armed = false;
a.addEventListener('click', () => {
if (!armed) { armed = true; a.classList.add('armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); }
else { armed = false; a.classList.remove('armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); }
});
onChange('safe', 'SAFE');
return { el: a, get: () => (armed ? 'armed' : 'safe'), fire: () => a.click() };
};
/* 09 lamp row — actionable list item: lamp + name + status, click to cycle */
GW.lampRow = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const name = opts.name || 'WH-1000XM4';
const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']];
const row = document.createElement('div'); row.className = 'lrow';
row.innerHTML = ``;
row.querySelector('b').textContent = name;
host.appendChild(row);
let idx;
const set = i => {
idx = i;
row.querySelector('.lamp').className = 'lamp ' + states[i][0];
row.querySelector('.what').textContent = states[i][1];
onChange(i, states[i][1]);
};
row.addEventListener('click', () => set((idx + 1) % states.length));
set(opts.state || 0);
return { el: row, get: () => idx, set };
};
/* 24 rotary selector — pick one of five printed detents by position */
GW.rotarySelector = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const values = opts.values || [4, 6, 8, 10, 12];
const fmt = opts.fmt || (v => 'size ' + v);
const POS = [[14, 53], [28, 18], [50, 7], [72, 18], [86, 53]], ANG = [-70, -35, 0, 35, 70];
const rs = document.createElement('span'); rs.className = 'rotsel';
values.forEach((v, i) => {
const p = document.createElement('span'); p.className = 'pos';
p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v;
rs.appendChild(p);
});
const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = ''; rs.appendChild(k);
host.appendChild(rs);
let idx;
const set = i => {
idx = ((i % values.length) + values.length) % values.length;
rs.querySelector('.ind').style.transform = `rotate(${ANG[idx]}deg)`;
rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('on', j === idx));
onChange(values[idx], fmt(values[idx]));
};
k.addEventListener('click', () => set(idx + 1));
set(opts.index !== undefined ? opts.index : 2);
return { el: rs, get: () => values[idx], set };
};
/* 25 slide-rule dial — lit pointer on a printed scale; click a mark or arrow-key */
GW.slideRule = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const values = opts.values || [4, 6, 8, 10, 12];
const fmt = opts.fmt || (v => 'pos ' + v);
const X = [12, 51, 90, 129, 168];
const t = document.createElement('span'); t.className = 'tuner';
t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
values.forEach((v, i) => {
t.insertAdjacentHTML('beforeend', `${v}`);
});
const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl);
host.appendChild(t);
let idx;
const set = i => {
idx = Math.max(0, Math.min(values.length - 1, i));
ndl.style.left = X[idx] + 'px';
t.querySelectorAll('.mk').forEach((m, j) => m.classList.toggle('on', j === idx));
onChange(values[idx], fmt(values[idx]));
};
t.addEventListener('click', e => {
const r = t.getBoundingClientRect(), x = e.clientX - r.left;
let best = 0, bd = 1e9; X.slice(0, values.length).forEach((tx, j) => { const d = Math.abs(tx - x); if (d < bd) { bd = d; best = j; } });
set(best); t.focus();
});
t.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); }
});
set(opts.index !== undefined ? opts.index : 2);
return { el: t, get: () => values[idx], set };
};
/* N01 rocker power switch — hard on/off, lit legend */
GW.rocker = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const r = document.createElement('span'); r.className = 'rocker';
r.innerHTML = `${opts.onLabel || 'ON'}${opts.offLabel || 'OFF'}`;
host.appendChild(r);
let on;
const set = v => { on = !!v; r.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
r.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: r, get: () => on, set };
};
/* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing.
opts.animate: reels obey play state (default: not prefers-reduced-motion) */
GW.transport = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches;
const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' };
const NAMES = { rew: 'REW', play: 'PLAY', stop: 'STOP', rec: 'REC' };
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:center';
wrap.innerHTML = `
`;
const bar = wrap.querySelector('.transport');
const btns = {};
for (const m of Object.keys(MODES)) {
const b = document.createElement('button'); b.className = 'tbtn' + (m === 'rec' ? ' rec' : '');
b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b;
b.addEventListener('click', () => set(m));
}
host.appendChild(wrap);
let mode;
const set = m => {
mode = m;
for (const k in btns) btns[k].classList.toggle('on', k === m);
wrap.querySelectorAll('.reel.spin').forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused');
onChange(m, NAMES[m]);
};
/* initial: light the mode but leave reel play-state to CSS, matching load behavior */
mode = opts.mode || 'play';
btns[mode].classList.add('on');
onChange(mode, NAMES[mode]);
return { el: wrap, get: () => mode, set };
};
/* N03 radio preset bank — mechanically exclusive presets */
GW.presetBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF'];
const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank);
const btns = items.map(t => { const b = document.createElement('button'); b.className = 'preset'; b.textContent = t; bank.appendChild(b); return b; });
let idx;
const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); };
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
set(opts.active || 0);
return { el: bank, get: () => idx, set };
};
/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap */
GW.dualKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const dk = document.createElement('span'); dk.className = 'dualknob';
dk.innerHTML = ``;
host.appendChild(dk);
const o = dk.querySelector('.outer'), n = dk.querySelector('.inner');
let oV, iV;
const paint = () => {
o.style.transform = `rotate(${-150 + oV / 100 * 300}deg)`;
n.style.transform = `rotate(${-150 + iV / 100 * 300}deg)`;
onChange(oV, iV);
};
dragDelta(o, () => oV, v => { oV = v; paint(); }, { min: 0, max: 100 });
dragDelta(n, () => iV, v => { iV = v; paint(); }, { min: 0, max: 100, stop: true });
oV = opts.outer !== undefined ? opts.outer : 50;
iV = opts.inner !== undefined ? opts.inner : 50;
paint();
return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = a; if (b !== undefined) iV = b; paint(); } };
};
/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */
GW.encoder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc);
const R = 27, cx = 33, cy = 33;
for (let i = 0; i < 12; i++) {
const a = (i * 30 - 90) * Math.PI / 180;
const d = document.createElement('span'); d.className = 'led';
d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px';
enc.appendChild(d);
}
const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = ''; enc.appendChild(k);
let val;
const set = v => {
val = v;
const lit = ((Math.floor(val) % 12) + 12) % 12;
enc.querySelectorAll('.led').forEach((l, i) => l.classList.toggle('on', i <= lit));
k.style.transform = `rotate(${val * 30}deg)`;
onChange(val, 'pos ' + Math.round(val));
};
dragDelta(enc, () => val, set, { min: 0, max: 100000, sens: 0.25 });
set(opts.value !== undefined ? opts.value : 7);
return { el: enc, get: () => val, set };
};
/* N06 keyed mode switch — guarded three-position mode, key-bit points at the live one */
GW.keySwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['OFF', 'ON', 'RUN'];
const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]];
const kl = document.createElement('span'); kl.className = 'keylock';
items.forEach((t, i) => {
const p = document.createElement('span'); p.className = 'kpos';
p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t;
kl.appendChild(p);
});
const body = document.createElement('span'); body.className = 'body';
body.innerHTML = '';
kl.appendChild(body); host.appendChild(kl);
let idx;
const set = i => {
idx = ((i % items.length) + items.length) % items.length;
body.querySelector('.bit').style.transform = `rotate(${ANG[idx]}deg)`;
kl.querySelectorAll('.kpos').forEach((p, j) => p.classList.toggle('on', j === idx));
onChange(idx, items[idx]);
};
body.addEventListener('click', () => set(idx + 1));
set(opts.index !== undefined ? opts.index : 1);
return { el: kl, get: () => idx, set };
};
/* N07 center-detented crossfader — throw to either side of zero */
GW.crossfader = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const xf = document.createElement('div'); xf.className = 'xfader';
xf.innerHTML = `${opts.aLabel || 'A'}${opts.bLabel || 'B'}`;
host.appendChild(xf);
let pct;
const set = p => {
pct = Math.max(0, Math.min(100, p));
xf.querySelector('.cap').style.left = pct + '%';
const v = Math.round(pct - 50);
onChange(v, (v > 0 ? '+' : '') + v);
};
dragX(xf, set);
set(opts.value !== undefined ? opts.value : 50);
return { el: xf, get: () => Math.round(pct - 50), set };
};
/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value */
GW.thumbwheel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const w = document.createElement('div'); w.className = 'thumbw';
w.innerHTML = '';
host.appendChild(w);
let val;
const set = v => {
val = Math.round(v);
const s = ((val % 100) + 100) % 100;
w.querySelector('.win').textContent = String(s).padStart(2, '0');
onChange(s, 'value ' + s);
};
dragDelta(w.querySelector('.thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 });
set(opts.value !== undefined ? opts.value : 42);
return { el: w, get: () => ((val % 100) + 100) % 100, set };
};
/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word */
GW.dipBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const bits = opts.bits || [true, false, true, true, false, false];
const bank = document.createElement('span'); bank.className = 'dip'; host.appendChild(bank);
const sws = bits.map(on => {
const s = document.createElement('span'); s.className = 'dipsw' + (on ? ' on' : '');
s.innerHTML = ''; bank.appendChild(s); return s;
});
const word = () => sws.map(x => x.classList.contains('on') ? '1' : '0').join('');
const upd = () => onChange(word(), word());
sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('on'); upd(); }));
upd();
return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('on', w[i] === '1')); upd(); } };
};
/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */
GW.jogWheel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const j = document.createElement('span'); j.className = 'jog';
j.innerHTML = ``;
host.appendChild(j);
let val;
const set = v => {
val = v;
j.querySelector('.inner').style.transform = `rotate(${val * 4}deg)`;
onChange(val, 'pos ' + Math.round(val));
};
dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 });
set(opts.value !== undefined ? opts.value : 0);
return { el: j, get: () => val, set };
};
/* ---- shared SVG defs: gradients/filters referenced by url(#id) are
document-scoped, and several are used across widgets. Builders ensure the
defs they use; the first caller creates it, later calls are no-ops. ---- */
let defsRoot = null;
const defsMade = new Set();
function defsHost() {
if (!defsRoot) {
const svg = document.createElementNS(SVGNS, 'svg');
svg.setAttribute('width', 0); svg.setAttribute('height', 0);
svg.setAttribute('aria-hidden', 'true');
svg.style.position = 'absolute';
defsRoot = document.createElementNS(SVGNS, 'defs');
svg.appendChild(defsRoot);
document.body.appendChild(svg);
}
return defsRoot;
}
function def(id, make) { if (defsMade.has(id)) return; defsMade.add(id); make(defsHost(), id); }
function gradDef(id, kind, attrs, stops) {
def(id, d => {
const g = svgEl(d, kind, Object.assign({ id }, attrs));
for (const [offset, color] of stops) svgEl(g, 'stop', { offset, 'stop-color': color });
});
}
function stageSvg(host, cls, vw, vh) {
const s = document.createElementNS(SVGNS, 'svg');
s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`);
s.setAttribute('width', vw); s.setAttribute('height', vh);
host.appendChild(s); return s;
}
/* instance-unique ids for defs that can't be shared (e.g. clip paths sized per instance) */
let uidN = 0;
function uid(prefix) { return prefix + '-' + (++uidN); }
/* R02 calibrated vernier dial — the disc turns under a fixed hairline */
GW.vernierDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75;
gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]);
gradDef('bakeKnob', 'radialGradient', { cx: '42%', cy: '36%', r: '75%' }, [['0', '#37332c'], ['1', '#060505']]);
svgEl(s, 'circle', { cx, cy, r: 72, fill: '#0c0b0a', stroke: '#262320' });
const disc = svgEl(s, 'g', {});
svgEl(disc, 'circle', { cx, cy, r: 66, fill: 'url(#vernFace)', stroke: '#b7b29a', 'stroke-width': 1 });
for (let u = 0; u <= 100; u++) {
const a = u * 3, major = u % 10 === 0, half = u % 5 === 0;
const len = major ? 10 : half ? 7 : 4.5, [x1, y1] = polar(cx, cy, 63, a), [x2, y2] = polar(cx, cy, 63 - len, a);
svgEl(disc, 'line', { x1, y1, x2, y2, stroke: '#3a3128', 'stroke-width': major ? 1.3 : half ? 1 : .7 });
if (major) {
const g = svgEl(disc, 'g', { transform: `rotate(${a},${cx},${cy})` });
svgEl(g, 'text', { x: cx, y: cy - 44, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#3a3128' }).textContent = u;
}
}
for (let k = 0; k < 12; k++) { const [x, y] = polar(cx, cy, 24, k * 30); svgEl(s, 'circle', { cx: x, cy: y, r: 4.8, fill: 'url(#bakeKnob)' }); }
svgEl(s, 'circle', { cx, cy, r: 24, fill: 'url(#bakeKnob)', stroke: '#000', 'stroke-width': .6 });
svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#bakeKnob)' });
svgEl(s, 'ellipse', { cx: 69, cy: 66, rx: 7, ry: 4, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-32,69,66)' });
svgEl(s, 'line', { x1: cx, y1: 6, x2: cx, y2: 22, stroke: 'var(--fail)', 'stroke-width': 2.2, 'stroke-linecap': 'round' });
let val;
const set = v => { val = v; disc.setAttribute('transform', `rotate(${-v * 3},75,75)`); onChange(val, val.toFixed(1)); };
dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.15 });
set(opts.value !== undefined ? opts.value : 42.0);
return { el: s, get: () => val, set };
};
/* R03 bat-handle toggle — chrome lever throws between lit legends */
GW.batToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44;
gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]);
gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]);
const lblOn = svgEl(s, 'text', { x: cx, y: 9, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
lblOn.textContent = opts.onLabel || 'ON';
const lblOff = svgEl(s, 'text', { x: cx, y: 87, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
lblOff.textContent = opts.offLabel || 'OFF';
const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 17 * Math.cos(a)) + ',' + (cy + 17 * Math.sin(a))); }
svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' });
svgEl(s, 'circle', { cx, cy, r: 9, fill: '#14110e', stroke: '#000' });
const lever = svgEl(s, 'g', {});
svgEl(lever, 'path', { d: `M ${cx - 3.2} ${cy} L ${cx - 1.8} 21 L ${cx + 1.8} 21 L ${cx + 3.2} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 });
svgEl(lever, 'ellipse', { cx, cy: 17, rx: 6.5, ry: 8, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 });
lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
let on;
const set = v => {
on = !!v;
lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
onChange(on, on ? 'ON' : 'OFF');
};
s.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: s, get: () => on, set };
};
/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc */
GW.flutedKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54;
gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]);
gradDef('bakeDome', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#3a362f'], ['1', '#0a0908']]);
for (let i = 0; i <= 10; i++) {
const a = -135 + i * 27, major = i % 5 === 0;
const [x1, y1] = polar(cx, cy, 38, a), [x2, y2] = polar(cx, cy, major ? 45 : 43.5, a);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': major ? 1.5 : 1 });
if (major) {
const [x, y] = polar(cx, cy, 50.5, a);
svgEl(s, 'text', { x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': 8, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i;
}
}
for (let k = 0; k < 18; k++) { const [x, y] = polar(cx, cy, 31, k * 20); svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: 'url(#bakeSk)' }); }
svgEl(s, 'circle', { cx, cy, r: 31, fill: 'url(#bakeSk)', stroke: '#000', 'stroke-width': .6 });
const idx = svgEl(s, 'line', { x1: cx, y1: cy - 30, x2: cx, y2: cy - 21, stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' });
svgEl(s, 'circle', { cx, cy, r: 19, fill: 'url(#bakeDome)' });
svgEl(s, 'ellipse', { cx: 49, cy: 46, rx: 6, ry: 3.5, fill: 'rgba(255,255,255,.14)', transform: 'rotate(-28,49,46)' });
let val;
const set = v => { val = v; idx.setAttribute('transform', `rotate(${v * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); };
dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.25 });
set(opts.value !== undefined ? opts.value : 63);
return { el: s, get: () => val, set };
};
/* R05 filter slider bank — one fader per band, teal arrow caps on black tracks */
GW.filterBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const freqs = opts.freqs || [78, 136, 235, 406, 701, 1210, 2090, 3620];
const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48]).slice();
const fmtHz = f => f < 1000 ? f : (f / 1000) + 'k';
const s = stageSvg(host, 'rsvg', 190, 96);
const y0 = 14, y1 = 84, x0 = 30, dx = 19.5;
for (const [lbl, y] of [['0', y0], ['20', y0 + (y1 - y0) / 3], ['40', y0 + 2 * (y1 - y0) / 3], ['60', y1]])
svgEl(s, 'text', { x: 14, y: y + 2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl;
svgEl(s, 'text', { x: 14, y: 94, 'text-anchor': 'end', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'dB';
const caps = [];
const set = (i, db) => {
db = Math.max(0, Math.min(60, db)); vals[i] = db;
caps[i].setAttribute('transform', `translate(0,${14 + db / 60 * 70})`);
onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`);
};
freqs.forEach((f, i) => {
const x = x0 + i * dx;
svgEl(s, 'text', { x, y: 8, 'text-anchor': 'middle', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = fmtHz(f);
svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: '#0a0908', 'stroke-width': 3.4, 'stroke-linecap': 'round' });
svgEl(s, 'line', { x1: x - .7, y1: y0, x2: x - .7, y2: y1, stroke: 'rgba(255,255,255,.05)', 'stroke-width': .7 });
const cap = svgEl(s, 'g', {});
svgEl(cap, 'polygon', { points: `${x - 8.5},${-4.5} ${x - 2.5},0 ${x - 8.5},${4.5}`, fill: 'var(--vfd)', stroke: '#123028', 'stroke-width': .6 });
svgEl(cap, 'polygon', { points: `${x + 8.5},${-4.5} ${x + 2.5},0 ${x + 8.5},${4.5}`, fill: 'var(--vfd)', stroke: '#123028', 'stroke-width': .6 });
svgEl(cap, 'circle', { cx: x, cy: 0, r: 1.5, fill: '#123028' });
caps.push(cap);
/* paint the initial position without announcing it */
cap.setAttribute('transform', `translate(0,${14 + vals[i] / 60 * 70})`);
const hit = svgEl(s, 'rect', { x: x - 9.5, y: y0 - 6, width: 19, height: y1 - y0 + 12, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragY(hit, pct => set(i, (100 - pct) / 100 * 60));
});
onChange(null, 'drag a band');
return { el: s, get: () => vals.slice(), set };
};
/* R06 chicken-head selector — tapered lever aims at the position */
GW.chickenHead = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]];
const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58;
gradDef('chickG', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#35312b'], ['1', '#070606']]);
const lbls = [];
items.forEach(([lbl, a]) => {
const [tx, ty] = polar(cx, cy, 42, a);
const t = svgEl(s, 'text', { x: tx, y: ty + 2.5, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
t.textContent = lbl; lbls.push(t);
const [mx1, my1] = polar(cx, cy, 33, a), [mx2, my2] = polar(cx, cy, 29, a);
svgEl(s, 'line', { x1: mx1, y1: my1, x2: mx2, y2: my2, stroke: 'var(--steel)', 'stroke-width': 1.2 });
});
svgEl(s, 'circle', { cx, cy, r: 12, fill: '#0c0b0a' });
const lever = svgEl(s, 'g', {});
svgEl(lever, 'path', { d: `M ${cx} ${cy - 29} L ${cx + 8.5} ${cy - 4} Q ${cx + 9} ${cy + 8} ${cx + 5} ${cy + 11} A 7 7 0 0 1 ${cx - 5} ${cy + 11} Q ${cx - 9} ${cy + 8} ${cx - 8.5} ${cy - 4} Z`, fill: 'url(#chickG)', stroke: '#000', 'stroke-width': .7 });
svgEl(lever, 'line', { x1: cx, y1: cy - 26, x2: cx, y2: cy + 6, stroke: '#4a463e', 'stroke-width': 1.4, 'stroke-linecap': 'round' });
lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
let idx;
const set = i => {
idx = ((i % items.length) + items.length) % items.length;
lever.style.transform = `rotate(${items[idx][1]}deg)`;
lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)'));
onChange(idx, items[idx][0]);
};
s.addEventListener('click', () => set(idx + 1));
set(opts.index !== undefined ? opts.index : 2);
return { el: s, get: () => idx, set };
};
/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate */
GW.slotFader = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120;
const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop);
gradDef('sfPlate', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#242019'], ['1', '#131110']]);
gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
svgEl(s, 'rect', { x: 14, y: 8, width: 62, height: 134, rx: 6, fill: 'url(#sfPlate)', stroke: '#0a0908', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 15.5, y: 9.5, width: 59, height: 131, rx: 5, fill: 'none', stroke: 'rgba(255,255,255,.05)', 'stroke-width': 1 });
for (const sy of [16, 134]) {
svgEl(s, 'circle', { cx, cy: sy, r: 3.2, fill: 'url(#sfScrew)', stroke: '#14110e', 'stroke-width': .6 });
const [x1, y1] = polar(cx, sy, 3, 112), [x2, y2] = polar(cx, sy, 3, 292);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#221f1a', 'stroke-width': 1 });
}
svgEl(s, 'rect', { x: 40.5, y: yTop - 4, width: 9, height: yBot - yTop + 8, rx: 4, fill: '#0b0a09', stroke: '#050404' });
for (const db of [12, 8, 4, 0, -4, -8, -12, -16, -20, -24]) {
const y = yOf(db);
svgEl(s, 'line', { x1: 26, y1: y, x2: 39, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 });
svgEl(s, 'line', { x1: 51, y1: y, x2: 64, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 });
svgEl(s, 'text', { x: 23, y: y + 2.2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: db === 0 ? 'var(--cream)' : 'var(--steel)' }).textContent = Math.abs(db);
}
const handle = svgEl(s, 'g', {});
svgEl(handle, 'polygon', { points: '45,0 58,-5 58,5', fill: '#b9b6ae', stroke: '#5e5a52', 'stroke-width': .5 });
svgEl(handle, 'rect', { x: 58, y: -7, width: 22, height: 14, rx: 3.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 });
svgEl(handle, 'rect', { x: 60, y: -5.2, width: 18, height: 2.2, rx: 1, fill: 'rgba(255,255,255,.4)' });
let db;
const set = v => {
db = Math.max(-24, Math.min(12, v));
handle.setAttribute('transform', `translate(0,${30 + (12 - db) / 36 * 90})`);
onChange(db, (db > 0 ? '+' : db < 0 ? '−' : '') + Math.abs(db).toFixed(1) + ' dB');
};
dragY(s, pct => set(-24 + pct / 100 * 36));
set(opts.value !== undefined ? opts.value : -4);
return { el: s, get: () => db, set };
};
/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it */
GW.spadeKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82;
const sweep = v => -80 + v / 10 * 160;
gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]);
gradDef('spadeMetal', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e9e7e0'], ['1', '#8d897f']]);
/* engraved arc: every stroke carries a faint light twin offset below (incised relief) */
const engrave = (x1, y1, x2, y2, w) => {
svgEl(s, 'line', { x1, y1: y1 + .8, x2, y2: y2 + .8, stroke: 'rgba(255,255,255,.13)', 'stroke-width': w });
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#050404', 'stroke-width': w });
};
for (let i = 0; i <= 40; i++) {
const v = i / 4, a = sweep(v), major = i % 4 === 0;
const [x1, y1] = polar(cx, cy, major ? 50 : 52, a), [x2, y2] = polar(cx, cy, 58, a);
engrave(x1, y1, x2, y2, major ? 1.6 : .9);
if (major) {
const [nx, ny] = polar(cx, cy, 65, a);
svgEl(s, 'text', { x: nx, y: ny + 3.2, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: 'rgba(255,255,255,.13)' }).textContent = v;
svgEl(s, 'text', { x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#0a0908' }).textContent = v;
}
}
const grp = svgEl(s, 'g', {});
svgEl(grp, 'path', { d: `M ${cx - 2} ${cy - 28} L ${cx - 4.5} ${cy - 42} L ${cx} ${cy - 54} L ${cx + 4.5} ${cy - 42} L ${cx + 2} ${cy - 28} Z`, fill: 'url(#spadeMetal)', stroke: '#55524a', 'stroke-width': .6 });
svgEl(grp, 'circle', { cx, cy, r: 30, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .8 });
svgEl(grp, 'circle', { cx, cy, r: 28.5, fill: 'none', stroke: '#0a0908', 'stroke-width': 3, 'stroke-dasharray': '2.2 2.2' });
svgEl(grp, 'circle', { cx, cy, r: 22, fill: 'url(#spadeKnob)' });
svgEl(grp, 'ellipse', { cx: cx - 8, cy: cy - 9, rx: 8, ry: 5, fill: 'rgba(255,255,255,.08)', transform: `rotate(-30,${cx - 8},${cy - 9})` });
let val;
const set = v => {
val = Math.max(0, Math.min(10, v));
grp.setAttribute('transform', `rotate(${-80 + val / 10 * 160},70,82)`);
onChange(val, val.toFixed(1));
};
dragDelta(s, () => val, set, { min: 0, max: 10, sens: .05 });
set(opts.value !== undefined ? opts.value : 8.3);
return { el: s, get: () => val, set };
};
/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring */
GW.multiBandDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]];
const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62;
const sweep = t => -70 + t * 140;
gradDef('mbFace', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#eee5c8'], ['1', '#dcd2ae']]);
gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]);
svgEl(s, 'rect', { x: 4, y: 4, width: 182, height: 102, rx: 12, fill: '#171412', stroke: '#060505' });
svgEl(s, 'rect', { x: 9, y: 9, width: 172, height: 92, rx: 9, fill: 'url(#mbFace)', stroke: '#0a0908' });
const INK = '#2c2318';
const rings = [];
[18, 26, 34, 42].forEach(r => {
const ring = svgEl(s, 'g', {});
const [x1, y1] = polar(cx, cy, r, -70), [x2, y2] = polar(cx, cy, r, 70);
svgEl(ring, 'path', { d: `M ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2}`, fill: 'none', stroke: INK, 'stroke-width': .9 });
for (let i = 0; i <= 8; i++) {
const a = sweep(i / 8), [tx1, ty1] = polar(cx, cy, r, a), [tx2, ty2] = polar(cx, cy, r + 3, a);
svgEl(ring, 'line', { x1: tx1, y1: ty1, x2: tx2, y2: ty2, stroke: INK, 'stroke-width': .8 });
}
rings.push(ring);
});
const needle = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: '#1a1613', 'stroke-width': 1.6, 'stroke-linecap': 'round' });
svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 });
svgEl(s, 'text', { x: 14, y: 100, 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'MEGACYCLES';
/* bandspread dial (click = band select) */
const bx = 142, by = 56;
for (let i = 0; i < 12; i++) { const a = i * 30, [x1, y1] = polar(bx, by, 24, a), [x2, y2] = polar(bx, by, 21, a); svgEl(s, 'line', { x1, y1, x2, y2, stroke: INK, 'stroke-width': .8 }); }
svgEl(s, 'circle', { cx: bx, cy: by, r: 17, fill: 'none', stroke: INK, 'stroke-width': .7, opacity: .5 });
const spread = svgEl(s, 'line', { x1: bx, y1: by, x2: bx, y2: by - 22, stroke: '#1a1613', 'stroke-width': 1.4, 'stroke-linecap': 'round' });
svgEl(s, 'circle', { cx: bx, cy: by, r: 6, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 });
svgEl(s, 'text', { x: bx, y: 96, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'BANDSPREAD';
let val, band;
const set = (v, b) => {
val = Math.max(0, Math.min(100, v)); band = b;
needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`);
spread.setAttribute('transform', `rotate(${-45 + b * 30},142,56)`);
rings.forEach((g, i) => g.setAttribute('opacity', i === b ? '1' : '.4'));
const [lo, hi] = ranges[b]; const mc = lo + val / 100 * (hi - lo);
onChange({ band: b, mc }, `B${b + 1} · ${mc.toFixed(2)} Mc`);
};
const dragHit = svgEl(s, 'rect', { x: 9, y: 9, width: 100, height: 92, fill: 'transparent' });
dragHit.style.cursor = 'ns-resize';
dragDelta(dragHit, () => val, v => set(v, band), { min: 0, max: 100 });
const clickHit = svgEl(s, 'rect', { x: 112, y: 9, width: 69, height: 92, fill: 'transparent' });
clickHit.style.cursor = 'pointer';
clickHit.addEventListener('click', () => set(val, (band + 1) % ranges.length));
set(opts.value !== undefined ? opts.value : 45, opts.band !== undefined ? opts.band : 2);
return { el: s, get: () => [val, band], set };
};
/* R16 entry keypad — worn keys feed the amber display; lamps watch state */
GW.entryKeypad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 150, 190);
gradDef('kpKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]);
gradDef('kpAmber', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--amber-grad-mid)']]);
const lamp = cy => svgEl(s, 'circle', { cx: 13, cy, r: 6.5, fill: '#3a0f0a', stroke: '#14100e', 'stroke-width': 1.5 });
const lampTop = lamp(20), lampBot = lamp(38);
svgEl(s, 'rect', { x: 26, y: 8, width: 116, height: 32, rx: 5, fill: '#0a0806', stroke: '#2c261d', 'stroke-width': 2 });
const disp = svgEl(s, 'text', { x: 84, y: 31, 'text-anchor': 'middle', 'font-size': 16, 'letter-spacing': '.22em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
svgEl(s, 'rect', { x: 24, y: 48, width: 118, height: 138, rx: 8, fill: '#1a1714', stroke: '#0a0908', 'stroke-width': 1.5 });
let buf = '';
const render = () => {
disp.textContent = buf.padEnd(6, '–');
lampTop.setAttribute('fill', buf ? 'var(--jewel-r)' : '#3a0f0a');
};
const press = k => {
if (k === '✓') { onChange(buf, buf ? 'OK · ' + buf : 'empty'); buf = ''; render(); return; }
if (k === '✗') {
buf = ''; render(); onChange(buf, 'cleared');
lampBot.setAttribute('fill', 'var(--jewel-r)');
setTimeout(() => lampBot.setAttribute('fill', '#3a0f0a'), 350); return;
}
if (buf.length < 6) { buf += k; render(); onChange(buf, buf); }
};
const KEYS = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['✓', '0', '✗']];
const jitter = [-2, 1, -1, 2, 0, -2, 1, -1, 2, 0, -1, 1];
KEYS.forEach((row, r) => row.forEach((k, c) => {
const x = 47 + c * 36, y = 68 + r * 32, amber = (k === '✓' || k === '✗');
const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
svgEl(g, 'rect', { x: x - 14, y: y - 11, width: 28, height: 24, rx: 4, fill: amber ? 'url(#kpAmber)' : 'url(#kpKey)', stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 });
svgEl(g, 'text', { x, y: y + 6, 'text-anchor': 'middle', 'font-size': 13, 'font-weight': 700, 'font-family': 'var(--mono)', fill: k === '✓' ? '#3f5a1f' : k === '✗' ? '#7a2a1a' : '#2b2721', transform: `rotate(${jitter[r * 3 + c]},${x},${y})` }).textContent = k;
g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); press(k); });
}));
render(); onChange('', 'enter a code');
return { el: s, get: () => buf, press };
};
/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab */
GW.thumbSlide = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v }));
const s = stageSvg(host, 'rsvg', 130, 150), y0 = 22, y1 = 122;
gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
gradDef('thTab', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2efe6'], ['1', '#c6c1b3']]);
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
const parts = [];
const set = (i, v) => {
v = Math.max(0, Math.min(100, Math.round(v))); chans[i].v = v;
const p = parts[i];
p.tab.setAttribute('transform', `translate(0,${28 + (100 - v) * 0.88})`);
p.nums.forEach(t => {
const on = Math.abs(parseInt(t.textContent) - v) <= 5;
t.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--gold)'); t.setAttribute('opacity', on ? '1' : '.55');
});
onChange(chans.map(c => c.v), `${chans[0].name} ${chans[0].v} · ${chans[1].name} ${chans[1].v}`);
};
chans.forEach((ch, i) => {
const x = 40 + i * 50;
svgEl(s, 'rect', { x: x - 9, y: y0 - 10, width: 18, height: y1 - y0 + 20, rx: 3, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
for (const sy of [y0 - 6, y1 + 6]) {
svgEl(s, 'circle', { cx: x, cy: sy, r: 2.4, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
svgEl(s, 'line', { x1: x - 1.8, y1: sy - 1, x2: x + 1.8, y2: sy + 1, stroke: '#14110e', 'stroke-width': .8 });
}
svgEl(s, 'rect', { x: x - 5, y: y0, width: 10, height: y1 - y0, fill: '#0d0a08', stroke: '#050404' });
const nums = [];
for (let n = 0; n <= 10; n++) {
const val = 100 - n * 10, y = y0 + 6 + n * (y1 - y0 - 12) / 10;
const t = svgEl(s, 'text', { x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .55 });
t.textContent = val; nums.push(t);
}
const tab = svgEl(s, 'g', {});
svgEl(tab, 'rect', { x: x + 7, y: -6, width: 8, height: 12, rx: 2, fill: 'url(#thTab)', stroke: '#7a766a', 'stroke-width': .6 });
svgEl(tab, 'rect', { x: x + 8.2, y: -1, width: 5.6, height: 1.6, fill: 'rgba(0,0,0,.25)' });
svgEl(s, 'text', { x, y: 145, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
const hit = svgEl(s, 'rect', { x: x - 12, y: y0 - 10, width: 30, height: y1 - y0 + 20, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
parts.push({ tab, nums });
dragY(hit, pct => set(i, pct));
});
set(0, chans[0].v); set(1, chans[1].v);
return { el: s, get: () => chans.map(c => c.v), set };
};
/* R19 waveform region editor — monochrome LCD, draggable S/E flags */
GW.waveRegion = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85;
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 98, rx: 6, fill: 'var(--scr-bg1,#0b0c0b)', stroke: 'var(--scr-brd,#2a2c2a)', 'stroke-width': 2 });
svgEl(s, 'text', { x: 12, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C1:START';
svgEl(s, 'text', { x: 78, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C2:OFF';
svgEl(s, 'text', { x: 136, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C3:END';
svgEl(s, 'line', { x1: x0, y1: yMid, x2: x1, y2: yMid, stroke: '#3a3c3a', 'stroke-width': .6, 'stroke-dasharray': '1.5 2' });
const bars = [];
for (let i = 0; i < N; i++) {
const x = x0 + (i / (N - 1)) * (x1 - x0);
const env = Math.abs(Math.sin(i * 0.19)) * Math.abs(Math.sin(i * 0.045)) * (i % 17 < 11 ? 1 : .25);
const h = Math.max(1.2, env * 26);
bars.push(svgEl(s, 'rect', { x: x - 0.8, y: yMid - h, width: 1.6, height: h * 2, fill: 'var(--scr-dim,#5a5c5a)' }));
}
const flag = lbl => {
const g = svgEl(s, 'g', {});
svgEl(g, 'line', { x1: 0, y1: 18, x2: 0, y2: 78, stroke: 'var(--scr-hi,#f2f4f2)', 'stroke-width': 1 });
svgEl(g, 'rect', { x: lbl === 'S' ? 0 : -8, y: 70, width: 8, height: 8, fill: 'var(--scr-hi,#f2f4f2)' });
svgEl(g, 'text', { x: lbl === 'S' ? 4 : -4, y: 76.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = lbl;
return g;
};
const flagS = flag('S'), flagE = flag('E');
svgEl(s, 'rect', { x: 10, y: 84, width: 78, height: 11, fill: 'var(--scr-hi,#e8eae8)' });
svgEl(s, 'text', { x: 14, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'ENC:ZOOM(1x)';
svgEl(s, 'text', { x: 96, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'A-3 M:[S]';
svgEl(s, 'rect', { x: 148, y: 84, width: 32, height: 11, fill: 'var(--scr-hi,#e8eae8)' });
svgEl(s, 'text', { x: 164, y: 92.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'MENU';
s.style.cursor = 'ew-resize';
let S, E;
const set = (sv, ev) => {
S = Math.max(0, Math.min(96, sv)); E = Math.max(4, Math.min(100, ev));
const xOf = p => x0 + p / 100 * (x1 - x0);
flagS.setAttribute('transform', `translate(${xOf(S)},0)`);
flagE.setAttribute('transform', `translate(${xOf(E)},0)`);
bars.forEach((b, i) => {
const p = i / (bars.length - 1) * 100;
b.setAttribute('fill', (p >= S && p <= E) ? 'var(--scr-hi,#f2f4f2)' : 'var(--scr-dim,#5a5c5a)');
});
onChange({ s: S, e: E }, `S ${Math.round(S)}% · E ${Math.round(E)}%`);
};
dragX(s, pct => {
/* move whichever flag is nearer the pointer */
if (Math.abs(pct - S) <= Math.abs(pct - E)) set(Math.min(pct, E - 4), E);
else set(S, Math.max(pct, S + 4));
});
set(opts.start !== undefined ? opts.start : 22, opts.end !== undefined ? opts.end : 76);
return { el: s, get: () => ({ s: S, e: E }), set };
};
/* R20 drum roller selector — numbered paper drum in a window, center chip reads it */
GW.drumRoller = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v }));
const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17;
gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
gradDef('drPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]);
gradDef('drCurve', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.4)'], ['.28', 'rgba(0,0,0,0)'], ['.72', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.4)']]);
const localDefs = svgEl(s, 'defs', {});
svgEl(s, 'text', { x: 65, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
const grps = [];
const set = (i, v) => {
v = Math.max(1, Math.min(10, v)); chans[i].v = v;
const p = grps[i];
p.nums.style.transform = `translateY(${(v - p.base) * step}px)`;
p.chip.textContent = Math.round(v);
onChange(chans.map(c => c.v), `${chans[0].name} ${Math.round(chans[0].v)} · ${chans[1].name} ${Math.round(chans[1].v)}`);
};
chans.forEach((ch, i) => {
const x = 45 + i * 50, cid = uid('drClip');
svgEl(s, 'text', { x: x + 18, y: 34, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
svgEl(s, 'text', { x: x + 18, y: 124, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
svgEl(s, 'rect', { x: x - 12, y: 24, width: 24, height: 104, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
const clip = svgEl(localDefs, 'clipPath', { id: cid });
svgEl(clip, 'rect', { x: x - 9, y: 28, width: 18, height: 96 });
svgEl(s, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drPaper)' });
const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` });
const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s';
for (let n = 1; n <= 10; n++)
svgEl(nums, 'text', { x, y: cy + 3 + (ch.v - n) * step, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }).textContent = n;
svgEl(g, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drCurve)', 'pointer-events': 'none' });
svgEl(s, 'rect', { x: x - 8, y: cy - 6.5, width: 16, height: 13, rx: 1.5, fill: '#1a1613', opacity: .92 });
const chip = svgEl(s, 'text', { x, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' });
svgEl(s, 'text', { x, y: 136, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
const hit = svgEl(s, 'rect', { x: x - 14, y: 24, width: 28, height: 104, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
grps.push({ nums, chip, base: ch.v });
dragDelta(hit, () => chans[i].v, v => set(i, v), { min: 1, max: 10, sens: .08 });
});
set(0, chans[0].v); set(1, chans[1].v);
return { el: s, get: () => chans.map(c => c.v), set };
};
/* R21 LED program row — exclusive select, the LED above the key carries the state */
GW.ledRow = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate'];
const s = stageSvg(host, 'rsvg', 190, 58);
gradDef('lrKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2b2823'], ['1', '#171512']]);
const leds = [];
let idx;
const set = i => {
idx = i;
leds.forEach((l, k) => {
const on = k === i;
l.setAttribute('fill', on ? 'var(--jewel-r)' : '#3a0f0a');
l.setAttribute('style', on ? 'filter:drop-shadow(0 0 3px rgba(255,91,69,.8))' : '');
});
onChange(idx, `${idx + 1} · ${items[idx]}`);
};
items.forEach((_, i) => {
const x = 16 + i * 22.6;
svgEl(s, 'text', { x, y: 9, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i + 1;
leds.push(svgEl(s, 'circle', { cx: x, cy: 17, r: 2.4, fill: '#3a0f0a' }));
const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
svgEl(g, 'rect', { x: x - 8.5, y: 24, width: 17, height: 22, rx: 2.5, fill: 'url(#lrKey)', stroke: '#0a0908', 'stroke-width': 1 });
svgEl(g, 'rect', { x: x - 6.5, y: 26.5, width: 13, height: 3, rx: 1.5, fill: 'rgba(255,255,255,.06)' });
g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); set(i); });
});
svgEl(s, 'text', { x: 95, y: 56, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'PROGRAM';
set(opts.index !== undefined ? opts.index : 5);
return { el: s, get: () => idx, set };
};
/* R22 three-position slide — chrome pill between detents, honest LED pair */
GW.pillSlide = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const positions = opts.positions || ['A', 'AB', 'B'];
const s = stageSvg(host, 'rsvg press', 110, 64), detX = [34, 55, 76];
gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
svgEl(s, 'text', { x: 55, y: 10, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'BASIC · VARIATION';
svgEl(s, 'rect', { x: 20, y: 16, width: 70, height: 14, rx: 7, fill: '#0b0a09', stroke: '#000', 'stroke-width': 1 });
svgEl(s, 'rect', { x: 21, y: 17, width: 68, height: 5, rx: 2.5, fill: 'rgba(255,255,255,.04)' });
const pill = svgEl(s, 'rect', { x: -13, y: 18.5, width: 26, height: 9, rx: 4.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 });
pill.style.transition = 'transform .12s';
const lbls = positions.map((lbl, i) => {
const t = svgEl(s, 'text', { x: detX[i], y: 40, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' });
t.textContent = lbl; return t;
});
svgEl(s, 'rect', { x: 32, y: 45, width: 46, height: 11, rx: 2, fill: '#141210', stroke: '#060505' });
const leds = [44, 66].map(x => svgEl(s, 'circle', { cx: x, cy: 50.5, r: 2.4, fill: '#3a0f0a' }));
let idx;
const set = i => {
idx = i;
pill.setAttribute('transform', `translate(${detX[i]},0)`);
lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)'));
const lit = [i === 0 || i === 1, i === 2 || i === 1];
leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a'));
onChange(idx, positions[i]);
};
s.addEventListener('click', e => {
const r = s.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * 110;
set(x < 45 ? 0 : x < 66 ? 1 : 2);
});
set(opts.index !== undefined ? opts.index : 1);
return { el: s, get: () => idx, set };
};
/* R23 spun-aluminum knob — machined rings in a knurled grip, red index */
GW.spunKnob = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52;
gradDef('spunFace', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#e8e6e0'], ['.7', '#b3b0a8'], ['1', '#8a877e']]);
for (let k = 0; k < 8; k++) { const [x, y] = polar(cx, cy, 45, -135 + k * 38.6); svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: 'var(--steel)' }); }
for (let k = 0; k < 22; k++) { const [x, y] = polar(cx, cy, 36, k * 16.36); svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: '#14110e' }); }
svgEl(s, 'circle', { cx, cy, r: 36, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 });
svgEl(s, 'circle', { cx, cy, r: 30, fill: 'url(#spunFace)', stroke: '#6e6b63', 'stroke-width': .8 });
for (const r of [24, 18, 12, 6])
svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: r % 12 === 0 ? 'rgba(0,0,0,.12)' : 'rgba(255,255,255,.3)', 'stroke-width': .6 });
const idx = svgEl(s, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 10, stroke: 'var(--fail)', 'stroke-width': 2.6, 'stroke-linecap': 'round' });
svgEl(s, 'ellipse', { cx: cx - 9, cy: cy - 11, rx: 9, ry: 5, fill: 'rgba(255,255,255,.28)', transform: `rotate(-32,${cx - 9},${cy - 11})` });
svgEl(s, 'text', { x: cx, y: 103, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'SPEED';
let val;
const set = v => {
val = Math.max(0, Math.min(100, v));
idx.setAttribute('transform', `rotate(${-135 + val / 100 * 270},${cx},${cy})`);
onChange(val, Math.round(val) + '%');
};
dragDelta(s, () => val, set, { min: 0, max: 100 });
set(opts.value !== undefined ? opts.value : 62);
return { el: s, get: () => val, set };
};
/* R24 stomp switch + jewel — press to engage, the jewel reports */
GW.stompSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55;
gradDef('stompDome', 'radialGradient', { cx: '40%', cy: '30%', r: '80%' }, [['0', '#f4f2ec'], ['.55', '#b9b6ae'], ['1', '#6e6b63']]);
gradDef('stompJewel', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#ffe9b0'], ['.45', 'var(--jewel-a)'], ['1', '#5c3d0c']]);
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
const glow = svgEl(s, 'circle', { cx, cy: 22, r: 14, fill: 'rgba(255,180,58,.35)', filter: 'url(#avGlow)', opacity: 0 });
const jewel = svgEl(s, 'circle', { cx, cy: 22, r: 9, fill: '#241f1b', stroke: '#0a0908', 'stroke-width': 1.5 });
for (let k = 0; k < 6; k++) {
const a1 = k * 60, a2 = k * 60 + 30;
const [x1, y1] = polar(cx, 22, 9, a1), [x2, y2] = polar(cx, 22, 9, a2);
svgEl(s, 'path', { d: `M ${cx} 22 L ${x1} ${y1} A 9 9 0 0 1 ${x2} ${y2} Z`, fill: k % 2 ? 'rgba(255,255,255,.09)' : 'rgba(0,0,0,.10)', 'pointer-events': 'none' });
}
for (let k = 0; k < 20; k++) { const [x, y] = polar(cx, 72, 25, k * 18); svgEl(s, 'circle', { cx: x, cy: y, r: 2.4, fill: '#14110e' }); }
svgEl(s, 'circle', { cx, cy: 72, r: 25, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 });
const dome = svgEl(s, 'g', {}); dome.style.transition = 'transform .08s';
svgEl(dome, 'circle', { cx, cy: 72, r: 19, fill: 'url(#stompDome)', stroke: '#55524a', 'stroke-width': .8 });
svgEl(dome, 'ellipse', { cx: cx - 6, cy: 64, rx: 8, ry: 4.5, fill: 'rgba(255,255,255,.5)', transform: `rotate(-24,${cx - 6},64)` });
let on;
const set = v => {
on = !!v;
jewel.setAttribute('fill', on ? 'url(#stompJewel)' : '#241f1b');
glow.setAttribute('opacity', on ? '1' : '0');
onChange(on, on ? 'ENGAGED' : 'bypass');
};
s.addEventListener('click', () => { dome.style.transform = 'translateY(1.5px)'; setTimeout(() => dome.style.transform = '', 90); set(!on); });
set(opts.on !== undefined ? opts.on : false);
return { el: s, get: () => on, set };
};
/* R27 winged gain selector — red T-bar over a dot ring, stepped detents */
GW.wingSelector = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10];
const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52;
gradDef('wingRed', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#d98a6f'], ['.6', 'var(--fail)'], ['1', '#6e2415']]);
steps.forEach((db, i) => {
const a = -135 + i / (steps.length - 1) * 270;
const [dx, dy] = polar(cx, cy, 38, a);
svgEl(s, 'circle', { cx: dx, cy: dy, r: 1.5, fill: 'var(--steel)' });
if (i % 2 === 1 || db === 0 || db === 10 || db === -80) {
const [tx, ty] = polar(cx, cy, 46.5, a);
svgEl(s, 'text', { x: tx, y: ty + 2.2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = db > 0 ? '+' + db : db;
}
});
const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .09s';
svgEl(grp, 'rect', { x: cx - 6.5, y: cy - 30, width: 13, height: 60, rx: 6, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 });
svgEl(grp, 'circle', { cx, cy, r: 15, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 });
svgEl(grp, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 18, stroke: 'var(--cream)', 'stroke-width': 2, 'stroke-linecap': 'round' });
svgEl(grp, 'ellipse', { cx: cx - 4, cy: cy - 8, rx: 5, ry: 3, fill: 'rgba(255,255,255,.18)', transform: `rotate(-30,${cx - 4},${cy - 8})` });
let idx;
const set = v => {
idx = Math.max(0, Math.min(steps.length - 1, Math.round(v)));
grp.style.transform = `rotate(${-135 + idx / (steps.length - 1) * 270}deg)`;
grp.style.transformOrigin = `${cx}px ${cy}px`;
const db = steps[idx];
onChange(db, (db > 0 ? '+' : '') + db + ' dB');
};
dragDelta(s, () => idx, set, { min: 0, max: steps.length - 1, sens: .05 });
set(opts.index !== undefined ? opts.index : 7);
return { el: s, get: () => idx, set };
};
/* R28 rotary disc switch — the whole disc turns between heavy positions */
GW.discSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]];
const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58;
gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
svgEl(s, 'circle', { cx, cy, r: 48, fill: '#1a1714', stroke: '#060505', 'stroke-width': 1.5 });
for (let k = 0; k < 4; k++) {
const [x, y] = polar(cx, cy, 44, 45 + k * 90);
svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
}
const lbls = positions.map(([lbl, a]) => {
const [tx, ty] = polar(cx, cy, 42, a);
const t = svgEl(s, 'text', { x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 5.8, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
t.textContent = lbl; return t;
});
svgEl(s, 'circle', { cx, cy, r: 36, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 });
const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .15s'; grp.style.transformOrigin = `${cx}px ${cy}px`;
svgEl(grp, 'rect', { x: cx - 27, y: cy - 8, width: 54, height: 16, rx: 8, fill: 'url(#discRed)', stroke: '#8f3520', 'stroke-width': 1 });
svgEl(grp, 'rect', { x: cx - 25, y: cy - 6.5, width: 50, height: 4, rx: 2, fill: 'rgba(255,255,255,.16)' });
svgEl(grp, 'line', { x1: cx + 20, y1: cy, x2: cx + 26, y2: cy, stroke: 'var(--cream)', 'stroke-width': 2.5, 'stroke-linecap': 'round' });
svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` });
let idx;
const set = i => {
idx = i;
grp.style.transform = `rotate(${positions[i][1]}deg)`;
lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)'));
onChange(idx, positions[i][0]);
};
s.addEventListener('click', () => set((idx + 1) % positions.length));
set(opts.index !== undefined ? opts.index : 1);
return { el: s, get: () => idx, set };
};
/* R29 guarded toggle — guard posts + red collar mark the critical throw */
GW.guardedToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52;
gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]);
gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]);
gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]);
const lblOn = svgEl(s, 'text', { x: cx, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' });
lblOn.textContent = opts.onLabel || 'ON';
const lblOff = svgEl(s, 'text', { x: cx, y: 96, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' });
lblOff.textContent = opts.offLabel || 'OFF';
svgEl(s, 'circle', { cx, cy, r: 13, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 });
const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 11 * Math.cos(a)) + ',' + (cy + 11 * Math.sin(a))); }
svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' });
svgEl(s, 'circle', { cx, cy, r: 6, fill: '#14110e', stroke: '#000' });
for (const gx of [16, 74]) {
svgEl(s, 'rect', { x: gx - 6, y: cy - 24, width: 12, height: 48, rx: 6, fill: 'url(#nutG)', stroke: '#2b2822', 'stroke-width': 1 });
svgEl(s, 'rect', { x: gx - 4, y: cy - 21, width: 3.5, height: 42, rx: 2, fill: 'rgba(255,255,255,.14)' });
}
const lever = svgEl(s, 'g', {});
svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 });
svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 });
for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
let on;
const set = v => {
on = !!v;
lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
onChange(on, on ? 'ON' : 'OFF');
};
s.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
return { el: s, get: () => on, set };
};
/* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob.
Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */
GW.timerDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58;
gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]);
gradDef('mtRed', 'radialGradient', { cx: '38%', cy: '32%', r: '80%' }, [['0', '#e0523a'], ['1', '#8f2416']]);
gradDef('mtHub', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#c9c4b8'], ['1', '#6e685c']]);
/* bezel + knurled coin edge */
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 47.5, fill: 'none', stroke: '#3a352c', 'stroke-width': 4, 'stroke-dasharray': '2 1.6' });
svgEl(s, 'circle', { cx, cy, r: 44, fill: 'url(#mtFace)', stroke: '#0a0908', 'stroke-width': 1 });
/* rotating scale: OFF at 0, minutes climb clockwise at 4.5 deg/min (60 at 270) */
const rot = svgEl(s, 'g', {});
for (let m = 0; m <= 60; m += 5) {
const major = m % 20 === 0;
const [x1, y1] = polar(cx, cy, 40, m * 4.5), [x2, y2] = polar(cx, cy, major ? 34 : 37, m * 4.5);
svgEl(rot, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': major ? 1.2 : .55, opacity: major ? .9 : .55 });
}
for (const m of [20, 40, 60]) {
const [x, y] = polar(cx, cy, 27, m * 4.5);
svgEl(rot, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = String(m);
}
const [ox, oy] = polar(cx, cy, 27, 0);
svgEl(rot, 'text', { x: ox, y: oy + 3, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#e0523a' }).textContent = 'OFF';
const [mx, my] = polar(cx, cy, 16, 180);
svgEl(rot, 'text', { x: mx, y: my + 2.5, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'MINUTES';
/* hub with screw slot */
svgEl(s, 'circle', { cx, cy, r: 9, fill: 'url(#mtHub)', stroke: '#3c382f', 'stroke-width': 1 });
svgEl(s, 'line', { x1: cx - 5.5, y1: cy, x2: cx + 5.5, y2: cy, stroke: '#4a4438', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 2.2, fill: '#8f897b' });
/* fixed red index at top + winding-direction arrow */
svgEl(s, 'line', { x1: cx, y1: cy - 44, x2: cx, y2: cy - 32, stroke: '#e0523a', 'stroke-width': 1.6 });
svgEl(s, 'path', { d: 'M 122 14 A 26 26 0 0 1 136 26', fill: 'none', stroke: 'var(--steel)', 'stroke-width': 1.6 });
svgEl(s, 'polygon', { points: '136,26 130.5,24.5 134.5,19.5', fill: 'var(--steel)' });
/* engraved plate captions */
const cap = (t, y) => svgEl(s, 'text', { x: 4, y, 'font-size': 6.2, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = t;
cap('PUSH TO STOP', 118); cap('TURN TO START', 128);
let min;
const set = m => {
min = Math.max(0, Math.min(60, m));
rot.setAttribute('transform', `rotate(${(-min * 4.5).toFixed(2)},${cx},${cy})`);
onChange(min, min > 0.5 ? 'T-' + Math.round(min) + ' MIN' : 'OFF');
};
/* dial drag surface (under the knob in DOM so the knob wins the overlap) */
const hit = svgEl(s, 'circle', { cx, cy, r: 50, fill: 'transparent' });
hit.style.cursor = 'grab';
dragDelta(hit, () => min, set, { min: 0, max: 60 });
/* red stop knob: fluted edge, domed center */
const knob = svgEl(s, 'g', {});
svgEl(knob, 'circle', { cx: 126, cy: 104, r: 20, fill: 'url(#mtRed)', stroke: '#5c150c', 'stroke-width': 1.5 });
svgEl(knob, 'circle', { cx: 126, cy: 104, r: 18, fill: 'none', stroke: 'rgba(0,0,0,.4)', 'stroke-width': 3.4, 'stroke-dasharray': '3 2.4' });
svgEl(knob, 'circle', { cx: 126, cy: 104, r: 8.5, fill: 'url(#mtRed)', stroke: 'rgba(0,0,0,.3)', 'stroke-width': .8 });
svgEl(knob, 'ellipse', { cx: 121, cy: 98, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.18)', transform: 'rotate(-28,121,98)' });
knob.style.cursor = 'pointer';
knob.addEventListener('click', () => { set(0); onChange(0, 'STOP · OFF'); });
set(opts.minutes !== undefined ? opts.minutes : 0);
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => { if (min > 0) { set(min - 1); if (min === 0) onChange(0, 'DING · OFF'); } }, 1000);
return { el: s, get: () => min, set };
};
/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */
GW.rockerPad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55;
gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]);
gradDef('rk4Arr', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#e9e4d6'], ['1', '#9d988b']]);
gradDef('rk4Nub', 'radialGradient', { cx: '38%', cy: '30%', r: '85%' }, [['0', '#4a453e'], ['1', '#0d0c0a']]);
/* recessed plate + rubber pad */
svgEl(s, 'rect', { x: 3, y: 3, width: 104, height: 104, rx: 18, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 });
svgEl(s, 'circle', { cx, cy, r: 45, fill: '#0f0d0b' });
svgEl(s, 'circle', { cx, cy, r: 41, fill: 'url(#rk4Pad)', stroke: '#060505', 'stroke-width': 1 });
/* four arrows, pointing outward */
const ARR = { up: '55,22 45,36 65,36', down: '55,88 45,74 65,74', left: '22,55 36,45 36,65', right: '88,55 74,45 74,65' };
const arrows = {};
for (const [dir, pts] of Object.entries(ARR))
arrows[dir] = svgEl(s, 'polygon', { points: pts, fill: 'url(#rk4Arr)', stroke: '#3c382f', 'stroke-width': .7 });
/* center pivot nub */
svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#rk4Nub)', stroke: '#060505', 'stroke-width': .8 });
svgEl(s, 'ellipse', { cx: cx - 2, cy: cy - 2.5, rx: 2.6, ry: 1.7, fill: 'rgba(255,255,255,.25)' });
/* quadrant hit zones (drawn last so they win), press flash + tracked position */
let rx = 0, ry = 0;
const press = (dir, dx, dy) => {
rx += dx; ry += dy;
onChange({ x: rx, y: ry, dir }, dir.toUpperCase() + ' · x ' + rx + ' y ' + ry);
arrows[dir].setAttribute('fill', 'var(--gold-hi)');
setTimeout(() => arrows[dir].setAttribute('fill', 'url(#rk4Arr)'), 160);
};
const zone = (dir, pts, dx, dy) => {
const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' });
z.style.cursor = 'pointer'; z.addEventListener('click', () => press(dir, dx, dy));
};
zone('up', `${cx},${cy} 20,20 90,20`, 0, 1);
zone('down', `${cx},${cy} 20,90 90,90`, 0, -1);
zone('left', `${cx},${cy} 20,20 20,90`, -1, 0);
zone('right', `${cx},${cy} 90,20 90,90`, 1, 0);
onChange({ x: 0, y: 0, dir: null }, 'x 0 y 0');
return { el: s, get: () => ({ x: rx, y: ry }) };
};
/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */
GW.fourWayToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]);
gradDef('fw4Ball', 'radialGradient', { cx: '36%', cy: '30%', r: '80%' }, [['0', '#f2f0ea'], ['.55', '#8e8a80'], ['1', '#3c3a34']]);
/* printed panel graphics: axes, circle, diagonal square, labels */
svgEl(s, 'line', { x1: 8, y1: cy, x2: 122, y2: cy, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 });
svgEl(s, 'line', { x1: cx, y1: 8, x2: cx, y2: 122, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 });
svgEl(s, 'text', { x: cx + 2, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'Y';
svgEl(s, 'text', { x: 116, y: cy - 4, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'X';
svgEl(s, 'circle', { cx, cy, r: 42, fill: 'none', stroke: 'var(--steel)', 'stroke-width': .9, opacity: .8 });
const QUAD = { A: 315, B: 45, C: 135, D: 225 };
const diag = Object.values(QUAD).map(a => polar(cx, cy, 42, a));
svgEl(s, 'polygon', { points: diag.map(p => p.join(',')).join(' '), fill: 'none', stroke: 'var(--steel)', 'stroke-width': .8, opacity: .8 });
const LAMP_AT = { A: [14, 14], B: [116, 14], C: [116, 116], D: [14, 116] };
const lamps = {};
for (const [q, a] of Object.entries(QUAD)) {
const [lx, ly] = polar(cx, cy, 42, a);
const [px, py] = LAMP_AT[q];
svgEl(s, 'line', { x1: lx, y1: ly, x2: px, y2: py, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .5 });
svgEl(s, 'circle', { cx: lx, cy: ly, r: 6.5, fill: '#14110e', stroke: 'var(--steel)', 'stroke-width': .9 });
svgEl(s, 'text', { x: lx, y: ly + 2.8, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = q;
svgEl(s, 'circle', { cx: px, cy: py, r: 5.5, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 });
lamps[q] = svgEl(s, 'circle', { cx: px, cy: py, r: 3.6, fill: '#3a3426' });
}
/* bezel + boot */
svgEl(s, 'circle', { cx, cy, r: 20, fill: '#0d0c0a', stroke: '#2a2722', 'stroke-width': 1.5 });
svgEl(s, 'circle', { cx, cy, r: 15, fill: '#17140f', stroke: '#060505', 'stroke-width': 1 });
/* lever: shaft + grip rotate to the selected diagonal; ball pivot stays centered */
const lever = svgEl(s, 'g', {});
svgEl(lever, 'polygon', { points: `${cx - 3},${cy} ${cx + 3},${cy} ${cx + 2},${cy - 26} ${cx - 2},${cy - 26}`, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 });
svgEl(lever, 'rect', { x: cx - 3.4, y: cy - 40, width: 6.8, height: 16, rx: 3, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 });
svgEl(s, 'circle', { cx, cy, r: 8.5, fill: 'url(#fw4Ball)', stroke: '#26241f', 'stroke-width': .8 });
svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' });
let pos = null;
const set = q => {
pos = q;
lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`);
for (const k of Object.keys(QUAD)) {
lamps[k].setAttribute('fill', k === q ? 'var(--jewel-a)' : '#3a3426');
lamps[k].setAttribute('filter', k === q ? 'drop-shadow(0 0 4px rgba(255,180,58,.8))' : 'none');
}
onChange(pos, 'POS ' + q);
};
/* quadrant click zones (diagonal quadrants, drawn last) */
const zone = (q, pts) => {
const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' });
z.style.cursor = 'pointer'; z.addEventListener('click', () => set(q));
};
zone('A', `${cx},${cy} ${cx},5 5,5 5,${cy}`);
zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`);
zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`);
zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`);
set(opts.position || 'C');
return { el: s, get: () => pos, set };
};
/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */
GW.pinMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV'];
const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD'];
const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */
const s = stageSvg(host, 'rsvg press', 160, 110);
const X0 = 42, Y0 = 30, DX = 19, DY = 15.5;
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
COLS.forEach((c, j) => svgEl(s, 'text', { x: X0 + j * DX, y: 16, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = c);
ROWS.forEach((r, i) => svgEl(s, 'text', { x: 34, y: Y0 + i * DY + 2, 'text-anchor': 'end', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r);
ROWS.forEach((r, i) => COLS.forEach((c, j) => {
const cx = X0 + j * DX, cy = Y0 + i * DY, key = r + '>' + c;
svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 });
const cap = svgEl(s, 'circle', { cx, cy, r: 4.4, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': 1, opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' });
const hit = svgEl(s, 'circle', { cx, cy, r: 8, fill: 'transparent' });
hit.style.cursor = 'pointer';
hit.addEventListener('click', () => {
const on = !pins.has(key);
if (on) pins.add(key); else pins.delete(key);
cap.setAttribute('opacity', on ? 1 : 0);
onChange([...pins], (on ? r + ' → ' + c : 'pulled ' + r + ' → ' + c) + ' · ' + pins.size + ' routes');
});
}));
onChange([...pins], pins.size + ' routes');
return { el: s, get: () => [...pins] };
};
/* R38 dead-man button — state exists only while the pointer holds it down */
GW.deadMan = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58;
gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]);
svgEl(s, 'rect', { x: 4, y: 4, width: 112, height: 102, rx: 12, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 });
svgEl(s, 'text', { x: cx, y: 18, 'text-anchor': 'middle', 'font-size': 6.2, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.caption || 'HOLD TO RUN';
const lamp = svgEl(s, 'circle', { cx: 104, cy: 16, r: 4.5, fill: '#1e2a12' });
const ring = svgEl(s, 'circle', { cx, cy, r: 33, fill: 'none', stroke: '#2c2820', 'stroke-width': 3 });
const btn = svgEl(s, 'g', {});
svgEl(btn, 'circle', { cx, cy, r: 28, fill: 'url(#dmBtn)', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(btn, 'circle', { cx, cy, r: 22, fill: 'none', stroke: 'rgba(255,255,255,.07)', 'stroke-width': 1 });
svgEl(btn, 'text', { x: cx, y: cy + 2.5, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--silver)' }).textContent = opts.label || 'RUN';
btn.style.cursor = 'pointer';
let t0 = null, iv = null;
const stop = () => {
if (t0 === null) return;
const held = ((Date.now() - t0) / 1000).toFixed(1);
t0 = null; clearInterval(iv);
btn.removeAttribute('transform');
ring.setAttribute('stroke', '#2c2820'); ring.removeAttribute('filter');
lamp.setAttribute('fill', '#1e2a12'); lamp.removeAttribute('filter');
onChange(false, 'SAFE (held ' + held + 's)');
};
btn.addEventListener('pointerdown', e => {
btn.setPointerCapture(e.pointerId);
t0 = Date.now();
btn.setAttribute('transform', 'translate(0,1.5)');
ring.setAttribute('stroke', 'var(--gold)'); ring.setAttribute('filter', 'drop-shadow(0 0 5px rgba(var(--glow-lo),.7))');
lamp.setAttribute('fill', 'var(--pass)'); lamp.setAttribute('filter', 'drop-shadow(0 0 4px rgba(116,147,47,.8))');
onChange(true, 'RUNNING 0.0s');
iv = setInterval(() => { if (t0 !== null) onChange(true, 'RUNNING ' + ((Date.now() - t0) / 1000).toFixed(1) + 's'); }, 100);
e.preventDefault();
});
btn.addEventListener('pointerup', stop);
btn.addEventListener('pointercancel', stop);
onChange(false, 'SAFE');
return { el: s, get: () => t0 !== null };
};
/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */
GW.telephoneDial = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63;
const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
const angOf = i => (60 - i * 30 + 360) % 360; /* 1 at 60cw, counterclockwise to 0 at 150 */
const STOP = 105;
gradDef('tdWheel', 'radialGradient', { cx: '42%', cy: '36%', r: '85%' }, [['0', '#8f8a7e'], ['1', '#4a463e']]);
svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1e1a16', stroke: '#0a0908', 'stroke-width': 1 });
/* rotating finger wheel: ring + hole openings (dark wells punched in the metal) */
const wheel = svgEl(s, 'g', {});
svgEl(wheel, 'circle', { cx, cy, r: 46, fill: 'none', stroke: 'url(#tdWheel)', 'stroke-width': 17, opacity: .95 });
DIGITS.forEach((_, i) => {
const [x, y] = polar(cx, cy, 38, angOf(i));
svgEl(wheel, 'circle', { cx: x, cy: y, r: 7.2, fill: '#1e1a16', stroke: '#26221c', 'stroke-width': 1.4 });
});
/* fixed digits ON TOP so they read through the holes (they stay put while the wheel spins) */
DIGITS.forEach((d, i) => {
const [x, y] = polar(cx, cy, 38, angOf(i));
svgEl(s, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 8.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = d;
});
svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#tdWheel)', stroke: '#26221c', 'stroke-width': 1 });
/* finger stop */
const [fx, fy] = polar(cx, cy, 52, STOP);
svgEl(s, 'rect', { x: fx - 2.5, y: fy - 8, width: 5, height: 16, rx: 2, fill: '#b8b2a4', stroke: '#3c382f', 'stroke-width': 1, transform: `rotate(${STOP},${fx},${fy})` });
let dialed = '', spinning = false;
const setW = a => wheel.setAttribute('transform', `rotate(${a},${cx},${cy})`);
const finish = d => { dialed = (dialed + d).slice(-10); onChange(dialed, dialed); spinning = false; };
DIGITS.forEach((d, i) => {
const [x, y] = polar(cx, cy, 38, angOf(i));
const hit = svgEl(s, 'circle', { cx: x, cy: y, r: 8.5, fill: 'transparent' });
hit.style.cursor = 'pointer';
hit.addEventListener('click', () => {
if (spinning) return; spinning = true;
const R = (STOP - angOf(i) + 360) % 360;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { setW(R); setTimeout(() => { setW(0); finish(d); }, 160); return; }
const t0 = performance.now(), Tf = R / 300 * 1000, Tb = R / 450 * 1000;
const step = t => {
const el = t - t0;
if (el < Tf) { setW(R * el / Tf); requestAnimationFrame(step); }
else if (el < Tf + 80) { setW(R); requestAnimationFrame(step); }
else if (el < Tf + 80 + Tb) { setW(R * (1 - (el - Tf - 80) / Tb)); requestAnimationFrame(step); }
else { setW(0); finish(d); }
};
requestAnimationFrame(step);
});
});
onChange('', 'dial a number');
return { el: s, get: () => dialed };
};
/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */
GW.breakerPanel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX'];
const s = stageSvg(host, 'rsvg press', 160, 110);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
const brk = [];
const draw = () => {
let closed = 0, trip = null;
brk.forEach(b => {
if (b.state === 'on') closed++; if (b.state === 'tripped') trip = b.name;
b.lamp.setAttribute('fill', b.state === 'on' ? 'var(--pass)' : '#1e2a12');
b.lamp.setAttribute('filter', b.state === 'on' ? 'drop-shadow(0 0 3px rgba(116,147,47,.8))' : 'none');
b.handle.setAttribute('y', b.state === 'on' ? 40 : b.state === 'tripped' ? 57 : 74);
b.collar.setAttribute('opacity', b.state === 'tripped' ? 1 : 0);
});
onChange(brk.map(b => b.state), closed + '/' + NAMES.length + ' closed' + (trip ? ' · ' + trip + ' TRIPPED' : ''));
};
NAMES.forEach((name, i) => {
const x = 14 + i * 30;
svgEl(s, 'rect', { x, y: 36, width: 20, height: 52, rx: 4, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1 });
const lamp = svgEl(s, 'circle', { cx: x + 10, cy: 26, r: 4, fill: '#1e2a12' });
const collar = svgEl(s, 'rect', { x: x + 4, y: 69, width: 12, height: 5, fill: 'var(--amber-warn)', opacity: 0 });
const handle = svgEl(s, 'rect', { x: x + 4, y: 40, width: 12, height: 14, rx: 2, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
svgEl(s, 'text', { x: x + 10, y: 98, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = name;
const hit = svgEl(s, 'rect', { x, y: 20, width: 20, height: 80, fill: 'transparent' });
hit.style.cursor = 'pointer';
const b = { name, state: i < 3 ? 'on' : 'off', lamp, collar, handle };
hit.addEventListener('click', () => { b.state = b.state === 'on' ? 'off' : b.state === 'off' ? 'on' : 'off'; draw(); });
brk.push(b);
});
const tb = svgEl(s, 'g', {});
svgEl(tb, 'rect', { x: 132, y: 40, width: 22, height: 16, rx: 4, fill: 'var(--fail)', stroke: '#5c150c', 'stroke-width': 1 });
svgEl(tb, 'text', { x: 143, y: 50.5, 'text-anchor': 'middle', 'font-size': 6, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'TRIP';
tb.style.cursor = 'pointer';
tb.addEventListener('click', () => { for (let i = brk.length - 1; i >= 0; i--) if (brk[i].state === 'on') { brk[i].state = 'tripped'; break; } draw(); });
draw();
return { el: s, get: () => brk.map(b => b.state) };
};
/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */
GW.dsky = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR'];
const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el);
el.innerHTML =
`${LAMPS.map(l => `
${l}
`).join('')}
` +
`` +
`
PROG
` +
`
VERB
` +
`
NOUN
` +
`
`;
const win = w => el.querySelector(`[data-w="${w}"]`);
const setWin = (w, txt) => { win(w).querySelector('.wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); };
const lampEl = l => el.querySelector(`[data-l="${l}"]`);
let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = '';
const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0'];
const pad = el.querySelector('.pad');
const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); };
const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('on'), 1200); onChange(vals, 'OPR ERR'); };
const commit = () => {
if (vals.verb.trim().length < 2) { oprErr(); return; }
const v = vals.verb, n = vals.noun.trim();
if (v === '35') {
LAMPS.forEach(l => lampEl(l).classList.add('on'));
setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('on')), 1400);
onChange(vals, 'V35 · lamp test');
}
else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); }
else onChange(vals, 'V' + v + (n ? ' N' + n : '') + ' ENTR');
vals.verb = ' '; vals.noun = ' '; entry = ''; mode = null; hot();
};
KEYS.forEach(k => {
const b = document.createElement('button'); b.className = 'key'; b.textContent = k;
b.addEventListener('click', () => {
if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); }
else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } }
else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('on')); onChange(vals, 'RSET'); }
else if (k === 'ENTR') commit();
else if (/\d/.test(k)) {
if (!mode) { oprErr(); return; }
entry = (entry + k).slice(0, 2); vals[mode] = entry.padEnd(2, ' '); setWin(mode, vals[mode]);
}
});
pad.appendChild(b);
});
vals.prog = '00'; setWin('prog', vals.prog); setWin('verb', ' '); setWin('noun', ' ');
onChange(vals, 'VERB ## ENTR');
return { el, get: () => ({ ...vals }) };
};
/* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it.
Runs its own reduced-motion-gated 2 s step interval once started. */
GW.camTimer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL'];
const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' };
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
STEPS.forEach((st, i) => {
const a0 = i * 30 - 90, a1 = a0 + 30;
const p0o = polar(cx, cy, 52, a0 + 90), p1o = polar(cx, cy, 52, a1 + 90), p0i = polar(cx, cy, 38, a0 + 90), p1i = polar(cx, cy, 38, a1 + 90);
svgEl(s, 'path', { d: `M ${p0i[0]} ${p0i[1]} L ${p0o[0]} ${p0o[1]} A 52 52 0 0 1 ${p1o[0]} ${p1o[1]} L ${p1i[0]} ${p1i[1]} A 38 38 0 0 0 ${p0i[0]} ${p0i[1]} Z`, fill: COLS[st], opacity: .75, stroke: '#0a0908', 'stroke-width': .8 });
const [tx, ty] = polar(cx, cy, 45, i * 30 + 15);
svgEl(s, 'text', { x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.4, 'font-weight': 700, 'font-family': 'var(--mono)', fill: st === 'OFF' ? 'var(--cream)' : '#14110e' }).textContent = st === 'OFF' ? 'OFF' : st[0];
});
svgEl(s, 'circle', { cx, cy, r: 34, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 });
STEPS.forEach((st, i) => {
if (i === 0) return; const [lx, ly] = polar(cx, cy, 27, i * 30 + 15);
svgEl(s, 'text', { x: lx, y: ly + 1.8, 'text-anchor': 'middle', 'font-size': 4.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = st;
});
svgEl(s, 'circle', { cx, cy, r: 17, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1.5 });
const ptr = svgEl(s, 'polygon', { points: `${cx - 2.5},${cy} ${cx + 2.5},${cy} ${cx},${cy - 31}`, fill: 'var(--gold-hi)', filter: 'drop-shadow(0 0 3px rgba(var(--glow-lo),.7))' });
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let pos;
const set = i => {
pos = i;
ptr.setAttribute('transform', `rotate(${i * 30 + 15},${cx},${cy})`);
onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]);
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => set((pos + 1) % STEPS.length));
set(opts.position !== undefined ? opts.position : 0);
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => { if (pos > 0) { const n = (pos + 1) % STEPS.length; set(n); if (n === 0) onChange(0, 'CYCLE DONE · OFF'); } }, 2000);
return { el: s, get: () => pos, set };
};
/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */
GW.knifeSwitch = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 110);
gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]);
const HX = 32, HY = 74; /* hinge pivot */
svgEl(s, 'rect', { x: 6, y: 56, width: 118, height: 44, rx: 6, fill: '#22262a', stroke: '#0c0d0e', 'stroke-width': 1.5 });
const lamp = svgEl(s, 'circle', { cx: 116, cy: 22, r: 4.5, fill: '#1e2a12' });
svgEl(s, 'text', { x: 116, y: 36, 'text-anchor': 'middle', 'font-size': 4.8, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'LIVE';
/* hinge post (left) and jaw clip (right), both on the base */
svgEl(s, 'rect', { x: HX - 6, y: HY - 6, width: 12, height: 16, rx: 2, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 });
svgEl(s, 'rect', { x: 96, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 });
svgEl(s, 'rect', { x: 104, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 });
/* blade + upright handle, hinged at (HX,HY) */
const blade = svgEl(s, 'g', {});
svgEl(blade, 'rect', { x: HX, y: HY - 3, width: 71, height: 6, rx: 2.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 });
svgEl(blade, 'rect', { x: 88, y: HY - 26, width: 7, height: 26, rx: 3, fill: '#14110e', stroke: '#000', 'stroke-width': 1 });
svgEl(blade, 'circle', { cx: 91.5, cy: HY - 26, r: 5, fill: '#14110e', stroke: '#000', 'stroke-width': 1 });
svgEl(s, 'circle', { cx: HX, cy: HY, r: 3, fill: '#5c3a18' });
blade.style.transformBox = 'view-box'; blade.style.transformOrigin = `${HX}px ${HY}px`;
blade.style.transition = 'transform .28s ease';
let closed;
const set = v => {
closed = !!v;
blade.style.transform = closed ? 'rotate(0deg)' : 'rotate(-46deg)';
lamp.setAttribute('fill', closed ? 'var(--pass)' : '#1e2a12');
lamp.setAttribute('filter', closed ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : 'none');
onChange(closed, closed ? 'CLOSED · live' : 'OPEN · visibly dead');
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => set(!closed));
set(opts.closed !== undefined ? opts.closed : true);
return { el: s, get: () => closed, set };
};
/* R49 decade box — four skirted knobs, one digit each; the value is their sum */
GW.decadeBox = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1'];
const digs = (opts.digits || [3, 5, 0, 0]).slice();
const s = stageSvg(host, 'rsvg', 160, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
const wins = [], knobs = [];
const total = () => digs.reduce((a, d, i) => a + d * MUL[i], 0);
const draw = () => {
wins.forEach((w, i) => w.textContent = String(digs[i]));
knobs.forEach((k, i) => k.setAttribute('transform', `rotate(${digs[i] * 36},${28 + i * 35},58)`));
onChange(total(), total().toLocaleString('en-US') + ' Ω');
};
MUL.forEach((_, i) => {
const cx = 28 + i * 35, cy = 58;
svgEl(s, 'rect', { x: cx - 8, y: 16, width: 16, height: 12, rx: 2, fill: '#0a0806', stroke: '#2c2820', 'stroke-width': 1 });
wins.push(svgEl(s, 'text', { x: cx, y: 25.5, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }));
svgEl(s, 'circle', { cx, cy, r: 15, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 }); /* skirt */
for (let t = 0; t < 10; t++) {
const [x1, y1] = polar(cx, cy, 14, t * 36), [x2, y2] = polar(cx, cy, 12, t * 36);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': .6, opacity: .7 });
}
const k = svgEl(s, 'g', {});
svgEl(k, 'circle', { cx, cy, r: 10, fill: '#2c2824', stroke: '#060505', 'stroke-width': 1 });
svgEl(k, 'line', { x1: cx, y1: cy - 9, x2: cx, y2: cy - 4, stroke: 'var(--gold-hi)', 'stroke-width': 1.8 });
knobs.push(k);
svgEl(s, 'text', { x: cx, y: 88, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = LBL[i];
const hit = svgEl(s, 'circle', { cx, cy, r: 16, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragDelta(hit, () => digs[i], v => { digs[i] = Math.round(Math.max(0, Math.min(9, v))); draw(); }, { min: 0, max: 9, sens: 9 / 70 });
});
draw();
return { el: s, get: () => total() };
};
/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle */
GW.twoHandSafety = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 160, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
const runLamp = svgEl(s, 'circle', { cx: 80, cy: 24, r: 5.5, fill: '#1e2a12' });
svgEl(s, 'text', { x: 80, y: 40, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'CYCLE';
const mkBtn = (cx, lbl) => {
const g = svgEl(s, 'g', {});
svgEl(g, 'ellipse', { cx, cy: 70, rx: 19, ry: 6, fill: '#0d0b09' });
const cap = svgEl(g, 'circle', { cx, cy: 62, r: 16, fill: '#8f2416', stroke: '#5c150c', 'stroke-width': 1.5 });
svgEl(g, 'ellipse', { cx: cx - 5, cy: 56, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.22)' });
svgEl(s, 'text', { x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl;
g.style.cursor = 'pointer'; return { g, cap };
};
const L = mkBtn(32, 'LEFT PALM'), R = mkBtn(128, 'RIGHT PALM');
let armed = null, armT = null, busy = false;
const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); };
const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); };
const press = side => {
if (busy) return;
if (armed === null) {
armed = side; flash(side === 'L' ? L.cap : R.cap);
onChange('armed', side + ' armed · other hand within 0.5s');
armT = setTimeout(fault, 500); return;
}
if (armed === side) { fault(); return; } /* same hand twice = tie-down */
clearTimeout(armT); armed = null; busy = true;
flash(side === 'L' ? L.cap : R.cap);
runLamp.setAttribute('fill', 'var(--pass)');
runLamp.setAttribute('filter', 'drop-shadow(0 0 5px rgba(116,147,47,.8))');
onChange('running', 'CYCLE RUNNING');
setTimeout(() => {
runLamp.setAttribute('fill', '#1e2a12'); runLamp.removeAttribute('filter');
busy = false; onChange('ready', 'ready');
}, 1500);
};
L.g.addEventListener('click', () => press('L'));
R.g.addEventListener('click', () => press('R'));
onChange('ready', 'ready');
return { el: s, press };
};
/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker */
GW.voiceLoop = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO'];
const el = document.createElement('div'); el.className = 'vloop'; host.appendChild(el);
const keys = LOOPS.map(l => {
const k = document.createElement('div'); k.className = 'vk';
k.innerHTML = l + ''; k.dataset.state = '0'; el.appendChild(k); return k;
});
/* a legible default: FD + A/G monitored, GNC talking */
keys[0].dataset.state = '1'; keys[0].classList.add('mon');
keys[4].dataset.state = '1'; keys[4].classList.add('mon');
keys[1].dataset.state = '2'; keys[1].classList.add('mon', 'tlk');
const refresh = () => {
const mon = keys.filter(k => k.dataset.state !== '0').length;
const tlk = keys.findIndex(k => k.dataset.state === '2');
onChange(keys.map(k => k.dataset.state), mon === 0 ? 'all loops dropped' : mon + ' monitored' + (tlk >= 0 ? ' · talk ' + LOOPS[tlk] : ''));
};
keys.forEach(k => k.addEventListener('click', () => {
const st = k.dataset.state;
if (st === '0') { k.dataset.state = '1'; k.classList.add('mon'); }
else if (st === '1') {
keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('tlk'); } });
k.dataset.state = '2'; k.classList.add('tlk');
}
else { k.dataset.state = '0'; k.classList.remove('mon', 'tlk', 'act'); }
refresh();
}));
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => {
const t = performance.now() / 1000;
keys.forEach((k, i) => {
if (k.dataset.state === '0') { k.classList.remove('act'); return; }
k.classList.toggle('act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55);
});
}, 300);
refresh();
return { el, get: () => keys.map(k => k.dataset.state) };
};
/* ---- meters & gauges ----
Tick contract: the page owns the clock and the signal; live meters expose
value-driven handles (set/push) that repaint synchronously and fire
onChange(value, text) like every other builder. Display-side state that
belongs to the instrument (peak-hold, history buffers) lives in here. */
/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc */
GW.needleGauge = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 50;
const el = document.createElement('div'); el.className = 'gauge';
el.innerHTML = `0%
`;
host.appendChild(el);
const ndl = el.querySelector('.ndl'), num = el.querySelector('.gv span');
function set(nv) {
v = nv;
ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
num.textContent = Math.round(v);
onChange(v, 'value ' + Math.round(v) + '%');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both */
GW.vuPair = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = opts.bars || 16;
const el = document.createElement('div'); el.className = 'vu';
el.innerHTML = `L
R
`;
host.appendChild(el);
const bars = el.querySelectorAll('.vubar');
bars.forEach(b => buildBars(b, n));
const pkL = { v: 0 }, pkR = { v: 0 };
function paint(bar, l, pk) {
const b = bar.children, lit = Math.round(l * n);
pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v);
for (let k = 0; k < n; k++) {
let c = k < lit ? (k >= n - 2 ? 'clip' : k >= n - 4 ? 'hot' : 'on') : '';
if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'peak';
b[k].className = c;
}
}
let lv = 0, rv = 0;
function set(l, r) {
lv = l; rv = r;
paint(bars[0], l, pkL); paint(bars[1], r, pkR);
onChange([l, r], 'L ' + Math.round(l * 100) + ' · R ' + Math.round(r * 100));
}
return { el, get: () => [lv, rv], set };
};
/* 12 mini 4-bar signal — compact activity meter; set(level) */
GW.miniSig = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'sig';
el.innerHTML = '';
host.appendChild(el);
let v = 0;
function set(l) {
v = l;
const b = el.children, lit = Math.round(l * 4);
for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'clip' : k >= 2 ? 'hot' : 'on') : '';
onChange(l, Math.round(l * 100) + '%');
}
return { el, get: () => v, set };
};
/* 13 signal ladder — stepped 0-4 strength; click cycles */
GW.signalLadder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 3;
const el = document.createElement('span'); el.className = 'ladder';
el.innerHTML = '';
host.appendChild(el);
function set(nv) {
v = nv;
const bars = el.children;
for (let i = 0; i < bars.length; i++) bars[i].style.background = i < v ? 'var(--gold)' : 'var(--wash)';
onChange(v, v + '/4');
}
el.onclick = () => set((v + 1) % 5);
set(v);
return { el, get: () => v, set };
};
/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set */
GW.fuelBar = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const warnAt = opts.warnAt ?? 20;
let v = opts.value ?? 50;
const el = document.createElement('div'); el.className = 'bar';
el.innerHTML = '';
host.appendChild(el);
const fill = el.querySelector('span');
function set(p) {
v = p;
fill.style.width = p + '%';
el.classList.toggle('warn', p < warnAt);
onChange(p, Math.round(p) + '%');
}
dragX(el, set);
set(v);
return { el, get: () => v, set };
};
/* 15 radial ring — percentage donut; drag up/down to set */
GW.radialRing = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 68;
const el = document.createElement('span'); el.className = 'ring';
el.innerHTML = '';
host.appendChild(el);
const num = el.querySelector('b');
function set(nv) {
v = nv;
el.style.setProperty('--p', v);
num.textContent = Math.round(v);
onChange(v, Math.round(v) + '%');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it */
GW.sparkline = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = opts.samples || 40;
const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
const el = document.createElement('span'); el.className = 'spark';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline');
const clamp = x => Math.max(0, Math.min(1, x));
function paint() {
line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - clamp(v) * 40 - 2}`).join(' '));
onChange(hist[n - 1], String(Math.round(clamp(hist[n - 1]) * 100)));
}
function push(v) { hist.push(v); hist.shift(); paint(); }
function fill(v) { hist.fill(v); paint(); }
return { el, get: () => hist[n - 1], push, fill };
};
/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */
GW.waveStrip = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'wave';
el.innerHTML = '';
host.appendChild(el);
const path = el.querySelector('path');
let amp = 0;
function set(samples, a) {
amp = a;
let d = 'M0 19';
if (samples.length < 2) d += ' L170 19';
else for (let i = 0; i < samples.length; i++)
d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + samples[i] * 14).toFixed(1)}`;
path.setAttribute('d', d);
onChange(amp, 'amp ' + Math.round(amp * 100) + '%');
}
set([], opts.amp ?? 0.6);
return { el, get: () => amp, set };
};
/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1 */
GW.scope = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const el = document.createElement('span'); el.className = 'scope';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline');
let vpp = 0;
function set(samples, v) {
vpp = v;
const n = samples.length;
line.setAttribute('points', samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + s * 22).toFixed(1)}`).join(' '));
onChange(vpp, 'Vpp ' + Math.round(vpp * 100));
}
return { el, get: () => vpp, set };
};
/* N12 spectrum / EQ bars — set(values) paints one column per band */
GW.eqBars = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const bands = opts.bands || 11, cells = opts.cells || 9;
const el = document.createElement('span'); el.className = 'eq';
host.appendChild(el);
for (let b = 0; b < bands; b++) {
const band = document.createElement('span'); band.className = 'band';
for (let s = 0; s < cells; s++) band.appendChild(document.createElement('i'));
el.appendChild(band);
}
let vals = [];
function set(values) {
vals = values;
let peak = 0;
for (let b = 0; b < bands; b++) {
const col = el.children[b].children, val = values[b], lit = Math.round(val * cells);
peak = Math.max(peak, val);
for (let k = 0; k < cells; k++) {
let c = '';
if (k < lit) c = (k >= cells - 1 ? 'clip' : k >= cells - 3 ? 'hot' : 'on');
col[k].className = c;
}
}
onChange(vals, 'peak ' + Math.round(peak * 100) + '%');
}
return { el, get: () => vals, set };
};
/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross */
GW.crossNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 55;
const el = document.createElement('div'); el.className = 'crossm';
el.innerHTML = `
FWDRFL
`;
host.appendChild(el);
const face = el.querySelector('.face'), nA = el.querySelector('.nA'), nB = el.querySelector('.nB');
function set(nv) {
v = nv;
const fwd = v, rfl = v * 0.68;
nA.style.transform = `rotate(${-42 + fwd / 100 * 84}deg)`;
nB.style.transform = `rotate(${42 - rfl / 100 * 84}deg)`;
onChange(v, 'FWD ' + Math.round(fwd) + ' · RFL ' + Math.round(rfl));
}
dragDelta(face, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* N14 thermometer column — mercury rises with the value */
GW.thermometer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 58;
const el = document.createElement('div'); el.className = 'thermo';
el.innerHTML = `906030
`;
host.appendChild(el);
const fill = el.querySelector('.fill');
function set(nv) {
v = nv;
fill.style.height = v + '%';
onChange(v, Math.round(30 + v / 100 * 60) + '°');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* N15 bourdon pressure gauge — needle over a printed arc with a redline */
GW.bourdon = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 45;
const el = document.createElement('div'); el.className = 'bourdon';
el.innerHTML = `
PSI
`;
host.appendChild(el);
const ndl = el.querySelector('.ndl');
function set(nv) {
v = nv;
ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
onChange(v, Math.round(v / 100 * 160) + ' PSI');
}
dragDelta(el, () => v, set, { min: 0, max: 100 });
set(v);
return { el, get: () => v, set };
};
/* N16 strip-chart recorder — scrolling history, pen rides the newest value;
push(v) appends, set(samples, current) replaces the trace (values 0..1) */
GW.stripChart = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const n = opts.samples || 60;
const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
const el = document.createElement('span'); el.className = 'strip';
el.innerHTML = '';
host.appendChild(el);
const line = el.querySelector('polyline'), pen = el.querySelector('.pen');
const clamp = x => Math.max(0, Math.min(1, x));
const yPx = v => 59 - clamp(v) * 56;
function paint(current) {
line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 176},${yPx(v).toFixed(1)}`).join(' '));
pen.style.top = yPx(current) + 'px';
onChange(current, String(Math.round(clamp(current) * 100)));
}
function push(v) { hist.push(v); hist.shift(); paint(v); }
function set(samples, current) {
for (let i = 0; i < n; i++) hist[i] = samples[i] ?? 0.5;
paint(current ?? hist[n - 1]);
}
return { el, get: () => hist[n - 1], push, set };
};
/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right */
GW.corrMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let p = opts.value ?? 58;
const el = document.createElement('div'); el.className = 'corr';
el.innerHTML = `
−10+1
`;
host.appendChild(el);
const ndl = el.querySelector('.ndl');
function set(np) {
p = np;
const v = (p - 50) / 50;
ndl.style.transform = `rotate(${v * 38}deg)`;
onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2));
}
dragX(el, set);
set(p);
return { el, get: () => p, set };
};
/* N18 battery-cell gauge — charge as discrete cells, the low end warns */
GW.battCells = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25;
let p = opts.value ?? 62;
const el = document.createElement('div'); el.className = 'batt';
el.innerHTML = '';
host.appendChild(el);
const cc = el.querySelector('.cells');
for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); }
function set(np) {
p = np;
const lit = Math.round(p / 100 * cells);
for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' warn' : '');
onChange(p, Math.round(p) + '%');
}
dragX(el, set);
set(p);
return { el, get: () => p, set };
};
/* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale;
set(t) positions instantly (0..1.02); ballistics belong to the signal owner */
GW.mcVu = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 150, 96);
const cx = 75, cy = 112, sweep = t => -43 + t * 86;
const glassId = uid('vuGlass'), clipId = uid('vuFace');
const defs = svgEl(s, 'defs', {});
const g1 = svgEl(defs, 'linearGradient', { id: glassId, x1: 0, y1: 0, x2: 0, y2: 1 });
svgEl(g1, 'stop', { offset: '0', 'stop-color': 'rgba(255,255,255,.13)' });
svgEl(g1, 'stop', { offset: '.35', 'stop-color': 'rgba(255,255,255,0)' });
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3 });
svgEl(s, 'rect', { x: .5, y: .5, width: 149, height: 95, rx: 7, fill: '#16130f', stroke: '#060505' });
svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: 'var(--cream)' });
const face = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const arcPt = t => polar(cx, cy, 58, sweep(t));
const [ax1, ay1] = arcPt(0), [ax2, ay2] = arcPt(1);
svgEl(face, 'path', { d: `M ${ax1} ${ay1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.2 });
const [rx1, ry1] = arcPt(.75);
svgEl(face, 'path', { d: `M ${rx1} ${ry1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3.5 });
for (const [db, t] of VUDB) {
const a = sweep(t), [x1, y1] = polar(cx, cy, 55, a), [x2, y2] = polar(cx, cy, 63, a);
svgEl(face, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1.1 });
}
for (const [db, t] of VUDB) {
if (![-20, -10, -5, -3, 0, 1, 2, 3].includes(db)) continue;
const [x, y] = polar(cx, cy, 69, sweep(t));
svgEl(face, 'text', {
x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.8, 'font-family': 'var(--mono)',
fill: db > 0 ? 'var(--fail)' : '#3a3128'
}).textContent = Math.abs(db);
}
svgEl(face, 'text', {
x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#3a3128'
}).textContent = 'VU';
const needle = svgEl(face, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6,
transform: `rotate(${sweep(.35)},${cx},${cy})`
});
svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' });
svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` });
let t = opts.value ?? .35;
function set(nt) {
t = Math.max(0, Math.min(1.02, nt));
needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`);
const db = vuDb(Math.min(1, t));
onChange(t, (db > 0 ? '+' : '') + db.toFixed(1) + ' VU');
}
return { el: s, get: () => t, set };
};
/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */
GW.roundMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80;
const clipId = uid('rndFace');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy: fy, r: 40 });
svgEl(s, 'circle', { cx, cy: fy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy: fy, r: 40, fill: 'var(--cream)' });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const pt = (r, t) => polar(cx, py, r, sweep(t));
const [ax1, ay1] = pt(44, 0), [ax2, ay2] = pt(44, 1);
svgEl(g, 'path', { d: `M ${ax1} ${ay1} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.1 });
const [rzx, rzy] = pt(44, .75);
svgEl(g, 'path', { d: `M ${rzx} ${rzy} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3 });
for (const [db, t] of VUDB) {
const [x1, y1] = pt(41.5, t), [x2, y2] = pt(47, t);
svgEl(g, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1 });
}
for (const [db, t] of VUDB) {
if (![-20, -10, -5, -3, 0, 3].includes(db)) continue;
const [x, y] = pt(52, t);
svgEl(g, 'text', {
x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)',
fill: db > 0 ? 'var(--fail)' : '#3a3128'
}).textContent = Math.abs(db);
}
svgEl(g, 'text', {
x: cx, y: fy + 14, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#3a3128'
}).textContent = 'dB';
const needle = svgEl(g, 'line', {
x1: cx, y1: py, x2: cx, y2: py - 46, stroke: '#1a1613', 'stroke-width': 1.5,
transform: `rotate(0,${cx},${py})`
});
svgEl(g, 'circle', { cx, cy: py, r: 5, fill: '#141110' });
svgEl(s, 'ellipse', { cx: cx - 8, cy: fy - 22, rx: 20, ry: 9, fill: 'rgba(255,255,255,.07)', transform: `rotate(-18,${cx - 8},${fy - 22})` });
let v = opts.value ?? 50;
function set(nv) {
v = nv;
const t = Math.max(0, Math.min(1, v / 100));
needle.setAttribute('transform', `rotate(${-40 + t * 80},${cx},${py})`);
const db = vuDb(t);
onChange(v, (db > 0 ? '+' : '') + db.toFixed(1) + ' dB');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */
GW.chromeMinMax = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, cy = 58;
gradDef('chrRing', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#eceef0'], ['.5', '#9aa0a8'], ['1', '#54585e']]);
gradDef('chrDome', 'radialGradient', { cx: '48%', cy: '20%', r: '85%' }, [['0', '#e6e8ec'], ['1', '#82878f']]);
svgEl(s, 'circle', { cx, cy: 54, r: 47, fill: 'url(#chrRing)' });
svgEl(s, 'circle', { cx, cy: 54, r: 40, fill: '#101214', stroke: '#2c2f33', 'stroke-width': 1 });
svgEl(s, 'path', { d: `M 21 60 A 34 34 0 0 1 89 60 Z`, fill: 'url(#chrDome)', stroke: '#55595f', 'stroke-width': .5 });
for (const r of [14, 22, 30])
svgEl(s, 'path', { d: `M ${cx - r} 60 A ${r} ${r} 0 0 1 ${cx + r} 60`, fill: 'none', stroke: 'rgba(255,255,255,.13)', 'stroke-width': .7 });
const ptr = svgEl(s, 'line', { x1: cx, y1: cy - 10, x2: cx, y2: cy - 31, stroke: '#17191b', 'stroke-width': 2.2, 'stroke-linecap': 'round' });
svgEl(s, 'circle', { cx, cy, r: 6, fill: '#23262a' });
for (let k = 0; k < 8; k++) {
const [x, y] = polar(cx, cy, 6, k * 45);
svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: '#23262a' });
}
svgEl(s, 'circle', { cx, cy, r: 1.6, fill: '#0c0d0e' });
svgEl(s, 'text', {
x: 24, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em',
'font-family': 'var(--mono)', fill: '#d7dade'
}).textContent = 'MIN';
svgEl(s, 'text', {
x: 86, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em',
'font-family': 'var(--mono)', fill: '#d7dade'
}).textContent = 'MAX';
svgEl(s, 'text', {
x: cx, y: 84, 'text-anchor': 'middle', 'font-size': 7, 'font-style': 'italic',
'font-family': 'var(--mono)', fill: '#787c82'
}).textContent = 'dupre.';
svgEl(s, 'ellipse', { cx: 47, cy: 34, rx: 22, ry: 9, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-16,47,34)' });
let v = opts.value ?? 50;
function set(nv) {
v = Math.max(0, Math.min(100, nv));
ptr.setAttribute('transform', `rotate(${-55 + v / 100 * 110},${cx},${cy})`);
onChange(v, v <= 2 ? 'MIN' : v >= 98 ? 'MAX' : Math.round(v) + '%');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */
GW.aviationGauge = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, cy = 52, sweep = t => -135 + t * 270;
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
svgEl(s, 'circle', { cx, cy, r: 47, fill: '#1b1917', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 41, fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1 });
const zone = (t1, t2, color) => {
const [x1, y1] = polar(cx, cy, 37, sweep(t1)), [x2, y2] = polar(cx, cy, 37, sweep(t2));
svgEl(s, 'path', { d: `M ${x1} ${y1} A 37 37 0 ${(t2 - t1) > .5 ? 1 : 0} 1 ${x2} ${y2}`, fill: 'none', stroke: color, 'stroke-width': 3, opacity: .85 });
};
zone(.15, .6, 'var(--pass)'); zone(.6, .85, 'var(--gold)'); zone(.85, 1, 'var(--fail)');
for (let i = 0; i <= 40; i++) {
const t = i / 40, major = i % 5 === 0, a = sweep(t);
const [x1, y1] = polar(cx, cy, 40, a), [x2, y2] = polar(cx, cy, major ? 32 : 36, a);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--gold)', 'stroke-width': major ? 1.6 : .8, opacity: major ? 1 : .7 });
if (major) {
const [nx, ny] = polar(cx, cy, 25, a);
svgEl(s, 'text', {
x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)',
fill: 'var(--gold-hi)'
}).textContent = i / 5;
}
}
svgEl(s, 'text', {
x: cx, y: cy + 16, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .8
}).textContent = '×100 RPM';
const needle = svgEl(s, 'g', {});
svgEl(needle, 'line', {
x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 4,
'stroke-linecap': 'round', opacity: .5, filter: 'url(#avGlow)'
});
svgEl(needle, 'line', {
x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 2.2,
'stroke-linecap': 'round'
});
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#1b1917', stroke: '#3a352c' });
svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 24, rx: 18, ry: 8, fill: 'rgba(255,255,255,.05)', transform: `rotate(-18,${cx - 10},${cy - 24})` });
let v = opts.value ?? 52;
function set(nv) {
v = Math.max(0, Math.min(100, nv));
needle.setAttribute('transform', `rotate(${-135 + v / 100 * 270},${cx},${cy})`);
onChange(v, (v / 100 * 8).toFixed(1) + ' ×100 rpm');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R13 edgewise strip meter — compressed log scale, the bar rides the edge */
GW.edgeMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]];
const s = stageSvg(host, 'rsvg drag', 70, 130);
const wy0 = 12, wy1 = 118;
const faceId = uid('edgeFace');
const defs = svgEl(s, 'defs', {});
const pg = svgEl(defs, 'linearGradient', { id: faceId, x1: 0, y1: 0, x2: 1, y2: 0 });
svgEl(pg, 'stop', { offset: '0', 'stop-color': '#efe6ca' });
svgEl(pg, 'stop', { offset: '1', 'stop-color': '#d9cfae' });
svgEl(s, 'rect', { x: 8, y: 6, width: 54, height: 118, rx: 8, fill: '#171412', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: wy1 - wy0, fill: `url(#${faceId})`, stroke: '#0a0908' });
const yOf = f => wy0 + f * (wy1 - wy0);
for (const [db, f] of SCALE) {
const y = yOf(f);
svgEl(s, 'line', { x1: 19, y1: y, x2: 40, y2: y, stroke: '#3a3128', 'stroke-width': 1 });
svgEl(s, 'text', {
x: 52, y: y + 2.4, 'text-anchor': 'end', 'font-size': 7, 'font-family': 'var(--mono)',
fill: '#3a3128'
}).textContent = db;
}
for (let i = 0; i < SCALE.length - 1; i++) {
const y = yOf((SCALE[i][1] + SCALE[i + 1][1]) / 2);
svgEl(s, 'line', { x1: 19, y1: y, x2: 32, y2: y, stroke: '#3a3128', 'stroke-width': .6, opacity: .7 });
}
const bar = svgEl(s, 'polygon', { points: '56,0 24,-1.4 24,1.4', fill: '#1a1613' });
svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: 24, fill: 'rgba(255,255,255,.08)' });
let frac = opts.value ?? .62;
function set(f) {
frac = Math.max(.08, Math.min(.94, f));
bar.setAttribute('transform', `translate(0,${wy0 + frac * (wy1 - wy0)})`);
let i = 0; while (i < SCALE.length - 2 && SCALE[i + 1][1] < frac) i++;
const [d1, f1] = SCALE[i], [d2, f2] = SCALE[i + 1];
const db = d1 + (d2 - d1) * Math.max(0, Math.min(1, (frac - f1) / (f2 - f1)));
onChange(frac, '−' + db.toFixed(1) + ' dB');
}
dragY(s, pct => set(.08 + (100 - pct) / 100 * .86));
set(frac);
return { el: s, get: () => frac, set };
};
/* R17 round CRT scope — pale phosphor face; the trace is the widget's own
animation, so it lives here with its reduced-motion gate */
GW.roundCrt = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 110, 104);
const cx = 55, cy = 52;
const faceId = uid('rcrtFace'), clipId = uid('rcrtClip');
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
/* the face gradient reads screen-family vars from this widget's subtree,
so it must live in the local defs, not the shared def sink */
const defs = svgEl(s, 'defs', {});
const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' });
svgEl(ph, 'stop', { offset: '0', 'stop-color': 'var(--crt-face1,#b9d8c0)' });
svgEl(ph, 'stop', { offset: '1', 'stop-color': 'var(--crt-face2,#8bb296)' });
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 38 });
svgEl(s, 'circle', { cx, cy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 });
for (let k = 0; k < 4; k++) {
const [x, y] = polar(cx, cy, 43, 45 + k * 90);
svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
}
svgEl(s, 'circle', { cx, cy, r: 38, fill: `url(#${faceId})` });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
for (let i = -4; i <= 4; i++) {
svgEl(g, 'line', { x1: cx + i * 9, y1: cy - 38, x2: cx + i * 9, y2: cy + 38, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 });
svgEl(g, 'line', { x1: cx - 38, y1: cy + i * 9, x2: cx + 38, y2: cy + i * 9, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 });
}
const glow = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: 'var(--crt-glow,#eef7ee)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' });
const trace = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: '#f4fcf4', 'stroke-width': 1.3 });
svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 16, rx: 20, ry: 10, fill: 'rgba(255,255,255,.10)', transform: `rotate(-20,${cx - 10},${cy - 16})` });
let tph = 0;
function tick() {
tph += 0.12;
const amp = 8 + 3 * Math.sin(tph * 0.35);
let pts = '';
for (let x = -38; x <= 38; x += 2) {
const y = Math.sin(x * 0.55 + tph * 2.5) * amp;
pts += `${cx + x},${(cy + y).toFixed(1)} `;
}
trace.setAttribute('points', pts.trim());
glow.setAttribute('points', pts.trim());
onChange(amp, 'Vpp ' + Math.round(amp * 2 * 4.5));
}
tick();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 80);
return { el: s, tick };
};
/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */
GW.attitudeIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 130);
const cx = 65, cy = 65;
const clipId = uid('aiClip');
gradDef('aiSky', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#54677d'], ['1', '#3a4c60']]);
gradDef('aiGnd', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#5c4630'], ['1', '#3c2e1e']]);
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 48 });
svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const ball = svgEl(g, 'g', {});
svgEl(ball, 'rect', { x: cx - 90, y: cy - 180, width: 180, height: 180, fill: 'url(#aiSky)' });
svgEl(ball, 'rect', { x: cx - 90, y: cy, width: 180, height: 180, fill: 'url(#aiGnd)' });
svgEl(ball, 'line', { x1: cx - 90, y1: cy, x2: cx + 90, y2: cy, stroke: '#f3e7c5', 'stroke-width': 1.4 });
for (const p of [-20, -10, 10, 20]) {
const y = cy - p * 1.6, w = p % 20 === 0 ? 16 : 10;
svgEl(ball, 'line', { x1: cx - w, y1: y, x2: cx + w, y2: y, stroke: '#f3e7c5', 'stroke-width': .8, opacity: .85 });
svgEl(ball, 'text', {
x: cx + w + 3, y: y + 2, 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#f3e7c5', opacity: .85
}).textContent = String(Math.abs(p));
}
/* fixed bank scale + pointer + miniature aircraft */
for (const b of [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60]) {
const [x1, y1] = polar(cx, cy, 47, b), [x2, y2] = polar(cx, cy, b % 30 === 0 ? 42 : 44, b);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': b === 0 ? 1.6 : .9, opacity: .9 });
}
svgEl(s, 'polygon', { points: `${cx - 3.5},${cy - 38} ${cx + 3.5},${cy - 38} ${cx},${cy - 44}`, fill: 'var(--gold-hi)' });
svgEl(s, 'path', {
d: `M ${cx - 22} ${cy} L ${cx - 7} ${cy} L ${cx - 4} ${cy + 4} M ${cx + 22} ${cy} L ${cx + 7} ${cy} L ${cx + 4} ${cy + 4}`,
fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round'
});
svgEl(s, 'circle', { cx, cy, r: 2, fill: 'var(--gold-hi)' });
let bank = 0, pitch = 0;
function set(b, p) {
bank = Math.max(-60, Math.min(60, b)); pitch = Math.max(-20, Math.min(20, p));
ball.setAttribute('transform', `rotate(${(-bank).toFixed(1)},${cx},${cy}) translate(0,${(pitch * 1.6).toFixed(1)})`);
onChange({ bank, pitch }, 'BANK ' + (bank < 0 ? 'L' : bank > 0 ? 'R' : '') + Math.abs(Math.round(bank)) +
' · PITCH ' + (pitch >= 0 ? '+' : '-') + String(Math.abs(Math.round(pitch))).padStart(2, '0'));
}
s.style.touchAction = 'none'; s.style.cursor = 'move';
s.addEventListener('pointerdown', e => {
s.setPointerCapture(e.pointerId);
const x0 = e.clientX, y0 = e.clientY, b0 = bank, p0 = pitch;
const mv = ev => set(b0 + (ev.clientX - x0) * 0.4, p0 + (ev.clientY - y0) * 0.25);
const up = () => { s.removeEventListener('pointermove', mv); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); };
s.addEventListener('pointermove', mv); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); e.preventDefault();
});
set(opts.bank ?? 0, opts.pitch ?? 0);
return { el: s, get: () => ({ bank, pitch }), set };
};
/* R44 heading bug + servo needle — drag parks the command; the needle chases
with honest servo lag (widget-owned animation, reduced-motion gated) */
GW.headingBug = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 130);
const cx = 65, cy = 65;
svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 });
for (let d = 0; d < 360; d += 10) {
const maj = d % 30 === 0;
const [x1, y1] = polar(cx, cy, 48, d), [x2, y2] = polar(cx, cy, maj ? 42 : 45, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': maj ? 1 : .5, opacity: maj ? .9 : .5 });
}
for (let d = 0; d < 360; d += 30) {
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : String(d / 10);
const [x, y] = polar(cx, cy, 35, d);
svgEl(s, 'text', {
x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': d % 90 === 0 ? 8 : 6.4, 'font-weight': 700,
'font-family': 'var(--mono)', fill: d % 90 === 0 ? 'var(--cream)' : 'var(--steel)'
}).textContent = lbl;
}
svgEl(s, 'line', { x1: cx, y1: cy - 56, x2: cx, y2: cy - 48, stroke: 'var(--fail)', 'stroke-width': 2 });
const bug = svgEl(s, 'path', {
d: `M ${cx - 6} ${cy - 49} L ${cx - 6} ${cy - 44} L ${cx - 2.5} ${cy - 44} L ${cx} ${cy - 47} L ${cx + 2.5} ${cy - 44} L ${cx + 6} ${cy - 44} L ${cx + 6} ${cy - 49} Z`,
fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': .7
});
const needle = svgEl(s, 'g', {});
svgEl(needle, 'polygon', {
points: `${cx - 2},${cy + 10} ${cx + 2},${cy + 10} ${cx + 1.2},${cy - 40} ${cx},${cy - 44} ${cx - 1.2},${cy - 40}`,
fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6
});
svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let cmd = opts.value ?? 90, act = cmd;
const draw = () => {
bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`);
needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`);
onChange({ cmd, act }, 'CMD ' + String(Math.round((cmd % 360 + 360) % 360)).padStart(3, '0') +
' · ACT ' + String(Math.round((act % 360 + 360) % 360)).padStart(3, '0'));
};
function set(v) { cmd = (v % 360 + 360) % 360; draw(); }
dragDelta(s, () => cmd, set, { min: -100000, max: 100000, sens: 1.2 });
draw();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => {
const diff = ((cmd - act + 540) % 360) - 180;
if (Math.abs(diff) < 0.4) { if (act !== cmd) { act = cmd; draw(); } return; }
act = (act + diff * 0.07 + 360) % 360; draw();
}, 80);
return { el: s, get: () => ({ cmd, act }), set };
};
/* R53 circular chart recorder — a day per revolution; the pen draws the cycle
(widget-owned clock, reduced-motion paints the full day once); click for fresh paper */
GW.chartRecorder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130);
const cx = 65, cy = 65;
svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 54, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': .8 });
for (const r of [20, 31, 42])
svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: '#b8ae98', 'stroke-width': .5 });
for (let h = 0; h < 24; h++) {
const a = h * 15;
const [x1, y1] = polar(cx, cy, 54, a), [x2, y2] = polar(cx, cy, h % 6 === 0 ? 12 : 48, a);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#b8ae98', 'stroke-width': h % 6 === 0 ? .6 : .35, opacity: .8 });
if (h % 6 === 0) {
const [tx, ty] = polar(cx, cy, 50, a + 5);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#6e6552'
}).textContent = String(h).padStart(2, '0');
}
}
const trace = svgEl(s, 'path', { d: '', fill: 'none', stroke: '#8f2416', 'stroke-width': 1.1, 'stroke-linejoin': 'round' });
const pen = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 54, stroke: '#4a463e', 'stroke-width': 1 });
const penDot = svgEl(s, 'circle', { cx, cy: cy - 30, r: 2, fill: '#8f2416' });
svgEl(s, 'circle', { cx, cy, r: 4, fill: '#4a463e' });
const val = h => 52 + 30 * Math.sin(h / 24 * 2 * Math.PI - 2.1) + 12 * Math.sin(h / 24 * 6 * Math.PI) + 4 * Math.sin(h * 2.7);
const rOf = v => 12 + Math.max(0, Math.min(100, v)) * 0.36;
let pts = [], hour = 0;
const draw = () => {
const a = hour * 15, r = rOf(val(hour));
pts.push(polar(cx, cy, r, a));
if (pts.length > 1) trace.setAttribute('d', 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L '));
pen.setAttribute('transform', `rotate(${a},${cx},${cy})`);
const [px, py] = polar(cx, cy, r, a);
penDot.setAttribute('cx', px); penDot.setAttribute('cy', py);
onChange(hour, String(Math.floor(hour)).padStart(2, '0') + ':' + (hour % 1 >= 0.5 ? '30' : '00') + ' · ' + Math.round(val(hour)) + '%');
};
const reset = () => { pts = []; hour = 0; trace.setAttribute('d', ''); draw(); };
s.style.cursor = 'pointer';
s.addEventListener('click', reset);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (hour = 0; hour < 24; hour += 0.25) draw();
hour = 23.75;
} else {
reset();
setInterval(() => { hour += 0.25; if (hour >= 24) { pts = []; hour = 0; } draw(); }, 375);
}
return { el: s, get: () => hour, reset };
};
/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */
GW.verticalTape = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 70, 130);
const CX = 38, CY = 65;
const MIN = 5, MAX = 35, PPU = 5.5; /* rpm x100, px per unit */
const clipId = uid('vtClip');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4 });
svgEl(s, 'rect', { x: 2, y: 2, width: 66, height: 126, rx: 7, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4, fill: '#0d0c0a', stroke: '#2c2820', 'stroke-width': 1 });
const tapeG = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const tape = svgEl(tapeG, 'g', {});
for (let u = MIN; u <= MAX; u++) {
const y = -u * PPU; /* higher value further up the tape */
const maj = u % 5 === 0;
svgEl(tape, 'line', { x1: maj ? 20 : 26, y1: y, x2: 34, y2: y, stroke: 'var(--cream)', 'stroke-width': maj ? 1.1 : .55, opacity: maj ? .95 : .6 });
if (maj) svgEl(tape, 'text', {
x: 48, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700,
'font-family': 'var(--mono)', fill: 'var(--cream)'
}).textContent = String(u);
}
/* fixed index: side pointer + line across the window at center height */
svgEl(s, 'polygon', { points: `8,${CY} 15,${CY - 4} 15,${CY + 4}`, fill: 'var(--gold-hi)' });
svgEl(s, 'line', { x1: 15, y1: CY, x2: 62, y2: CY, stroke: 'var(--gold-hi)', 'stroke-width': 1, opacity: .85 });
svgEl(s, 'text', {
x: CX, y: 126, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.1em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'RPM x100';
let v = opts.value ?? 24;
function set(nv) {
v = Math.max(MIN, Math.min(MAX, nv));
tape.setAttribute('transform', `translate(0,${(CY + v * PPU).toFixed(1)})`);
onChange(v, 'RPM ' + Math.round(v * 100));
}
s.style.cursor = 'ns-resize';
dragDelta(s, () => v, set, { min: MIN, max: MAX, sens: (MAX - MIN) / 140 });
set(v);
return { el: s, get: () => v, set };
};
/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */
GW.twinNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 120, 110);
const cx = 60, cy = 62, R = 44;
svgEl(s, 'circle', { cx, cy, r: 52, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 46, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 });
/* mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL */
const angL = v => -170 + v / 4 * 160; /* 0..4 -> -170..-10 (left side, cw from top) */
const angR = v => 170 - v / 4 * 160; /* 0..4 -> 170..10 (right side) */
for (let v = 0; v <= 4; v++) {
for (const ang of [angL(v), angR(v)]) {
const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 5, ang);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': 1, opacity: .9 });
const [tx, ty] = polar(cx, cy, R - 11, ang);
svgEl(s, 'text', {
x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)',
fill: 'var(--cream)'
}).textContent = String(v);
}
}
for (let v = 0.5; v < 4; v += 0.5) {
if (v % 1 === 0) continue;
for (const ang of [angL(v), angR(v)]) {
const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 3, ang);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .5, opacity: .55 });
}
}
svgEl(s, 'text', {
x: cx - 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'FUEL';
svgEl(s, 'text', {
x: cx + 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'OIL';
svgEl(s, 'text', {
x: cx, y: cy - 26, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.06em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'kg/cm2';
const needle = color => {
const g = svgEl(s, 'g', {});
svgEl(g, 'polygon', {
points: `${cx - 1.6},${cy + 8} ${cx + 1.6},${cy + 8} ${cx + 1},${cy - R + 8} ${cx},${cy - R + 4} ${cx - 1},${cy - R + 8}`,
fill: color, stroke: 'rgba(0,0,0,.4)', 'stroke-width': .5
}); return g;
};
const nF = needle('#e0523a'), nO = needle('#bfc4d0');
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let vF = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1;
const draw = () => {
nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`);
nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`);
onChange([vF, vO], 'FUEL ' + vF.toFixed(1) + ' · OIL ' + vO.toFixed(1));
};
function set(f, o) { vF = Math.max(0, Math.min(4, f)); vO = Math.max(0, Math.min(4, o)); draw(); }
/* each half is its own drag surface */
const half = (x, get, setV) => {
const hit = svgEl(s, 'rect', { x, y: 10, width: 60, height: 104, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragDelta(hit, get, v => { setV(Math.max(0, Math.min(4, v))); draw(); }, { min: 0, max: 4, sens: 4 / 110 });
};
half(0, () => vF, v => vF = v);
half(60, () => vO, v => vO = v);
draw();
return { el: s, get: () => [vF, vO], set };
};
/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */
GW.comfortMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 122);
const cx = 65, cy = 60;
const clipId = uid('cmClip');
gradDef('cmBrass', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8b25c'], ['.5', '#a07c30'], ['1', '#6e5218']]);
const defs = svgEl(s, 'defs', {});
svgEl(s, 'circle', { cx, cy, r: 56, fill: 'url(#cmBrass)', stroke: '#4a3610', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 53, fill: 'none', stroke: '#7a5c1e', 'stroke-width': 2.5, 'stroke-dasharray': '2 1.4' });
svgEl(s, 'circle', { cx, cy, r: 49, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': 1 });
const PT = [38, 96], PH = [92, 96]; /* temp pivot (BL), humidity pivot (BR) */
const angT = t => 105 - (t - 40) / 60 * 70; /* 40..100F -> bearing 105..35 from PT */
const angH = h => -105 + h / 100 * 70; /* 0..100% -> bearing -105..-35 from PH */
/* scale ticks along each sweep, near the rim */
for (let t = 40; t <= 100; t += 10) {
const a = angT(t);
const [x1, y1] = polar(PT[0], PT[1], 86, a), [x2, y2] = polar(PT[0], PT[1], 82, a);
if (Math.hypot(x1 - cx, y1 - cy) < 48) {
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': t % 20 === 0 ? 1 : .5 });
if (t % 20 === 0) {
const [tx, ty] = polar(PT[0], PT[1], 78, a);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#3c382f'
}).textContent = String(t);
}
}
}
for (let h = 0; h <= 100; h += 10) {
const a = angH(h);
const [x1, y1] = polar(PH[0], PH[1], 86, a), [x2, y2] = polar(PH[0], PH[1], 82, a);
if (Math.hypot(x1 - cx, y1 - cy) < 48) {
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': h % 20 === 0 ? 1 : .5 });
if (h % 20 === 0) {
const [tx, ty] = polar(PH[0], PH[1], 78, a);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#3c382f'
}).textContent = String(h);
}
}
}
/* printed zone verdicts; the active one goes red */
const zones = {};
const zone = (key, txt, x, y, rot, size) => {
zones[key] = svgEl(s, 'text', {
x, y, 'text-anchor': 'middle',
'font-size': size || 5.2, 'font-weight': 700, 'letter-spacing': '.06em', 'font-family': 'var(--mono)',
fill: '#8f897b', transform: `rotate(${rot},${x},${y})`
}); zones[key].textContent = txt;
};
zone('humid', 'TOO HUMID', 42, 34, -32);
zone('warm', 'TOO WARM', 89, 34, 32);
zone('right', 'JUST RIGHT', 65, 58, 0, 6.2);
zone('dry', 'TOO DRY', 38, 74, 28);
zone('cold', 'TOO COLD', 93, 74, -28);
svgEl(s, 'text', {
x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.6, 'letter-spacing': '.18em',
'font-family': 'var(--mono)', fill: '#3c382f'
}).textContent = 'WEATHER STATION';
/* needles: temp from bottom-left, humidity from bottom-right — clipped to the face */
const nclip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(nclip, 'circle', { cx, cy, r: 49 });
const ng = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const needle = () => {
const g = svgEl(ng, 'g', {});
svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: -84, stroke: '#c23a28', 'stroke-width': 1.6, 'stroke-linecap': 'round' });
svgEl(g, 'circle', { cx: 0, cy: 0, r: 2.6, fill: '#8f2416' }); return g;
};
const nT = needle(), nH = needle();
nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`);
nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`);
let vT = opts.temp ?? 72, vH = opts.humidity ?? 45;
const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right';
const draw = () => {
nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`);
nH.setAttribute('transform', `translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`);
const v = verdict();
for (const k of Object.keys(zones)) zones[k].setAttribute('fill', k === v ? '#c23a28' : '#8f897b');
onChange([vT, vH], Math.round(vT) + 'F · ' + Math.round(vH) + '% RH · ' + zones[v].textContent);
};
function set(t, h) { vT = Math.max(40, Math.min(100, t)); vH = Math.max(0, Math.min(100, h)); draw(); }
const half = (x, get, setV, min, max) => {
const hit = svgEl(s, 'rect', { x, y: 8, width: 65, height: 106, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragDelta(hit, get, v => { setV(Math.max(min, Math.min(max, v))); draw(); }, { min, max, sens: (max - min) / 110 });
};
half(0, () => vT, v => vT = v, 40, 100);
half(65, () => vH, v => vH = v, 0, 100);
draw();
return { el: s, get: () => [vT, vH], set };
};
/* ================= indicators & readouts ================= */
/* 18 status lamps — one lamp per health state; click any lamp to cycle it */
GW.statusLamps = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp busy'];
const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy'];
const states = (opts.states || [0, 1, 2, 3, 4]).slice();
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;gap:9px;align-items:center';
const lamps = states.map((st, i) => {
const l = document.createElement('span'); l.className = CLS[st]; l.style.cursor = 'pointer';
l.addEventListener('click', () => set(i, states[i] + 1));
wrap.appendChild(l); return l;
});
host.appendChild(wrap);
function set(i, st) {
states[i] = ((st % CLS.length) + CLS.length) % CLS.length;
lamps[i].className = CLS[states[i]];
onChange(states.slice(), NAMES[states[i]]);
}
onChange(states.slice(), states.length === 5 ? 'five states' : states.length + ' states');
return { el: wrap, get: () => states.slice(), set };
};
/* 19 badges — labelled flags; click a badge to cycle its variant */
GW.badges = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['badge', 'badge red', 'badge ghost'];
const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]];
const vars = items.map(it => it[1]);
const wrap = document.createElement('span');
const els = items.map(([txt, v], i) => {
const b = document.createElement('span'); b.className = CLS[v]; b.textContent = txt; b.style.cursor = 'pointer';
b.addEventListener('click', () => set(i, vars[i] + 1));
wrap.appendChild(b);
if (i < items.length - 1) wrap.appendChild(document.createTextNode(' '));
return b;
});
host.appendChild(wrap);
function set(i, v) {
vars[i] = ((v % CLS.length) + CLS.length) % CLS.length;
els[i].className = CLS[vars[i]];
onChange(vars.slice(), items[i][0]);
}
onChange(vars.slice(), items.length === 3 ? 'three variants' : items.length + ' variants');
return { el: wrap, get: () => vars.slice(), set };
};
/* 20 tabular readout — mm:ss countdown; builder owns the state, the page
drives tick() on its own clock (tick contract); click pauses/resumes */
GW.tabularReadout = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
wrap.innerHTML = '';
const out = wrap.querySelector('.readout');
wrap.querySelector('.u').textContent = opts.unit || 'timer';
host.appendChild(wrap);
let secs, run = opts.run !== undefined ? !!opts.run : true;
const draw = () => {
out.textContent = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0');
onChange(secs, run ? 'running' : 'paused');
};
const set = v => { secs = ((v % 3600) + 3600) % 3600; draw(); };
out.addEventListener('click', () => { run = !run; draw(); });
set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
return { el: wrap, get: () => secs, set, tick: () => { if (run) set(secs - 1); } };
};
/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9 */
GW.engravedLabel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const e = document.createElement('span'); e.className = 'engrave';
e.append(opts.label || 'outputs');
const c = document.createElement('span'); c.className = 'cnt'; e.appendChild(c);
host.appendChild(e);
let n;
const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); };
e.addEventListener('click', () => set(n % 9 + 1));
set(opts.count !== undefined ? opts.count : 3);
return { el: e, get: () => n, set };
};
/* 22 output well — streaming step log, lamp per step; click streams the next
demo step; push([lampCls, name, evidence]) appends programmatically */
GW.outputWell = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']];
const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']];
const keep = opts.keep || 5;
const w = document.createElement('div'); w.className = 'owell'; host.appendChild(w);
const add = s => {
const d = document.createElement('div'); d.className = 'ostep';
d.innerHTML = `${s[1]}${s[2]}`;
w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild);
};
seed.forEach(add);
const push = s => { add(s); onChange(s, '+ ' + s[1]); };
let i = 0;
w.addEventListener('click', () => { i = (i + 1) % steps.length; push(steps[i]); });
onChange(null, 'click to stream');
return { el: w, push };
};
/* 23 toast — one-line transient confirmation; click fires the next demo
message; fire(msg) shows any message with the fade-in */
GW.toast = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved'];
const t = document.createElement('span'); t.className = 'toastw';
t.textContent = opts.text || msgs[msgs.length - 1];
host.appendChild(t);
const fire = msg => {
t.textContent = msg; t.style.opacity = '0';
requestAnimationFrame(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '1'; });
};
let n = 0;
t.addEventListener('click', () => { n++; fire(msgs[n % msgs.length]); onChange(n, 'fired ' + n); });
onChange(0, 'click to fire');
return { el: t, fire };
};
/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments */
GW.nixie = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const digits = opts.digits || 2;
const max = Math.pow(10, digits);
const nx = document.createElement('span'); nx.className = 'nixie';
for (let i = 0; i < digits; i++) {
const tu = document.createElement('span'); tu.className = 'tube'; tu.innerHTML = ''; nx.appendChild(tu);
}
host.appendChild(nx);
let v;
const set = x => {
v = ((x % max) + max) % max;
const s = String(v).padStart(digits, '0');
[...nx.children].forEach((tu, i) => {
tu.classList.toggle('off', i < digits - 1 && v < Math.pow(10, digits - 1 - i));
tu.querySelector('b').textContent = s[i];
});
onChange(v, s);
};
nx.addEventListener('click', () => set(v + 1));
set(opts.value !== undefined ? opts.value : 8);
return { el: nx, get: () => v, set };
};
/* N20 split-flap — windows flip to the next word with the mechanical clack;
the page drives next() on its own cadence; first paint is silent like the original */
GW.splitFlap = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN'];
const animate = opts.animate !== undefined ? !!opts.animate : true;
const cells = opts.cells || 3;
const f = document.createElement('span'); f.className = 'flap';
for (let i = 0; i < cells; i++) {
const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = ''; f.appendChild(d);
}
host.appendChild(f);
let idx = 0;
const paint = () => {
const w = words[idx].padEnd(cells + 1, ' ');
[...f.children].forEach((dd, i) => {
const ch = w[i] || ' ', bel = dd.querySelector('b');
if (bel.textContent !== ch) {
bel.textContent = ch;
if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); }
}
});
};
const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); };
f.addEventListener('click', () => set(idx + 1));
paint();
return { el: f, get: () => idx, set, next: () => set(idx + 1) };
};
/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick();
click adds a minute */
GW.sevenSeg = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const sv = document.createElement('span'); sv.className = 'seven'; host.appendChild(sv);
let secs;
const set = v => {
secs = ((v % 3600) + 3600) % 3600;
const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0');
sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '' + seg7(ss[0]) + seg7(ss[1]);
onChange(secs, mm + ':' + ss);
};
sv.addEventListener('click', () => set(secs + 60));
set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
return { el: sv, get: () => secs, set, tick: () => set(secs - 1) };
};
/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message */
GW.vfdMarquee = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C'];
const W = opts.width || 176;
const m = document.createElement('span'); m.className = 'vfdm';
m.innerHTML = '';
const t = m.querySelector('.txt'); t.textContent = msgs[0];
host.appendChild(m);
let mi = 0, x = W;
m.addEventListener('click', () => {
mi = (mi + 1) % msgs.length; t.textContent = msgs[mi]; x = W;
onChange(mi, 'msg ' + (mi + 1) + '/' + msgs.length);
});
onChange(0, 'scrolling');
return {
el: m, get: () => mi,
tick: () => { x -= 1.1; if (x < -t.offsetWidth) x = W; t.style.left = x + 'px'; }
};
};
/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET
lifecycle; TEST proves the bulbs then restores the board */
GW.annunciator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['acell', 'acell warn', 'acell fault'];
const NAMES = ['ok', 'warn', 'fault'];
const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]];
const wrap = document.createElement('div'); wrap.className = 'annwrap';
const grid = document.createElement('div'); grid.className = 'annun'; wrap.appendChild(grid);
const bar = document.createElement('div'); bar.className = 'annbar';
const mc = document.createElement('span'); mc.className = 'mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc);
['ACK', 'TEST', 'RESET'].forEach(a => {
const b = document.createElement('button'); b.className = 'key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b);
});
wrap.appendChild(bar); host.appendChild(wrap);
const els = cells.map(([label, st]) => {
const c = document.createElement('span'); c.className = CLS[st]; c.textContent = label; grid.appendChild(c); return c;
});
let acked = false;
const active = () => grid.querySelectorAll('.warn,.fault').length;
const refresh = () => {
const n = active();
mc.classList.toggle('on', n > 0); mc.classList.toggle('fl', n > 0 && !acked);
onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK'));
};
els.forEach((c, i) => c.addEventListener('click', () => {
let j = CLS.indexOf(c.className.trim()); if (j < 0) j = 0; j = (j + 1) % CLS.length; c.className = CLS[j];
if (j > 0) acked = false; /* a new alarm re-arms the flasher */
refresh(); onChange(j, cells[i][0] + ' → ' + NAMES[j]);
}));
bar.querySelectorAll('.key').forEach(k => k.addEventListener('click', () => {
const a = k.dataset.k;
if (a === 'ACK') { if (active() > 0) acked = true; refresh(); }
else if (a === 'RESET') { els.forEach(c => c.className = 'acell'); acked = false; refresh(); }
else if (a === 'TEST') {
const prev = els.map(c => c.className);
els.forEach(c => c.className = 'acell fault');
mc.classList.add('on', 'fl'); onChange(null, 'lamp test');
setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100);
}
}));
refresh();
return {
el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))),
set: (i, st) => { els[i].className = CLS[st]; if (st > 0) acked = false; refresh(); }
};
};
/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */
GW.jewels = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)'];
const NAMES = ['red', 'amber', 'green'];
const init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center';
init.forEach(st => {
const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : '');
if (st >= 0) j.style.setProperty('--jc', cols[st]);
j.addEventListener('click', () => {
if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; }
const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur);
if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); }
else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); }
});
wrap.appendChild(j);
});
host.appendChild(wrap);
onChange(null, 'click to cycle');
return { el: wrap };
};
/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */
GW.tapeCounter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4;
const ct = document.createElement('span'); ct.className = 'counter'; host.appendChild(ct);
for (let idx = 0; idx < wheels; idx++) {
const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : '');
const col = document.createElement('span'); col.className = 'col';
for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); }
w.appendChild(col); ct.appendChild(w);
}
const max = Math.pow(10, wheels);
let v;
const set = x => {
v = ((Math.round(x) % max) + max) % max;
const s = String(v).padStart(wheels, '0');
[...ct.children].forEach((w, i) => { w.querySelector('.col').style.top = (-(+s[i] + 1) * 34) + 'px'; });
onChange(v, s);
};
set(opts.value !== undefined ? opts.value : 471300);
return { el: ct, get: () => v, set };
};
/* N26 analog clock — engraved ticks + three hands; the page owns the time source
and drives set(h, m, s); silent until the first set, like the live original */
GW.analogClock = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cl = document.createElement('span'); cl.className = 'clock';
for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); }
cl.insertAdjacentHTML('beforeend', '');
host.appendChild(cl);
const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh');
const set = (h, m, s) => {
sh.style.transform = `rotate(${s * 6}deg)`;
mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`;
hh.style.transform = `rotate(${(h % 12) * 30 + m * 0.5}deg)`;
onChange([h, m, s], String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'));
};
return { el: cl, set };
};
/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high;
drag the pointer to tune */
GW.freqScale = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const W = opts.width || 184;
const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20];
const unit = opts.unit || 'MHz';
const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]);
const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12);
const fs = document.createElement('span'); fs.className = 'freqscale';
const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band);
marks.forEach(m => {
const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk);
const mk = document.createElement('span'); mk.className = 'mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk);
});
for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) {
const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk);
}
const ptr = document.createElement('span'); ptr.className = 'fptr'; fs.appendChild(ptr);
host.appendChild(fs);
let pct;
const set = p => {
pct = Math.max(0, Math.min(100, p));
ptr.style.left = (6 + pct / 100 * (W - 12)) + 'px';
const f = Math.pow(10, lo + (pct / 100) * (hi - lo));
onChange(f, (f < 10 ? f.toFixed(1) : Math.round(f)) + ' ' + unit);
};
dragX(fs, set);
set(opts.value !== undefined ? opts.value : 45);
return { el: fs, get: () => pct, set };
};
/* N28 patch bay — jack grid with SVG cables; click one jack then another to
patch or unpatch the pair; redraws itself on window resize */
GW.patchBay = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const rows = opts.rows || 2, cols = opts.cols || 4;
const patch = document.createElement('div'); patch.className = 'patch';
const jacks = [];
for (let r = 0; r < rows; r++) {
const rowEl = document.createElement('div'); rowEl.className = 'row';
for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); }
patch.appendChild(rowEl);
}
const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg);
host.appendChild(patch);
let conns = (opts.conns || ['0-5', '2-7']).slice(); let pending = null;
const key = (a, b) => Math.min(a, b) + '-' + Math.max(a, b);
const draw = () => {
while (svg.firstChild) svg.removeChild(svg.firstChild);
const pr = patch.getBoundingClientRect();
jacks.forEach(j => j.classList.remove('hot'));
conns.forEach(pair => {
const [a, b] = pair.split('-').map(Number);
const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect();
const x1 = ra.left - pr.left + ra.width / 2, y1 = ra.top - pr.top + ra.height / 2;
const x2 = rb.left - pr.left + rb.width / 2, y2 = rb.top - pr.top + rb.height / 2;
const p = document.createElementNS(SVGNS, 'path');
const my = Math.max(y1, y2) + 16; p.setAttribute('d', `M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`); svg.appendChild(p);
jacks[a].classList.add('hot'); jacks[b].classList.add('hot');
});
onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables');
};
jacks.forEach(j => {
j.addEventListener('click', () => {
const id = +j.dataset.id;
if (pending == null) { pending = id; j.classList.add('sel'); return; }
if (pending === id) { pending = null; j.classList.remove('sel'); return; }
const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k);
jacks[pending].classList.remove('sel'); pending = null; draw();
});
});
draw(); window.addEventListener('resize', draw);
return { el: patch, get: () => conns.slice(), set: c => { conns = c.slice(); draw(); } };
};
/* R10 data matrix readout — a page of labeled amber fields; click cycles pages;
a page marked live re-renders on the builder's own 1s clock (reduced-motion gated) */
GW.dataMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const pages = opts.pages || [
['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']],
['NET', () => ['WIFI @HYATT 300M', 'VPN UP DNS OK', 'BT 2 PAIRED']],
['TIME', () => {
const d = new Date(), p = n => String(n).padStart(2, '0');
return [`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`,
'UP 14D 06:12'];
}, true]];
const s = stageSvg(host, 'rsvg press', 190, 74);
def('dmxGlow', d => {
const fl = svgEl(d, 'filter', { id: 'dmxGlow', x: '-30%', y: '-30%', width: '160%', height: '160%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.1 });
});
/* the background gradient reads screen-family vars from this widget's subtree,
so it lives in the local defs, not the shared def sink */
const bgId = uid('dmxBg');
const defs = svgEl(s, 'defs', {});
const bg = svgEl(defs, 'linearGradient', { id: bgId, x1: 0, y1: 0, x2: 0, y2: 1 });
svgEl(bg, 'stop', { offset: '0', 'stop-color': 'var(--scr-bg1,#140d06)' });
svgEl(bg, 'stop', { offset: '1', 'stop-color': 'var(--scr-bg2,#0a0705)' });
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 72, rx: 5, fill: `url(#${bgId})`, stroke: '#2c261d' });
const lines = [];
for (let r = 0; r < 3; r++) {
const glow = svgEl(s, 'text', {
x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))', opacity: .6, filter: 'url(#dmxGlow)'
});
const crisp = svgEl(s, 'text', {
x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))'
});
lines.push([glow, crisp]);
}
let pi;
const set = i => {
pi = ((i % pages.length) + pages.length) % pages.length;
const [name, rowsFn] = pages[pi];
rowsFn().forEach((txt, r) => { lines[r][0].textContent = txt; lines[r][1].textContent = txt; });
onChange(pi, 'page ' + name);
};
s.addEventListener('click', () => set(pi + 1));
set(opts.page || 0);
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => { if (pages[pi][2]) set(pi); }, 1000);
return { el: s, get: () => pi, set };
};
/* R11 warning flag window — the striped mechanical flag slides into the window
when the condition trips; click trips and clears it */
GW.warningFlag = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 120, 64);
def('barber', d => {
const pat = svgEl(d, 'pattern', { id: 'barber', width: 10, height: 10, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' });
svgEl(pat, 'rect', { x: 0, y: 0, width: 10, height: 10, fill: '#17130c' });
svgEl(pat, 'rect', { x: 0, y: 0, width: 5, height: 10, fill: 'var(--gold)' });
});
const clipId = uid('flagWin');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 14, y: 20, width: 56, height: 24 });
svgEl(s, 'rect', { x: 11, y: 17, width: 62, height: 30, rx: 3, fill: '#0a0908', stroke: '#2c261d', 'stroke-width': 2 });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const stripes = svgEl(g, 'rect', { x: 14, y: 20, width: 56, height: 24, fill: 'url(#barber)' });
stripes.style.transition = 'transform .18s'; stripes.style.transform = 'translateX(-58px)';
svgEl(s, 'text', {
x: 92, y: 35, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = opts.label || 'VIB';
let on;
const set = v => { on = !!v; stripes.style.transform = on ? 'translateX(0)' : 'translateX(-58px)'; onChange(on, on ? 'FLAG' : 'clear'); };
s.addEventListener('click', () => set(!on));
set(opts.on || false);
return { el: s, get: () => on, set };
};
/* R25 fourteen-segment display — the starburst alphanumeric that spells words;
click cycles the word */
GW.seg14 = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP'];
const MAP = {
A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'], C: ['a', 'd', 'e', 'f'],
D: ['a', 'b', 'c', 'd', 'i', 'l'], E: ['a', 'd', 'e', 'f', 'g1', 'g2'], H: ['b', 'c', 'e', 'f', 'g1', 'g2'],
L: ['d', 'e', 'f'], M: ['b', 'c', 'e', 'f', 'h', 'j'], O: ['a', 'b', 'c', 'd', 'e', 'f'],
P: ['a', 'b', 'e', 'f', 'g1', 'g2'], T: ['a', 'i', 'l'], Z: ['a', 'd', 'j', 'k']
};
const s = stageSvg(host, 'rsvg press', 130, 54);
svgEl(s, 'rect', { x: 1, y: 1, width: 128, height: 52, rx: 5, fill: '#0a0f08', stroke: '#1c2a16', 'stroke-width': 2 });
/* segment shapes in a 22x36 local box */
const TH = 2.6, W = 22, H = 36, mid = H / 2;
const segs = {
a: [[2, 0], [W - 2, 0], [W - 4, TH], [4, TH]],
d: [[2, H], [W - 2, H], [W - 4, H - TH], [4, H - TH]],
f: [[0, 2], [TH, 4], [TH, mid - 2], [0, mid - 1]],
e: [[0, H - 2], [TH, H - 4], [TH, mid + 2], [0, mid + 1]],
b: [[W, 2], [W - TH, 4], [W - TH, mid - 2], [W, mid - 1]],
c: [[W, H - 2], [W - TH, H - 4], [W - TH, mid + 2], [W, mid + 1]],
g1: [[2, mid], [4, mid - TH / 2], [W / 2 - 1, mid - TH / 2], [W / 2 - 1, mid + TH / 2], [4, mid + TH / 2]],
g2: [[W - 2, mid], [W - 4, mid - TH / 2], [W / 2 + 1, mid - TH / 2], [W / 2 + 1, mid + TH / 2], [W - 4, mid + TH / 2]],
i: [[W / 2 - TH / 2, 3], [W / 2 + TH / 2, 3], [W / 2 + TH / 2, mid - 2], [W / 2 - TH / 2, mid - 2]],
l: [[W / 2 - TH / 2, H - 3], [W / 2 + TH / 2, H - 3], [W / 2 + TH / 2, mid + 2], [W / 2 - TH / 2, mid + 2]],
h: [[3, 3], [5.5, 3], [W / 2 - 2, mid - 3], [W / 2 - 4.5, mid - 3]],
j: [[W - 3, 3], [W - 5.5, 3], [W / 2 + 2, mid - 3], [W / 2 + 4.5, mid - 3]],
k: [[3, H - 3], [5.5, H - 3], [W / 2 - 2, mid + 3], [W / 2 - 4.5, mid + 3]],
m: [[W - 3, H - 3], [W - 5.5, H - 3], [W / 2 + 2, mid + 3], [W / 2 + 4.5, mid + 3]]
};
const cells = [];
for (let d = 0; d < 4; d++) {
const x0 = 13 + d * 28, y0 = 9, cell = {};
for (const k in segs) {
cell[k] = svgEl(s, 'polygon', { points: segs[k].map(p => `${x0 + p[0]},${y0 + p[1]}`).join(' '), fill: 'var(--sevoff)' });
}
cells.push(cell);
}
let wi;
const set = i => {
wi = ((i % words.length) + words.length) % words.length;
const word = words[wi].padEnd(4, ' ');
cells.forEach((cell, d) => {
const lit = MAP[word[d]] || [];
for (const k in cell) {
const on = lit.includes(k);
cell[k].setAttribute('fill', on ? 'var(--sevgrn)' : 'var(--sevoff)');
cell[k].setAttribute('style', on ? 'filter:drop-shadow(0 0 2.5px rgba(87,211,87,.6))' : '');
}
});
onChange(wi, words[wi]);
};
s.addEventListener('click', () => set(wi + 1));
set(opts.index || 0);
return { el: s, get: () => wi, set };
};
/* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag
places the peak in both axes at once */
GW.responseGraph = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 190, 110);
const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 };
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 108, rx: 5, fill: '#0d1410', stroke: '#22301f', 'stroke-width': 2 });
const FREQS = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000];
const xOf = f => RG.x0 + (Math.log10(f) - RG.fLo) / (RG.fHi - RG.fLo) * (RG.x1 - RG.x0);
const yOf = db => RG.y0 + (RG.db - db) / (2 * RG.db) * (RG.y1 - RG.y0);
FREQS.forEach(f => {
const x = xOf(f);
svgEl(s, 'line', { x1: x, y1: RG.y0, x2: x, y2: RG.y1, stroke: '#22301f', 'stroke-width': .6 });
svgEl(s, 'text', {
x, y: RG.y1 + 9, 'text-anchor': 'middle', 'font-size': 4.8, 'font-family': 'var(--mono)',
fill: 'var(--steel)'
}).textContent = f < 1000 ? f : (f / 1000) + 'k';
});
for (const db of [12, 6, 0, -6, -12]) {
const y = yOf(db);
svgEl(s, 'line', { x1: RG.x0, y1: y, x2: RG.x1, y2: y, stroke: '#22301f', 'stroke-width': db === 0 ? 1 : .6 });
svgEl(s, 'text', {
x: RG.x0 - 4, y: y + 2, 'text-anchor': 'end', 'font-size': 4.8, 'font-family': 'var(--mono)',
fill: 'var(--steel)'
}).textContent = (db > 0 ? '+' : '') + db;
}
const glowLine = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' });
const curve = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold)', 'stroke-width': 1.6 });
const handle = svgEl(s, 'circle', { r: 4, fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': 1 });
handle.style.cursor = 'move';
let fc, gain;
const set = (f, g) => {
fc = Math.max(32, Math.min(16000, f)); gain = Math.max(-12, Math.min(12, g));
const sigma = 0.28; let pts = '';
for (let i = 0; i <= 76; i++) {
const lf = RG.fLo + i / 76 * (RG.fHi - RG.fLo);
const db = gain * Math.exp(-Math.pow(lf - Math.log10(fc), 2) / (2 * sigma * sigma));
pts += `${xOf(Math.pow(10, lf)).toFixed(1)},${yOf(db).toFixed(1)} `;
}
curve.setAttribute('points', pts.trim()); glowLine.setAttribute('points', pts.trim());
handle.setAttribute('cx', xOf(fc)); handle.setAttribute('cy', yOf(gain));
const fLbl = fc < 1000 ? Math.round(fc) + ' Hz' : (fc / 1000).toFixed(1) + ' kHz';
onChange([fc, gain], `${fLbl} · ${gain >= 0 ? '+' : ''}${gain.toFixed(1)} dB`);
};
/* 2D drag: the plot places the peak in both axes at once */
s.style.touchAction = 'none';
s.addEventListener('pointerdown', e => {
s.setPointerCapture(e.pointerId);
const move = ev => {
const r = s.getBoundingClientRect(), sx = 190 / r.width, sy = 110 / r.height;
const px = (ev.clientX - r.left) * sx, py = (ev.clientY - r.top) * sy;
const lf = RG.fLo + Math.max(0, Math.min(1, (px - RG.x0) / (RG.x1 - RG.x0))) * (RG.fHi - RG.fLo);
const g = RG.db - Math.max(0, Math.min(1, (py - RG.y0) / (RG.y1 - RG.y0))) * 2 * RG.db;
set(Math.pow(10, lf), g);
};
move(e);
const up = () => { s.removeEventListener('pointermove', move); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); };
s.addEventListener('pointermove', move); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up);
e.preventDefault();
});
set(opts.fc !== undefined ? opts.fc : 1200, opts.gain !== undefined ? opts.gain : 6.5);
return { el: s, get: () => [fc, gain], set };
};
/* R30 telegraph indicator — the pointer names the active state on labeled
sectors, engine-telegraph style; click steps the state */
GW.telegraphIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const states = opts.states || ['OK', 'TEST', 'BUSY', 'STOP', 'ATTN', 'FAULT'];
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55, N = states.length;
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#171412', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 43, fill: 'var(--cream)', stroke: '#b7b29a', 'stroke-width': 1 });
states.forEach((st, i) => {
const a = i * 360 / N - 90 + 180 / N;
const [lx1, ly1] = polar(cx, cy, 9, i * 360 / N), [lx2, ly2] = polar(cx, cy, 43, i * 360 / N);
svgEl(s, 'line', { x1: lx1, y1: ly1, x2: lx2, y2: ly2, stroke: '#2b2418', 'stroke-width': 1 });
const g = svgEl(s, 'g', { transform: `rotate(${a + 90},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 7, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#2b2418'
}).textContent = st;
});
const ptr = svgEl(s, 'g', {}); ptr.style.transition = 'transform .18s'; ptr.style.transformOrigin = `${cx}px ${cy}px`;
svgEl(ptr, 'polygon', { points: `${cx - 2.5},${cy} ${cx},${cy - 38} ${cx + 2.5},${cy}`, fill: 'var(--fail)', stroke: '#7a2a1a', 'stroke-width': .5 });
svgEl(ptr, 'rect', { x: cx + 1, y: cy - 16, width: 10, height: 7, rx: 1, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': .6 });
svgEl(s, 'circle', { cx, cy, r: 8, fill: '#171412', stroke: '#000' });
let si;
const set = i => {
si = ((i % N) + N) % N;
ptr.style.transform = `rotate(${si * 360 / N + 180 / N}deg)`;
onChange(si, states[si]);
};
s.addEventListener('click', () => set(si + 1));
set(opts.index || 0);
return { el: s, get: () => si, set };
};
/* R31 radar sweep — the beam circles the bearing ring on the builder's own
clock (reduced-motion gated), contacts bloom as it passes; click marks the bearing */
GW.radarSweep = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const contacts = opts.contacts || [[60, .55], [205, .7], [318, .4]];
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
const clipId = uid('radarClip');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 46 });
svgEl(s, 'circle', { cx, cy, r: 62, fill: '#1a1714', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 52, fill: 'var(--scr-bg1,#120b05)', stroke: 'var(--scr-brd,#3a2a12)', 'stroke-width': 1 });
for (let d = 0; d < 360; d += 30) {
const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 47, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': 1, opacity: .8 });
const g = svgEl(s, 'g', { transform: `rotate(${d},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 47.5, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: 'var(--scr-ink,var(--gold))', opacity: .9
}).textContent = String(d).padStart(3, '0');
}
for (let d = 0; d < 360; d += 10) {
if (d % 30 === 0) continue;
const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 49, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': .6, opacity: .5 });
}
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
svgEl(g, 'circle', { cx, cy, r: 46, fill: 'var(--scr-bg2,#0d0803)' });
for (const r of [15, 30, 45])
svgEl(g, 'circle', { cx, cy, r, fill: 'none', stroke: 'var(--scr-ink,var(--gold))', 'stroke-opacity': .18, 'stroke-width': .7 });
const sweep = svgEl(g, 'g', {});
for (let t = 0; t < 5; t++) {
const a0 = -14 + t * 2.8, a1 = a0 + 2.8;
const [x1, y1] = polar(cx, cy, 46, a0), [x2, y2] = polar(cx, cy, 46, a1);
svgEl(sweep, 'path', {
d: `M ${cx} ${cy} L ${x1} ${y1} A 46 46 0 0 1 ${x2} ${y2} Z`,
fill: 'var(--scr-ink,var(--gold))', 'fill-opacity': (0.04 + t * 0.045).toFixed(3)
});
}
svgEl(sweep, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: 'var(--scr-hi,var(--gold-hi))', 'stroke-width': 1.4,
transform: `rotate(0,${cx},${cy})`, opacity: .95
});
const blips = contacts.map(([bd, rf]) => {
const [x, y] = polar(cx, cy, 46 * rf, bd);
return svgEl(g, 'circle', { cx: x, cy: y, r: 2.2, fill: 'var(--scr-hi,var(--gold-hi))', opacity: 0 });
});
svgEl(s, 'circle', { cx, cy, r: 3, fill: 'var(--scr-ink,var(--gold))', opacity: .8 });
const mark = svgEl(s, 'text', {
x: cx, y: 124, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
});
mark.textContent = 'BEARING —';
let a = opts.bearing !== undefined ? opts.bearing : 210;
const tick = () => {
a = (a + 2.6) % 360;
sweep.setAttribute('transform', `rotate(${a},${cx},${cy})`);
contacts.forEach(([bd], i) => {
const d = (a - bd + 360) % 360; /* degrees since beam passed */
blips[i].setAttribute('opacity', d < 200 ? Math.max(0, .95 - d / 200) : 0);
});
mark.textContent = mark.textContent.startsWith('MARK') ? mark.textContent :
'BEARING ' + String(Math.round(a) % 360).padStart(3, '0');
};
s.addEventListener('click', () => {
const b = String(Math.round(a) % 360).padStart(3, '0');
mark.textContent = 'MARK ' + b;
onChange(a, 'mark ' + b);
});
tick(); onChange(a, 'sweeping');
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 50);
return { el: s, get: () => a, tick };
};
/* R35 day-date disc calendar — coaxial printed discs rotate so today reads
under the fixed hand; starts on today; click rolls midnight forward */
GW.dayDateCal = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135;
gradDef('ddFace', 'radialGradient', { cx: '46%', cy: '40%', r: '75%' }, [['0', '#efe9da'], ['1', '#cfc8b6']]);
/* movement plate + white calendar disc */
svgEl(s, 'circle', { cx, cy, r: 62, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
for (let k = 0; k < 5; k++) {
const [x, y] = polar(cx, cy, 58, 20 + k * 72);
svgEl(s, 'circle', { cx: x, cy: y, r: 1.8, fill: '#060505' });
}
svgEl(s, 'circle', { cx, cy, r: 54, fill: 'url(#ddFace)', stroke: '#8f897b', 'stroke-width': .8 });
/* date ring: 31 radial numerals on its own group */
const dateG = svgEl(s, 'g', {});
for (let d = 1; d <= 31; d++) {
const a = (d - 1) * (360 / 31);
const g = svgEl(dateG, 'g', { transform: `rotate(${a},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 46, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#14110e'
}).textContent = String(d);
}
/* inner weekday disc: 7 names twice around, separated by a hairline ring */
svgEl(s, 'circle', { cx, cy, r: 38, fill: 'none', stroke: '#8f897b', 'stroke-width': .7 });
const dayG = svgEl(s, 'g', {});
for (let i = 0; i < 14; i++) {
const a = i * (360 / 14);
const g = svgEl(dayG, 'g', { transform: `rotate(${a},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 6.8, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#14110e'
}).textContent = DAYS[i % 7];
}
/* fixed yellow hand from hub toward the read index, and the hub */
const [hx, hy] = polar(cx, cy, 44, HAND);
svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: '#e8cf4a', 'stroke-width': 3, 'stroke-linecap': 'round' });
svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
svgEl(s, 'circle', { cx, cy, r: 6, fill: '#b8b2a4', stroke: '#5c574c', 'stroke-width': 1.2 });
svgEl(s, 'circle', { cx, cy, r: 2.4, fill: '#7a7466' });
s.style.cursor = 'pointer';
let date, day;
const set = (d, w) => {
date = d; day = w;
dateG.setAttribute('transform', `rotate(${(HAND - (d - 1) * (360 / 31)).toFixed(2)},${cx},${cy})`);
dayG.setAttribute('transform', `rotate(${(HAND - w * (360 / 14)).toFixed(2)},${cx},${cy})`);
onChange([d, w], DAYS[w] + ' ' + d);
};
s.addEventListener('click', () => set(date % 31 + 1, (day + 1) % 7));
const now = new Date();
set(opts.date !== undefined ? opts.date : now.getDate(),
opts.day !== undefined ? opts.day : now.getDay());
return { el: s, get: () => [date, day], set };
};
/* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots
to paint; starts on the reference K */
GW.dotMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5;
const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100'];
const s = stageSvg(host, 'rsvg press', 108, 108);
svgEl(s, 'rect', { x: 2, y: 2, width: 104, height: 104, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 6, y: 6, width: 96, height: 96, rx: 5, fill: '#0d0505', stroke: '#241010', 'stroke-width': 1 });
let lit = 0;
const paint = (d, on) => {
d.on = on;
d.el.setAttribute('fill', on ? '#ff4a30' : '#2a1210');
d.el.setAttribute('filter', on ? 'drop-shadow(0 0 3.5px rgba(255,74,48,.85))' : 'none');
};
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2, cy: Y0 + r * STEP + STEP / 2, r: 4.1, fill: '#2a1210' });
el.style.cursor = 'pointer';
const d = { el, on: false };
paint(d, glyph[r][c] === '1'); if (d.on) lit++;
el.addEventListener('click', () => {
paint(d, !d.on); lit += d.on ? 1 : -1;
onChange(lit, lit + ' / 64 lit');
});
}
onChange(lit, lit + ' / 64 lit');
return { el: s, get: () => lit };
};
/* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the
color swap at the midpoint; click a disc to flip it */
GW.flipDisc = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13;
const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */
const s = stageSvg(host, 'rsvg press', 120, 92);
svgEl(s, 'rect', { x: 2, y: 2, width: 116, height: 88, rx: 7, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
let lit = 0;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2 - 2, cy: Y0 + r * STEP + STEP / 2 - 2, r: 5.6, stroke: '#060505', 'stroke-width': .8 });
el.style.transformBox = 'fill-box'; el.style.transformOrigin = 'center'; el.style.transition = 'transform .11s ease-in';
el.style.cursor = 'pointer';
const d = { el, on: glyph[r][c] === '1' };
el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); if (d.on) lit++;
el.addEventListener('click', () => {
d.on = !d.on; lit += d.on ? 1 : -1;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); }
else {
el.style.transform = 'scaleX(0)';
setTimeout(() => { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); el.style.transform = 'scaleX(1)'; }, 115);
}
onChange(lit, lit + ' discs showing');
});
}
onChange(lit, lit + ' discs showing');
return { el: s, get: () => lit };
};
/* R46 dekatron counting ring — each click pulses the neon glow one cathode
around; the wrap flashes the carry dot */
GW.dekatron = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60;
svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 46, fill: '#0d0a08', stroke: '#2a221c', 'stroke-width': 1 });
svgEl(s, 'ellipse', { cx: cx - 14, cy: cy - 18, rx: 20, ry: 11, fill: 'rgba(255,255,255,.05)', transform: `rotate(-24,${cx - 14},${cy - 18})` });
const dots = [];
for (let i = 0; i < 10; i++) {
const [x, y] = polar(cx, cy, 34, i * 36);
svgEl(s, 'text', {
x: polar(cx, cy, 43, i * 36)[0], y: polar(cx, cy, 43, i * 36)[1] + 2, 'text-anchor': 'middle', 'font-size': 5.2,
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = String(i);
dots.push(svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: '#3a2a20' }));
}
const carry = svgEl(s, 'circle', { cx, cy: cy - 6, r: 3, fill: '#2a1c14' });
svgEl(s, 'text', {
x: cx, y: cy + 8, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.12em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'CARRY';
let pos = opts.count || 0, carries = 0;
const draw = () => {
dots.forEach((d, i) => {
const on = i === pos;
d.setAttribute('fill', on ? '#ff9a4c' : '#3a2a20');
d.setAttribute('filter', on ? 'drop-shadow(0 0 5px rgba(255,154,76,.9))' : 'none');
});
onChange(pos, 'COUNT ' + pos + (carries ? ' · CARRY x' + carries : ''));
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => {
pos = (pos + 1) % 10;
if (pos === 0) {
carries++; carry.setAttribute('fill', '#ff9a4c');
carry.setAttribute('filter', 'drop-shadow(0 0 4px rgba(255,154,76,.9))');
setTimeout(() => { carry.setAttribute('fill', '#2a1c14'); carry.removeAttribute('filter'); }, 220);
}
draw();
});
draw();
return { el: s, get: () => pos };
};
/* R47 landing gear indicator — three greens or nothing; the lever cycles
down → transit (amber pulse) → up */
GW.gearIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 140, 110);
svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
const POS = [['NOSE', 48, 28], ['L', 24, 74], ['R', 72, 74]];
const lamps = {};
POS.forEach(([n, x, y]) => {
svgEl(s, 'circle', { cx: x, cy: y, r: 9, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1.5 });
lamps[n] = svgEl(s, 'circle', { cx: x, cy: y, r: 6.2, fill: '#16240f' });
svgEl(s, 'text', {
x, y: y + 18, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = n;
});
/* gear lever: slot + wheel-shaped knob */
svgEl(s, 'rect', { x: 106, y: 22, width: 8, height: 66, rx: 4, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 });
const lever = svgEl(s, 'g', {});
svgEl(lever, 'rect', { x: 107.5, y: 36, width: 5, height: 26, fill: '#8f8a7e' });
svgEl(lever, 'circle', { cx: 110, cy: 32, r: 8, fill: '#26221c', stroke: '#4a463e', 'stroke-width': 2.4 });
svgEl(lever, 'circle', { cx: 110, cy: 32, r: 3, fill: '#4a463e' });
svgEl(s, 'text', {
x: 110, y: 100, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'GEAR';
let state = opts.state || 'down', busy = false;
const paint = () => {
const c = state === 'down' ? 'var(--pass)' : state === 'transit' ? 'var(--amber-warn)' : '#16240f';
for (const n of Object.keys(lamps)) {
lamps[n].setAttribute('fill', c);
lamps[n].setAttribute('filter', state === 'down' ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' :
state === 'transit' ? 'drop-shadow(0 0 4px rgba(var(--glow-lo),.7))' : 'none');
lamps[n].style.animation = state === 'transit' ? 'pulse var(--pulse-rate) ease-in-out infinite' : 'none';
}
lever.setAttribute('transform', state === 'up' ? 'translate(0,-8)' : state === 'transit' ? 'translate(0,-4)' : '');
onChange(state, state === 'down' ? 'GEAR DOWN · 3 GREEN' : state === 'up' ? 'GEAR UP' : 'IN TRANSIT');
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => {
if (busy) return;
const target = state === 'down' ? 'up' : 'down';
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { state = target; paint(); return; }
busy = true; state = 'transit'; paint();
setTimeout(() => { state = target; busy = false; paint(); }, 1500);
});
paint();
return { el: s, get: () => state };
};
/* R52 blinkenlights front panel — address/data lamps ripple with a live
pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the
switch register; flip the SR bits and the pattern changes */
GW.blinkenlights = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 170, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 8, y: 8, width: 154, height: 62, rx: 5, fill: '#14090e' });
const mkRow = (y, lbl) => {
const row = [];
svgEl(s, 'text', {
x: 12, y: y + 2.6, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)',
fill: '#b08a96'
}).textContent = lbl;
for (let i = 0; i < 12; i++) row.push(svgEl(s, 'circle', { cx: 46 + i * 10, cy: y, r: 3, fill: '#2a1210' }));
return row;
};
const addr = mkRow(24, 'ADDR'), data = mkRow(46, 'DATA');
const paint = (row, val) => row.forEach((d, i) => {
const on = (val >> (11 - i)) & 1;
d.setAttribute('fill', on ? '#ffb43a' : '#2a1210');
d.setAttribute('filter', on ? 'drop-shadow(0 0 3px rgba(255,180,58,.8))' : 'none');
});
/* switch register: 8 mini toggles */
let sr = opts.sr !== undefined ? opts.sr : 0b10100101;
const toggles = [];
for (let i = 0; i < 8; i++) {
const x = 46 + i * 14;
svgEl(s, 'rect', { x: x - 3, y: 76, width: 6, height: 16, rx: 3, fill: '#0d0b09', stroke: '#4a2432', 'stroke-width': 1 });
const t = svgEl(s, 'circle', { cx: x, cy: 80, r: 4, fill: '#8f8a7e', stroke: '#3c382f', 'stroke-width': 1 });
t.style.cursor = 'pointer';
const hit = svgEl(s, 'rect', { x: x - 7, y: 72, width: 14, height: 24, fill: 'transparent' });
hit.style.cursor = 'pointer';
hit.addEventListener('click', () => { sr ^= (1 << (7 - i)); draw(); });
toggles.push(t);
}
svgEl(s, 'text', {
x: 12, y: 83, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)',
fill: '#b08a96'
}).textContent = 'SR';
let pc = 0o1234;
const draw = () => {
toggles.forEach((t, i) => t.setAttribute('cy', ((sr >> (7 - i)) & 1) ? 76 : 84));
onChange(sr, 'SR ' + sr.toString(8).padStart(3, '0') + ' (octal)');
};
const tick = () => { pc = ((pc * 5 + sr + 1) & 0xFFF); paint(addr, pc); paint(data, (pc ^ (pc << 3) ^ sr) & 0xFFF); };
draw(); tick();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 250);
return { el: s, get: () => sr, tick };
};
/* ---- widget CSS: injected once, grows as builders move in ---- */
/* widget-internal CSS (moved from the gallery