/* 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 = ``;
for (const k of 'abcdefg') {
const on = lit.includes(k);
const col = on ? (cls === 'red' ? 'var(--sevred)' : 'var(--sevgrn)') : 'var(--sevoff)';
s += ` `;
}
return 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-*).
opts.onStyle / offStyle / offText / thumb pick a named style per axis from
GW.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's
setStyle(axis, name) restyles a live instance (the gallery chips use it). */
GW.slideToggle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw);
const setStyle = (axis, name) => {
const o = (GW.slideToggle.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v);
};
/* opts.preset names an intent; per-axis opts still win over it, so a caller can
take 'armed' and swap only the thumb. setPreset re-applies all four axes at
once and returns the axis map so a host UI can resync its own controls. */
const preset = GW.slideToggle.PRESETS[opts.preset] || GW.slideToggle.PRESETS.panel;
/* AXIS_ORDER is load-bearing, not cosmetic: onText overrides the ink that the
`on` style sets, so applying the axes in any other order silently drops it. */
const setPreset = name => {
const p = GW.slideToggle.PRESETS[name];
if (!p) return null;
for (const axis of GW.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]);
return p;
};
const pick = { on: opts.onStyle, onText: opts.onText, off: opts.offStyle,
offText: opts.offText, thumb: opts.thumb };
for (const axis of GW.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]);
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, setStyle, setPreset };
};
/* named presets — a combination that carries intent, which is what a consumer
actually reaches for; the STYLES axes below are how one is built. The `on`
tone does the semantic work and the thumb material backs it: a touchscreen
pill wears plastic, a run or armed switch wears metal. */
GW.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb'];
GW.slideToggle.PRESETS = {
panel: { on: 'amber', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' },
run: { on: 'green', onText: 'panel', off: 'dark', offText: 'dim', thumb: 'chrome' },
armed: { on: 'red', onText: 'cream', off: 'dark', offText: 'white', thumb: 'chrome' },
caution: { on: 'warn', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' },
/* dark — neither state lights the pill; the legend alone carries the state,
green for on and Control red ("OFF marks") for off. The chrome thumb stays
the position affordance, since with both pills dark it is the only one. */
dark: { on: 'dark', onText: 'green', off: 'dark', offText: 'red', thumb: 'chrome' },
};
/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */
GW.slideToggle.STYLES = {
on: {
amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } },
green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } },
/* red/warn make the ENGAGED state the notable one — mute, record, airplane
mode. Before them the only red was on the `off` axis, which paints the
disengaged state: the opposite claim. Palette semantics: --fail is
"muted keys", --amber-warn is "warn cells", distinct from panel amber. */
red: { dot: 'var(--fail)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-on-brd': 'var(--fail)', '--sw-on-ink': 'var(--cream)' } },
warn: { dot: 'var(--amber-warn)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#f7d193,var(--amber-warn))', '--sw-on-brd': 'var(--amber-warn)', '--sw-on-ink': 'var(--panel)' } },
dark: { dot: '#242019', vars: { '--sw-on-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-on-brd': 'var(--slate)', '--sw-on-ink': 'var(--cream)' } },
},
off: {
dark: { dot: '#242019', vars: { '--sw-off-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-off-brd': 'var(--slate)' } },
red: { dot: 'var(--fail)', vars: { '--sw-off-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-off-brd': 'var(--fail)' } },
},
/* onText overrides the ink the `on` style sets. The two are not independent —
ink must contrast with its own background, which is why each `on` atom still
carries a sensible default; naming onText is how a caller says "keep the
pill, change the legend". That is what makes the `dark` preset possible:
both states unlit, the legend colour carrying the state on its own. */
onText: {
panel: { dot: 'var(--panel)', vars: { '--sw-on-ink': 'var(--panel)' } },
cream: { dot: 'var(--cream)', vars: { '--sw-on-ink': 'var(--cream)' } },
green: { dot: 'var(--sevgrn)', vars: { '--sw-on-ink': 'var(--sevgrn)' } },
},
offText: {
white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } },
/* Control red — the palette's "OFF marks" ink, so a red OFF legend is the
kit's existing grammar rather than a new claim. */
red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } },
/* steel is the kit's engraved-caption ink: lets an off toggle recede where a
panel carries many of them and eight cream OFFs would all shout equally. */
dim: { dot: 'var(--steel)', vars: { '--sw-off-ink': 'var(--steel)' } },
black: { dot: '#14110e', vars: { '--sw-off-ink': '#14110e' } },
},
thumb: {
light: { dot: '#d8d2c4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a)' } },
dark: { dot: '#2c2824', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#4a453e,#16130f)' } },
chrome: { dot: '#9aa2b4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#dfe6f2,#6d7484)' } },
brass: { dot: '#b8944a', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#e2c47a,#8a6a24)' } },
},
};
/* 02 console keys — mutually exclusive push buttons; {label, tone} per key.
tone picks the engaged look: undefined = gold (the default lit key), 'green' =
run green, 'red' = terracotta. Reading order runs safe -> live -> muted, and
LIVE takes green because that is what --pass means everywhere else in the kit
(the palette names it "run lamps, gear greens, monitor bars, LIVE lamps"). */
GW.consoleKeys = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const keys = opts.keys || GW.consoleKeys.DEFAULT_KEYS;
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 = Math.max(0, Math.min(keys.length - 1, i));
btns.forEach((b, j) => {
b.classList.remove('on', 'green', 'red');
if (j === idx) b.classList.add(keys[idx].tone || 'on');
});
onChange(idx, keys[idx].label);
};
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
/* The default set engages LIVE, not the first key: it is the meaningful
resting state for a console and it shows the green. A caller supplying its
own keys gets the first one unless it says otherwise. */
set(opts.active !== undefined ? opts.active
: (keys === GW.consoleKeys.DEFAULT_KEYS ? 1 : 0));
return { el: wrap, get: () => idx, set };
};
GW.consoleKeys.DEFAULT_KEYS = [
{ label: 'SCAN' }, { label: 'LIVE', tone: 'green' }, { label: 'MUTED', tone: 'red' },
];
/* R57 ABC entry keypad — alphanumeric text on a faceplate.
Modelled on the industrial membrane keypad (reference: fleet/kiosk gear,
2026-07-16-abc-keypad-membrane-color.png): a 0-9 block beside A-Z laid out
ALPHABETICALLY, not QWERTY. That is the convention wherever the operator
can't be assumed to touch-type, and it is what makes this a faceplate rather
than a keyboard — R16's alphanumeric sibling.
Departures from the photo, all deliberate and all noted at their site:
- Its letters are blue. The kit has no blue control colour (blue appears
only as a screen phosphor and a jewel lens), so the letters take the
standard pale keycap and the digits a darker one — which keeps the
photo's two-tone digit/letter grouping without importing a foreign hue.
CLEAR/NO and ENTER/YES keep the reference's red and green, which are
already --fail and --pass.
- Letters left, digits right (the stainless reference's arrangement).
- DEL added, CANCEL dropped. See the layout block below for both. */
GW.abcKeypad = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MAX = opts.max || 16;
const s = stageSvg(host, 'rsvg', 232, 226);
gradDef('abcKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]);
gradDef('abcNum', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#6f6a61'], ['1', '#464239']]);
gradDef('abcRed', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d98a6f'], ['1', 'var(--fail)']]);
gradDef('abcGrn', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#9cbf5e'], ['1', 'var(--pass)']]);
gradDef('abcAmb', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--gold)']]);
/* faceplate + the recessed window the legend prints into */
svgEl(s, 'rect', { x: 2, y: 2, width: 228, height: 222, rx: 7, fill: '#17140f', stroke: '#0a0908', 'stroke-width': 2 });
/* The window is a screen like the scope's or the marquee's, so it takes the
screen families through the --scr-* vars, shipped colours as the fallbacks:
nothing moves until a chip is clicked. Both the glass and the ink recolour —
a screen that changes its text and keeps its backlight isn't a screen. */
svgEl(s, 'rect', { class: 'kp-win', x: 12, y: 10, width: 208, height: 30, rx: 4,
fill: 'var(--scr-bg1, #0a0806)', stroke: 'var(--scr-brd, #2c261d)', 'stroke-width': 2 });
const disp = svgEl(s, 'text', { x: 20, y: 31, 'font-size': 14, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi, var(--gold-hi))' });
let buf = '';
/* The window shows the tail: a passphrase outruns the plate long before MAX.
Spaces are drawn as ␣ rather than as themselves. SVG collapses whitespace,
so a real space is invisible — and past the truncation boundary there are no
pad dots left for it to displace, which makes SPACE a keypress with no
feedback at all: press, see nothing, press again, and now carry two spaces
you can't see in a passphrase you can't read back. Only the DISPLAY is
substituted; buf keeps the real character. */
const show = t => t.replace(/ /g, '␣');
const render = () => {
disp.textContent = buf.length > 13 ? '‹' + show(buf.slice(-12)) : show(buf).padEnd(13, '·');
};
/* press is the allowlist, not the keydown handler above it. Filtering only in
the handler guards the web and leaves the Emacs port wide open: that port
installs KEYS into a keymap and calls press directly, with no handler in the
stack, so an unguarded press would append a stray 'F1' as literal text.
Gated on ACTIONS rather than on KEYS because they are different sets — CLR
is a real key on the plate that no keystroke maps to. */
const press = k => {
if (!GW.abcKeypad.ACTIONS.has(k)) return;
if (k === 'ENT') { onChange(buf, buf ? 'ENTER · ' + buf : 'empty'); return; }
if (k === 'DEL') { buf = buf.slice(0, -1); render(); onChange(buf, buf || 'empty'); return; }
if (k === 'CLR') { buf = ''; render(); onChange(buf, 'cleared'); return; }
if (buf.length >= MAX) { onChange(buf, 'full · ' + buf); return; }
buf += (k === 'SPC' ? ' ' : k); render(); onChange(buf, buf);
};
/* [label, col, row, width-in-cols, tone].
Letters left, numbers right — the stainless reference's arrangement rather
than the membrane's. The membrane photo puts its digit block on the left,
which lands A-L in columns 3-5 while M-X starts at column 0: the alphabet
stops column-aligning with itself halfway down and the eye has to jump.
This way A-Z reads straight down the plate in one continuous block and the
digits keep their own quadrant. */
const L = [];
const DIG = ['1','2','3','4','5','6','7','8','9'];
[...'ABCDEFGHIJKL'].forEach((c, i) => L.push([c, i % 3, Math.floor(i / 3), 1, 'key']));
[...'MNOPQRSTUVWX'].forEach((c, i) => L.push([c, i % 6, 4 + Math.floor(i / 6), 1, 'key']));
/* DEL earns its own key: without a backspace the only exit from a typo is CLR,
which on a long passphrase means retyping the lot.
The three function keys read as a ladder, so the colour says what a key
costs before you read its legend: amber DEL takes one character back, red
CLR throws the whole entry away, green ENT commits it. Plain caps do
nothing you'd regret. (The stainless reference's backspace is plain steel,
but that plate has no other coloured key to be graded against.)
DEL sits in the block beside the digits and CLR is exiled to the far corner,
which is the opposite of where they started. Two reasons, pulling the same
way: DEL is the one you reach for constantly and CLR is the one you reach
for almost never, and a mis-hit on DEL costs one character where a mis-hit
on CLR costs the whole entry. Frequency and blast radius both say the safe
key gets the good spot.
The reference plate also carries CANCEL, and this one deliberately doesn't.
On that device the plate IS the whole terminal, so CANCEL has a transaction
to abandon; here the keypad is one control inside a panel that owns its own
dismiss, which left CANCEL doing exactly what CLR does. A key that means
nothing the panel doesn't already mean is a key worth removing. */
L.push(['Y', 0, 6, 1, 'key'], ['Z', 1, 6, 1, 'key'], ['SPC', 2, 6, 3, 'key'],
['CLR', 5, 6, 1, 'red']);
DIG.forEach((d, i) => L.push([d, 3 + (i % 3), Math.floor(i / 3), 1, 'num']));
L.push(['DEL', 3, 3, 1, 'amb'], ['0', 4, 3, 1, 'num'], ['ENT', 5, 3, 1, 'grn']);
const FILL = { key: 'url(#abcKey)', num: 'url(#abcNum)', red: 'url(#abcRed)', grn: 'url(#abcGrn)', amb: 'url(#abcAmb)' };
const INK = { key: '#2b2721', num: '#efeae0', red: 'var(--cream)', grn: 'var(--cream)', amb: 'var(--panel)' };
/* every function key wears a three-letter legend, so they read as one family
and each fits a single-column cap; only SPACE has the width to spell out */
const FACE = { SPC: 'SPACE' };
/* per-key rotation: a real plate is printed, not typeset — the legends sit a
fraction off true. Same trick R16 uses, keyed off the index so it is stable. */
const jitter = i => [-1.4, .9, -.6, 1.2, 0, -1.1, .7, -.9, 1.3, .4, -.5, 1][i % 12];
L.forEach(([k, c, r, w, tone], i) => {
const x = 14 + c * 35.5, y = 50 + r * 24.5, wd = w * 35.5 - 5.5;
const g = svgEl(s, 'g', {}); g.setAttribute('class', 'kp-key'); g.dataset.k = k;
g.style.cursor = 'pointer'; g.style.transition = 'transform .07s';
svgEl(g, 'rect', { x, y, width: wd, height: 20, rx: 3.5, fill: FILL[tone], stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 });
const t = svgEl(g, 'text', { x: x + wd / 2, y: y + 14.5, 'text-anchor': 'middle',
'font-size': w > 1 ? 8 : 11, 'font-weight': 700, 'font-family': 'var(--mono)', fill: INK[tone],
transform: `rotate(${jitter(i)},${x + wd / 2},${y + 10})` });
t.textContent = FACE[k] || k;
g.addEventListener('click', () => {
g.style.transform = 'translateY(1.5px)';
setTimeout(() => { g.style.transform = ''; }, 80);
press(k);
});
});
/* Keyboard, per the README's keyboard contract. The listener is bound to the
pad's own focusable element, never to the document: a global binding would
type into this card from anywhere on a 110-card page and fight the gallery's
own Escape handler. GW.slideRule is the precedent. */
s.setAttribute('class', 'rsvg kp-pad');
s.setAttribute('tabindex', '0');
s.addEventListener('click', () => s.focus());
s.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return; /* leave shortcuts alone */
const name = e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key);
const k = GW.abcKeypad.KEYS[name];
if (!k) return; /* not on the plate: let it bubble */
/* Spend preventDefault only where there is a default worth killing — Space
scrolls the page, Backspace can navigate back. Tab and Escape are never
ours: Tab is how the page stays navigable, Escape belongs to the audit
stepper, and neither reaches here anyway because they are not in KEYS. */
if (name === 'Space' || name === 'Backspace') e.preventDefault();
press(k);
});
render(); onChange('', 'type a passphrase');
return { el: s, get: () => buf, press };
};
/* The plate's keys, declared as a table so every target reads the same intent.
Deliberately not a function over a DOM event: the Emacs port installs this
into a keymap and never sees a keydown, so a function would force it to
re-derive what the widget accepts and the two bindings would drift apart.
This is also press()'s allowlist — press appends whatever it is handed, so
without the table a stray 'F1' would land in the buffer as text. */
GW.abcKeypad.KEYS = (() => {
const m = {};
for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') m[c] = c;
m.Space = 'SPC'; m.Backspace = 'DEL'; m.Enter = 'ENT';
return m;
})();
/* The plate's whole vocabulary — every argument press accepts, from any caller.
A superset of the KEYS values: CLR is on the plate but no keystroke reaches it
(Escape is the obvious candidate and belongs to the gallery's audit stepper). */
GW.abcKeypad.ACTIONS = new Set([
...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 'SPC', 'DEL', 'ENT', 'CLR',
]);
/* 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. opts.accent picks the lit-segment
color from GW.segmented.STYLES (amber / green / red); defaults match the
stylesheet fallbacks. setStyle(axis, name) restyles a live instance. */
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 setStyle = (axis, name) => {
const o = (GW.segmented.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) seg.style.setProperty(k, v);
};
setStyle('accent', opts.accent || 'amber');
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, setStyle };
};
/* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */
GW.segmented.STYLES = {
accent: {
amber: { dot: 'var(--gold-hi)', vars: { '--seg-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--seg-on-ink': 'var(--panel)' } },
green: { dot: '#8fb944', vars: { '--seg-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--seg-on-ink': 'var(--panel)' } },
red: { dot: 'var(--fail)', vars: { '--seg-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--seg-on-ink': 'var(--panel)' } },
},
};
/* 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. The printed numerals
are the majors; the integer units between them get minor ticks and are
selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step
one unit. set() takes a stop index; get() returns the value. opts.skin picks
a face from GW.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue);
setStyle(axis, name) restyles a live instance. */
GW.slideRule = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const majors = opts.values || [4, 6, 8, 10, 12];
const fmt = opts.fmt || (v => 'pos ' + v);
const X = [12, 51, 90, 129, 168];
/* expand to unit stops: every integer between adjacent numeric majors */
const stops = [];
majors.forEach((v, i) => {
stops.push({ v, x: X[i], major: true });
const b = majors[i + 1];
if (typeof v === 'number' && typeof b === 'number')
for (let u = v + 1; u < b; u++)
stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false });
});
const t = document.createElement('span'); t.className = 'tuner';
t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
const setStyle = (axis, name) => {
const o = (GW.slideRule.STYLES[axis] || {})[name];
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v);
};
setStyle('skin', opts.skin || 'warm');
stops.forEach(s => {
t.insertAdjacentHTML('beforeend', s.major
? `${s.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(stops.length - 1, i));
ndl.style.left = stops[idx].x + 'px';
t.querySelectorAll('.mk').forEach(m => m.classList.toggle('on', +m.textContent === stops[idx].v));
onChange(stops[idx].v, fmt(stops[idx].v));
};
t.addEventListener('click', e => {
const r = t.getBoundingClientRect();
/* rect is visual px but stop coords are layout px — divide the CSS zoom out */
const x = (e.clientX - r.left) * (t.offsetWidth / r.width);
let best = 0, bd = 1e9; stops.forEach((s, j) => { const d = Math.abs(s.x - 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); }
});
const initV = opts.value !== undefined ? opts.value : majors[opts.index !== undefined ? opts.index : 2];
const init = stops.findIndex(s => s.v === initV);
set(init < 0 ? 0 : init);
return { el: t, get: () => stops[idx].v, set, setStyle };
};
/* named faces: dot = swatch color for pickers, vars = --tn-* overrides.
warm restates the stylesheet fallbacks so switching back is exact. */
GW.slideRule.STYLES = {
skin: {
warm: { dot: 'var(--gold-hi)', vars: {
'--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c',
'--tn-glow': 'rgba(var(--glow-lo),.12)', '--tn-tick': 'var(--steel)', '--tn-ink': 'var(--steel)',
'--tn-on': 'var(--gold-hi)', '--tn-onglow': 'rgba(var(--glow-hi),.6)',
'--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.85)' } },
chrome: { dot: '#dfe6f2', vars: {
'--tn-bg': 'linear-gradient(180deg,#e9e9e5,#bcbcb6)', '--tn-brd': '#8e8e88',
'--tn-glow': 'rgba(255,255,255,.28)', '--tn-tick': '#3c3a34', '--tn-ink': '#3c3a34',
'--tn-on': '#14110e', '--tn-onglow': 'none',
'--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.45)' } },
black: { dot: '#1c1a18', vars: {
'--tn-bg': 'linear-gradient(180deg,#17181a,#08090a)', '--tn-brd': '#000',
'--tn-glow': 'rgba(255,255,255,.05)', '--tn-tick': '#c8cac8', '--tn-ink': '#c8cac8',
'--tn-on': '#f2f4f2', '--tn-onglow': 'rgba(242,244,242,.5)',
'--tn-ndl': '#ff3b28', '--tn-ndlglow': 'rgba(255,59,40,.9)' } },
blue: { dot: '#8fb4d8', vars: {
'--tn-bg': 'linear-gradient(180deg,#0c1520,#070c14)', '--tn-brd': '#1c2c40',
'--tn-glow': 'rgba(120,170,230,.16)', '--tn-tick': '#8fb4d8', '--tn-ink': '#8fb4d8',
'--tn-on': '#cfe4ff', '--tn-onglow': 'rgba(150,200,255,.7)',
'--tn-ndl': '#e8f2ff', '--tn-ndlglow': 'rgba(180,220,255,.9)' } },
},
};
/* 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 — a dense fader wall on a real faceplate. Period skins
as constructor opts backed by GW.filterBank.STYLES, three independent axes:
panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE
(chrome T / short ribbed / tall block fader), and capColor — the cap
FINISH (black white-index / color stripes / chrome / cream). opts.style
picks a native trio (silver+chrome+chrome, studio+ribbed+red);
opts.panel / opts.caps / opts.capColor override an axis; setStyle(axis, name)
restyles live (values kept). After the Pioneer SG-9500, the Technics
SH-8065, and the Zaxcom Oasis block faders. */
GW.filterBank = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340];
const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length);
while (vals.length < freqs.length) vals.push(30);
const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
const ST = GW.filterBank.STYLES;
const native = {
silver: { caps: 'chrome', capColor: 'chrome' },
studio: { caps: 'ribbed', capColor: 'red' },
};
const nat = native[opts.style] || {};
const cur = {
panel: ST.panel[opts.panel] ? opts.panel : (ST.panel[opts.style] ? opts.style : 'silver'),
caps: ST.caps[opts.caps] ? opts.caps : (nat.caps || 'block'),
capColor: ST.capColor[opts.capColor] ? opts.capColor : (nat.capColor || 'black'),
};
gradDef('fbPlateSilver', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8d8d4'], ['.5', '#c6c6c1'], ['1', '#adada7']]);
gradDef('fbPlateStudio', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#221f1c'], ['1', '#131110']]);
gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]);
gradDef('fbCapBlack', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2a2724'], ['1', '#171412']]);
gradDef('fbCapCream', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2ecd8'], ['1', '#cfc8ae']]);
const s = stageSvg(host, 'rsvg', 250, 112);
const y0 = 20, y1 = 92, x0 = 36, dx = (214 - x0) / (freqs.length - 1);
const yOf = db => y0 + db / 60 * (y1 - y0);
const caps = [];
const place = i => caps[i].setAttribute('transform', `translate(0,${yOf(vals[i])})`);
const drawCap = (g, kind, x) => {
const c = ST.capColor[cur.capColor];
if (kind === 'chrome') {
svgEl(g, 'rect', { x: x - 6, y: -3.25, width: 12, height: 6.5, rx: .9, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
/* bare metal needs no painted stripe — chrome-on-chrome stays lineless */
if (cur.capColor !== 'chrome')
svgEl(g, 'line', { x1: x - 6, y1: 0, x2: x + 6, y2: 0, stroke: c.index, 'stroke-width': 1 });
svgEl(g, 'line', { x1: x - 6, y1: 2, x2: x + 6, y2: 2, stroke: c.rib, 'stroke-width': .5 });
} else if (kind === 'ribbed') {
svgEl(g, 'rect', { x: x - 5, y: -4, width: 10, height: 8, rx: 1, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
for (const dy of [-2.4, 2.4])
svgEl(g, 'line', { x1: x - 4, y1: dy, x2: x + 4, y2: dy, stroke: c.rib, 'stroke-width': .6 });
svgEl(g, 'line', { x1: x - 5, y1: 0, x2: x + 5, y2: 0, stroke: c.index, 'stroke-width': 1.1 });
} else { /* block — tall ribbed fader after the Zaxcom Oasis; fine rib
groups above and below a mid-cap index line that marks the value */
svgEl(g, 'rect', { x: x - 4.5, y: -8, width: 9, height: 16, rx: 1.1, fill: c.body, stroke: c.edge, 'stroke-width': .6 });
for (const dy of [-6.4, -4.8, -3.2, -1.6, 1.6, 3.2, 4.8, 6.4])
svgEl(g, 'line', { x1: x - 3.7, y1: dy, x2: x + 3.7, y2: dy, stroke: c.rib, 'stroke-width': .6 });
svgEl(g, 'line', { x1: x - 4.5, y1: 0, x2: x + 4.5, y2: 0, stroke: c.index, 'stroke-width': 1.2 });
}
};
const set = (i, db) => {
db = Math.max(0, Math.min(60, db)); vals[i] = db; place(i);
onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`);
};
const render = () => {
const st = ST.panel[cur.panel];
s.replaceChildren(); caps.length = 0;
svgEl(s, 'rect', { x: 1.5, y: 1.5, width: 247, height: 109, rx: 5, fill: st.plate, stroke: st.edge, 'stroke-width': 1 });
svgEl(s, 'rect', { x: 3, y: 3, width: 244, height: 106, rx: 4, fill: 'none', stroke: st.bevel, 'stroke-width': .7 });
[[9, 9], [241, 9], [9, 103], [241, 103]].forEach(([sx, sy], k) => {
svgEl(s, 'circle', { cx: sx, cy: sy, r: 2.7, fill: st.screw, stroke: 'rgba(0,0,0,.55)', 'stroke-width': .6 });
svgEl(s, 'line', { x1: sx - 1.9, y1: sy, x2: sx + 1.9, y2: sy, stroke: 'rgba(0,0,0,.6)', 'stroke-width': .7, transform: `rotate(${25 + k * 40},${sx},${sy})` });
});
for (let db = 0; db <= 60; db += 10) {
const y = yOf(db), major = db % 20 === 0, len = major ? 4 : 2.5;
svgEl(s, 'line', { x1: 24 - len, y1: y, x2: 24, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 });
svgEl(s, 'line', { x1: 226, y1: y, x2: 226 + len, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 });
if (major) {
svgEl(s, 'text', { x: 17, y: y + 2, 'text-anchor': 'end', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db;
svgEl(s, 'text', { x: 233, y: y + 2, 'text-anchor': 'start', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db;
}
}
svgEl(s, 'text', { x: 20, y: 100, 'text-anchor': 'end', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.dim }).textContent = 'dB';
freqs.forEach((f, i) => {
const x = x0 + i * dx;
svgEl(s, 'text', { x, y: 13, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.ink }).textContent = fmtHz(f);
svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: st.track, 'stroke-width': st.trackW, 'stroke-linecap': 'round' });
if (st.trackHi) svgEl(s, 'line', { x1: x - st.trackW / 2 - .35, y1: y0, x2: x - st.trackW / 2 - .35, y2: y1, stroke: st.trackHi, 'stroke-width': .7 });
const cap = svgEl(s, 'g', {});
drawCap(cap, cur.caps, x);
caps.push(cap); place(i);
const hit = svgEl(s, 'rect', { x: x - dx / 2, y: y0 - 6, width: dx, height: y1 - y0 + 12, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragY(hit, pct => set(i, (100 - pct) / 100 * 60));
});
};
const setStyle = (axis, name) => {
if (!ST[axis] || !ST[axis][name]) return;
cur[axis] = name; render();
};
render();
onChange(null, 'drag a band');
return { el: s, get: () => vals.slice(), set, setStyle };
};
/* named styles per axis: dot = swatch color for pickers. caps is the cap
shape; capColor is the cap finish (body + rib + index line), applicable to
any shape. */
GW.filterBank.STYLES = {
panel: {
silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null },
studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' },
},
caps: {
block: { dot: '#2c2824' },
ribbed: { dot: '#6f6a5e' },
chrome: { dot: '#dfe6f2' },
},
capColor: {
black: { dot: '#211e1b', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#f2f4f2', edge: '#000' },
red: { dot: 'var(--sevred)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevred)', edge: '#000' },
green: { dot: 'var(--sevgrn)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevgrn)', edge: '#000' },
blue: { dot: '#4f9fe0', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#4f9fe0', edge: '#000' },
amber: { dot: 'var(--gold-hi)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--gold-hi)', edge: '#000' },
chrome: { dot: '#dfe6f2', body: 'url(#sfChrome)', rib: 'rgba(0,0,0,.28)', index: '#14110e', edge: '#3c3a34' },
cream: { dot: '#efe9d4', body: 'url(#fbCapCream)', rib: 'rgba(0,0,0,.18)', index: '#3a3128', edge: '#8a8474' },
},
};
/* 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 = `
FWD RFL
`;
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 = `90 60 30
`;
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 = `
−1 0 +1
`;
host.appendChild(el);
const ndl = el.querySelector('.ndl');
function set(np) {
p = np;
const v = (p - 50) / 50;
ndl.style.transform = `rotate(${v * 38}deg)`;
onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2));
}
dragX(el, set);
set(p);
return { el, get: () => p, set };
};
/* N18 battery-cell gauge — charge as discrete cells, the low end warns */
GW.battCells = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25;
let p = opts.value ?? 62;
const el = document.createElement('div'); el.className = 'batt';
el.innerHTML = '
';
host.appendChild(el);
const cc = el.querySelector('.cells');
for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); }
function set(np) {
p = np;
const lit = Math.round(p / 100 * cells);
for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' warn' : '');
onChange(p, Math.round(p) + '%');
}
dragX(el, set);
set(p);
return { el, get: () => p, set };
};
/* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale;
set(t) positions instantly (0..1.02); ballistics belong to the signal owner */
GW.mcVu = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 150, 96);
const cx = 75, cy = 112, sweep = t => -43 + t * 86;
const glassId = uid('vuGlass'), clipId = uid('vuFace');
const defs = svgEl(s, 'defs', {});
const g1 = svgEl(defs, 'linearGradient', { id: glassId, x1: 0, y1: 0, x2: 0, y2: 1 });
svgEl(g1, 'stop', { offset: '0', 'stop-color': 'rgba(255,255,255,.13)' });
svgEl(g1, 'stop', { offset: '.35', 'stop-color': 'rgba(255,255,255,0)' });
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3 });
svgEl(s, 'rect', { x: .5, y: .5, width: 149, height: 95, rx: 7, fill: '#16130f', stroke: '#060505' });
svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: 'var(--cream)' });
const face = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const arcPt = t => polar(cx, cy, 58, sweep(t));
const [ax1, ay1] = arcPt(0), [ax2, ay2] = arcPt(1);
svgEl(face, 'path', { d: `M ${ax1} ${ay1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.2 });
const [rx1, ry1] = arcPt(.75);
svgEl(face, 'path', { d: `M ${rx1} ${ry1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3.5 });
for (const [db, t] of VUDB) {
const a = sweep(t), [x1, y1] = polar(cx, cy, 55, a), [x2, y2] = polar(cx, cy, 63, a);
svgEl(face, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1.1 });
}
for (const [db, t] of VUDB) {
if (![-20, -10, -5, -3, 0, 1, 2, 3].includes(db)) continue;
const [x, y] = polar(cx, cy, 69, sweep(t));
svgEl(face, 'text', {
x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.8, 'font-family': 'var(--mono)',
fill: db > 0 ? 'var(--fail)' : '#3a3128'
}).textContent = Math.abs(db);
}
svgEl(face, 'text', {
x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#3a3128'
}).textContent = 'VU';
const needle = svgEl(face, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6,
transform: `rotate(${sweep(.35)},${cx},${cy})`
});
svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' });
svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` });
let t = opts.value ?? .35;
function set(nt) {
t = Math.max(0, Math.min(1.02, nt));
needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`);
const db = vuDb(Math.min(1, t));
onChange(t, (db > 0 ? '+' : '') + db.toFixed(1) + ' VU');
}
return { el: s, get: () => t, set };
};
/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */
GW.roundMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80;
const clipId = uid('rndFace');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy: fy, r: 40 });
svgEl(s, 'circle', { cx, cy: fy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy: fy, r: 40, fill: 'var(--cream)' });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const pt = (r, t) => polar(cx, py, r, sweep(t));
const [ax1, ay1] = pt(44, 0), [ax2, ay2] = pt(44, 1);
svgEl(g, 'path', { d: `M ${ax1} ${ay1} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.1 });
const [rzx, rzy] = pt(44, .75);
svgEl(g, 'path', { d: `M ${rzx} ${rzy} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3 });
for (const [db, t] of VUDB) {
const [x1, y1] = pt(41.5, t), [x2, y2] = pt(47, t);
svgEl(g, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1 });
}
for (const [db, t] of VUDB) {
if (![-20, -10, -5, -3, 0, 3].includes(db)) continue;
const [x, y] = pt(52, t);
svgEl(g, 'text', {
x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)',
fill: db > 0 ? 'var(--fail)' : '#3a3128'
}).textContent = Math.abs(db);
}
svgEl(g, 'text', {
x: cx, y: fy + 14, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#3a3128'
}).textContent = 'dB';
const needle = svgEl(g, 'line', {
x1: cx, y1: py, x2: cx, y2: py - 46, stroke: '#1a1613', 'stroke-width': 1.5,
transform: `rotate(0,${cx},${py})`
});
svgEl(g, 'circle', { cx, cy: py, r: 5, fill: '#141110' });
svgEl(s, 'ellipse', { cx: cx - 8, cy: fy - 22, rx: 20, ry: 9, fill: 'rgba(255,255,255,.07)', transform: `rotate(-18,${cx - 8},${fy - 22})` });
let v = opts.value ?? 50;
function set(nv) {
v = nv;
const t = Math.max(0, Math.min(1, v / 100));
needle.setAttribute('transform', `rotate(${-40 + t * 80},${cx},${py})`);
const db = vuDb(t);
onChange(v, (db > 0 ? '+' : '') + db.toFixed(1) + ' dB');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */
GW.chromeMinMax = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, cy = 58;
gradDef('chrRing', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#eceef0'], ['.5', '#9aa0a8'], ['1', '#54585e']]);
gradDef('chrDome', 'radialGradient', { cx: '48%', cy: '20%', r: '85%' }, [['0', '#e6e8ec'], ['1', '#82878f']]);
svgEl(s, 'circle', { cx, cy: 54, r: 47, fill: 'url(#chrRing)' });
svgEl(s, 'circle', { cx, cy: 54, r: 40, fill: '#101214', stroke: '#2c2f33', 'stroke-width': 1 });
svgEl(s, 'path', { d: `M 21 60 A 34 34 0 0 1 89 60 Z`, fill: 'url(#chrDome)', stroke: '#55595f', 'stroke-width': .5 });
for (const r of [14, 22, 30])
svgEl(s, 'path', { d: `M ${cx - r} 60 A ${r} ${r} 0 0 1 ${cx + r} 60`, fill: 'none', stroke: 'rgba(255,255,255,.13)', 'stroke-width': .7 });
const ptr = svgEl(s, 'line', { x1: cx, y1: cy - 10, x2: cx, y2: cy - 31, stroke: '#17191b', 'stroke-width': 2.2, 'stroke-linecap': 'round' });
svgEl(s, 'circle', { cx, cy, r: 6, fill: '#23262a' });
for (let k = 0; k < 8; k++) {
const [x, y] = polar(cx, cy, 6, k * 45);
svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: '#23262a' });
}
svgEl(s, 'circle', { cx, cy, r: 1.6, fill: '#0c0d0e' });
svgEl(s, 'text', {
x: 24, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em',
'font-family': 'var(--mono)', fill: '#d7dade'
}).textContent = 'MIN';
svgEl(s, 'text', {
x: 86, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em',
'font-family': 'var(--mono)', fill: '#d7dade'
}).textContent = 'MAX';
svgEl(s, 'text', {
x: cx, y: 84, 'text-anchor': 'middle', 'font-size': 7, 'font-style': 'italic',
'font-family': 'var(--mono)', fill: '#787c82'
}).textContent = 'dupre.';
svgEl(s, 'ellipse', { cx: 47, cy: 34, rx: 22, ry: 9, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-16,47,34)' });
let v = opts.value ?? 50;
function set(nv) {
v = Math.max(0, Math.min(100, nv));
ptr.setAttribute('transform', `rotate(${-55 + v / 100 * 110},${cx},${cy})`);
onChange(v, v <= 2 ? 'MIN' : v >= 98 ? 'MAX' : Math.round(v) + '%');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */
GW.aviationGauge = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
const cx = 55, cy = 52, sweep = t => -135 + t * 270;
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
svgEl(s, 'circle', { cx, cy, r: 47, fill: '#1b1917', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 41, fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1 });
const zone = (t1, t2, color) => {
const [x1, y1] = polar(cx, cy, 37, sweep(t1)), [x2, y2] = polar(cx, cy, 37, sweep(t2));
svgEl(s, 'path', { d: `M ${x1} ${y1} A 37 37 0 ${(t2 - t1) > .5 ? 1 : 0} 1 ${x2} ${y2}`, fill: 'none', stroke: color, 'stroke-width': 3, opacity: .85 });
};
zone(.15, .6, 'var(--pass)'); zone(.6, .85, 'var(--gold)'); zone(.85, 1, 'var(--fail)');
for (let i = 0; i <= 40; i++) {
const t = i / 40, major = i % 5 === 0, a = sweep(t);
const [x1, y1] = polar(cx, cy, 40, a), [x2, y2] = polar(cx, cy, major ? 32 : 36, a);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--gold)', 'stroke-width': major ? 1.6 : .8, opacity: major ? 1 : .7 });
if (major) {
const [nx, ny] = polar(cx, cy, 25, a);
svgEl(s, 'text', {
x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)',
fill: 'var(--gold-hi)'
}).textContent = i / 5;
}
}
svgEl(s, 'text', {
x: cx, y: cy + 16, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .8
}).textContent = '×100 RPM';
const needle = svgEl(s, 'g', {});
svgEl(needle, 'line', {
x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 4,
'stroke-linecap': 'round', opacity: .5, filter: 'url(#avGlow)'
});
svgEl(needle, 'line', {
x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 2.2,
'stroke-linecap': 'round'
});
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#1b1917', stroke: '#3a352c' });
svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 24, rx: 18, ry: 8, fill: 'rgba(255,255,255,.05)', transform: `rotate(-18,${cx - 10},${cy - 24})` });
let v = opts.value ?? 52;
function set(nv) {
v = Math.max(0, Math.min(100, nv));
needle.setAttribute('transform', `rotate(${-135 + v / 100 * 270},${cx},${cy})`);
onChange(v, (v / 100 * 8).toFixed(1) + ' ×100 rpm');
}
dragDelta(s, () => v, set, { min: 0, max: 100 });
set(v);
return { el: s, get: () => v, set };
};
/* R13 edgewise strip meter — compressed log scale, the bar rides the edge */
GW.edgeMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]];
const s = stageSvg(host, 'rsvg drag', 70, 130);
const wy0 = 12, wy1 = 118;
const faceId = uid('edgeFace');
const defs = svgEl(s, 'defs', {});
const pg = svgEl(defs, 'linearGradient', { id: faceId, x1: 0, y1: 0, x2: 1, y2: 0 });
svgEl(pg, 'stop', { offset: '0', 'stop-color': '#efe6ca' });
svgEl(pg, 'stop', { offset: '1', 'stop-color': '#d9cfae' });
svgEl(s, 'rect', { x: 8, y: 6, width: 54, height: 118, rx: 8, fill: '#171412', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: wy1 - wy0, fill: `url(#${faceId})`, stroke: '#0a0908' });
const yOf = f => wy0 + f * (wy1 - wy0);
for (const [db, f] of SCALE) {
const y = yOf(f);
svgEl(s, 'line', { x1: 19, y1: y, x2: 40, y2: y, stroke: '#3a3128', 'stroke-width': 1 });
svgEl(s, 'text', {
x: 52, y: y + 2.4, 'text-anchor': 'end', 'font-size': 7, 'font-family': 'var(--mono)',
fill: '#3a3128'
}).textContent = db;
}
for (let i = 0; i < SCALE.length - 1; i++) {
const y = yOf((SCALE[i][1] + SCALE[i + 1][1]) / 2);
svgEl(s, 'line', { x1: 19, y1: y, x2: 32, y2: y, stroke: '#3a3128', 'stroke-width': .6, opacity: .7 });
}
const bar = svgEl(s, 'polygon', { points: '56,0 24,-1.4 24,1.4', fill: '#1a1613' });
svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: 24, fill: 'rgba(255,255,255,.08)' });
let frac = opts.value ?? .62;
function set(f) {
frac = Math.max(.08, Math.min(.94, f));
bar.setAttribute('transform', `translate(0,${wy0 + frac * (wy1 - wy0)})`);
let i = 0; while (i < SCALE.length - 2 && SCALE[i + 1][1] < frac) i++;
const [d1, f1] = SCALE[i], [d2, f2] = SCALE[i + 1];
const db = d1 + (d2 - d1) * Math.max(0, Math.min(1, (frac - f1) / (f2 - f1)));
onChange(frac, '−' + db.toFixed(1) + ' dB');
}
dragY(s, pct => set(.08 + (100 - pct) / 100 * .86));
set(frac);
return { el: s, get: () => frac, set };
};
/* R17 round CRT scope — pale phosphor face; the trace is the widget's own
animation, so it lives here with its reduced-motion gate */
GW.roundCrt = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 110, 104);
const cx = 55, cy = 52;
const faceId = uid('rcrtFace'), clipId = uid('rcrtClip');
gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]);
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
/* the face gradient reads screen-family vars from this widget's subtree,
so it must live in the local defs, not the shared def sink */
const defs = svgEl(s, 'defs', {});
const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' });
svgEl(ph, 'stop', { offset: '0', 'stop-color': 'var(--crt-face1,#b9d8c0)' });
svgEl(ph, 'stop', { offset: '1', 'stop-color': 'var(--crt-face2,#8bb296)' });
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 38 });
svgEl(s, 'circle', { cx, cy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 });
for (let k = 0; k < 4; k++) {
const [x, y] = polar(cx, cy, 43, 45 + k * 90);
svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 });
}
svgEl(s, 'circle', { cx, cy, r: 38, fill: `url(#${faceId})` });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
for (let i = -4; i <= 4; i++) {
svgEl(g, 'line', { x1: cx + i * 9, y1: cy - 38, x2: cx + i * 9, y2: cy + 38, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 });
svgEl(g, 'line', { x1: cx - 38, y1: cy + i * 9, x2: cx + 38, y2: cy + i * 9, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 });
}
const glow = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: 'var(--crt-glow,#eef7ee)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' });
const trace = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: '#f4fcf4', 'stroke-width': 1.3 });
svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 16, rx: 20, ry: 10, fill: 'rgba(255,255,255,.10)', transform: `rotate(-20,${cx - 10},${cy - 16})` });
let tph = 0;
function tick() {
tph += 0.12;
const amp = 8 + 3 * Math.sin(tph * 0.35);
let pts = '';
for (let x = -38; x <= 38; x += 2) {
const y = Math.sin(x * 0.55 + tph * 2.5) * amp;
pts += `${cx + x},${(cy + y).toFixed(1)} `;
}
trace.setAttribute('points', pts.trim());
glow.setAttribute('points', pts.trim());
onChange(amp, 'Vpp ' + Math.round(amp * 2 * 4.5));
}
tick();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 80);
return { el: s, tick };
};
/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */
GW.attitudeIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 130);
const cx = 65, cy = 65;
const clipId = uid('aiClip');
gradDef('aiSky', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#54677d'], ['1', '#3a4c60']]);
gradDef('aiGnd', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#5c4630'], ['1', '#3c2e1e']]);
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 48 });
svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const ball = svgEl(g, 'g', {});
svgEl(ball, 'rect', { x: cx - 90, y: cy - 180, width: 180, height: 180, fill: 'url(#aiSky)' });
svgEl(ball, 'rect', { x: cx - 90, y: cy, width: 180, height: 180, fill: 'url(#aiGnd)' });
svgEl(ball, 'line', { x1: cx - 90, y1: cy, x2: cx + 90, y2: cy, stroke: '#f3e7c5', 'stroke-width': 1.4 });
for (const p of [-20, -10, 10, 20]) {
const y = cy - p * 1.6, w = p % 20 === 0 ? 16 : 10;
svgEl(ball, 'line', { x1: cx - w, y1: y, x2: cx + w, y2: y, stroke: '#f3e7c5', 'stroke-width': .8, opacity: .85 });
svgEl(ball, 'text', {
x: cx + w + 3, y: y + 2, 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#f3e7c5', opacity: .85
}).textContent = String(Math.abs(p));
}
/* fixed bank scale + pointer + miniature aircraft */
for (const b of [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60]) {
const [x1, y1] = polar(cx, cy, 47, b), [x2, y2] = polar(cx, cy, b % 30 === 0 ? 42 : 44, b);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': b === 0 ? 1.6 : .9, opacity: .9 });
}
svgEl(s, 'polygon', { points: `${cx - 3.5},${cy - 38} ${cx + 3.5},${cy - 38} ${cx},${cy - 44}`, fill: 'var(--gold-hi)' });
svgEl(s, 'path', {
d: `M ${cx - 22} ${cy} L ${cx - 7} ${cy} L ${cx - 4} ${cy + 4} M ${cx + 22} ${cy} L ${cx + 7} ${cy} L ${cx + 4} ${cy + 4}`,
fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round'
});
svgEl(s, 'circle', { cx, cy, r: 2, fill: 'var(--gold-hi)' });
let bank = 0, pitch = 0;
function set(b, p) {
bank = Math.max(-60, Math.min(60, b)); pitch = Math.max(-20, Math.min(20, p));
ball.setAttribute('transform', `rotate(${(-bank).toFixed(1)},${cx},${cy}) translate(0,${(pitch * 1.6).toFixed(1)})`);
onChange({ bank, pitch }, 'BANK ' + (bank < 0 ? 'L' : bank > 0 ? 'R' : '') + Math.abs(Math.round(bank)) +
' · PITCH ' + (pitch >= 0 ? '+' : '-') + String(Math.abs(Math.round(pitch))).padStart(2, '0'));
}
s.style.touchAction = 'none'; s.style.cursor = 'move';
s.addEventListener('pointerdown', e => {
s.setPointerCapture(e.pointerId);
const x0 = e.clientX, y0 = e.clientY, b0 = bank, p0 = pitch;
const mv = ev => set(b0 + (ev.clientX - x0) * 0.4, p0 + (ev.clientY - y0) * 0.25);
const up = () => { s.removeEventListener('pointermove', mv); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); };
s.addEventListener('pointermove', mv); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); e.preventDefault();
});
set(opts.bank ?? 0, opts.pitch ?? 0);
return { el: s, get: () => ({ bank, pitch }), set };
};
/* R44 heading bug + servo needle — drag parks the command; the needle chases
with honest servo lag (widget-owned animation, reduced-motion gated) */
GW.headingBug = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 130);
const cx = 65, cy = 65;
svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 });
for (let d = 0; d < 360; d += 10) {
const maj = d % 30 === 0;
const [x1, y1] = polar(cx, cy, 48, d), [x2, y2] = polar(cx, cy, maj ? 42 : 45, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': maj ? 1 : .5, opacity: maj ? .9 : .5 });
}
for (let d = 0; d < 360; d += 30) {
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : String(d / 10);
const [x, y] = polar(cx, cy, 35, d);
svgEl(s, 'text', {
x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': d % 90 === 0 ? 8 : 6.4, 'font-weight': 700,
'font-family': 'var(--mono)', fill: d % 90 === 0 ? 'var(--cream)' : 'var(--steel)'
}).textContent = lbl;
}
svgEl(s, 'line', { x1: cx, y1: cy - 56, x2: cx, y2: cy - 48, stroke: 'var(--fail)', 'stroke-width': 2 });
const bug = svgEl(s, 'path', {
d: `M ${cx - 6} ${cy - 49} L ${cx - 6} ${cy - 44} L ${cx - 2.5} ${cy - 44} L ${cx} ${cy - 47} L ${cx + 2.5} ${cy - 44} L ${cx + 6} ${cy - 44} L ${cx + 6} ${cy - 49} Z`,
fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': .7
});
const needle = svgEl(s, 'g', {});
svgEl(needle, 'polygon', {
points: `${cx - 2},${cy + 10} ${cx + 2},${cy + 10} ${cx + 1.2},${cy - 40} ${cx},${cy - 44} ${cx - 1.2},${cy - 40}`,
fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6
});
svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let cmd = opts.value ?? 90, act = cmd;
const draw = () => {
bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`);
needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`);
onChange({ cmd, act }, 'CMD ' + String(Math.round((cmd % 360 + 360) % 360)).padStart(3, '0') +
' · ACT ' + String(Math.round((act % 360 + 360) % 360)).padStart(3, '0'));
};
function set(v) { cmd = (v % 360 + 360) % 360; draw(); }
dragDelta(s, () => cmd, set, { min: -100000, max: 100000, sens: 1.2 });
draw();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => {
const diff = ((cmd - act + 540) % 360) - 180;
if (Math.abs(diff) < 0.4) { if (act !== cmd) { act = cmd; draw(); } return; }
act = (act + diff * 0.07 + 360) % 360; draw();
}, 80);
return { el: s, get: () => ({ cmd, act }), set };
};
/* R53 circular chart recorder — a day per revolution; the pen draws the cycle
(widget-owned clock, reduced-motion paints the full day once); click for fresh paper */
GW.chartRecorder = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 130, 130);
const cx = 65, cy = 65;
svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 54, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': .8 });
for (const r of [20, 31, 42])
svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: '#b8ae98', 'stroke-width': .5 });
for (let h = 0; h < 24; h++) {
const a = h * 15;
const [x1, y1] = polar(cx, cy, 54, a), [x2, y2] = polar(cx, cy, h % 6 === 0 ? 12 : 48, a);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#b8ae98', 'stroke-width': h % 6 === 0 ? .6 : .35, opacity: .8 });
if (h % 6 === 0) {
const [tx, ty] = polar(cx, cy, 50, a + 5);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#6e6552'
}).textContent = String(h).padStart(2, '0');
}
}
const trace = svgEl(s, 'path', { d: '', fill: 'none', stroke: '#8f2416', 'stroke-width': 1.1, 'stroke-linejoin': 'round' });
const pen = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 54, stroke: '#4a463e', 'stroke-width': 1 });
const penDot = svgEl(s, 'circle', { cx, cy: cy - 30, r: 2, fill: '#8f2416' });
svgEl(s, 'circle', { cx, cy, r: 4, fill: '#4a463e' });
const val = h => 52 + 30 * Math.sin(h / 24 * 2 * Math.PI - 2.1) + 12 * Math.sin(h / 24 * 6 * Math.PI) + 4 * Math.sin(h * 2.7);
const rOf = v => 12 + Math.max(0, Math.min(100, v)) * 0.36;
let pts = [], hour = 0;
const draw = () => {
const a = hour * 15, r = rOf(val(hour));
pts.push(polar(cx, cy, r, a));
if (pts.length > 1) trace.setAttribute('d', 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L '));
pen.setAttribute('transform', `rotate(${a},${cx},${cy})`);
const [px, py] = polar(cx, cy, r, a);
penDot.setAttribute('cx', px); penDot.setAttribute('cy', py);
onChange(hour, String(Math.floor(hour)).padStart(2, '0') + ':' + (hour % 1 >= 0.5 ? '30' : '00') + ' · ' + Math.round(val(hour)) + '%');
};
const reset = () => { pts = []; hour = 0; trace.setAttribute('d', ''); draw(); };
s.style.cursor = 'pointer';
s.addEventListener('click', reset);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (hour = 0; hour < 24; hour += 0.25) draw();
hour = 23.75;
} else {
reset();
setInterval(() => { hour += 0.25; if (hour >= 24) { pts = []; hour = 0; } draw(); }, 375);
}
return { el: s, get: () => hour, reset };
};
/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */
GW.verticalTape = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 70, 130);
const CX = 38, CY = 65;
const MIN = 5, MAX = 35, PPU = 5.5; /* rpm x100, px per unit */
const clipId = uid('vtClip');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4 });
svgEl(s, 'rect', { x: 2, y: 2, width: 66, height: 126, rx: 7, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4, fill: '#0d0c0a', stroke: '#2c2820', 'stroke-width': 1 });
const tapeG = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const tape = svgEl(tapeG, 'g', {});
for (let u = MIN; u <= MAX; u++) {
const y = -u * PPU; /* higher value further up the tape */
const maj = u % 5 === 0;
svgEl(tape, 'line', { x1: maj ? 20 : 26, y1: y, x2: 34, y2: y, stroke: 'var(--cream)', 'stroke-width': maj ? 1.1 : .55, opacity: maj ? .95 : .6 });
if (maj) svgEl(tape, 'text', {
x: 48, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700,
'font-family': 'var(--mono)', fill: 'var(--cream)'
}).textContent = String(u);
}
/* fixed index: side pointer + line across the window at center height */
svgEl(s, 'polygon', { points: `8,${CY} 15,${CY - 4} 15,${CY + 4}`, fill: 'var(--gold-hi)' });
svgEl(s, 'line', { x1: 15, y1: CY, x2: 62, y2: CY, stroke: 'var(--gold-hi)', 'stroke-width': 1, opacity: .85 });
svgEl(s, 'text', {
x: CX, y: 126, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.1em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'RPM x100';
let v = opts.value ?? 24;
function set(nv) {
v = Math.max(MIN, Math.min(MAX, nv));
tape.setAttribute('transform', `translate(0,${(CY + v * PPU).toFixed(1)})`);
onChange(v, 'RPM ' + Math.round(v * 100));
}
s.style.cursor = 'ns-resize';
dragDelta(s, () => v, set, { min: MIN, max: MAX, sens: (MAX - MIN) / 140 });
set(v);
return { el: s, get: () => v, set };
};
/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */
GW.twinNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 120, 110);
const cx = 60, cy = 62, R = 44;
svgEl(s, 'circle', { cx, cy, r: 52, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 46, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 });
/* mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL */
const angL = v => -170 + v / 4 * 160; /* 0..4 -> -170..-10 (left side, cw from top) */
const angR = v => 170 - v / 4 * 160; /* 0..4 -> 170..10 (right side) */
for (let v = 0; v <= 4; v++) {
for (const ang of [angL(v), angR(v)]) {
const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 5, ang);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': 1, opacity: .9 });
const [tx, ty] = polar(cx, cy, R - 11, ang);
svgEl(s, 'text', {
x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)',
fill: 'var(--cream)'
}).textContent = String(v);
}
}
for (let v = 0.5; v < 4; v += 0.5) {
if (v % 1 === 0) continue;
for (const ang of [angL(v), angR(v)]) {
const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 3, ang);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .5, opacity: .55 });
}
}
svgEl(s, 'text', {
x: cx - 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'FUEL';
svgEl(s, 'text', {
x: cx + 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'OIL';
svgEl(s, 'text', {
x: cx, y: cy - 26, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.06em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'kg/cm2';
const needle = color => {
const g = svgEl(s, 'g', {});
svgEl(g, 'polygon', {
points: `${cx - 1.6},${cy + 8} ${cx + 1.6},${cy + 8} ${cx + 1},${cy - R + 8} ${cx},${cy - R + 4} ${cx - 1},${cy - R + 8}`,
fill: color, stroke: 'rgba(0,0,0,.4)', 'stroke-width': .5
}); return g;
};
const nF = needle('#e0523a'), nO = needle('#bfc4d0');
svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
let vF = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1;
const draw = () => {
nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`);
nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`);
onChange([vF, vO], 'FUEL ' + vF.toFixed(1) + ' · OIL ' + vO.toFixed(1));
};
function set(f, o) { vF = Math.max(0, Math.min(4, f)); vO = Math.max(0, Math.min(4, o)); draw(); }
/* each half is its own drag surface */
const half = (x, get, setV) => {
const hit = svgEl(s, 'rect', { x, y: 10, width: 60, height: 104, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragDelta(hit, get, v => { setV(Math.max(0, Math.min(4, v))); draw(); }, { min: 0, max: 4, sens: 4 / 110 });
};
half(0, () => vF, v => vF = v);
half(60, () => vO, v => vO = v);
draw();
return { el: s, get: () => [vF, vO], set };
};
/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */
GW.comfortMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 130, 122);
const cx = 65, cy = 60;
const clipId = uid('cmClip');
gradDef('cmBrass', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8b25c'], ['.5', '#a07c30'], ['1', '#6e5218']]);
const defs = svgEl(s, 'defs', {});
svgEl(s, 'circle', { cx, cy, r: 56, fill: 'url(#cmBrass)', stroke: '#4a3610', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 53, fill: 'none', stroke: '#7a5c1e', 'stroke-width': 2.5, 'stroke-dasharray': '2 1.4' });
svgEl(s, 'circle', { cx, cy, r: 49, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': 1 });
const PT = [38, 96], PH = [92, 96]; /* temp pivot (BL), humidity pivot (BR) */
const angT = t => 105 - (t - 40) / 60 * 70; /* 40..100F -> bearing 105..35 from PT */
const angH = h => -105 + h / 100 * 70; /* 0..100% -> bearing -105..-35 from PH */
/* scale ticks along each sweep, near the rim */
for (let t = 40; t <= 100; t += 10) {
const a = angT(t);
const [x1, y1] = polar(PT[0], PT[1], 86, a), [x2, y2] = polar(PT[0], PT[1], 82, a);
if (Math.hypot(x1 - cx, y1 - cy) < 48) {
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': t % 20 === 0 ? 1 : .5 });
if (t % 20 === 0) {
const [tx, ty] = polar(PT[0], PT[1], 78, a);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#3c382f'
}).textContent = String(t);
}
}
}
for (let h = 0; h <= 100; h += 10) {
const a = angH(h);
const [x1, y1] = polar(PH[0], PH[1], 86, a), [x2, y2] = polar(PH[0], PH[1], 82, a);
if (Math.hypot(x1 - cx, y1 - cy) < 48) {
svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': h % 20 === 0 ? 1 : .5 });
if (h % 20 === 0) {
const [tx, ty] = polar(PH[0], PH[1], 78, a);
svgEl(s, 'text', {
x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: '#3c382f'
}).textContent = String(h);
}
}
}
/* printed zone verdicts; the active one goes red */
const zones = {};
const zone = (key, txt, x, y, rot, size) => {
zones[key] = svgEl(s, 'text', {
x, y, 'text-anchor': 'middle',
'font-size': size || 5.2, 'font-weight': 700, 'letter-spacing': '.06em', 'font-family': 'var(--mono)',
fill: '#8f897b', transform: `rotate(${rot},${x},${y})`
}); zones[key].textContent = txt;
};
zone('humid', 'TOO HUMID', 42, 34, -32);
zone('warm', 'TOO WARM', 89, 34, 32);
zone('right', 'JUST RIGHT', 65, 58, 0, 6.2);
zone('dry', 'TOO DRY', 38, 74, 28);
zone('cold', 'TOO COLD', 93, 74, -28);
svgEl(s, 'text', {
x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.6, 'letter-spacing': '.18em',
'font-family': 'var(--mono)', fill: '#3c382f'
}).textContent = 'WEATHER STATION';
/* needles: temp from bottom-left, humidity from bottom-right — clipped to the face */
const nclip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(nclip, 'circle', { cx, cy, r: 49 });
const ng = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const needle = () => {
const g = svgEl(ng, 'g', {});
svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: -84, stroke: '#c23a28', 'stroke-width': 1.6, 'stroke-linecap': 'round' });
svgEl(g, 'circle', { cx: 0, cy: 0, r: 2.6, fill: '#8f2416' }); return g;
};
const nT = needle(), nH = needle();
nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`);
nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`);
let vT = opts.temp ?? 72, vH = opts.humidity ?? 45;
const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right';
const draw = () => {
nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`);
nH.setAttribute('transform', `translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`);
const v = verdict();
for (const k of Object.keys(zones)) zones[k].setAttribute('fill', k === v ? '#c23a28' : '#8f897b');
onChange([vT, vH], Math.round(vT) + 'F · ' + Math.round(vH) + '% RH · ' + zones[v].textContent);
};
function set(t, h) { vT = Math.max(40, Math.min(100, t)); vH = Math.max(0, Math.min(100, h)); draw(); }
const half = (x, get, setV, min, max) => {
const hit = svgEl(s, 'rect', { x, y: 8, width: 65, height: 106, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
dragDelta(hit, get, v => { setV(Math.max(min, Math.min(max, v))); draw(); }, { min, max, sens: (max - min) / 110 });
};
half(0, () => vT, v => vT = v, 40, 100);
half(65, () => vH, v => vH = v, 0, 100);
draw();
return { el: s, get: () => [vT, vH], set };
};
/* ================= indicators & readouts ================= */
/* 18 status lamps — one lamp per health state; click any lamp to cycle it */
GW.statusLamps = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp busy'];
const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy'];
const states = (opts.states || [0, 1, 2, 3, 4]).slice();
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;gap:9px;align-items:center';
const lamps = states.map((st, i) => {
const l = document.createElement('span'); l.className = CLS[st]; l.style.cursor = 'pointer';
l.addEventListener('click', () => set(i, states[i] + 1));
wrap.appendChild(l); return l;
});
host.appendChild(wrap);
function set(i, st) {
states[i] = ((st % CLS.length) + CLS.length) % CLS.length;
lamps[i].className = CLS[states[i]];
onChange(states.slice(), NAMES[states[i]]);
}
onChange(states.slice(), states.length === 5 ? 'five states' : states.length + ' states');
return { el: wrap, get: () => states.slice(), set };
};
/* 19 badges — labelled flags; click a badge to cycle its variant */
GW.badges = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['badge', 'badge red', 'badge ghost'];
const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]];
const vars = items.map(it => it[1]);
const wrap = document.createElement('span');
const els = items.map(([txt, v], i) => {
const b = document.createElement('span'); b.className = CLS[v]; b.textContent = txt; b.style.cursor = 'pointer';
b.addEventListener('click', () => set(i, vars[i] + 1));
wrap.appendChild(b);
if (i < items.length - 1) wrap.appendChild(document.createTextNode(' '));
return b;
});
host.appendChild(wrap);
function set(i, v) {
vars[i] = ((v % CLS.length) + CLS.length) % CLS.length;
els[i].className = CLS[vars[i]];
onChange(vars.slice(), items[i][0]);
}
onChange(vars.slice(), items.length === 3 ? 'three variants' : items.length + ' variants');
return { el: wrap, get: () => vars.slice(), set };
};
/* 20 tabular readout — mm:ss countdown; builder owns the state, the page
drives tick() on its own clock (tick contract); click pauses/resumes */
GW.tabularReadout = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
wrap.innerHTML = '
';
const out = wrap.querySelector('.readout');
wrap.querySelector('.u').textContent = opts.unit || 'timer';
host.appendChild(wrap);
let secs, run = opts.run !== undefined ? !!opts.run : true;
const draw = () => {
out.textContent = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0');
onChange(secs, run ? 'running' : 'paused');
};
const set = v => { secs = ((v % 3600) + 3600) % 3600; draw(); };
out.addEventListener('click', () => { run = !run; draw(); });
set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
return { el: wrap, get: () => secs, set, tick: () => { if (run) set(secs - 1); } };
};
/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9 */
GW.engravedLabel = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const e = document.createElement('span'); e.className = 'engrave';
e.append(opts.label || 'outputs');
const c = document.createElement('span'); c.className = 'cnt'; e.appendChild(c);
host.appendChild(e);
let n;
const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); };
e.addEventListener('click', () => set(n % 9 + 1));
set(opts.count !== undefined ? opts.count : 3);
return { el: e, get: () => n, set };
};
/* 22 output well — streaming step log, lamp per step; click streams the next
demo step; push([lampCls, name, evidence]) appends programmatically */
GW.outputWell = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']];
const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']];
const keep = opts.keep || 5;
const w = document.createElement('div'); w.className = 'owell'; host.appendChild(w);
const add = s => {
const d = document.createElement('div'); d.className = 'ostep';
d.innerHTML = `${s[1]} ${s[2]} `;
w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild);
};
seed.forEach(add);
const push = s => { add(s); onChange(s, '+ ' + s[1]); };
let i = 0;
w.addEventListener('click', () => { i = (i + 1) % steps.length; push(steps[i]); });
onChange(null, 'click to stream');
return { el: w, push };
};
/* 23 toast — one-line transient confirmation; click fires the next demo
message; fire(msg) shows any message with the fade-in */
GW.toast = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved'];
const t = document.createElement('span'); t.className = 'toastw';
t.textContent = opts.text || msgs[msgs.length - 1];
host.appendChild(t);
const fire = msg => {
t.textContent = msg; t.style.opacity = '0';
requestAnimationFrame(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '1'; });
};
let n = 0;
t.addEventListener('click', () => { n++; fire(msgs[n % msgs.length]); onChange(n, 'fired ' + n); });
onChange(0, 'click to fire');
return { el: t, fire };
};
/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments */
GW.nixie = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const digits = opts.digits || 2;
const max = Math.pow(10, digits);
const nx = document.createElement('span'); nx.className = 'nixie';
for (let i = 0; i < digits; i++) {
const tu = document.createElement('span'); tu.className = 'tube'; tu.innerHTML = ' '; nx.appendChild(tu);
}
host.appendChild(nx);
let v;
const set = x => {
v = ((x % max) + max) % max;
const s = String(v).padStart(digits, '0');
[...nx.children].forEach((tu, i) => {
tu.classList.toggle('off', i < digits - 1 && v < Math.pow(10, digits - 1 - i));
tu.querySelector('b').textContent = s[i];
});
onChange(v, s);
};
nx.addEventListener('click', () => set(v + 1));
set(opts.value !== undefined ? opts.value : 8);
return { el: nx, get: () => v, set };
};
/* N20 split-flap — windows flip to the next word with the mechanical clack;
the page drives next() on its own cadence; first paint is silent like the original */
GW.splitFlap = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN'];
const animate = opts.animate !== undefined ? !!opts.animate : true;
const cells = opts.cells || 3;
const f = document.createElement('span'); f.className = 'flap';
for (let i = 0; i < cells; i++) {
const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = ' '; f.appendChild(d);
}
host.appendChild(f);
let idx = 0;
const paint = () => {
const w = words[idx].padEnd(cells + 1, ' ');
[...f.children].forEach((dd, i) => {
const ch = w[i] || ' ', bel = dd.querySelector('b');
if (bel.textContent !== ch) {
bel.textContent = ch;
if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); }
}
});
};
const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); };
f.addEventListener('click', () => set(idx + 1));
paint();
return { el: f, get: () => idx, set, next: () => set(idx + 1) };
};
/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick();
click adds a minute */
GW.sevenSeg = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const sv = document.createElement('span'); sv.className = 'seven'; host.appendChild(sv);
let secs;
const set = v => {
secs = ((v % 3600) + 3600) % 3600;
const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0');
sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + ' ' + seg7(ss[0]) + seg7(ss[1]);
onChange(secs, mm + ':' + ss);
};
sv.addEventListener('click', () => set(secs + 60));
set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10);
return { el: sv, get: () => secs, set, tick: () => set(secs - 1) };
};
/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message */
GW.vfdMarquee = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C'];
const W = opts.width || 176;
const m = document.createElement('span'); m.className = 'vfdm';
m.innerHTML = ' ';
const t = m.querySelector('.txt'); t.textContent = msgs[0];
host.appendChild(m);
let mi = 0, x = W;
m.addEventListener('click', () => {
mi = (mi + 1) % msgs.length; t.textContent = msgs[mi]; x = W;
onChange(mi, 'msg ' + (mi + 1) + '/' + msgs.length);
});
onChange(0, 'scrolling');
return {
el: m, get: () => mi,
tick: () => { x -= 1.1; if (x < -t.offsetWidth) x = W; t.style.left = x + 'px'; }
};
};
/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET
lifecycle; TEST proves the bulbs then restores the board */
GW.annunciator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const CLS = ['acell', 'acell warn', 'acell fault'];
const NAMES = ['ok', 'warn', 'fault'];
const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]];
const wrap = document.createElement('div'); wrap.className = 'annwrap';
const grid = document.createElement('div'); grid.className = 'annun'; wrap.appendChild(grid);
const bar = document.createElement('div'); bar.className = 'annbar';
const mc = document.createElement('span'); mc.className = 'mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc);
['ACK', 'TEST', 'RESET'].forEach(a => {
const b = document.createElement('button'); b.className = 'key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b);
});
wrap.appendChild(bar); host.appendChild(wrap);
const els = cells.map(([label, st]) => {
const c = document.createElement('span'); c.className = CLS[st]; c.textContent = label; grid.appendChild(c); return c;
});
let acked = false;
const active = () => grid.querySelectorAll('.warn,.fault').length;
const refresh = () => {
const n = active();
mc.classList.toggle('on', n > 0); mc.classList.toggle('fl', n > 0 && !acked);
onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK'));
};
els.forEach((c, i) => c.addEventListener('click', () => {
let j = CLS.indexOf(c.className.trim()); if (j < 0) j = 0; j = (j + 1) % CLS.length; c.className = CLS[j];
if (j > 0) acked = false; /* a new alarm re-arms the flasher */
refresh(); onChange(j, cells[i][0] + ' → ' + NAMES[j]);
}));
bar.querySelectorAll('.key').forEach(k => k.addEventListener('click', () => {
const a = k.dataset.k;
if (a === 'ACK') { if (active() > 0) acked = true; refresh(); }
else if (a === 'RESET') { els.forEach(c => c.className = 'acell'); acked = false; refresh(); }
else if (a === 'TEST') {
const prev = els.map(c => c.className);
els.forEach(c => c.className = 'acell fault');
mc.classList.add('on', 'fl'); onChange(null, 'lamp test');
setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100);
}
}));
refresh();
return {
el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))),
set: (i, st) => { els[i].className = CLS[st]; if (st > 0) acked = false; refresh(); }
};
};
/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */
GW.jewels = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)'];
const NAMES = ['red', 'amber', 'green'];
const init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center';
init.forEach(st => {
const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : '');
if (st >= 0) j.style.setProperty('--jc', cols[st]);
j.addEventListener('click', () => {
if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; }
const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur);
if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); }
else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); }
});
wrap.appendChild(j);
});
host.appendChild(wrap);
onChange(null, 'click to cycle');
return { el: wrap };
};
/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */
GW.tapeCounter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4;
const ct = document.createElement('span'); ct.className = 'counter'; host.appendChild(ct);
for (let idx = 0; idx < wheels; idx++) {
const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : '');
const col = document.createElement('span'); col.className = 'col';
for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); }
w.appendChild(col); ct.appendChild(w);
}
const max = Math.pow(10, wheels);
let v;
const set = x => {
v = ((Math.round(x) % max) + max) % max;
const s = String(v).padStart(wheels, '0');
[...ct.children].forEach((w, i) => { w.querySelector('.col').style.top = (-(+s[i] + 1) * 34) + 'px'; });
onChange(v, s);
};
set(opts.value !== undefined ? opts.value : 471300);
return { el: ct, get: () => v, set };
};
/* N26 analog clock — engraved ticks + three hands; the page owns the time source
and drives set(h, m, s); silent until the first set, like the live original */
GW.analogClock = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const cl = document.createElement('span'); cl.className = 'clock';
for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); }
cl.insertAdjacentHTML('beforeend', ' ');
host.appendChild(cl);
const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh');
const set = (h, m, s) => {
sh.style.transform = `rotate(${s * 6}deg)`;
mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`;
hh.style.transform = `rotate(${(h % 12) * 30 + m * 0.5}deg)`;
onChange([h, m, s], String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'));
};
return { el: cl, set };
};
/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high;
drag the pointer to tune */
GW.freqScale = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const W = opts.width || 184;
const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20];
const unit = opts.unit || 'MHz';
const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]);
const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12);
const fs = document.createElement('span'); fs.className = 'freqscale';
const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band);
marks.forEach(m => {
const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk);
const mk = document.createElement('span'); mk.className = 'mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk);
});
for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) {
const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk);
}
const ptr = document.createElement('span'); ptr.className = 'fptr'; fs.appendChild(ptr);
host.appendChild(fs);
let pct;
const set = p => {
pct = Math.max(0, Math.min(100, p));
ptr.style.left = (6 + pct / 100 * (W - 12)) + 'px';
const f = Math.pow(10, lo + (pct / 100) * (hi - lo));
onChange(f, (f < 10 ? f.toFixed(1) : Math.round(f)) + ' ' + unit);
};
dragX(fs, set);
set(opts.value !== undefined ? opts.value : 45);
return { el: fs, get: () => pct, set };
};
/* N28 patch bay — jack grid with SVG cables; click one jack then another to
patch or unpatch the pair; redraws itself on window resize */
GW.patchBay = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const rows = opts.rows || 2, cols = opts.cols || 4;
const patch = document.createElement('div'); patch.className = 'patch';
const jacks = [];
for (let r = 0; r < rows; r++) {
const rowEl = document.createElement('div'); rowEl.className = 'row';
for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); }
patch.appendChild(rowEl);
}
const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg);
host.appendChild(patch);
let conns = (opts.conns || ['0-5', '2-7']).slice(); let pending = null;
const key = (a, b) => Math.min(a, b) + '-' + Math.max(a, b);
const draw = () => {
while (svg.firstChild) svg.removeChild(svg.firstChild);
const pr = patch.getBoundingClientRect();
jacks.forEach(j => j.classList.remove('hot'));
conns.forEach(pair => {
const [a, b] = pair.split('-').map(Number);
const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect();
const x1 = ra.left - pr.left + ra.width / 2, y1 = ra.top - pr.top + ra.height / 2;
const x2 = rb.left - pr.left + rb.width / 2, y2 = rb.top - pr.top + rb.height / 2;
const p = document.createElementNS(SVGNS, 'path');
const my = Math.max(y1, y2) + 16; p.setAttribute('d', `M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`); svg.appendChild(p);
jacks[a].classList.add('hot'); jacks[b].classList.add('hot');
});
onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables');
};
jacks.forEach(j => {
j.addEventListener('click', () => {
const id = +j.dataset.id;
if (pending == null) { pending = id; j.classList.add('sel'); return; }
if (pending === id) { pending = null; j.classList.remove('sel'); return; }
const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k);
jacks[pending].classList.remove('sel'); pending = null; draw();
});
});
draw(); window.addEventListener('resize', draw);
return { el: patch, get: () => conns.slice(), set: c => { conns = c.slice(); draw(); } };
};
/* R10 data matrix readout — a page of labeled amber fields; click cycles pages;
a page marked live re-renders on the builder's own 1s clock (reduced-motion gated) */
GW.dataMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const pages = opts.pages || [
['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']],
['NET', () => ['WIFI @HYATT 300M', 'VPN UP DNS OK', 'BT 2 PAIRED']],
['TIME', () => {
const d = new Date(), p = n => String(n).padStart(2, '0');
return [`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`,
'UP 14D 06:12'];
}, true]];
const s = stageSvg(host, 'rsvg press', 190, 74);
def('dmxGlow', d => {
const fl = svgEl(d, 'filter', { id: 'dmxGlow', x: '-30%', y: '-30%', width: '160%', height: '160%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.1 });
});
/* the background gradient reads screen-family vars from this widget's subtree,
so it lives in the local defs, not the shared def sink */
const bgId = uid('dmxBg');
const defs = svgEl(s, 'defs', {});
const bg = svgEl(defs, 'linearGradient', { id: bgId, x1: 0, y1: 0, x2: 0, y2: 1 });
svgEl(bg, 'stop', { offset: '0', 'stop-color': 'var(--scr-bg1,#140d06)' });
svgEl(bg, 'stop', { offset: '1', 'stop-color': 'var(--scr-bg2,#0a0705)' });
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 72, rx: 5, fill: `url(#${bgId})`, stroke: '#2c261d' });
const lines = [];
for (let r = 0; r < 3; r++) {
const glow = svgEl(s, 'text', {
x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))', opacity: .6, filter: 'url(#dmxGlow)'
});
const crisp = svgEl(s, 'text', {
x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))'
});
lines.push([glow, crisp]);
}
let pi;
const set = i => {
pi = ((i % pages.length) + pages.length) % pages.length;
const [name, rowsFn] = pages[pi];
rowsFn().forEach((txt, r) => { lines[r][0].textContent = txt; lines[r][1].textContent = txt; });
onChange(pi, 'page ' + name);
};
s.addEventListener('click', () => set(pi + 1));
set(opts.page || 0);
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
setInterval(() => { if (pages[pi][2]) set(pi); }, 1000);
return { el: s, get: () => pi, set };
};
/* R11 warning flag window — the striped mechanical flag slides into the window
when the condition trips; click trips and clears it */
GW.warningFlag = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 120, 64);
def('barber', d => {
const pat = svgEl(d, 'pattern', { id: 'barber', width: 10, height: 10, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' });
svgEl(pat, 'rect', { x: 0, y: 0, width: 10, height: 10, fill: '#17130c' });
svgEl(pat, 'rect', { x: 0, y: 0, width: 5, height: 10, fill: 'var(--gold)' });
});
const clipId = uid('flagWin');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'rect', { x: 14, y: 20, width: 56, height: 24 });
svgEl(s, 'rect', { x: 11, y: 17, width: 62, height: 30, rx: 3, fill: '#0a0908', stroke: '#2c261d', 'stroke-width': 2 });
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
const stripes = svgEl(g, 'rect', { x: 14, y: 20, width: 56, height: 24, fill: 'url(#barber)' });
stripes.style.transition = 'transform .18s'; stripes.style.transform = 'translateX(-58px)';
svgEl(s, 'text', {
x: 92, y: 35, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.14em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = opts.label || 'VIB';
let on;
const set = v => { on = !!v; stripes.style.transform = on ? 'translateX(0)' : 'translateX(-58px)'; onChange(on, on ? 'FLAG' : 'clear'); };
s.addEventListener('click', () => set(!on));
set(opts.on || false);
return { el: s, get: () => on, set };
};
/* R25 fourteen-segment display — the starburst alphanumeric that spells words;
click cycles the word */
GW.seg14 = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP'];
const MAP = {
A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'], C: ['a', 'd', 'e', 'f'],
D: ['a', 'b', 'c', 'd', 'i', 'l'], E: ['a', 'd', 'e', 'f', 'g1', 'g2'], H: ['b', 'c', 'e', 'f', 'g1', 'g2'],
L: ['d', 'e', 'f'], M: ['b', 'c', 'e', 'f', 'h', 'j'], O: ['a', 'b', 'c', 'd', 'e', 'f'],
P: ['a', 'b', 'e', 'f', 'g1', 'g2'], T: ['a', 'i', 'l'], Z: ['a', 'd', 'j', 'k']
};
const s = stageSvg(host, 'rsvg press', 130, 54);
svgEl(s, 'rect', { x: 1, y: 1, width: 128, height: 52, rx: 5, fill: '#0a0f08', stroke: '#1c2a16', 'stroke-width': 2 });
/* segment shapes in a 22x36 local box */
const TH = 2.6, W = 22, H = 36, mid = H / 2;
const segs = {
a: [[2, 0], [W - 2, 0], [W - 4, TH], [4, TH]],
d: [[2, H], [W - 2, H], [W - 4, H - TH], [4, H - TH]],
f: [[0, 2], [TH, 4], [TH, mid - 2], [0, mid - 1]],
e: [[0, H - 2], [TH, H - 4], [TH, mid + 2], [0, mid + 1]],
b: [[W, 2], [W - TH, 4], [W - TH, mid - 2], [W, mid - 1]],
c: [[W, H - 2], [W - TH, H - 4], [W - TH, mid + 2], [W, mid + 1]],
g1: [[2, mid], [4, mid - TH / 2], [W / 2 - 1, mid - TH / 2], [W / 2 - 1, mid + TH / 2], [4, mid + TH / 2]],
g2: [[W - 2, mid], [W - 4, mid - TH / 2], [W / 2 + 1, mid - TH / 2], [W / 2 + 1, mid + TH / 2], [W - 4, mid + TH / 2]],
i: [[W / 2 - TH / 2, 3], [W / 2 + TH / 2, 3], [W / 2 + TH / 2, mid - 2], [W / 2 - TH / 2, mid - 2]],
l: [[W / 2 - TH / 2, H - 3], [W / 2 + TH / 2, H - 3], [W / 2 + TH / 2, mid + 2], [W / 2 - TH / 2, mid + 2]],
h: [[3, 3], [5.5, 3], [W / 2 - 2, mid - 3], [W / 2 - 4.5, mid - 3]],
j: [[W - 3, 3], [W - 5.5, 3], [W / 2 + 2, mid - 3], [W / 2 + 4.5, mid - 3]],
k: [[3, H - 3], [5.5, H - 3], [W / 2 - 2, mid + 3], [W / 2 - 4.5, mid + 3]],
m: [[W - 3, H - 3], [W - 5.5, H - 3], [W / 2 + 2, mid + 3], [W / 2 + 4.5, mid + 3]]
};
const cells = [];
for (let d = 0; d < 4; d++) {
const x0 = 13 + d * 28, y0 = 9, cell = {};
for (const k in segs) {
cell[k] = svgEl(s, 'polygon', { points: segs[k].map(p => `${x0 + p[0]},${y0 + p[1]}`).join(' '), fill: 'var(--sevoff)' });
}
cells.push(cell);
}
let wi;
const set = i => {
wi = ((i % words.length) + words.length) % words.length;
const word = words[wi].padEnd(4, ' ');
cells.forEach((cell, d) => {
const lit = MAP[word[d]] || [];
for (const k in cell) {
const on = lit.includes(k);
cell[k].setAttribute('fill', on ? 'var(--sevgrn)' : 'var(--sevoff)');
cell[k].setAttribute('style', on ? 'filter:drop-shadow(0 0 2.5px rgba(87,211,87,.6))' : '');
}
});
onChange(wi, words[wi]);
};
s.addEventListener('click', () => set(wi + 1));
set(opts.index || 0);
return { el: s, get: () => wi, set };
};
/* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag
places the peak in both axes at once */
GW.responseGraph = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 190, 110);
const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 };
def('avGlow', d => {
const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' });
svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 });
});
svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 108, rx: 5, fill: '#0d1410', stroke: '#22301f', 'stroke-width': 2 });
const FREQS = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000];
const xOf = f => RG.x0 + (Math.log10(f) - RG.fLo) / (RG.fHi - RG.fLo) * (RG.x1 - RG.x0);
const yOf = db => RG.y0 + (RG.db - db) / (2 * RG.db) * (RG.y1 - RG.y0);
FREQS.forEach(f => {
const x = xOf(f);
svgEl(s, 'line', { x1: x, y1: RG.y0, x2: x, y2: RG.y1, stroke: '#22301f', 'stroke-width': .6 });
svgEl(s, 'text', {
x, y: RG.y1 + 9, 'text-anchor': 'middle', 'font-size': 4.8, 'font-family': 'var(--mono)',
fill: 'var(--steel)'
}).textContent = f < 1000 ? f : (f / 1000) + 'k';
});
for (const db of [12, 6, 0, -6, -12]) {
const y = yOf(db);
svgEl(s, 'line', { x1: RG.x0, y1: y, x2: RG.x1, y2: y, stroke: '#22301f', 'stroke-width': db === 0 ? 1 : .6 });
svgEl(s, 'text', {
x: RG.x0 - 4, y: y + 2, 'text-anchor': 'end', 'font-size': 4.8, 'font-family': 'var(--mono)',
fill: 'var(--steel)'
}).textContent = (db > 0 ? '+' : '') + db;
}
const glowLine = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' });
const curve = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold)', 'stroke-width': 1.6 });
const handle = svgEl(s, 'circle', { r: 4, fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': 1 });
handle.style.cursor = 'move';
let fc, gain;
const set = (f, g) => {
fc = Math.max(32, Math.min(16000, f)); gain = Math.max(-12, Math.min(12, g));
const sigma = 0.28; let pts = '';
for (let i = 0; i <= 76; i++) {
const lf = RG.fLo + i / 76 * (RG.fHi - RG.fLo);
const db = gain * Math.exp(-Math.pow(lf - Math.log10(fc), 2) / (2 * sigma * sigma));
pts += `${xOf(Math.pow(10, lf)).toFixed(1)},${yOf(db).toFixed(1)} `;
}
curve.setAttribute('points', pts.trim()); glowLine.setAttribute('points', pts.trim());
handle.setAttribute('cx', xOf(fc)); handle.setAttribute('cy', yOf(gain));
const fLbl = fc < 1000 ? Math.round(fc) + ' Hz' : (fc / 1000).toFixed(1) + ' kHz';
onChange([fc, gain], `${fLbl} · ${gain >= 0 ? '+' : ''}${gain.toFixed(1)} dB`);
};
/* 2D drag: the plot places the peak in both axes at once */
s.style.touchAction = 'none';
s.addEventListener('pointerdown', e => {
s.setPointerCapture(e.pointerId);
const move = ev => {
const r = s.getBoundingClientRect(), sx = 190 / r.width, sy = 110 / r.height;
const px = (ev.clientX - r.left) * sx, py = (ev.clientY - r.top) * sy;
const lf = RG.fLo + Math.max(0, Math.min(1, (px - RG.x0) / (RG.x1 - RG.x0))) * (RG.fHi - RG.fLo);
const g = RG.db - Math.max(0, Math.min(1, (py - RG.y0) / (RG.y1 - RG.y0))) * 2 * RG.db;
set(Math.pow(10, lf), g);
};
move(e);
const up = () => { s.removeEventListener('pointermove', move); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); };
s.addEventListener('pointermove', move); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up);
e.preventDefault();
});
set(opts.fc !== undefined ? opts.fc : 1200, opts.gain !== undefined ? opts.gain : 6.5);
return { el: s, get: () => [fc, gain], set };
};
/* R30 telegraph indicator — the pointer names the active state on labeled
sectors, engine-telegraph style; click steps the state */
GW.telegraphIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const states = opts.states || ['OK', 'TEST', 'BUSY', 'STOP', 'ATTN', 'FAULT'];
const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55, N = states.length;
svgEl(s, 'circle', { cx, cy, r: 50, fill: '#171412', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 43, fill: 'var(--cream)', stroke: '#b7b29a', 'stroke-width': 1 });
states.forEach((st, i) => {
const a = i * 360 / N - 90 + 180 / N;
const [lx1, ly1] = polar(cx, cy, 9, i * 360 / N), [lx2, ly2] = polar(cx, cy, 43, i * 360 / N);
svgEl(s, 'line', { x1: lx1, y1: ly1, x2: lx2, y2: ly2, stroke: '#2b2418', 'stroke-width': 1 });
const g = svgEl(s, 'g', { transform: `rotate(${a + 90},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 7, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#2b2418'
}).textContent = st;
});
const ptr = svgEl(s, 'g', {}); ptr.style.transition = 'transform .18s'; ptr.style.transformOrigin = `${cx}px ${cy}px`;
svgEl(ptr, 'polygon', { points: `${cx - 2.5},${cy} ${cx},${cy - 38} ${cx + 2.5},${cy}`, fill: 'var(--fail)', stroke: '#7a2a1a', 'stroke-width': .5 });
svgEl(ptr, 'rect', { x: cx + 1, y: cy - 16, width: 10, height: 7, rx: 1, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': .6 });
svgEl(s, 'circle', { cx, cy, r: 8, fill: '#171412', stroke: '#000' });
let si;
const set = i => {
si = ((i % N) + N) % N;
ptr.style.transform = `rotate(${si * 360 / N + 180 / N}deg)`;
onChange(si, states[si]);
};
s.addEventListener('click', () => set(si + 1));
set(opts.index || 0);
return { el: s, get: () => si, set };
};
/* R31 radar sweep — the beam circles the bearing ring on the builder's own
clock (reduced-motion gated), contacts bloom as it passes; click marks the bearing */
GW.radarSweep = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const contacts = opts.contacts || [[60, .55], [205, .7], [318, .4]];
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
const clipId = uid('radarClip');
const defs = svgEl(s, 'defs', {});
const clip = svgEl(defs, 'clipPath', { id: clipId });
svgEl(clip, 'circle', { cx, cy, r: 46 });
svgEl(s, 'circle', { cx, cy, r: 62, fill: '#1a1714', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 52, fill: 'var(--scr-bg1,#120b05)', stroke: 'var(--scr-brd,#3a2a12)', 'stroke-width': 1 });
for (let d = 0; d < 360; d += 30) {
const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 47, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': 1, opacity: .8 });
const g = svgEl(s, 'g', { transform: `rotate(${d},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 47.5, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)',
fill: 'var(--scr-ink,var(--gold))', opacity: .9
}).textContent = String(d).padStart(3, '0');
}
for (let d = 0; d < 360; d += 10) {
if (d % 30 === 0) continue;
const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 49, d);
svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': .6, opacity: .5 });
}
const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` });
svgEl(g, 'circle', { cx, cy, r: 46, fill: 'var(--scr-bg2,#0d0803)' });
for (const r of [15, 30, 45])
svgEl(g, 'circle', { cx, cy, r, fill: 'none', stroke: 'var(--scr-ink,var(--gold))', 'stroke-opacity': .18, 'stroke-width': .7 });
const sweep = svgEl(g, 'g', {});
for (let t = 0; t < 5; t++) {
const a0 = -14 + t * 2.8, a1 = a0 + 2.8;
const [x1, y1] = polar(cx, cy, 46, a0), [x2, y2] = polar(cx, cy, 46, a1);
svgEl(sweep, 'path', {
d: `M ${cx} ${cy} L ${x1} ${y1} A 46 46 0 0 1 ${x2} ${y2} Z`,
fill: 'var(--scr-ink,var(--gold))', 'fill-opacity': (0.04 + t * 0.045).toFixed(3)
});
}
svgEl(sweep, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: 'var(--scr-hi,var(--gold-hi))', 'stroke-width': 1.4,
transform: `rotate(0,${cx},${cy})`, opacity: .95
});
const blips = contacts.map(([bd, rf]) => {
const [x, y] = polar(cx, cy, 46 * rf, bd);
return svgEl(g, 'circle', { cx: x, cy: y, r: 2.2, fill: 'var(--scr-hi,var(--gold-hi))', opacity: 0 });
});
svgEl(s, 'circle', { cx, cy, r: 3, fill: 'var(--scr-ink,var(--gold))', opacity: .8 });
const mark = svgEl(s, 'text', {
x: cx, y: 124, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
});
mark.textContent = 'BEARING —';
let a = opts.bearing !== undefined ? opts.bearing : 210;
const tick = () => {
a = (a + 2.6) % 360;
sweep.setAttribute('transform', `rotate(${a},${cx},${cy})`);
contacts.forEach(([bd], i) => {
const d = (a - bd + 360) % 360; /* degrees since beam passed */
blips[i].setAttribute('opacity', d < 200 ? Math.max(0, .95 - d / 200) : 0);
});
mark.textContent = mark.textContent.startsWith('MARK') ? mark.textContent :
'BEARING ' + String(Math.round(a) % 360).padStart(3, '0');
};
s.addEventListener('click', () => {
const b = String(Math.round(a) % 360).padStart(3, '0');
mark.textContent = 'MARK ' + b;
onChange(a, 'mark ' + b);
});
tick(); onChange(a, 'sweeping');
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 50);
return { el: s, get: () => a, tick };
};
/* R35 day-date disc calendar — coaxial printed discs rotate so today reads
under the fixed hand; starts on today; click rolls midnight forward */
GW.dayDateCal = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135;
gradDef('ddFace', 'radialGradient', { cx: '46%', cy: '40%', r: '75%' }, [['0', '#efe9da'], ['1', '#cfc8b6']]);
/* movement plate + white calendar disc */
svgEl(s, 'circle', { cx, cy, r: 62, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
for (let k = 0; k < 5; k++) {
const [x, y] = polar(cx, cy, 58, 20 + k * 72);
svgEl(s, 'circle', { cx: x, cy: y, r: 1.8, fill: '#060505' });
}
svgEl(s, 'circle', { cx, cy, r: 54, fill: 'url(#ddFace)', stroke: '#8f897b', 'stroke-width': .8 });
/* date ring: 31 radial numerals on its own group */
const dateG = svgEl(s, 'g', {});
for (let d = 1; d <= 31; d++) {
const a = (d - 1) * (360 / 31);
const g = svgEl(dateG, 'g', { transform: `rotate(${a},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 46, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#14110e'
}).textContent = String(d);
}
/* inner weekday disc: 7 names twice around, separated by a hairline ring */
svgEl(s, 'circle', { cx, cy, r: 38, fill: 'none', stroke: '#8f897b', 'stroke-width': .7 });
const dayG = svgEl(s, 'g', {});
for (let i = 0; i < 14; i++) {
const a = i * (360 / 14);
const g = svgEl(dayG, 'g', { transform: `rotate(${a},${cx},${cy})` });
svgEl(g, 'text', {
x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 6.8, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#14110e'
}).textContent = DAYS[i % 7];
}
/* fixed yellow hand from hub toward the read index, and the hub */
const [hx, hy] = polar(cx, cy, 44, HAND);
svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: '#e8cf4a', 'stroke-width': 3, 'stroke-linecap': 'round' });
svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
svgEl(s, 'circle', { cx, cy, r: 6, fill: '#b8b2a4', stroke: '#5c574c', 'stroke-width': 1.2 });
svgEl(s, 'circle', { cx, cy, r: 2.4, fill: '#7a7466' });
s.style.cursor = 'pointer';
let date, day;
const set = (d, w) => {
date = d; day = w;
dateG.setAttribute('transform', `rotate(${(HAND - (d - 1) * (360 / 31)).toFixed(2)},${cx},${cy})`);
dayG.setAttribute('transform', `rotate(${(HAND - w * (360 / 14)).toFixed(2)},${cx},${cy})`);
onChange([d, w], DAYS[w] + ' ' + d);
};
s.addEventListener('click', () => set(date % 31 + 1, (day + 1) % 7));
const now = new Date();
set(opts.date !== undefined ? opts.date : now.getDate(),
opts.day !== undefined ? opts.day : now.getDay());
return { el: s, get: () => [date, day], set };
};
/* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots
to paint; starts on the reference K */
GW.dotMatrix = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5;
const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100'];
const s = stageSvg(host, 'rsvg press', 108, 108);
svgEl(s, 'rect', { x: 2, y: 2, width: 104, height: 104, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 6, y: 6, width: 96, height: 96, rx: 5, fill: '#0d0505', stroke: '#241010', 'stroke-width': 1 });
let lit = 0;
const paint = (d, on) => {
d.on = on;
d.el.setAttribute('fill', on ? '#ff4a30' : '#2a1210');
d.el.setAttribute('filter', on ? 'drop-shadow(0 0 3.5px rgba(255,74,48,.85))' : 'none');
};
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2, cy: Y0 + r * STEP + STEP / 2, r: 4.1, fill: '#2a1210' });
el.style.cursor = 'pointer';
const d = { el, on: false };
paint(d, glyph[r][c] === '1'); if (d.on) lit++;
el.addEventListener('click', () => {
paint(d, !d.on); lit += d.on ? 1 : -1;
onChange(lit, lit + ' / 64 lit');
});
}
onChange(lit, lit + ' / 64 lit');
return { el: s, get: () => lit };
};
/* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the
color swap at the midpoint; click a disc to flip it */
GW.flipDisc = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13;
const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */
const s = stageSvg(host, 'rsvg press', 120, 92);
svgEl(s, 'rect', { x: 2, y: 2, width: 116, height: 88, rx: 7, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
let lit = 0;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2 - 2, cy: Y0 + r * STEP + STEP / 2 - 2, r: 5.6, stroke: '#060505', 'stroke-width': .8 });
el.style.transformBox = 'fill-box'; el.style.transformOrigin = 'center'; el.style.transition = 'transform .11s ease-in';
el.style.cursor = 'pointer';
const d = { el, on: glyph[r][c] === '1' };
el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); if (d.on) lit++;
el.addEventListener('click', () => {
d.on = !d.on; lit += d.on ? 1 : -1;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); }
else {
el.style.transform = 'scaleX(0)';
setTimeout(() => { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); el.style.transform = 'scaleX(1)'; }, 115);
}
onChange(lit, lit + ' discs showing');
});
}
onChange(lit, lit + ' discs showing');
return { el: s, get: () => lit };
};
/* R46 dekatron counting ring — each click pulses the neon glow one cathode
around; the wrap flashes the carry dot */
GW.dekatron = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60;
svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 });
svgEl(s, 'circle', { cx, cy, r: 46, fill: '#0d0a08', stroke: '#2a221c', 'stroke-width': 1 });
svgEl(s, 'ellipse', { cx: cx - 14, cy: cy - 18, rx: 20, ry: 11, fill: 'rgba(255,255,255,.05)', transform: `rotate(-24,${cx - 14},${cy - 18})` });
const dots = [];
for (let i = 0; i < 10; i++) {
const [x, y] = polar(cx, cy, 34, i * 36);
svgEl(s, 'text', {
x: polar(cx, cy, 43, i * 36)[0], y: polar(cx, cy, 43, i * 36)[1] + 2, 'text-anchor': 'middle', 'font-size': 5.2,
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = String(i);
dots.push(svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: '#3a2a20' }));
}
const carry = svgEl(s, 'circle', { cx, cy: cy - 6, r: 3, fill: '#2a1c14' });
svgEl(s, 'text', {
x: cx, y: cy + 8, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.12em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'CARRY';
let pos = opts.count || 0, carries = 0;
const draw = () => {
dots.forEach((d, i) => {
const on = i === pos;
d.setAttribute('fill', on ? '#ff9a4c' : '#3a2a20');
d.setAttribute('filter', on ? 'drop-shadow(0 0 5px rgba(255,154,76,.9))' : 'none');
});
onChange(pos, 'COUNT ' + pos + (carries ? ' · CARRY x' + carries : ''));
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => {
pos = (pos + 1) % 10;
if (pos === 0) {
carries++; carry.setAttribute('fill', '#ff9a4c');
carry.setAttribute('filter', 'drop-shadow(0 0 4px rgba(255,154,76,.9))');
setTimeout(() => { carry.setAttribute('fill', '#2a1c14'); carry.removeAttribute('filter'); }, 220);
}
draw();
});
draw();
return { el: s, get: () => pos };
};
/* R47 landing gear indicator — three greens or nothing; the lever cycles
down → transit (amber pulse) → up */
GW.gearIndicator = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 140, 110);
svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
const POS = [['NOSE', 48, 28], ['L', 24, 74], ['R', 72, 74]];
const lamps = {};
POS.forEach(([n, x, y]) => {
svgEl(s, 'circle', { cx: x, cy: y, r: 9, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1.5 });
lamps[n] = svgEl(s, 'circle', { cx: x, cy: y, r: 6.2, fill: '#16240f' });
svgEl(s, 'text', {
x, y: y + 18, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = n;
});
/* gear lever: slot + wheel-shaped knob */
svgEl(s, 'rect', { x: 106, y: 22, width: 8, height: 66, rx: 4, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 });
const lever = svgEl(s, 'g', {});
svgEl(lever, 'rect', { x: 107.5, y: 36, width: 5, height: 26, fill: '#8f8a7e' });
svgEl(lever, 'circle', { cx: 110, cy: 32, r: 8, fill: '#26221c', stroke: '#4a463e', 'stroke-width': 2.4 });
svgEl(lever, 'circle', { cx: 110, cy: 32, r: 3, fill: '#4a463e' });
svgEl(s, 'text', {
x: 110, y: 100, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em',
'font-family': 'var(--mono)', fill: 'var(--steel)'
}).textContent = 'GEAR';
let state = opts.state || 'down', busy = false;
const paint = () => {
const c = state === 'down' ? 'var(--pass)' : state === 'transit' ? 'var(--amber-warn)' : '#16240f';
for (const n of Object.keys(lamps)) {
lamps[n].setAttribute('fill', c);
lamps[n].setAttribute('filter', state === 'down' ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' :
state === 'transit' ? 'drop-shadow(0 0 4px rgba(var(--glow-lo),.7))' : 'none');
lamps[n].style.animation = state === 'transit' ? 'pulse var(--pulse-rate) ease-in-out infinite' : 'none';
}
lever.setAttribute('transform', state === 'up' ? 'translate(0,-8)' : state === 'transit' ? 'translate(0,-4)' : '');
onChange(state, state === 'down' ? 'GEAR DOWN · 3 GREEN' : state === 'up' ? 'GEAR UP' : 'IN TRANSIT');
};
s.style.cursor = 'pointer';
s.addEventListener('click', () => {
if (busy) return;
const target = state === 'down' ? 'up' : 'down';
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { state = target; paint(); return; }
busy = true; state = 'transit'; paint();
setTimeout(() => { state = target; busy = false; paint(); }, 1500);
});
paint();
return { el: s, get: () => state };
};
/* R52 blinkenlights front panel — address/data lamps ripple with a live
pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the
switch register; flip the SR bits and the pattern changes */
GW.blinkenlights = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 170, 100);
svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 });
svgEl(s, 'rect', { x: 8, y: 8, width: 154, height: 62, rx: 5, fill: '#14090e' });
const mkRow = (y, lbl) => {
const row = [];
svgEl(s, 'text', {
x: 12, y: y + 2.6, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)',
fill: '#b08a96'
}).textContent = lbl;
for (let i = 0; i < 12; i++) row.push(svgEl(s, 'circle', { cx: 46 + i * 10, cy: y, r: 3, fill: '#2a1210' }));
return row;
};
const addr = mkRow(24, 'ADDR'), data = mkRow(46, 'DATA');
const paint = (row, val) => row.forEach((d, i) => {
const on = (val >> (11 - i)) & 1;
d.setAttribute('fill', on ? '#ffb43a' : '#2a1210');
d.setAttribute('filter', on ? 'drop-shadow(0 0 3px rgba(255,180,58,.8))' : 'none');
});
/* switch register: 8 mini toggles */
let sr = opts.sr !== undefined ? opts.sr : 0b10100101;
const toggles = [];
for (let i = 0; i < 8; i++) {
const x = 46 + i * 14;
svgEl(s, 'rect', { x: x - 3, y: 76, width: 6, height: 16, rx: 3, fill: '#0d0b09', stroke: '#4a2432', 'stroke-width': 1 });
const t = svgEl(s, 'circle', { cx: x, cy: 80, r: 4, fill: '#8f8a7e', stroke: '#3c382f', 'stroke-width': 1 });
t.style.cursor = 'pointer';
const hit = svgEl(s, 'rect', { x: x - 7, y: 72, width: 14, height: 24, fill: 'transparent' });
hit.style.cursor = 'pointer';
hit.addEventListener('click', () => { sr ^= (1 << (7 - i)); draw(); });
toggles.push(t);
}
svgEl(s, 'text', {
x: 12, y: 83, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)',
fill: '#b08a96'
}).textContent = 'SR';
let pc = 0o1234;
const draw = () => {
toggles.forEach((t, i) => t.setAttribute('cy', ((sr >> (7 - i)) & 1) ? 76 : 84));
onChange(sr, 'SR ' + sr.toString(8).padStart(3, '0') + ' (octal)');
};
const tick = () => { pc = ((pc * 5 + sr + 1) & 0xFFF); paint(addr, pc); paint(data, (pc ^ (pc << 3) ^ sr) & 0xFFF); };
draw(); tick();
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 250);
return { el: s, get: () => sr, tick };
};
/* ---- widget CSS: injected once, grows as builders move in ---- */
/* widget-internal CSS (moved from the gallery