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