From 63577703fe59f03e98a039fec8be56c2fc32826b Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 22:15:12 -0500 Subject: refactor(gallery): extract meters R01, R07-R09, R13, R17 into GW builders The VU ballistics chase stays page-side and drives mcVu.set(t); the R17 trace animation is widget-owned so it moves into the builder with its reduced-motion gate. The R17 face gradient keeps instance-local defs because its stops read screen-family vars from the widget subtree; probe-fams selectors follow the retired rcrt ids to the card handle. --- docs/prototypes/widgets.js | 296 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) (limited to 'docs/prototypes/widgets.js') diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index d7b66f3..49991bf 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -2147,6 +2147,302 @@ GW.battCells = function (host, opts = {}) { 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 }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { -- cgit v1.2.3