/* 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 };
};
/* ---- widget CSS: injected once, grows as builders move in ---- */
const GW_CSS = ``;
function ensureCss() {
if (document.getElementById('gw-css') || !GW_CSS) return;
const st = document.createElement('style'); st.id = 'gw-css'; st.textContent = GW_CSS;
document.head.appendChild(st);
}
if (document.head) ensureCss();
else document.addEventListener('DOMContentLoaded', ensureCss);
Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
window.GW = GW;
})();