/* widgets.js — retro-instrument widget library (GW namespace). Classic script: load after the token :root block (tokens.json → gen_tokens.py), before any GW.* call. Works from file:// — no modules, no build step. Contract: each gallery card in panel-widget-gallery.html is the visual + behavioral spec its builder is judged against. Spec: docs/specs/2026-07-12-component-generation-spec.org */ (function () { 'use strict'; const GW = {}; /* ================= shared engine ================= */ const SVGNS = 'http://www.w3.org/2000/svg'; function svgEl(parent, tag, attrs) { const e = document.createElementNS(SVGNS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); parent.appendChild(e); return e; } function polar(cx, cy, r, deg) { const a = deg * Math.PI / 180; return [cx + r * Math.sin(a), cy - r * Math.cos(a)]; } /* ---- pointer-drag helpers (rect-ratio based, so CSS zoom is transparent) ---- */ function dragX(el, onPct) { el.style.touchAction = 'none'; const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100))); }; el.addEventListener('pointerdown', e => { el.setPointerCapture(e.pointerId); at(e); const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); }); } function dragY(el, onPct) { el.style.touchAction = 'none'; const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (r.bottom - e.clientY) / r.height * 100))); }; el.addEventListener('pointerdown', e => { el.setPointerCapture(e.pointerId); at(e); const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); }); } function dragDelta(el, get, set, opts) { opts = opts || {}; el.style.touchAction = 'none'; const min = opts.min == null ? 0 : opts.min, max = opts.max == null ? 100 : opts.max, sens = opts.sens || ((max - min) / 160); el.addEventListener('pointerdown', e => { if (opts.stop) e.stopPropagation(); el.setPointerCapture(e.pointerId); const y0 = e.clientY, v0 = get(); const mv = ev => { let v = v0 + (y0 - ev.clientY) * sens; set(Math.max(min, Math.min(max, v))); }; const up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); }); } /* ---- seven-segment digit builder ---- */ const SEG = { '0': 'abcdef', '1': 'bc', '2': 'abged', '3': 'abgcd', '4': 'fbgc', '5': 'afgcd', '6': 'afgedc', '7': 'abc', '8': 'abcdefg', '9': 'abcdfg', '-': 'g', ' ': '' }; function hseg(cy) { return `5,${cy} 7,${cy - 2} 17,${cy - 2} 19,${cy} 17,${cy + 2} 7,${cy + 2}`; } function vseg(cx, ty, by) { return `${cx},${ty} ${cx - 2},${ty + 2} ${cx - 2},${by - 2} ${cx},${by} ${cx + 2},${by - 2} ${cx + 2},${ty + 2}`; } const SEGPTS = { a: hseg(3), g: hseg(22), d: hseg(41), f: vseg(5, 4, 21), b: vseg(19, 4, 21), e: vseg(5, 23, 40), c: vseg(19, 23, 40) }; function seg7(ch, cls) { const lit = SEG[ch] || ''; let s = ``; for (const k of 'abcdefg') { const on = lit.includes(k); const col = on ? (cls === 'red' ? 'var(--sevred)' : 'var(--sevgrn)') : 'var(--sevoff)'; s += ``; } return s + ''; } function buildBars(el, n) { el.innerHTML = ''; for (let k = 0; k < n; k++) el.appendChild(document.createElement('i')); } /* ---- VU law: authentic nonlinear dB scale, dB → 0..1 sweep position ---- */ const VUDB = [[-20, 0], [-10, .20], [-7, .31], [-5, .40], [-3, .50], [-2, .58], [-1, .66], [0, .75], [1, .84], [2, .91], [3, 1]]; /* interpolate a dB reading from a 0..1 sweep position */ function vuDb(t) { let i = 0; while (i < VUDB.length - 2 && VUDB[i + 1][1] < t) i++; const [d1, t1] = VUDB[i], [d2, t2] = VUDB[i + 1]; return d1 + (d2 - d1) * Math.max(0, Math.min(1, (t - t1) / (t2 - t1))); } /* ---- screen-color families: period phosphor/LCD palettes as scoped CSS vars. Shipped literals are the fallbacks, so defaults are pixel-identical until applied. ---- */ const SCREEN_FAMS = { green: { '--scr-hi': '#7fe0a0', '--scr-ink': '#58b87e', '--scr-dim': '#3d5c46', '--scr-bg1': '#0a120c', '--scr-bg2': '#050c07', '--scr-bgc': '#04140a', '--scr-bge': '#020a05', '--scr-brd': '#123018', '--scr-grat': 'rgba(111,206,51,.18)', '--scr-gratc': 'rgba(111,206,51,.35)', '--scr-glow': 'rgba(127,224,160,.8)', '--crt-face1': '#b9d8c0', '--crt-face2': '#8bb296', '--crt-glow': '#eef7ee' }, amber: { '--scr-hi': '#ffbe54', '--scr-ink': '#e2a038', '--scr-dim': '#7d5c16', '--scr-bg1': '#140d06', '--scr-bg2': '#0a0705', '--scr-bgc': '#140d06', '--scr-bge': '#0a0603', '--scr-brd': '#33230e', '--scr-grat': 'rgba(226,160,56,.18)', '--scr-gratc': 'rgba(226,160,56,.35)', '--scr-glow': 'rgba(255,190,84,.8)', '--crt-face1': '#d8cba6', '--crt-face2': '#b3a276', '--crt-glow': '#ffe7c0' }, red: { '--scr-hi': '#ff9a4c', '--scr-ink': '#e0742e', '--scr-dim': '#5c3416', '--scr-bg1': '#140a05', '--scr-bg2': '#0c0603', '--scr-bgc': '#140a05', '--scr-bge': '#0a0502', '--scr-brd': '#331c0c', '--scr-grat': 'rgba(224,116,46,.18)', '--scr-gratc': 'rgba(224,116,46,.35)', '--scr-glow': 'rgba(255,154,76,.8)', '--crt-face1': '#d8b9a6', '--crt-face2': '#b39276', '--crt-glow': '#ffdcc0' }, blue: { '--scr-hi': '#cfe4ff', '--scr-ink': '#94b8d8', '--scr-dim': '#3a4c60', '--scr-bg1': '#0a0e14', '--scr-bg2': '#05080c', '--scr-bgc': '#0a1018', '--scr-bge': '#04070c', '--scr-brd': '#16283a', '--scr-grat': 'rgba(148,184,216,.18)', '--scr-gratc': 'rgba(148,184,216,.35)', '--scr-glow': 'rgba(207,228,255,.8)', '--crt-face1': '#c2d2dd', '--crt-face2': '#93a8b6', '--crt-glow': '#eaf2ff' }, vfd: { '--scr-hi': '#63e6c8', '--scr-ink': '#46b89e', '--scr-dim': '#1e4a40', '--scr-bg1': '#06100d', '--scr-bg2': '#040b09', '--scr-bgc': '#041410', '--scr-bge': '#020a08', '--scr-brd': '#0e3028', '--scr-grat': 'rgba(70,184,158,.18)', '--scr-gratc': 'rgba(70,184,158,.35)', '--scr-glow': 'rgba(99,230,200,.8)', '--crt-face1': '#b0d8cd', '--crt-face2': '#84ada2', '--crt-glow': '#dcfff5' }, white: { '--scr-hi': '#f2f4f2', '--scr-ink': '#c8cac8', '--scr-dim': '#5a5c5a', '--scr-bg1': '#0b0c0b', '--scr-bg2': '#070807', '--scr-bgc': '#0d0e0d', '--scr-bge': '#060706', '--scr-brd': '#2a2c2a', '--scr-grat': 'rgba(200,202,200,.18)', '--scr-gratc': 'rgba(200,202,200,.35)', '--scr-glow': 'rgba(242,244,242,.7)', '--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' }, }; /* ================= widget builders ================= Every builder: GW.name(host, opts) → handle. host is an empty element the widget renders into. opts.onChange(value, text) fires on every state change, including the initial paint; text is the widget's canonical readout string. */ const noop = () => {}; /* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*). */ GW.slideToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw); let on; const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; sw.addEventListener('click', () => set(!on)); set(opts.on !== undefined ? opts.on : true); return { el: sw, get: () => on, set }; }; /* 02 console keys — mutually exclusive push buttons; {label, red} per key */ GW.consoleKeys = function (host, opts = {}) { const onChange = opts.onChange || noop; const keys = opts.keys || [{ label: 'LIVE' }, { label: 'SCAN' }, { label: 'MUTED', red: true }]; const wrap = document.createElement('span'); host.appendChild(wrap); const btns = keys.map(k => { const b = document.createElement('button'); b.className = 'key'; b.textContent = k.label; wrap.appendChild(b); return b; }); let idx; const set = i => { idx = i; btns.forEach((b, j) => { b.classList.remove('on', 'red'); if (j === i) b.classList.add(keys[i].red ? 'red' : 'on'); }); onChange(i, keys[i].label); }; btns.forEach((b, i) => b.addEventListener('click', () => set(i))); set(opts.active || 0); return { el: wrap, get: () => idx, set }; }; /* 03 horizontal fader — continuous 0-100 */ GW.faderH = function (host, opts = {}) { const onChange = opts.onChange || noop; const f = document.createElement('div'); f.className = 'fader'; f.innerHTML = '
'; host.appendChild(f); let val; const set = p => { val = Math.max(0, Math.min(100, p)); f.querySelector('.fill').style.width = val + '%'; f.querySelector('.cap').style.left = val + '%'; onChange(val, 'level ' + Math.round(val)); }; dragX(f, set); set(opts.value !== undefined ? opts.value : 68); return { el: f, get: () => val, set }; }; /* 04 vertical fader — one channel-strip fader; compose per channel */ GW.faderV = function (host, opts = {}) { const onChange = opts.onChange || noop; const f = document.createElement('div'); f.className = 'vfader'; f.innerHTML = '
'; host.appendChild(f); let val; const set = p => { val = Math.max(0, Math.min(100, p)); f.querySelector('.fill').style.height = val + '%'; f.querySelector('.cap').style.bottom = val + '%'; onChange(val, 'level ' + Math.round(val)); }; dragY(f, set); set(opts.value !== undefined ? opts.value : 60); return { el: f, get: () => val, set }; }; /* 05 rotary knob — drag up/down to turn, -150°..+150° sweep */ GW.knob = function (host, opts = {}) { const onChange = opts.onChange || noop; const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100; const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = ''; host.appendChild(k); let val; const set = v => { val = Math.max(min, Math.min(max, v)); k.querySelector('.ind').style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`; onChange(val, String(Math.round(val))); }; dragDelta(k, () => val, set, { min, max }); set(opts.value !== undefined ? opts.value : 53); return { el: k, get: () => val, set }; }; /* 06 segmented selector — pick one of a few */ GW.segmented = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['TIMER', 'ALARM', 'POMO']; const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg); const btns = items.map(t => { const b = document.createElement('button'); b.textContent = t; seg.appendChild(b); return b; }); let idx; const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); }; btns.forEach((b, i) => b.addEventListener('click', () => set(i))); set(opts.active || 0); return { el: seg, get: () => idx, set }; }; /* 07 chip toggle — inline binary inside a line of text */ GW.chipToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const chip = document.createElement('span'); chip.className = 'chip'; chip.textContent = opts.label || 'discoverable on'; host.appendChild(chip); let on; const set = v => { on = !!v; chip.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; chip.addEventListener('click', () => set(!on)); set(opts.on !== undefined ? opts.on : true); return { el: chip, get: () => on, set }; }; /* 08 arm-to-fire — two-stage confirm for destructive actions */ GW.armButton = function (host, opts = {}) { const onChange = opts.onChange || noop; const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again'; const a = document.createElement('button'); a.className = 'arm'; a.textContent = label; host.appendChild(a); let armed = false; a.addEventListener('click', () => { if (!armed) { armed = true; a.classList.add('armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); } else { armed = false; a.classList.remove('armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); } }); onChange('safe', 'SAFE'); return { el: a, get: () => (armed ? 'armed' : 'safe'), fire: () => a.click() }; }; /* 09 lamp row — actionable list item: lamp + name + status, click to cycle */ GW.lampRow = function (host, opts = {}) { const onChange = opts.onChange || noop; const name = opts.name || 'WH-1000XM4'; const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']]; const row = document.createElement('div'); row.className = 'lrow'; row.innerHTML = ``; row.querySelector('b').textContent = name; host.appendChild(row); let idx; const set = i => { idx = i; row.querySelector('.lamp').className = 'lamp ' + states[i][0]; row.querySelector('.what').textContent = states[i][1]; onChange(i, states[i][1]); }; row.addEventListener('click', () => set((idx + 1) % states.length)); set(opts.state || 0); return { el: row, get: () => idx, set }; }; /* 24 rotary selector — pick one of five printed detents by position */ GW.rotarySelector = function (host, opts = {}) { const onChange = opts.onChange || noop; const values = opts.values || [4, 6, 8, 10, 12]; const fmt = opts.fmt || (v => 'size ' + v); const POS = [[14, 53], [28, 18], [50, 7], [72, 18], [86, 53]], ANG = [-70, -35, 0, 35, 70]; const rs = document.createElement('span'); rs.className = 'rotsel'; values.forEach((v, i) => { const p = document.createElement('span'); p.className = 'pos'; p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v; rs.appendChild(p); }); const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = ''; rs.appendChild(k); host.appendChild(rs); let idx; const set = i => { idx = ((i % values.length) + values.length) % values.length; rs.querySelector('.ind').style.transform = `rotate(${ANG[idx]}deg)`; rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('on', j === idx)); onChange(values[idx], fmt(values[idx])); }; k.addEventListener('click', () => set(idx + 1)); set(opts.index !== undefined ? opts.index : 2); return { el: rs, get: () => values[idx], set }; }; /* 25 slide-rule dial — lit pointer on a printed scale; click a mark or arrow-key */ GW.slideRule = function (host, opts = {}) { const onChange = opts.onChange || noop; const values = opts.values || [4, 6, 8, 10, 12]; const fmt = opts.fmt || (v => 'pos ' + v); const X = [12, 51, 90, 129, 168]; const t = document.createElement('span'); t.className = 'tuner'; t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value'); values.forEach((v, i) => { t.insertAdjacentHTML('beforeend', `${v}`); }); const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl); host.appendChild(t); let idx; const set = i => { idx = Math.max(0, Math.min(values.length - 1, i)); ndl.style.left = X[idx] + 'px'; t.querySelectorAll('.mk').forEach((m, j) => m.classList.toggle('on', j === idx)); onChange(values[idx], fmt(values[idx])); }; t.addEventListener('click', e => { const r = t.getBoundingClientRect(), x = e.clientX - r.left; let best = 0, bd = 1e9; X.slice(0, values.length).forEach((tx, j) => { const d = Math.abs(tx - x); if (d < bd) { bd = d; best = j; } }); set(best); t.focus(); }); t.addEventListener('keydown', e => { if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); } }); set(opts.index !== undefined ? opts.index : 2); return { el: t, get: () => values[idx], set }; }; /* N01 rocker power switch — hard on/off, lit legend */ GW.rocker = function (host, opts = {}) { const onChange = opts.onChange || noop; const r = document.createElement('span'); r.className = 'rocker'; r.innerHTML = `${opts.onLabel || 'ON'}${opts.offLabel || 'OFF'}`; host.appendChild(r); let on; const set = v => { on = !!v; r.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; r.addEventListener('click', () => set(!on)); set(opts.on !== undefined ? opts.on : true); return { el: r, get: () => on, set }; }; /* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing. opts.animate: reels obey play state (default: not prefers-reduced-motion) */ GW.transport = function (host, opts = {}) { const onChange = opts.onChange || noop; const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches; const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' }; const NAMES = { rew: 'REW', play: 'PLAY', stop: 'STOP', rec: 'REC' }; const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:center'; wrap.innerHTML = `
`; const bar = wrap.querySelector('.transport'); const btns = {}; for (const m of Object.keys(MODES)) { const b = document.createElement('button'); b.className = 'tbtn' + (m === 'rec' ? ' rec' : ''); b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b; b.addEventListener('click', () => set(m)); } host.appendChild(wrap); let mode; const set = m => { mode = m; for (const k in btns) btns[k].classList.toggle('on', k === m); wrap.querySelectorAll('.reel.spin').forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused'); onChange(m, NAMES[m]); }; /* initial: light the mode but leave reel play-state to CSS, matching load behavior */ mode = opts.mode || 'play'; btns[mode].classList.add('on'); onChange(mode, NAMES[mode]); return { el: wrap, get: () => mode, set }; }; /* N03 radio preset bank — mechanically exclusive presets */ GW.presetBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF']; const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank); const btns = items.map(t => { const b = document.createElement('button'); b.className = 'preset'; b.textContent = t; bank.appendChild(b); return b; }); let idx; const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); }; btns.forEach((b, i) => b.addEventListener('click', () => set(i))); set(opts.active || 0); return { el: bank, get: () => idx, set }; }; /* N04 concentric dual knob — two values on one spindle, outer ring + inner cap */ GW.dualKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const dk = document.createElement('span'); dk.className = 'dualknob'; dk.innerHTML = ``; host.appendChild(dk); const o = dk.querySelector('.outer'), n = dk.querySelector('.inner'); let oV, iV; const paint = () => { o.style.transform = `rotate(${-150 + oV / 100 * 300}deg)`; n.style.transform = `rotate(${-150 + iV / 100 * 300}deg)`; onChange(oV, iV); }; dragDelta(o, () => oV, v => { oV = v; paint(); }, { min: 0, max: 100 }); dragDelta(n, () => iV, v => { iV = v; paint(); }, { min: 0, max: 100, stop: true }); oV = opts.outer !== undefined ? opts.outer : 50; iV = opts.inner !== undefined ? opts.inner : 50; paint(); return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = a; if (b !== undefined) iV = b; paint(); } }; }; /* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */ GW.encoder = function (host, opts = {}) { const onChange = opts.onChange || noop; const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc); const R = 27, cx = 33, cy = 33; for (let i = 0; i < 12; i++) { const a = (i * 30 - 90) * Math.PI / 180; const d = document.createElement('span'); d.className = 'led'; d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px'; enc.appendChild(d); } const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = ''; enc.appendChild(k); let val; const set = v => { val = v; const lit = ((Math.floor(val) % 12) + 12) % 12; enc.querySelectorAll('.led').forEach((l, i) => l.classList.toggle('on', i <= lit)); k.style.transform = `rotate(${val * 30}deg)`; onChange(val, 'pos ' + Math.round(val)); }; dragDelta(enc, () => val, set, { min: 0, max: 100000, sens: 0.25 }); set(opts.value !== undefined ? opts.value : 7); return { el: enc, get: () => val, set }; }; /* N06 keyed mode switch — guarded three-position mode, key-bit points at the live one */ GW.keySwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['OFF', 'ON', 'RUN']; const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]]; const kl = document.createElement('span'); kl.className = 'keylock'; items.forEach((t, i) => { const p = document.createElement('span'); p.className = 'kpos'; p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t; kl.appendChild(p); }); const body = document.createElement('span'); body.className = 'body'; body.innerHTML = ''; kl.appendChild(body); host.appendChild(kl); let idx; const set = i => { idx = ((i % items.length) + items.length) % items.length; body.querySelector('.bit').style.transform = `rotate(${ANG[idx]}deg)`; kl.querySelectorAll('.kpos').forEach((p, j) => p.classList.toggle('on', j === idx)); onChange(idx, items[idx]); }; body.addEventListener('click', () => set(idx + 1)); set(opts.index !== undefined ? opts.index : 1); return { el: kl, get: () => idx, set }; }; /* N07 center-detented crossfader — throw to either side of zero */ GW.crossfader = function (host, opts = {}) { const onChange = opts.onChange || noop; const xf = document.createElement('div'); xf.className = 'xfader'; xf.innerHTML = `
${opts.aLabel || 'A'}${opts.bLabel || 'B'}
`; host.appendChild(xf); let pct; const set = p => { pct = Math.max(0, Math.min(100, p)); xf.querySelector('.cap').style.left = pct + '%'; const v = Math.round(pct - 50); onChange(v, (v > 0 ? '+' : '') + v); }; dragX(xf, set); set(opts.value !== undefined ? opts.value : 50); return { el: xf, get: () => Math.round(pct - 50), set }; }; /* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value */ GW.thumbwheel = function (host, opts = {}) { const onChange = opts.onChange || noop; const w = document.createElement('div'); w.className = 'thumbw'; w.innerHTML = ''; host.appendChild(w); let val; const set = v => { val = Math.round(v); const s = ((val % 100) + 100) % 100; w.querySelector('.win').textContent = String(s).padStart(2, '0'); onChange(s, 'value ' + s); }; dragDelta(w.querySelector('.thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 }); set(opts.value !== undefined ? opts.value : 42); return { el: w, get: () => ((val % 100) + 100) % 100, set }; }; /* N09 DIP-switch bank — hard flags, up is on; readout is the binary word */ GW.dipBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const bits = opts.bits || [true, false, true, true, false, false]; const bank = document.createElement('span'); bank.className = 'dip'; host.appendChild(bank); const sws = bits.map(on => { const s = document.createElement('span'); s.className = 'dipsw' + (on ? ' on' : ''); s.innerHTML = ''; bank.appendChild(s); return s; }); const word = () => sws.map(x => x.classList.contains('on') ? '1' : '0').join(''); const upd = () => onChange(word(), word()); sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('on'); upd(); })); upd(); return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('on', w[i] === '1')); upd(); } }; }; /* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */ GW.jogWheel = function (host, opts = {}) { const onChange = opts.onChange || noop; const j = document.createElement('span'); j.className = 'jog'; j.innerHTML = ``; host.appendChild(j); let val; const set = v => { val = v; j.querySelector('.inner').style.transform = `rotate(${val * 4}deg)`; onChange(val, 'pos ' + Math.round(val)); }; dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 }); set(opts.value !== undefined ? opts.value : 0); return { el: j, get: () => val, set }; }; /* ---- shared SVG defs: gradients/filters referenced by url(#id) are document-scoped, and several are used across widgets. Builders ensure the defs they use; the first caller creates it, later calls are no-ops. ---- */ let defsRoot = null; const defsMade = new Set(); function defsHost() { if (!defsRoot) { const svg = document.createElementNS(SVGNS, 'svg'); svg.setAttribute('width', 0); svg.setAttribute('height', 0); svg.setAttribute('aria-hidden', 'true'); svg.style.position = 'absolute'; defsRoot = document.createElementNS(SVGNS, 'defs'); svg.appendChild(defsRoot); document.body.appendChild(svg); } return defsRoot; } function def(id, make) { if (defsMade.has(id)) return; defsMade.add(id); make(defsHost(), id); } function gradDef(id, kind, attrs, stops) { def(id, d => { const g = svgEl(d, kind, Object.assign({ id }, attrs)); for (const [offset, color] of stops) svgEl(g, 'stop', { offset, 'stop-color': color }); }); } function stageSvg(host, cls, vw, vh) { const s = document.createElementNS(SVGNS, 'svg'); s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`); s.setAttribute('width', vw); s.setAttribute('height', vh); host.appendChild(s); return s; } /* R02 calibrated vernier dial — the disc turns under a fixed hairline */ GW.vernierDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75; gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]); gradDef('bakeKnob', 'radialGradient', { cx: '42%', cy: '36%', r: '75%' }, [['0', '#37332c'], ['1', '#060505']]); svgEl(s, 'circle', { cx, cy, r: 72, fill: '#0c0b0a', stroke: '#262320' }); const disc = svgEl(s, 'g', {}); svgEl(disc, 'circle', { cx, cy, r: 66, fill: 'url(#vernFace)', stroke: '#b7b29a', 'stroke-width': 1 }); for (let u = 0; u <= 100; u++) { const a = u * 3, major = u % 10 === 0, half = u % 5 === 0; const len = major ? 10 : half ? 7 : 4.5, [x1, y1] = polar(cx, cy, 63, a), [x2, y2] = polar(cx, cy, 63 - len, a); svgEl(disc, 'line', { x1, y1, x2, y2, stroke: '#3a3128', 'stroke-width': major ? 1.3 : half ? 1 : .7 }); if (major) { const g = svgEl(disc, 'g', { transform: `rotate(${a},${cx},${cy})` }); svgEl(g, 'text', { x: cx, y: cy - 44, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#3a3128' }).textContent = u; } } for (let k = 0; k < 12; k++) { const [x, y] = polar(cx, cy, 24, k * 30); svgEl(s, 'circle', { cx: x, cy: y, r: 4.8, fill: 'url(#bakeKnob)' }); } svgEl(s, 'circle', { cx, cy, r: 24, fill: 'url(#bakeKnob)', stroke: '#000', 'stroke-width': .6 }); svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#bakeKnob)' }); svgEl(s, 'ellipse', { cx: 69, cy: 66, rx: 7, ry: 4, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-32,69,66)' }); svgEl(s, 'line', { x1: cx, y1: 6, x2: cx, y2: 22, stroke: 'var(--fail)', 'stroke-width': 2.2, 'stroke-linecap': 'round' }); let val; const set = v => { val = v; disc.setAttribute('transform', `rotate(${-v * 3},75,75)`); onChange(val, val.toFixed(1)); }; dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.15 }); set(opts.value !== undefined ? opts.value : 42.0); return { el: s, get: () => val, set }; }; /* R03 bat-handle toggle — chrome lever throws between lit legends */ GW.batToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44; gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]); gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]); const lblOn = svgEl(s, 'text', { x: cx, y: 9, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); lblOn.textContent = opts.onLabel || 'ON'; const lblOff = svgEl(s, 'text', { x: cx, y: 87, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); lblOff.textContent = opts.offLabel || 'OFF'; const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 17 * Math.cos(a)) + ',' + (cy + 17 * Math.sin(a))); } svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' }); svgEl(s, 'circle', { cx, cy, r: 9, fill: '#14110e', stroke: '#000' }); const lever = svgEl(s, 'g', {}); svgEl(lever, 'path', { d: `M ${cx - 3.2} ${cy} L ${cx - 1.8} 21 L ${cx + 1.8} 21 L ${cx + 3.2} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 }); svgEl(lever, 'ellipse', { cx, cy: 17, rx: 6.5, ry: 8, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 }); lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s'; let on; const set = v => { on = !!v; lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)'; lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)'); lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)'); onChange(on, on ? 'ON' : 'OFF'); }; s.addEventListener('click', () => set(!on)); set(opts.on !== undefined ? opts.on : true); return { el: s, get: () => on, set }; }; /* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc */ GW.flutedKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54; gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]); gradDef('bakeDome', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#3a362f'], ['1', '#0a0908']]); for (let i = 0; i <= 10; i++) { const a = -135 + i * 27, major = i % 5 === 0; const [x1, y1] = polar(cx, cy, 38, a), [x2, y2] = polar(cx, cy, major ? 45 : 43.5, a); svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': major ? 1.5 : 1 }); if (major) { const [x, y] = polar(cx, cy, 50.5, a); svgEl(s, 'text', { x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': 8, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i; } } for (let k = 0; k < 18; k++) { const [x, y] = polar(cx, cy, 31, k * 20); svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: 'url(#bakeSk)' }); } svgEl(s, 'circle', { cx, cy, r: 31, fill: 'url(#bakeSk)', stroke: '#000', 'stroke-width': .6 }); const idx = svgEl(s, 'line', { x1: cx, y1: cy - 30, x2: cx, y2: cy - 21, stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' }); svgEl(s, 'circle', { cx, cy, r: 19, fill: 'url(#bakeDome)' }); svgEl(s, 'ellipse', { cx: 49, cy: 46, rx: 6, ry: 3.5, fill: 'rgba(255,255,255,.14)', transform: 'rotate(-28,49,46)' }); let val; const set = v => { val = v; idx.setAttribute('transform', `rotate(${v * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); }; dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.25 }); set(opts.value !== undefined ? opts.value : 63); return { el: s, get: () => val, set }; }; /* R05 filter slider bank — one fader per band, teal arrow caps on black tracks */ GW.filterBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const freqs = opts.freqs || [78, 136, 235, 406, 701, 1210, 2090, 3620]; const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48]).slice(); const fmtHz = f => f < 1000 ? f : (f / 1000) + 'k'; const s = stageSvg(host, 'rsvg', 190, 96); const y0 = 14, y1 = 84, x0 = 30, dx = 19.5; for (const [lbl, y] of [['0', y0], ['20', y0 + (y1 - y0) / 3], ['40', y0 + 2 * (y1 - y0) / 3], ['60', y1]]) svgEl(s, 'text', { x: 14, y: y + 2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl; svgEl(s, 'text', { x: 14, y: 94, 'text-anchor': 'end', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'dB'; const caps = []; const set = (i, db) => { db = Math.max(0, Math.min(60, db)); vals[i] = db; caps[i].setAttribute('transform', `translate(0,${14 + db / 60 * 70})`); onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`); }; freqs.forEach((f, i) => { const x = x0 + i * dx; svgEl(s, 'text', { x, y: 8, 'text-anchor': 'middle', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = fmtHz(f); svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: '#0a0908', 'stroke-width': 3.4, 'stroke-linecap': 'round' }); svgEl(s, 'line', { x1: x - .7, y1: y0, x2: x - .7, y2: y1, stroke: 'rgba(255,255,255,.05)', 'stroke-width': .7 }); const cap = svgEl(s, 'g', {}); svgEl(cap, 'polygon', { points: `${x - 8.5},${-4.5} ${x - 2.5},0 ${x - 8.5},${4.5}`, fill: 'var(--vfd)', stroke: '#123028', 'stroke-width': .6 }); svgEl(cap, 'polygon', { points: `${x + 8.5},${-4.5} ${x + 2.5},0 ${x + 8.5},${4.5}`, fill: 'var(--vfd)', stroke: '#123028', 'stroke-width': .6 }); svgEl(cap, 'circle', { cx: x, cy: 0, r: 1.5, fill: '#123028' }); caps.push(cap); /* paint the initial position without announcing it */ cap.setAttribute('transform', `translate(0,${14 + vals[i] / 60 * 70})`); const hit = svgEl(s, 'rect', { x: x - 9.5, y: y0 - 6, width: 19, height: y1 - y0 + 12, fill: 'transparent' }); hit.style.cursor = 'ns-resize'; dragY(hit, pct => set(i, (100 - pct) / 100 * 60)); }); onChange(null, 'drag a band'); return { el: s, get: () => vals.slice(), set }; }; /* R06 chicken-head selector — tapered lever aims at the position */ GW.chickenHead = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]]; const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58; gradDef('chickG', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#35312b'], ['1', '#070606']]); const lbls = []; items.forEach(([lbl, a]) => { const [tx, ty] = polar(cx, cy, 42, a); const t = svgEl(s, 'text', { x: tx, y: ty + 2.5, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); t.textContent = lbl; lbls.push(t); const [mx1, my1] = polar(cx, cy, 33, a), [mx2, my2] = polar(cx, cy, 29, a); svgEl(s, 'line', { x1: mx1, y1: my1, x2: mx2, y2: my2, stroke: 'var(--steel)', 'stroke-width': 1.2 }); }); svgEl(s, 'circle', { cx, cy, r: 12, fill: '#0c0b0a' }); const lever = svgEl(s, 'g', {}); svgEl(lever, 'path', { d: `M ${cx} ${cy - 29} L ${cx + 8.5} ${cy - 4} Q ${cx + 9} ${cy + 8} ${cx + 5} ${cy + 11} A 7 7 0 0 1 ${cx - 5} ${cy + 11} Q ${cx - 9} ${cy + 8} ${cx - 8.5} ${cy - 4} Z`, fill: 'url(#chickG)', stroke: '#000', 'stroke-width': .7 }); svgEl(lever, 'line', { x1: cx, y1: cy - 26, x2: cx, y2: cy + 6, stroke: '#4a463e', 'stroke-width': 1.4, 'stroke-linecap': 'round' }); lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s'; let idx; const set = i => { idx = ((i % items.length) + items.length) % items.length; lever.style.transform = `rotate(${items[idx][1]}deg)`; lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); onChange(idx, items[idx][0]); }; s.addEventListener('click', () => set(idx + 1)); set(opts.index !== undefined ? opts.index : 2); return { el: s, get: () => idx, set }; }; /* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate */ GW.slotFader = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120; const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop); gradDef('sfPlate', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#242019'], ['1', '#131110']]); gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); svgEl(s, 'rect', { x: 14, y: 8, width: 62, height: 134, rx: 6, fill: 'url(#sfPlate)', stroke: '#0a0908', 'stroke-width': 1.5 }); svgEl(s, 'rect', { x: 15.5, y: 9.5, width: 59, height: 131, rx: 5, fill: 'none', stroke: 'rgba(255,255,255,.05)', 'stroke-width': 1 }); for (const sy of [16, 134]) { svgEl(s, 'circle', { cx, cy: sy, r: 3.2, fill: 'url(#sfScrew)', stroke: '#14110e', 'stroke-width': .6 }); const [x1, y1] = polar(cx, sy, 3, 112), [x2, y2] = polar(cx, sy, 3, 292); svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#221f1a', 'stroke-width': 1 }); } svgEl(s, 'rect', { x: 40.5, y: yTop - 4, width: 9, height: yBot - yTop + 8, rx: 4, fill: '#0b0a09', stroke: '#050404' }); for (const db of [12, 8, 4, 0, -4, -8, -12, -16, -20, -24]) { const y = yOf(db); svgEl(s, 'line', { x1: 26, y1: y, x2: 39, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 }); svgEl(s, 'line', { x1: 51, y1: y, x2: 64, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 }); svgEl(s, 'text', { x: 23, y: y + 2.2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: db === 0 ? 'var(--cream)' : 'var(--steel)' }).textContent = Math.abs(db); } const handle = svgEl(s, 'g', {}); svgEl(handle, 'polygon', { points: '45,0 58,-5 58,5', fill: '#b9b6ae', stroke: '#5e5a52', 'stroke-width': .5 }); svgEl(handle, 'rect', { x: 58, y: -7, width: 22, height: 14, rx: 3.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 }); svgEl(handle, 'rect', { x: 60, y: -5.2, width: 18, height: 2.2, rx: 1, fill: 'rgba(255,255,255,.4)' }); let db; const set = v => { db = Math.max(-24, Math.min(12, v)); handle.setAttribute('transform', `translate(0,${30 + (12 - db) / 36 * 90})`); onChange(db, (db > 0 ? '+' : db < 0 ? '−' : '') + Math.abs(db).toFixed(1) + ' dB'); }; dragY(s, pct => set(-24 + pct / 100 * 36)); set(opts.value !== undefined ? opts.value : -4); return { el: s, get: () => db, set }; }; /* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it */ GW.spadeKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82; const sweep = v => -80 + v / 10 * 160; gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]); gradDef('spadeMetal', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e9e7e0'], ['1', '#8d897f']]); /* engraved arc: every stroke carries a faint light twin offset below (incised relief) */ const engrave = (x1, y1, x2, y2, w) => { svgEl(s, 'line', { x1, y1: y1 + .8, x2, y2: y2 + .8, stroke: 'rgba(255,255,255,.13)', 'stroke-width': w }); svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#050404', 'stroke-width': w }); }; for (let i = 0; i <= 40; i++) { const v = i / 4, a = sweep(v), major = i % 4 === 0; const [x1, y1] = polar(cx, cy, major ? 50 : 52, a), [x2, y2] = polar(cx, cy, 58, a); engrave(x1, y1, x2, y2, major ? 1.6 : .9); if (major) { const [nx, ny] = polar(cx, cy, 65, a); svgEl(s, 'text', { x: nx, y: ny + 3.2, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: 'rgba(255,255,255,.13)' }).textContent = v; svgEl(s, 'text', { x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#0a0908' }).textContent = v; } } const grp = svgEl(s, 'g', {}); svgEl(grp, 'path', { d: `M ${cx - 2} ${cy - 28} L ${cx - 4.5} ${cy - 42} L ${cx} ${cy - 54} L ${cx + 4.5} ${cy - 42} L ${cx + 2} ${cy - 28} Z`, fill: 'url(#spadeMetal)', stroke: '#55524a', 'stroke-width': .6 }); svgEl(grp, 'circle', { cx, cy, r: 30, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .8 }); svgEl(grp, 'circle', { cx, cy, r: 28.5, fill: 'none', stroke: '#0a0908', 'stroke-width': 3, 'stroke-dasharray': '2.2 2.2' }); svgEl(grp, 'circle', { cx, cy, r: 22, fill: 'url(#spadeKnob)' }); svgEl(grp, 'ellipse', { cx: cx - 8, cy: cy - 9, rx: 8, ry: 5, fill: 'rgba(255,255,255,.08)', transform: `rotate(-30,${cx - 8},${cy - 9})` }); let val; const set = v => { val = Math.max(0, Math.min(10, v)); grp.setAttribute('transform', `rotate(${-80 + val / 10 * 160},70,82)`); onChange(val, val.toFixed(1)); }; dragDelta(s, () => val, set, { min: 0, max: 10, sens: .05 }); set(opts.value !== undefined ? opts.value : 8.3); return { el: s, get: () => val, set }; }; /* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring */ GW.multiBandDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]]; const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62; const sweep = t => -70 + t * 140; gradDef('mbFace', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#eee5c8'], ['1', '#dcd2ae']]); gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]); svgEl(s, 'rect', { x: 4, y: 4, width: 182, height: 102, rx: 12, fill: '#171412', stroke: '#060505' }); svgEl(s, 'rect', { x: 9, y: 9, width: 172, height: 92, rx: 9, fill: 'url(#mbFace)', stroke: '#0a0908' }); const INK = '#2c2318'; const rings = []; [18, 26, 34, 42].forEach(r => { const ring = svgEl(s, 'g', {}); const [x1, y1] = polar(cx, cy, r, -70), [x2, y2] = polar(cx, cy, r, 70); svgEl(ring, 'path', { d: `M ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2}`, fill: 'none', stroke: INK, 'stroke-width': .9 }); for (let i = 0; i <= 8; i++) { const a = sweep(i / 8), [tx1, ty1] = polar(cx, cy, r, a), [tx2, ty2] = polar(cx, cy, r + 3, a); svgEl(ring, 'line', { x1: tx1, y1: ty1, x2: tx2, y2: ty2, stroke: INK, 'stroke-width': .8 }); } rings.push(ring); }); const needle = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: '#1a1613', 'stroke-width': 1.6, 'stroke-linecap': 'round' }); svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 }); svgEl(s, 'text', { x: 14, y: 100, 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'MEGACYCLES'; /* bandspread dial (click = band select) */ const bx = 142, by = 56; for (let i = 0; i < 12; i++) { const a = i * 30, [x1, y1] = polar(bx, by, 24, a), [x2, y2] = polar(bx, by, 21, a); svgEl(s, 'line', { x1, y1, x2, y2, stroke: INK, 'stroke-width': .8 }); } svgEl(s, 'circle', { cx: bx, cy: by, r: 17, fill: 'none', stroke: INK, 'stroke-width': .7, opacity: .5 }); const spread = svgEl(s, 'line', { x1: bx, y1: by, x2: bx, y2: by - 22, stroke: '#1a1613', 'stroke-width': 1.4, 'stroke-linecap': 'round' }); svgEl(s, 'circle', { cx: bx, cy: by, r: 6, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 }); svgEl(s, 'text', { x: bx, y: 96, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'BANDSPREAD'; let val, band; const set = (v, b) => { val = Math.max(0, Math.min(100, v)); band = b; needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`); spread.setAttribute('transform', `rotate(${-45 + b * 30},142,56)`); rings.forEach((g, i) => g.setAttribute('opacity', i === b ? '1' : '.4')); const [lo, hi] = ranges[b]; const mc = lo + val / 100 * (hi - lo); onChange({ band: b, mc }, `B${b + 1} · ${mc.toFixed(2)} Mc`); }; const dragHit = svgEl(s, 'rect', { x: 9, y: 9, width: 100, height: 92, fill: 'transparent' }); dragHit.style.cursor = 'ns-resize'; dragDelta(dragHit, () => val, v => set(v, band), { min: 0, max: 100 }); const clickHit = svgEl(s, 'rect', { x: 112, y: 9, width: 69, height: 92, fill: 'transparent' }); clickHit.style.cursor = 'pointer'; clickHit.addEventListener('click', () => set(val, (band + 1) % ranges.length)); set(opts.value !== undefined ? opts.value : 45, opts.band !== undefined ? opts.band : 2); return { el: s, get: () => [val, band], set }; }; /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { if (document.getElementById('gw-css') || !GW_CSS) return; const st = document.createElement('style'); st.id = 'gw-css'; st.textContent = GW_CSS; document.head.appendChild(st); } if (document.head) ensureCss(); else document.addEventListener('DOMContentLoaded', ensureCss); Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS }); window.GW = GW; })();