aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-12 21:33:05 -0500
committerCraig Jennings <c@cjennings.net>2026-07-12 21:33:05 -0500
commitfb94b0fe842d42cf7145d23ad0f0d2a3f8f23c76 (patch)
tree1c21083deb19acb41c5d0ef4a76246b1349668be
parent7e081036a5a29316b702a58b28a1ce21c685d4a4 (diff)
downloadarchsetup-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.
-rw-r--r--docs/prototypes/panel-widget-gallery.html283
-rw-r--r--docs/prototypes/widgets.js314
2 files changed, 335 insertions, 262 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index 947fd16..b192fb0 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -1614,28 +1614,28 @@ card(C,'N10','Jog / shuttle wheel',
(st,rd)=>GW.jogWheel(st,{onChange:(v,t)=>rd(t)}),
'<b>scrub fine, shuttle fast.</b> The edit-deck wheel. Drag to jog the inner wheel; readout shows the position.');
card(C,'R02','Calibrated vernier dial',
- `<svg id="vern" class="rsvg drag" viewBox="0 0 150 150" width="150" height="150"></svg>`,
+ (st,rd)=>GW.vernierDial(st,{value:42.0,onChange:(v,t)=>rd(t)}),
'<b>precision on a rotating disc.</b> The big lab dial — the calibrated disc turns under a fixed hairline. Drag up/down; readout reads to a tenth. After a 1950s lab-oscillator vernier.');
card(C,'R03','Bat-handle toggle',
- `<svg id="batsw" class="rsvg press" viewBox="0 0 70 90" width="70" height="90"></svg>`,
+ (st,rd)=>GW.batToggle(st,{on:true,onChange:(v,t)=>rd(t)}),
'<b>the period power switch.</b> Chrome bat lever on a hex bushing, snaps up ON / down OFF. Click to throw it. After a lab-instrument panel toggle.');
card(C,'R04','Bakelite fluted knob',
- `<svg id="bakek" class="rsvg drag" viewBox="0 0 110 110" width="110" height="110"></svg>`,
+ (st,rd)=>GW.flutedKnob(st,{value:63,onChange:(v,t)=>rd(t)}),
'<b>the skirted console knob.</b> Scalloped bakelite skirt, glossy dome, amber index over a printed 0-10 scale. Drag up/down to turn. After a vintage console mixer knob.');
card(C,'R05','Filter slider bank',
- `<svg id="fbank" class="rsvg" viewBox="0 0 190 96" width="190" height="96"></svg>`,
+ (st,rd)=>GW.filterBank(st,{onChange:(v,t)=>rd(t)}),
'<b>a wall of band faders.</b> One slider per band, arrow-head caps on black tracks, dB rail at the side. Drag any cap; readout names band and level. After a variable multi-band filter.');
card(C,'R06','Chicken-head selector',
- `<svg id="chick" class="rsvg press" viewBox="0 0 110 96" width="110" height="96"></svg>`,
+ (st,rd)=>GW.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}),
'<b>the pointer-lever switch.</b> The tapered bakelite lever IS the indicator — it aims at the engraved position. Click to step through. After a modulator mode switch.');
card(C,'R12','Chrome slot fader',
- `<svg id="slotf" class="rsvg drag" viewBox="0 0 90 150" width="90" height="150"></svg>`,
+ (st,rd)=>GW.slotFader(st,{value:-4,onChange:(v,t)=>rd(t)}),
'<b>the console gain slide.</b> A chrome T-handle rides a recessed slot, the dB scale engraved on the plate — gain above zero, cut below. Drag; readout shows signed dB. After a vintage channel-strip output fader.');
card(C,'R14','Spade-pointer tuning knob',
- `<svg id="spade" class="rsvg drag" viewBox="0 0 140 124" width="140" height="124"></svg>`,
+ (st,rd)=>GW.spadeKnob(st,{value:8.3,onChange:(v,t)=>rd(t)}),
'<b>the pointer rides the knob.</b> A knurled bakelite knob carries a bright spade pointer over a scale engraved into the panel itself. Drag up/down to tune. After a 1920s radio receiver dial.');
card(C,'R15','Multi-band dial',
- `<svg id="mband" class="rsvg" viewBox="0 0 190 110" width="190" height="110"></svg>`,
+ (st,rd)=>GW.multiBandDial(st,{onChange:(v,t)=>rd(t)}),
'<b>four scales, one needle.</b> Nested band arcs on a cream face; the active ring reads bold. Drag the left dial to tune; click the bandspread dial to switch bands. After a shortwave receiver dial pair.');
card(C,'R16','Entry keypad',
`<svg id="keypad" class="rsvg" viewBox="0 0 150 190" width="150" height="190"></svg>`,
@@ -2079,147 +2079,15 @@ function setMcVu(t){t=Math.max(0,Math.min(1.02,t));mcvu.t=t;
function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12);
setMcVu(mcvu.t+(target-mcvu.t)*(target>mcvu.t?0.32:0.10));}
-/* R02 calibrated vernier dial: the disc turns under a fixed hairline */
-let vernV=42.0,vernDisc=null;
-(function(){const s=$('vern'),cx=75,cy=75;
- const defs=svgEl(s,'defs',{});
- const fg=svgEl(defs,'radialGradient',{id:'vernFace',cx:'50%',cy:'45%',r:'60%'});
- svgEl(fg,'stop',{offset:'0','stop-color':'#f7f2da'});
- svgEl(fg,'stop',{offset:'1','stop-color':'#e4dfc2'});
- const bk=svgEl(defs,'radialGradient',{id:'bakeKnob',cx:'42%',cy:'36%',r:'75%'});
- svgEl(bk,'stop',{offset:'0','stop-color':'#37332c'});
- svgEl(bk,'stop',{offset:'1','stop-color':'#060505'});
- svgEl(s,'circle',{cx,cy,r:72,fill:'#0c0b0a',stroke:'#262320'});
- vernDisc=svgEl(s,'g',{});
- svgEl(vernDisc,'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(vernDisc,'line',{x1,y1,x2,y2,stroke:'#3a3128','stroke-width':major?1.3:half?1:.7});
- if(major){const g=svgEl(vernDisc,'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'});})();
-function setVern(v){vernV=v;vernDisc.setAttribute('transform',`rotate(${-v*3},75,75)`);setRd('R02',v.toFixed(1));}
-dragDelta($('vern'),()=>vernV,setVern,{min:0,max:100,sens:0.15});setVern(42.0);
-
-/* R03 bat-handle toggle */
-let batOn=true,batLever=null,batLblOn=null,batLblOff=null;
-(function(){const s=$('batsw'),cx=35,cy=44;
- const defs=svgEl(s,'defs',{});
- const ng=svgEl(defs,'linearGradient',{id:'nutG',x1:0,y1:0,x2:0,y2:1});
- svgEl(ng,'stop',{offset:'0','stop-color':'#8a8578'});
- svgEl(ng,'stop',{offset:'1','stop-color':'#4e4a42'});
- const cg=svgEl(defs,'linearGradient',{id:'chromeG',x1:0,y1:0,x2:1,y2:0});
- svgEl(cg,'stop',{offset:'0','stop-color':'#7e7a70'});
- svgEl(cg,'stop',{offset:'.45','stop-color':'#e8e5db'});
- svgEl(cg,'stop',{offset:'1','stop-color':'#8a867c'});
- const tg=svgEl(defs,'radialGradient',{id:'tipG',cx:'40%',cy:'32%',r:'75%'});
- svgEl(tg,'stop',{offset:'0','stop-color':'#f2efe8'});
- svgEl(tg,'stop',{offset:'1','stop-color':'#7e7a70'});
- batLblOn=svgEl(s,'text',{x:cx,y:9,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em',
- 'font-family':'var(--mono)',fill:'var(--gold-hi)'});batLblOn.textContent='ON';
- batLblOff=svgEl(s,'text',{x:cx,y:87,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em',
- 'font-family':'var(--mono)',fill:'var(--dim)'});batLblOff.textContent='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'});
- batLever=svgEl(s,'g',{});
- svgEl(batLever,'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(batLever,'ellipse',{cx,cy:17,rx:6.5,ry:8,fill:'url(#tipG)',stroke:'#5e5a52','stroke-width':.6});
- batLever.style.transformOrigin=`${cx}px ${cy}px`;batLever.style.transition='transform .12s';
- $('batsw').onclick=()=>setBat(!batOn);})();
-function setBat(on){batOn=on;batLever.style.transform=on?'rotate(0deg)':'rotate(180deg)';
- batLblOn.setAttribute('fill',on?'var(--gold-hi)':'var(--dim)');
- batLblOff.setAttribute('fill',on?'var(--dim)':'var(--gold-hi)');setRd('R03',on?'ON':'OFF');}
-setBat(true);
-
-/* R04 bakelite fluted knob over a printed 0-10 scale */
-let bakeV=63,bakeIdx=null;
-(function(){const s=$('bakek'),cx=55,cy=54;
- const defs=svgEl(s,'defs',{});
- const sk=svgEl(defs,'radialGradient',{id:'bakeSk',cx:'42%',cy:'36%',r:'80%'});
- svgEl(sk,'stop',{offset:'0','stop-color':'#312d27'});
- svgEl(sk,'stop',{offset:'1','stop-color':'#050404'});
- const dm=svgEl(defs,'radialGradient',{id:'bakeDome',cx:'40%',cy:'32%',r:'80%'});
- svgEl(dm,'stop',{offset:'0','stop-color':'#3a362f'});
- svgEl(dm,'stop',{offset:'1','stop-color':'#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});
- bakeIdx=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)'});})();
-function setBake(v){bakeV=v;bakeIdx.setAttribute('transform',`rotate(${v*2.7-135},55,54)`);
- setRd('R04',(v/10).toFixed(1)+' / 10');}
-dragDelta($('bakek'),()=>bakeV,setBake,{min:0,max:100,sens:0.25});setBake(63);
-
-/* vuDb (VU law) lives in widgets.js (GW) */
-
-/* R05 filter slider bank: one fader per band, arrow caps on black tracks */
-const FB_FREQ=[78,136,235,406,701,1210,2090,3620];
-const fbank={vals:[18,30,42,25,35,55,20,48],caps:[]};
-(function(){const s=$('fbank'),y0=14,y1=84,x0=30,dx=19.5;
- const yOf=v=>y0+v/60*(y1-y0);
- 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';
- FB_FREQ.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=f<1000?f:(f/1000)+'k';
- 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'});
- fbank.caps.push(cap);
- 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=>setFbank(i,(100-pct)/100*60));});
- })();
-function setFbank(i,db){db=Math.max(0,Math.min(60,db));fbank.vals[i]=db;
- fbank.caps[i].setAttribute('transform',`translate(0,${14+db/60*70})`);
- setRd('R05',`${FB_FREQ[i]<1000?FB_FREQ[i]:(FB_FREQ[i]/1000)+'k'} Hz · −${Math.round(db)} dB`);}
-fbank.vals.forEach((v,i)=>setFbank(i,v));setRd('R05','drag a band');
-
-/* R06 chicken-head selector: the tapered lever aims at the position */
-const CHICK_POS=[['OFF',-60],['LO',-20],['MID',20],['HI',60]];
-let chickI=2,chickLever=null,chickLbls=[];
-(function(){const s=$('chick'),cx=55,cy=58;
- const defs=svgEl(s,'defs',{});
- const bg=svgEl(defs,'radialGradient',{id:'chickG',cx:'42%',cy:'34%',r:'80%'});
- svgEl(bg,'stop',{offset:'0','stop-color':'#35312b'});
- svgEl(bg,'stop',{offset:'1','stop-color':'#070606'});
- CHICK_POS.forEach(([lbl,a],i)=>{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;chickLbls.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'});
- chickLever=svgEl(s,'g',{});
- svgEl(chickLever,'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(chickLever,'line',{x1:cx,y1:cy-26,x2:cx,y2:cy+6,stroke:'#4a463e','stroke-width':1.4,'stroke-linecap':'round'});
- chickLever.style.transformOrigin=`${cx}px ${cy}px`;chickLever.style.transition='transform .12s';
- $('chick').onclick=()=>setChick((chickI+1)%CHICK_POS.length);})();
-function setChick(i){chickI=i;chickLever.style.transform=`rotate(${CHICK_POS[i][1]}deg)`;
- chickLbls.forEach((t,k)=>t.setAttribute('fill',k===i?'var(--gold-hi)':'var(--dim)'));
- setRd('R06',CHICK_POS[i][0]);}
-setChick(2);
+/* R02-R06, R12, R14, R15 extracted to widgets.js (GW.*) */
+
+
+
+
+
+
+
+
/* R07 round panel meter: porthole bezel, same VU law as R01 */
let rndT=.5,rndNeedle=null;
@@ -2368,42 +2236,7 @@ function setFlag(on){flagOn=on;flagStripes.style.transform=on?'translateX(0)':'t
setRd('R11',on?'FLAG':'clear');}
setFlag(false);
-/* R12 chrome slot fader: engraved dB scale, chrome T-handle */
-let slotDb=-4,slotHandle=null;
-(function(){const s=$('slotf'),cx=45,yTop=30,yBot=120;
- const yOf=db=>yTop+(12-db)/36*(yBot-yTop);
- const defs=svgEl(s,'defs',{});
- const pl=svgEl(defs,'linearGradient',{id:'sfPlate',x1:0,y1:0,x2:1,y2:1});
- svgEl(pl,'stop',{offset:'0','stop-color':'#242019'});
- svgEl(pl,'stop',{offset:'1','stop-color':'#131110'});
- const ch=svgEl(defs,'linearGradient',{id:'sfChrome',x1:0,y1:0,x2:0,y2:1});
- svgEl(ch,'stop',{offset:'0','stop-color':'#f0efec'});
- svgEl(ch,'stop',{offset:'.45','stop-color':'#c4c1b9'});
- svgEl(ch,'stop',{offset:'1','stop-color':'#75726a'});
- const sc=svgEl(defs,'radialGradient',{id:'sfScrew',cx:'40%',cy:'35%',r:'75%'});
- svgEl(sc,'stop',{offset:'0','stop-color':'#9b968a'});
- svgEl(sc,'stop',{offset:'1','stop-color':'#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);}
- slotHandle=svgEl(s,'g',{});
- svgEl(slotHandle,'polygon',{points:'45,0 58,-5 58,5',fill:'#b9b6ae',stroke:'#5e5a52','stroke-width':.5});
- svgEl(slotHandle,'rect',{x:58,y:-7,width:22,height:14,rx:3.5,fill:'url(#sfChrome)',stroke:'#4e4a42','stroke-width':.6});
- svgEl(slotHandle,'rect',{x:60,y:-5.2,width:18,height:2.2,rx:1,fill:'rgba(255,255,255,.4)'});
- dragY(s,pct=>setSlot(-24+pct/100*36));})();
-function setSlot(db){slotDb=Math.max(-24,Math.min(12,db));
- slotHandle.setAttribute('transform',`translate(0,${30+(12-slotDb)/36*90})`);
- setRd('R12',(slotDb>0?'+':slotDb<0?'−':'')+Math.abs(slotDb).toFixed(1)+' dB');}
-setSlot(-4);
+
/* R13 edgewise strip meter: compressed log scale, bar rides the edge */
const EDGE_SCALE=[[0,.08],[3,.26],[6,.44],[12,.62],[20,.78],[40,.94]];
@@ -2433,83 +2266,9 @@ function setEdge(f){edgeFrac=Math.max(.08,Math.min(.94,f));
setRd('R13','−'+db.toFixed(1)+' dB');}
setEdge(.62);
-/* R14 spade-pointer tuning knob: engraved panel arc, pointer rides the knob */
-let spadeV=8.3,spadeGrp=null;
-(function(){const s=$('spade'),cx=70,cy=82,sweep=v=>-80+v/10*160;
- const defs=svgEl(s,'defs',{});
- const bk=svgEl(defs,'radialGradient',{id:'spadeKnob',cx:'42%',cy:'34%',r:'80%'});
- svgEl(bk,'stop',{offset:'0','stop-color':'#33302a'});
- svgEl(bk,'stop',{offset:'1','stop-color':'#080706'});
- const mt=svgEl(defs,'linearGradient',{id:'spadeMetal',x1:0,y1:0,x2:1,y2:1});
- svgEl(mt,'stop',{offset:'0','stop-color':'#e9e7e0'});
- svgEl(mt,'stop',{offset:'1','stop-color':'#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;}}
- spadeGrp=svgEl(s,'g',{});
- svgEl(spadeGrp,'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(spadeGrp,'circle',{cx,cy,r:30,fill:'url(#spadeKnob)',stroke:'#000','stroke-width':.8});
- svgEl(spadeGrp,'circle',{cx,cy,r:28.5,fill:'none',stroke:'#0a0908','stroke-width':3,'stroke-dasharray':'2.2 2.2'});
- svgEl(spadeGrp,'circle',{cx,cy,r:22,fill:'url(#spadeKnob)'});
- svgEl(spadeGrp,'ellipse',{cx:cx-8,cy:cy-9,rx:8,ry:5,fill:'rgba(255,255,255,.08)',transform:`rotate(-30,${cx-8},${cy-9})`});})();
-function setSpade(v){spadeV=Math.max(0,Math.min(10,v));
- spadeGrp.setAttribute('transform',`rotate(${-80+spadeV/10*160},70,82)`);
- setRd('R14',spadeV.toFixed(1));}
-dragDelta($('spade'),()=>spadeV,setSpade,{min:0,max:10,sens:.05});setSpade(8.3);
-
-/* R15 multi-band dial: nested arcs, one needle; bandspread selects the ring */
-const MBAND_RANGES=[[0.54,1.6],[1.6,5.1],[5.1,15.5],[15.5,30.5]];
-let mbandV=45,mbandB=2,mbNeedle=null,mbRings=[],mbSpread=null;
-(function(){const s=$('mband'),cx=62,cy=62,sweep=t=>-70+t*140;
- const defs=svgEl(s,'defs',{});
- const pf=svgEl(defs,'linearGradient',{id:'mbFace',x1:0,y1:0,x2:1,y2:1});
- svgEl(pf,'stop',{offset:'0','stop-color':'#eee5c8'});
- svgEl(pf,'stop',{offset:'1','stop-color':'#dcd2ae'});
- 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';
- [18,26,34,42].forEach((r,bi)=>{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});}
- mbRings.push(ring);});
- mbNeedle=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});
- mbSpread=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';
- const dragHit=svgEl(s,'rect',{x:9,y:9,width:100,height:92,fill:'transparent'});
- dragHit.style.cursor='ns-resize';
- dragDelta(dragHit,()=>mbandV,v=>setMband(v,mbandB),{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',()=>setMband(mbandV,(mbandB+1)%4));})();
-function setMband(v,b){mbandV=Math.max(0,Math.min(100,v));mbandB=b;
- mbNeedle.setAttribute('transform',`rotate(${-70+mbandV/100*140},62,62)`);
- mbSpread.setAttribute('transform',`rotate(${-45+b*30},142,56)`);
- mbRings.forEach((g,i)=>g.setAttribute('opacity',i===b?'1':'.4'));
- const [lo,hi]=MBAND_RANGES[b];const mc=lo+mbandV/100*(hi-lo);
- setRd('R15',`B${b+1} · ${mc.toFixed(2)} Mc`);}
-setMband(45,2);
+
+
+
/* R16 entry keypad: worn keys feed the amber display; lamps watch state */
let kpBuf='',kpDisp=null,kpLampTop=null,kpLampBot=null;
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() {