diff options
Diffstat (limited to 'docs/prototypes/widgets.js')
| -rw-r--r-- | docs/prototypes/widgets.js | 3222 |
1 files changed, 3222 insertions, 0 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js new file mode 100644 index 0000000..f3e1127 --- /dev/null +++ b/docs/prototypes/widgets.js @@ -0,0 +1,3222 @@ +/* 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 = `<svg class="seg7 ${cls || ''}" viewBox="0 0 24 44">`; + for (const k of 'abcdefg') { + const on = lit.includes(k); + const col = on ? (cls === 'red' ? 'var(--sevred)' : 'var(--sevgrn)') : 'var(--sevoff)'; + s += `<polygon points="${SEGPTS[k]}" fill="${col}"/>`; + } + return s + '</svg>'; +} + +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 = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>'; + 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 = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>'; + 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 = '<span class="ind"></span>'; + 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 = `<span class="lamp"></span><span class="who"><b></b></span><span class="what"></span>`; + 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 = '<span class="ind"></span>'; 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', `<span class="tick" style="left:${X[i]}px"></span><span class="mk" style="left:${X[i]}px">${v}</span>`); + }); + 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 = `<span class="half top">${opts.onLabel || 'ON'}</span><span class="half bot">${opts.offLabel || 'OFF'}</span>`; + 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 = `<div class="reels"><span class="reel spin"><i></i><i></i><i></i></span><span class="reel spin"><i></i><i></i><i></i></span></div><div class="transport"></div>`; + 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 = `<span class="outer"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span><span class="inner"><span class="ind"></span></span>`; + 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 = '<span class="ind"></span>'; 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 = '<span class="bit"></span><span class="barrel"></span>'; + 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 = `<div class="slot"></div><div class="detent"></div><span class="end" style="left:0">${opts.aLabel || 'A'}</span><span class="end" style="right:0">${opts.bLabel || 'B'}</span><div class="cap"></div>`; + 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 = '<span class="thumbwheel"></span><span class="win"></span>'; + 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 = '<i></i>'; 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 = `<span class="shuttle" style="--sh:40deg"></span><span class="inner"><span class="dimple"></span></span>`; + 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 = + `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` + + `<div class="right"><div class="wins">` + + `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` + + `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` + + `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` + + `</div><div class="pad"></div></div>`; + 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 + '<span class="bar"></span>'; 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 = `<div class="dial"><div class="arc"></div> + <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div> + <div class="ndl"></div><div class="hub"></div></div><div class="gv"><span>0</span>%</div>`; + 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 = `<div class="vurow"><span class="ch">L</span><span class="vubar"></span></div> + <div class="vurow"><span class="ch">R</span><span class="vubar"></span></div>`; + 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 = '<i></i><i></i><i></i><i></i>'; + 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 = '<i></i><i></i><i></i><i></i>'; + 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 = '<span></span>'; + 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 = '<b></b>'; + 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 = '<svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg>'; + 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 = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>'; + 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 = '<span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>'; + 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 = `<div class="face"><div class="arc"></div><div class="nA"></div><div class="nB"></div></div> + <div class="lbl"><span>FWD</span><span>RFL</span></div>`; + 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 = `<div class="scale"><span>90</span><span>60</span><span>30</span></div> + <div class="wrapcol"><div class="tube"><div class="fill"></div></div><div class="bulb"></div></div>`; + 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 = `<svg viewBox="0 0 82 82"><path d="M14 62 A34 34 0 0 1 68 62" fill="none" stroke="#2c2f32" stroke-width="3"/> + <path d="M52 22 A34 34 0 0 1 68 62" fill="none" stroke="#cb6b4d" stroke-width="3"/></svg> + <div class="ndl"></div><div class="hub"></div><div class="cap">PSI</div>`; + 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 = '<span class="rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="pen" style="top:31px"></span>'; + 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 = `<div class="face"><div class="arc"></div><div class="zero"></div><div class="ndl"></div></div> + <div class="lbl"><span>−1</span><span>0</span><span>+1</span></div>`; + 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 = '<div class="cells"></div><span class="nub"></span>'; + 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 = '<div class="readout"></div><span class="u"></span>'; + 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 = `<span class="lamp ${s[0]}"></span><span><b>${s[1]}</b><span class="ev">${s[2]}</span></span>`; + 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 = '<b></b>'; 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 = '<b></b>'; 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]) + '<span class="colon"><i></i><i></i></span>' + 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 = '<span class="txt"></span><span class="mesh"></span>'; + 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', '<span class="hh"></span><span class="mh"></span><span class="sh"></span><span class="pin"></span>'); + 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; +})(); |
