diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-12 21:33:05 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-12 21:33:05 -0500 |
| commit | fb94b0fe842d42cf7145d23ad0f0d2a3f8f23c76 (patch) | |
| tree | 1c21083deb19acb41c5d0ef4a76246b1349668be /docs/prototypes/widgets.js | |
| parent | 7e081036a5a29316b702a58b28a1ce21c685d4a4 (diff) | |
| download | archsetup-fb94b0fe842d42cf7145d23ad0f0d2a3f8f23c76.tar.gz archsetup-fb94b0fe842d42cf7145d23ad0f0d2a3f8f23c76.zip | |
refactor(gallery): extract R02-R06, R12, R14, R15 into GW builders
First SVG-widget batch: vernier dial, bat toggle, fluted knob, filter bank, chicken-head, slot fader, spade knob, and multi-band dial. widgets.js gains the shared-defs mechanism — document-scoped gradients (nutG, chromeG, spadeKnob, sfScrew, ...) now live in one hidden defs svg, created idempotently by whichever builder needs them first, so cross-widget url(#id) references survive extraction and any widget renders standalone. Probes green; behavioral pass now 60 checks.
Diffstat (limited to 'docs/prototypes/widgets.js')
| -rw-r--r-- | docs/prototypes/widgets.js | 314 |
1 files changed, 314 insertions, 0 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index eba1fd3..2cc7b51 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -519,6 +519,320 @@ GW.jogWheel = function (host, opts = {}) { 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() { |
