aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-12 22:15:12 -0500
committerCraig Jennings <c@cjennings.net>2026-07-12 22:15:12 -0500
commit63577703fe59f03e98a039fec8be56c2fc32826b (patch)
treeba52513b31cfcf72e5c1fc8c9a2825e74ac7d01d /docs/prototypes/widgets.js
parentf210e7fce4df8daf69d87f7c8c7d83d4f13673c4 (diff)
downloadarchsetup-63577703fe59f03e98a039fec8be56c2fc32826b.tar.gz
archsetup-63577703fe59f03e98a039fec8be56c2fc32826b.zip
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.
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js296
1 files changed, 296 insertions, 0 deletions
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() {