From 066bdac1c195fa27d4b71f2ba53ee9265ee7c833 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 22:40:22 -0500 Subject: refactor(gallery): extract indicators R10, R11, R25, R26, R30, R31 into GW builders --- docs/prototypes/widgets.js | 314 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) (limited to 'docs/prototypes/widgets.js') diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index f3e1127..4cd25eb 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -3207,6 +3207,320 @@ GW.patchBay = function (host, opts = {}) { 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 }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { -- cgit v1.2.3