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