From fb94b0fe842d42cf7145d23ad0f0d2a3f8f23c76 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 21:33:05 -0500 Subject: refactor(gallery): extract R02-R06, R12, R14, R15 into GW builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/prototypes/panel-widget-gallery.html | 283 ++------------------------- docs/prototypes/widgets.js | 314 ++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+), 262 deletions(-) (limited to 'docs') 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)}), 'scrub fine, shuttle fast. The edit-deck wheel. Drag to jog the inner wheel; readout shows the position.'); card(C,'R02','Calibrated vernier dial', - ``, + (st,rd)=>GW.vernierDial(st,{value:42.0,onChange:(v,t)=>rd(t)}), 'precision on a rotating disc. 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', - ``, + (st,rd)=>GW.batToggle(st,{on:true,onChange:(v,t)=>rd(t)}), 'the period power switch. 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', - ``, + (st,rd)=>GW.flutedKnob(st,{value:63,onChange:(v,t)=>rd(t)}), 'the skirted console knob. 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', - ``, + (st,rd)=>GW.filterBank(st,{onChange:(v,t)=>rd(t)}), 'a wall of band faders. 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', - ``, + (st,rd)=>GW.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}), 'the pointer-lever switch. 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', - ``, + (st,rd)=>GW.slotFader(st,{value:-4,onChange:(v,t)=>rd(t)}), 'the console gain slide. 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', - ``, + (st,rd)=>GW.spadeKnob(st,{value:8.3,onChange:(v,t)=>rd(t)}), 'the pointer rides the knob. 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', - ``, + (st,rd)=>GW.multiBandDial(st,{onChange:(v,t)=>rd(t)}), 'four scales, one needle. 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', ``, @@ -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() { -- cgit v1.2.3