From 142f8180fe679ce00d212248cef54364c0ae5685 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 21:50:14 -0500 Subject: refactor(gallery): extract controls R27-R29, R32-R34, R37, R38 into GW builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Winged gain selector, rotary disc switch, guarded toggle, mechanical timer dial, four-way rocker, four-way toggle selector, pin routing matrix, and dead-man button. The timer dial keeps its reduced-motion-gated 1 Hz wind-down inside the builder — it's widget behavior, not a gallery meter loop. discRed moves to the shared defs so the guarded toggle renders standalone. Behavioral pass now 96 checks; both probes green. --- docs/prototypes/panel-widget-gallery.html | 313 +--------------------------- docs/prototypes/widgets.js | 333 ++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+), 305 deletions(-) (limited to 'docs/prototypes') diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 709b2e8..e08db62 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -1662,28 +1662,28 @@ card(C,'R24','Stomp switch + jewel', (st,rd)=>GW.stompSwitch(st,{on:false,onChange:(v,t)=>rd(t)}), 'the engage pair. A chrome dome stomp and the faceted jewel that reports it — press to engage, the jewel lights; press again to bypass. Click it. After a tremolo pedal footswitch.'); card(C,'R27','Winged gain selector', - ``, + (st,rd)=>GW.wingSelector(st,{index:7,onChange:(v,t)=>rd(t)}), 'the red T-bar, stepped. A bar-grip handle over a dot ring, snapping between labeled gain detents. Drag up/down to step it. After a classic mic-preamp gain switch.'); card(C,'R28','Rotary disc switch', - ``, + (st,rd)=>GW.discSwitch(st,{index:1,onChange:(v,t)=>rd(t)}), 'the master power disc. The whole red disc turns, grip bar molded in — a heavy three-position selector for OFF, ON, and COMBINE. Click to step. After a marine battery switch.'); card(C,'R29','Guarded toggle', - ``, + (st,rd)=>GW.guardedToggle(st,{on:true,onChange:(v,t)=>rd(t)}), 'friction sized to consequence. Guard posts flank the lever and a red collar marks it critical — the avionics way to prevent an accidental throw. Click to throw it deliberately. After a flight-deck switch panel.'); card(C,'R32','Mechanical timer dial', - ``, + (st,rd)=>GW.timerDial(st,{minutes:0,onChange:(v,t)=>rd(t)}), 'turn to start, push to stop. Drag the knurled dial to wind minutes onto the fixed red index; it winds back down to OFF on its own (demo runs a minute per second). Click the red knob to stop it dead. After a mechanical interval timer.'); card(C,'R33','Four-way rocker', - ``, + (st,rd)=>GW.rockerPad(st,{onChange:(v,t)=>rd(t)}), 'discrete direction, one press at a time. Click an arrow (the whole quadrant is the target) to step that way; the readout tracks the cursor position it drives. After a remote-control navigation pad.'); card(C,'R34','Four-way toggle selector', - ``, + (st,rd)=>GW.fourWayToggle(st,{position:'C',onChange:(v,t)=>rd(t)}), 'a lever that lives in one of four states. Click a quadrant and the ball lever throws to A, B, C or D; the matching corner lamp takes the light. The rocker steps — this one selects. After a test-panel quadrant switch.'); card(C,'R37','Pin routing matrix', - ``, + (st,rd)=>GW.pinMatrix(st,{onChange:(v,t)=>rd(t)}), 'many-to-many routing you can read. Sources are rows, destinations are columns; click an intersection to seat a pin and the route exists. Every simultaneous route stays visible. After the EMS VCS3 pin matrix.'); card(C,'R38','Dead-man button', - ``, + (st,rd)=>GW.deadMan(st,{onChange:(v,t)=>rd(t)}), 'active only while held. Press and hold — the ring arms, the lamp runs, the dwell clock counts. Let go anywhere and it drops to SAFE. The kit has toggles and momentaries; this is the only control whose state IS your grip.'); card(C,'R39','Rotary telephone dial', ``, @@ -2299,87 +2299,6 @@ function tickRcrt(){rcrtPh+=0.12;const amp=8+3*Math.sin(rcrtPh*0.35);let pts=''; tickRcrt(); if(!matchMedia('(prefers-reduced-motion: reduce)').matches)setInterval(tickRcrt,80); -/* R27 winged gain selector: red T-bar over a dot ring, stepped detents */ -const WINGK_STEPS=[-80,-70,-60,-50,-40,-30,-20,-10,-5,0,5,10]; -let wingI=7,wingGrp=null; -(function(){const s=$('wingk'),cx=55,cy=52; - const defs=svgEl(s,'defs',{}); - const rg2=svgEl(defs,'radialGradient',{id:'wingRed',cx:'40%',cy:'32%',r:'80%'}); - svgEl(rg2,'stop',{offset:'0','stop-color':'#d98a6f'}); - svgEl(rg2,'stop',{offset:'.6','stop-color':'var(--fail)'}); - svgEl(rg2,'stop',{offset:'1','stop-color':'#6e2415'}); - WINGK_STEPS.forEach((db,i)=>{const a=-135+i/(WINGK_STEPS.length-1)*270; - const [dx,dy]=polar(cx,cy,38,a); - svgEl(s,'circle',{cx:dx,cy:dy,r:1.5,fill:'var(--steel)'}); - if(i%2===1||db===0||db===10||db===-80){const [tx,ty]=polar(cx,cy,46.5,a); - svgEl(s,'text',{x:tx,y:ty+2.2,'text-anchor':'middle','font-size':5.5,'font-family':'var(--mono)', - fill:'var(--steel)'}).textContent=db>0?'+'+db:db;}}); - wingGrp=svgEl(s,'g',{});wingGrp.style.transition='transform .09s'; - svgEl(wingGrp,'rect',{x:cx-6.5,y:cy-30,width:13,height:60,rx:6,fill:'url(#wingRed)',stroke:'#4e150a','stroke-width':.8}); - svgEl(wingGrp,'circle',{cx,cy,r:15,fill:'url(#wingRed)',stroke:'#4e150a','stroke-width':.8}); - svgEl(wingGrp,'line',{x1:cx,y1:cy-28,x2:cx,y2:cy-18,stroke:'var(--cream)','stroke-width':2,'stroke-linecap':'round'}); - svgEl(wingGrp,'ellipse',{cx:cx-4,cy:cy-8,rx:5,ry:3,fill:'rgba(255,255,255,.18)',transform:`rotate(-30,${cx-4},${cy-8})`});})(); -function setWing(v){const i=Math.max(0,Math.min(WINGK_STEPS.length-1,Math.round(v)));wingI=i; - wingGrp.style.transform=`rotate(${-135+i/(WINGK_STEPS.length-1)*270}deg)`; - wingGrp.style.transformOrigin='55px 52px'; - const db=WINGK_STEPS[i]; - setRd('R27',(db>0?'+':'')+db+' dB');} -dragDelta($('wingk'),()=>wingI,setWing,{min:0,max:WINGK_STEPS.length-1,sens:.05});setWing(7); - -/* R28 rotary disc switch: the whole disc turns between heavy positions */ -const DISCSW_POS=[['OFF',-45],['ON',45],['COMBINE',135]]; -let discI=1,discGrp=null,discLbls=[]; -(function(){const s=$('discsw'),cx=55,cy=58; - const defs=svgEl(s,'defs',{}); - const rr=svgEl(defs,'radialGradient',{id:'discRed',cx:'42%',cy:'34%',r:'85%'}); - svgEl(rr,'stop',{offset:'0','stop-color':'#d98a6f'}); - svgEl(rr,'stop',{offset:'.65','stop-color':'var(--fail)'}); - svgEl(rr,'stop',{offset:'1','stop-color':'#7a2a1a'}); - svgEl(s,'circle',{cx,cy,r:48,fill:'#1a1714',stroke:'#060505','stroke-width':1.5}); - for(let k=0;k<4;k++){const [x,y]=polar(cx,cy,44,45+k*90); - svgEl(s,'circle',{cx:x,cy:y,r:2.6,fill:'url(#sfScrew)',stroke:'#0a0908','stroke-width':.5});} - DISCSW_POS.forEach(([lbl,a],i)=>{const [tx,ty]=polar(cx,cy,42,a); - const t=svgEl(s,'text',{x:tx,y:ty+2.4,'text-anchor':'middle','font-size':5.8,'letter-spacing':'.06em', - 'font-family':'var(--mono)',fill:'var(--dim)'});t.textContent=lbl;discLbls.push(t);}); - svgEl(s,'circle',{cx,cy,r:36,fill:'url(#discRed)',stroke:'#4e150a','stroke-width':1}); - discGrp=svgEl(s,'g',{});discGrp.style.transition='transform .15s';discGrp.style.transformOrigin=`${cx}px ${cy}px`; - svgEl(discGrp,'rect',{x:cx-27,y:cy-8,width:54,height:16,rx:8,fill:'url(#discRed)',stroke:'#8f3520','stroke-width':1}); - svgEl(discGrp,'rect',{x:cx-25,y:cy-6.5,width:50,height:4,rx:2,fill:'rgba(255,255,255,.16)'}); - svgEl(discGrp,'line',{x1:cx+20,y1:cy,x2:cx+26,y2:cy,stroke:'var(--cream)','stroke-width':2.5,'stroke-linecap':'round'}); - svgEl(s,'ellipse',{cx:cx-12,cy:cy-14,rx:12,ry:6,fill:'rgba(255,255,255,.10)',transform:`rotate(-28,${cx-12},${cy-14})`}); - $('discsw').onclick=()=>setDisc((discI+1)%DISCSW_POS.length);})(); -function setDisc(i){discI=i; - discGrp.style.transform=`rotate(${DISCSW_POS[i][1]}deg)`; - discLbls.forEach((t,k)=>t.setAttribute('fill',k===i?'var(--gold-hi)':'var(--dim)')); - setRd('R28',DISCSW_POS[i][0]);} -setDisc(1); - -/* R29 guarded toggle: guard posts + red collar mark the critical throw */ -let guardOn=true,guardLever=null,guardLblOn=null,guardLblOff=null; -(function(){const s=$('guardsw'),cx=45,cy=52; - guardLblOn=svgEl(s,'text',{x:cx,y:12,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em', - 'font-family':'var(--mono)',fill:'var(--gold-hi)'});guardLblOn.textContent='ON'; - guardLblOff=svgEl(s,'text',{x:cx,y:96,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em', - 'font-family':'var(--mono)',fill:'var(--dim)'});guardLblOff.textContent='OFF'; - svgEl(s,'circle',{cx,cy,r:13,fill:'url(#discRed)',stroke:'#4e150a','stroke-width':1}); - const pts=[];for(let k=0;k<6;k++){const a=(k*60+30)*Math.PI/180;pts.push((cx+11*Math.cos(a))+','+(cy+11*Math.sin(a)));} - svgEl(s,'polygon',{points:pts.join(' '),fill:'url(#nutG)',stroke:'#2b2822'}); - svgEl(s,'circle',{cx,cy,r:6,fill:'#14110e',stroke:'#000'}); - for(const gx of [16,74]){ - svgEl(s,'rect',{x:gx-6,y:cy-24,width:12,height:48,rx:6,fill:'url(#nutG)',stroke:'#2b2822','stroke-width':1}); - svgEl(s,'rect',{x:gx-4,y:cy-21,width:3.5,height:42,rx:2,fill:'rgba(255,255,255,.14)'});} - guardLever=svgEl(s,'g',{}); - svgEl(guardLever,'path',{d:`M ${cx-3} ${cy} L ${cx-2} 26 L ${cx+2} 26 L ${cx+3} ${cy} Z`,fill:'url(#chromeG)',stroke:'#4e4a42','stroke-width':.5}); - svgEl(guardLever,'rect',{x:cx-4.5,y:16,width:9,height:12,rx:2,fill:'url(#tipG)',stroke:'#5e5a52','stroke-width':.6}); - for(let r=0;r<4;r++)svgEl(guardLever,'line',{x1:cx-3.5,y1:18.5+r*2.4,x2:cx+3.5,y2:18.5+r*2.4,stroke:'rgba(0,0,0,.25)','stroke-width':.8}); - guardLever.style.transformOrigin=`${cx}px ${cy}px`;guardLever.style.transition='transform .12s'; - $('guardsw').onclick=()=>setGuard(!guardOn);})(); -function setGuard(on){guardOn=on;guardLever.style.transform=on?'rotate(0deg)':'rotate(180deg)'; - guardLblOn.setAttribute('fill',on?'var(--gold-hi)':'var(--dim)'); - guardLblOff.setAttribute('fill',on?'var(--dim)':'var(--gold-hi)'); - setRd('R29',on?'ON':'OFF');} -setGuard(true); - /* R30 telegraph indicator: the pointer names the state on labeled sectors */ const TELIND_STATES=['OK','TEST','BUSY','STOP','ATTN','FAULT']; let telI=0,telPtr=null; @@ -2648,162 +2567,6 @@ if(!reduced){ document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='paused'); } -/* R32 mechanical timer dial: dial rotates under a fixed index; wind by drag, stop by the red knob */ -let mtMin=0,mtRot=null; -(function(){const s=$('mtimer'),cx=62,cy=58; - const defs=svgEl(s,'defs',{}); - const fc=svgEl(defs,'radialGradient',{id:'mtFace',cx:'50%',cy:'42%',r:'75%'}); - svgEl(fc,'stop',{offset:'0','stop-color':'#282320'}); - svgEl(fc,'stop',{offset:'1','stop-color':'#14110e'}); - const rd=svgEl(defs,'radialGradient',{id:'mtRed',cx:'38%',cy:'32%',r:'80%'}); - svgEl(rd,'stop',{offset:'0','stop-color':'#e0523a'}); - svgEl(rd,'stop',{offset:'1','stop-color':'#8f2416'}); - const hb=svgEl(defs,'linearGradient',{id:'mtHub',x1:0,y1:0,x2:0,y2:1}); - svgEl(hb,'stop',{offset:'0','stop-color':'#c9c4b8'}); - svgEl(hb,'stop',{offset:'1','stop-color':'#6e685c'}); - // bezel + knurled coin edge - svgEl(s,'circle',{cx,cy,r:50,fill:'#17140f',stroke:'#060505','stroke-width':2}); - svgEl(s,'circle',{cx,cy,r:47.5,fill:'none',stroke:'#3a352c','stroke-width':4,'stroke-dasharray':'2 1.6'}); - svgEl(s,'circle',{cx,cy,r:44,fill:'url(#mtFace)',stroke:'#0a0908','stroke-width':1}); - // rotating scale: OFF at 0, minutes climb clockwise at 4.5 deg/min (60 at 270) - mtRot=svgEl(s,'g',{}); - for(let m=0;m<=60;m+=5){const major=m%20===0; - const [x1,y1]=polar(cx,cy,40,m*4.5),[x2,y2]=polar(cx,cy,major?34:37,m*4.5); - svgEl(mtRot,'line',{x1,y1,x2,y2,stroke:'var(--cream)','stroke-width':major?1.2:.55,opacity:major?.9:.55});} - for(const m of [20,40,60]){const [x,y]=polar(cx,cy,27,m*4.5); - svgEl(mtRot,'text',{x,y:y+3,'text-anchor':'middle','font-size':9,'font-weight':700, - 'font-family':'var(--mono)',fill:'var(--cream)'}).textContent=String(m);} - const [ox,oy]=polar(cx,cy,27,0); - svgEl(mtRot,'text',{x:ox,y:oy+3,'text-anchor':'middle','font-size':7.5,'font-weight':700, - 'font-family':'var(--mono)',fill:'#e0523a'}).textContent='OFF'; - const [mx,my]=polar(cx,cy,16,180); - svgEl(mtRot,'text',{x:mx,y:my+2.5,'text-anchor':'middle','font-size':5.4,'letter-spacing':'.14em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='MINUTES'; - // hub with screw slot - svgEl(s,'circle',{cx,cy,r:9,fill:'url(#mtHub)',stroke:'#3c382f','stroke-width':1}); - svgEl(s,'line',{x1:cx-5.5,y1:cy,x2:cx+5.5,y2:cy,stroke:'#4a4438','stroke-width':2}); - svgEl(s,'circle',{cx,cy,r:2.2,fill:'#8f897b'}); - // fixed red index at top + winding-direction arrow - svgEl(s,'line',{x1:cx,y1:cy-44,x2:cx,y2:cy-32,stroke:'#e0523a','stroke-width':1.6}); - svgEl(s,'path',{d:'M 122 14 A 26 26 0 0 1 136 26',fill:'none',stroke:'var(--steel)','stroke-width':1.6}); - svgEl(s,'polygon',{points:'136,26 130.5,24.5 134.5,19.5',fill:'var(--steel)'}); - // engraved plate captions - const cap=(t,y)=>svgEl(s,'text',{x:4,y,'font-size':6.2,'letter-spacing':'.1em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent=t; - cap('PUSH TO STOP',118); cap('TURN TO START',128); - // dial drag surface (under the knob in DOM so the knob wins the overlap) - const hit=svgEl(s,'circle',{cx,cy,r:50,fill:'transparent'}); - hit.style.cursor='grab'; - dragDelta(hit,()=>mtMin,v=>setMtimer(v),{min:0,max:60}); - // red stop knob: fluted edge, domed center - const knob=svgEl(s,'g',{}); - svgEl(knob,'circle',{cx:126,cy:104,r:20,fill:'url(#mtRed)',stroke:'#5c150c','stroke-width':1.5}); - svgEl(knob,'circle',{cx:126,cy:104,r:18,fill:'none',stroke:'rgba(0,0,0,.4)','stroke-width':3.4,'stroke-dasharray':'3 2.4'}); - svgEl(knob,'circle',{cx:126,cy:104,r:8.5,fill:'url(#mtRed)',stroke:'rgba(0,0,0,.3)','stroke-width':.8}); - svgEl(knob,'ellipse',{cx:121,cy:98,rx:6,ry:3.4,fill:'rgba(255,255,255,.18)',transform:'rotate(-28,121,98)'}); - knob.style.cursor='pointer'; - knob.addEventListener('click',()=>{setMtimer(0);setRd('R32','STOP · OFF');});})(); -function setMtimer(m){mtMin=Math.max(0,Math.min(60,m)); - mtRot.setAttribute('transform',`rotate(${(-mtMin*4.5).toFixed(2)},62,58)`); - setRd('R32',mtMin>0.5?'T-'+Math.round(mtMin)+' MIN':'OFF');} -setMtimer(0); -if(!matchMedia('(prefers-reduced-motion: reduce)').matches) - setInterval(()=>{if(mtMin>0){setMtimer(mtMin-1);if(mtMin===0)setRd('R32','DING · OFF');}},1000); - -/* R33 four-way rocker: quadrant clicks step a tracked cursor; arrows flash on press */ -(function(){const s=$('rocker4'),cx=55,cy=55; - const defs=svgEl(s,'defs',{}); - const pd=svgEl(defs,'radialGradient',{id:'rk4Pad',cx:'42%',cy:'34%',r:'85%'}); - svgEl(pd,'stop',{offset:'0','stop-color':'#33302b'}); - svgEl(pd,'stop',{offset:'1','stop-color':'#141210'}); - const ar=svgEl(defs,'linearGradient',{id:'rk4Arr',x1:0,y1:0,x2:0,y2:1}); - svgEl(ar,'stop',{offset:'0','stop-color':'#e9e4d6'}); - svgEl(ar,'stop',{offset:'1','stop-color':'#9d988b'}); - const nb=svgEl(defs,'radialGradient',{id:'rk4Nub',cx:'38%',cy:'30%',r:'85%'}); - svgEl(nb,'stop',{offset:'0','stop-color':'#4a453e'}); - svgEl(nb,'stop',{offset:'1','stop-color':'#0d0c0a'}); - // recessed plate + rubber pad - svgEl(s,'rect',{x:3,y:3,width:104,height:104,rx:18,fill:'#211e1a',stroke:'#0c0b09','stroke-width':1.5}); - svgEl(s,'circle',{cx,cy,r:45,fill:'#0f0d0b'}); - svgEl(s,'circle',{cx,cy,r:41,fill:'url(#rk4Pad)',stroke:'#060505','stroke-width':1}); - // four arrows, pointing outward - const ARR={up:'55,22 45,36 65,36',down:'55,88 45,74 65,74',left:'22,55 36,45 36,65',right:'88,55 74,45 74,65'}; - const arrows={}; - for(const [dir,pts] of Object.entries(ARR)) - arrows[dir]=svgEl(s,'polygon',{points:pts,fill:'url(#rk4Arr)',stroke:'#3c382f','stroke-width':.7}); - // center pivot nub - svgEl(s,'circle',{cx,cy,r:7,fill:'url(#rk4Nub)',stroke:'#060505','stroke-width':.8}); - svgEl(s,'ellipse',{cx:cx-2,cy:cy-2.5,rx:2.6,ry:1.7,fill:'rgba(255,255,255,.25)'}); - // quadrant hit zones (drawn last so they win), press flash + tracked position - let rx=0,ry=0; - const press=(dir,dx,dy)=>{rx+=dx;ry+=dy; - setRd('R33',dir.toUpperCase()+' · x '+rx+' y '+ry); - arrows[dir].setAttribute('fill','var(--gold-hi)'); - setTimeout(()=>arrows[dir].setAttribute('fill','url(#rk4Arr)'),160);}; - const zone=(dir,pts,dx,dy)=>{const z=svgEl(s,'polygon',{points:pts,fill:'transparent'}); - z.style.cursor='pointer';z.addEventListener('click',()=>press(dir,dx,dy));}; - zone('up',`${cx},${cy} 20,20 90,20`,0,1); - zone('down',`${cx},${cy} 20,90 90,90`,0,-1); - zone('left',`${cx},${cy} 20,20 20,90`,-1,0); - zone('right',`${cx},${cy} 90,20 90,90`,1,0); - setRd('R33','x 0 y 0');})(); - -/* R34 four-way toggle selector: ball lever throws to a diagonal; corner lamps show the state */ -(function(){const s=$('fourway'),cx=65,cy=65; - const defs=svgEl(s,'defs',{}); - const ch=svgEl(defs,'linearGradient',{id:'fw4Chrome',x1:0,y1:0,x2:1,y2:1}); - svgEl(ch,'stop',{offset:'0','stop-color':'#e8e6e0'}); - svgEl(ch,'stop',{offset:'.5','stop-color':'#9a968c'}); - svgEl(ch,'stop',{offset:'1','stop-color':'#55524a'}); - const bl=svgEl(defs,'radialGradient',{id:'fw4Ball',cx:'36%',cy:'30%',r:'80%'}); - svgEl(bl,'stop',{offset:'0','stop-color':'#f2f0ea'}); - svgEl(bl,'stop',{offset:'.55','stop-color':'#8e8a80'}); - svgEl(bl,'stop',{offset:'1','stop-color':'#3c3a34'}); - // printed panel graphics: axes, circle, diagonal square, labels - svgEl(s,'line',{x1:8,y1:cy,x2:122,y2:cy,stroke:'var(--steel)','stroke-width':.7,opacity:.7}); - svgEl(s,'line',{x1:cx,y1:8,x2:cx,y2:122,stroke:'var(--steel)','stroke-width':.7,opacity:.7}); - svgEl(s,'text',{x:cx+2,y:13,'font-size':7,'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='Y'; - svgEl(s,'text',{x:116,y:cy-4,'font-size':7,'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='X'; - svgEl(s,'circle',{cx,cy,r:42,fill:'none',stroke:'var(--steel)','stroke-width':.9,opacity:.8}); - const QUAD={A:315,B:45,C:135,D:225}; - const diag=Object.values(QUAD).map(a=>polar(cx,cy,42,a)); - svgEl(s,'polygon',{points:diag.map(p=>p.join(',')).join(' '),fill:'none',stroke:'var(--steel)','stroke-width':.8,opacity:.8}); - const LAMP_AT={A:[14,14],B:[116,14],C:[116,116],D:[14,116]}; - const lamps={}; - for(const [q,a] of Object.entries(QUAD)){ - const [lx,ly]=polar(cx,cy,42,a); - const [px,py]=LAMP_AT[q]; - svgEl(s,'line',{x1:lx,y1:ly,x2:px,y2:py,stroke:'var(--steel)','stroke-width':.7,opacity:.5}); - svgEl(s,'circle',{cx:lx,cy:ly,r:6.5,fill:'#14110e',stroke:'var(--steel)','stroke-width':.9}); - svgEl(s,'text',{x:lx,y:ly+2.8,'text-anchor':'middle','font-size':7.5,'font-weight':700, - 'font-family':'var(--mono)',fill:'var(--cream)'}).textContent=q; - svgEl(s,'circle',{cx:px,cy:py,r:5.5,fill:'#0d0b09',stroke:'#2c2820','stroke-width':1}); - lamps[q]=svgEl(s,'circle',{cx:px,cy:py,r:3.6,fill:'#3a3426'});} - // bezel + boot - svgEl(s,'circle',{cx,cy,r:20,fill:'#0d0c0a',stroke:'#2a2722','stroke-width':1.5}); - svgEl(s,'circle',{cx,cy,r:15,fill:'#17140f',stroke:'#060505','stroke-width':1}); - // lever: shaft + grip rotate to the selected diagonal; ball pivot stays centered - const lever=svgEl(s,'g',{}); - svgEl(lever,'polygon',{points:`${cx-3},${cy} ${cx+3},${cy} ${cx+2},${cy-26} ${cx-2},${cy-26}`,fill:'url(#fw4Chrome)',stroke:'#3c3a34','stroke-width':.6}); - svgEl(lever,'rect',{x:cx-3.4,y:cy-40,width:6.8,height:16,rx:3,fill:'url(#fw4Chrome)',stroke:'#3c3a34','stroke-width':.6}); - svgEl(s,'circle',{cx,cy,r:8.5,fill:'url(#fw4Ball)',stroke:'#26241f','stroke-width':.8}); - svgEl(s,'ellipse',{cx:cx-2.6,cy:cy-3,rx:2.8,ry:1.9,fill:'rgba(255,255,255,.5)'}); - let fwPos=null; - const setFw=q=>{fwPos=q; - lever.setAttribute('transform',`rotate(${QUAD[q]},${cx},${cy})`); - for(const k of Object.keys(QUAD)){ - lamps[k].setAttribute('fill',k===q?'var(--jewel-a)':'#3a3426'); - lamps[k].setAttribute('filter',k===q?'drop-shadow(0 0 4px rgba(255,180,58,.8))':'none');} - setRd('R34','POS '+q);}; - // quadrant click zones (diagonal quadrants, drawn last) - const zone=(q,pts)=>{const z=svgEl(s,'polygon',{points:pts,fill:'transparent'}); - z.style.cursor='pointer';z.addEventListener('click',()=>setFw(q));}; - zone('A',`${cx},${cy} ${cx},5 5,5 5,${cy}`); - zone('B',`${cx},${cy} ${cx},5 125,5 125,${cy}`); - zone('C',`${cx},${cy} 125,${cy} 125,125 ${cx},125`); - zone('D',`${cx},${cy} ${cx},125 5,125 5,${cy}`); - setFw('C');})(); - /* R35 day-date disc calendar: coaxial printed discs rotate so today reads under the fixed hand */ const DD_DAYS=['SUN','MON','TUE','WED','THU','FRI','SAT']; let ddDate=1,ddDay=0,ddDateG=null,ddDayG=null; @@ -2862,66 +2625,6 @@ function setDdcal(d,w){ddDate=d;ddDay=w;const HAND=135; setRd('R36',lit+' / 64 lit');});} setRd('R36',lit+' / 64 lit');})(); -/* R37 pin routing matrix: click an intersection to seat/pull a pin; many-to-many */ -(function(){const s=$('pinmx'); - const ROWS=['OSC1','OSC2','LFO','NOIS','ENV'],COLS=['VCF','VCA','PAN','DLY','OUT','MOD']; - const X0=42,Y0=30,DX=19,DY=15.5; - svgEl(s,'rect',{x:2,y:2,width:156,height:106,rx:8,fill:'#1c1916',stroke:'#060505','stroke-width':1.5}); - COLS.forEach((c,j)=>svgEl(s,'text',{x:X0+j*DX,y:16,'text-anchor':'middle','font-size':5.4, - 'letter-spacing':'.05em','font-family':'var(--mono)',fill:'var(--steel)'}).textContent=c); - ROWS.forEach((r,i)=>svgEl(s,'text',{x:34,y:Y0+i*DY+2,'text-anchor':'end','font-size':5.4, - 'letter-spacing':'.05em','font-family':'var(--mono)',fill:'var(--steel)'}).textContent=r); - const pins=new Set(['OSC1>VCF','LFO>PAN','ENV>VCA']); // a legible default patch - ROWS.forEach((r,i)=>COLS.forEach((c,j)=>{ - const cx=X0+j*DX,cy=Y0+i*DY,key=r+'>'+c; - svgEl(s,'circle',{cx,cy,r:3.4,fill:'#0a0908',stroke:'#2c2820','stroke-width':1}); - const cap=svgEl(s,'circle',{cx,cy,r:4.4,fill:'var(--gold)',stroke:'#7d5c16','stroke-width':1, - opacity:pins.has(key)?1:0,filter:'drop-shadow(0 1px 2px rgba(0,0,0,.6))'}); - const hit=svgEl(s,'circle',{cx,cy,r:8,fill:'transparent'}); - hit.style.cursor='pointer'; - hit.addEventListener('click',()=>{const on=!pins.has(key); - if(on)pins.add(key);else pins.delete(key); - cap.setAttribute('opacity',on?1:0); - setRd('R37',(on?r+' → '+c:'pulled '+r+' → '+c)+' · '+pins.size+' routes');});})); - setRd('R37',pins.size+' routes');})(); - -/* R38 dead-man button: state exists only while the pointer holds it down */ -(function(){const s=$('deadman'),cx=60,cy=58; - const defs=svgEl(s,'defs',{}); - const rb=svgEl(defs,'radialGradient',{id:'dmBtn',cx:'40%',cy:'32%',r:'85%'}); - svgEl(rb,'stop',{offset:'0','stop-color':'#3a3631'}); - svgEl(rb,'stop',{offset:'1','stop-color':'#16130f'}); - svgEl(s,'rect',{x:4,y:4,width:112,height:102,rx:12,fill:'#211e1a',stroke:'#0c0b09','stroke-width':1.5}); - svgEl(s,'text',{x:cx,y:18,'text-anchor':'middle','font-size':6.2,'letter-spacing':'.16em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='HOLD TO RUN'; - const lamp=svgEl(s,'circle',{cx:104,cy:16,r:4.5,fill:'#1e2a12'}); - const ring=svgEl(s,'circle',{cx,cy,r:33,fill:'none',stroke:'#2c2820','stroke-width':3}); - const btn=svgEl(s,'g',{}); - svgEl(btn,'circle',{cx,cy,r:28,fill:'url(#dmBtn)',stroke:'#060505','stroke-width':1.5}); - svgEl(btn,'circle',{cx,cy,r:22,fill:'none',stroke:'rgba(255,255,255,.07)','stroke-width':1}); - svgEl(btn,'text',{x:cx,y:cy+2.5,'text-anchor':'middle','font-size':7,'letter-spacing':'.1em', - 'font-family':'var(--mono)',fill:'var(--silver)'}).textContent='RUN'; - btn.style.cursor='pointer'; - let dmT0=null,dmIv=null; - const stop=()=>{if(dmT0===null)return; - const held=((Date.now()-dmT0)/1000).toFixed(1); - dmT0=null;clearInterval(dmIv); - btn.removeAttribute('transform'); - ring.setAttribute('stroke','#2c2820');ring.removeAttribute('filter'); - lamp.setAttribute('fill','#1e2a12');lamp.removeAttribute('filter'); - setRd('R38','SAFE (held '+held+'s)');}; - btn.addEventListener('pointerdown',e=>{btn.setPointerCapture(e.pointerId); - dmT0=Date.now(); - btn.setAttribute('transform','translate(0,1.5)'); - ring.setAttribute('stroke','var(--gold)');ring.setAttribute('filter','drop-shadow(0 0 5px rgba(var(--glow-lo),.7))'); - lamp.setAttribute('fill','var(--pass)');lamp.setAttribute('filter','drop-shadow(0 0 4px rgba(116,147,47,.8))'); - setRd('R38','RUNNING 0.0s'); - dmIv=setInterval(()=>{if(dmT0!==null)setRd('R38','RUNNING '+((Date.now()-dmT0)/1000).toFixed(1)+'s');},100); - e.preventDefault();}); - btn.addEventListener('pointerup',stop); - btn.addEventListener('pointercancel',stop); - setRd('R38','SAFE');})(); - /* R39 rotary telephone dial: click a hole; the wheel winds to the stop and returns */ (function(){const s=$('teldial'),cx=65,cy=63; const DIGITS=['1','2','3','4','5','6','7','8','9','0']; diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index cd74771..f213518 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -1139,6 +1139,339 @@ GW.stompSwitch = function (host, opts = {}) { return { el: s, get: () => on, set }; }; +/* R27 winged gain selector — red T-bar over a dot ring, stepped detents */ +GW.wingSelector = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10]; + const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; + gradDef('wingRed', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#d98a6f'], ['.6', 'var(--fail)'], ['1', '#6e2415']]); + steps.forEach((db, i) => { + const a = -135 + i / (steps.length - 1) * 270; + const [dx, dy] = polar(cx, cy, 38, a); + svgEl(s, 'circle', { cx: dx, cy: dy, r: 1.5, fill: 'var(--steel)' }); + if (i % 2 === 1 || db === 0 || db === 10 || db === -80) { + const [tx, ty] = polar(cx, cy, 46.5, a); + svgEl(s, 'text', { x: tx, y: ty + 2.2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = db > 0 ? '+' + db : db; + } + }); + const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .09s'; + svgEl(grp, 'rect', { x: cx - 6.5, y: cy - 30, width: 13, height: 60, rx: 6, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 }); + svgEl(grp, 'circle', { cx, cy, r: 15, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 }); + svgEl(grp, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 18, stroke: 'var(--cream)', 'stroke-width': 2, 'stroke-linecap': 'round' }); + svgEl(grp, 'ellipse', { cx: cx - 4, cy: cy - 8, rx: 5, ry: 3, fill: 'rgba(255,255,255,.18)', transform: `rotate(-30,${cx - 4},${cy - 8})` }); + let idx; + const set = v => { + idx = Math.max(0, Math.min(steps.length - 1, Math.round(v))); + grp.style.transform = `rotate(${-135 + idx / (steps.length - 1) * 270}deg)`; + grp.style.transformOrigin = `${cx}px ${cy}px`; + const db = steps[idx]; + onChange(db, (db > 0 ? '+' : '') + db + ' dB'); + }; + dragDelta(s, () => idx, set, { min: 0, max: steps.length - 1, sens: .05 }); + set(opts.index !== undefined ? opts.index : 7); + return { el: s, get: () => idx, set }; +}; + +/* R28 rotary disc switch — the whole disc turns between heavy positions */ +GW.discSwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]]; + const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58; + gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + svgEl(s, 'circle', { cx, cy, r: 48, fill: '#1a1714', stroke: '#060505', 'stroke-width': 1.5 }); + for (let k = 0; k < 4; k++) { + const [x, y] = polar(cx, cy, 44, 45 + k * 90); + svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 }); + } + const lbls = positions.map(([lbl, a]) => { + const [tx, ty] = polar(cx, cy, 42, a); + const t = svgEl(s, 'text', { x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 5.8, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + t.textContent = lbl; return t; + }); + svgEl(s, 'circle', { cx, cy, r: 36, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 }); + const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .15s'; grp.style.transformOrigin = `${cx}px ${cy}px`; + svgEl(grp, 'rect', { x: cx - 27, y: cy - 8, width: 54, height: 16, rx: 8, fill: 'url(#discRed)', stroke: '#8f3520', 'stroke-width': 1 }); + svgEl(grp, 'rect', { x: cx - 25, y: cy - 6.5, width: 50, height: 4, rx: 2, fill: 'rgba(255,255,255,.16)' }); + svgEl(grp, 'line', { x1: cx + 20, y1: cy, x2: cx + 26, y2: cy, stroke: 'var(--cream)', 'stroke-width': 2.5, 'stroke-linecap': 'round' }); + svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` }); + let idx; + const set = i => { + idx = i; + grp.style.transform = `rotate(${positions[i][1]}deg)`; + lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)')); + onChange(idx, positions[i][0]); + }; + s.addEventListener('click', () => set((idx + 1) % positions.length)); + set(opts.index !== undefined ? opts.index : 1); + return { el: s, get: () => idx, set }; +}; + +/* R29 guarded toggle — guard posts + red collar mark the critical throw */ +GW.guardedToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52; + gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]); + 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: 12, '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: 96, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + lblOff.textContent = opts.offLabel || 'OFF'; + svgEl(s, 'circle', { cx, cy, r: 13, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 }); + const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 11 * Math.cos(a)) + ',' + (cy + 11 * Math.sin(a))); } + svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#14110e', stroke: '#000' }); + for (const gx of [16, 74]) { + svgEl(s, 'rect', { x: gx - 6, y: cy - 24, width: 12, height: 48, rx: 6, fill: 'url(#nutG)', stroke: '#2b2822', 'stroke-width': 1 }); + svgEl(s, 'rect', { x: gx - 4, y: cy - 21, width: 3.5, height: 42, rx: 2, fill: 'rgba(255,255,255,.14)' }); + } + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 }); + svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 }); + for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 }); + 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 }; +}; + +/* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob. + Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */ +GW.timerDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58; + gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]); + gradDef('mtRed', 'radialGradient', { cx: '38%', cy: '32%', r: '80%' }, [['0', '#e0523a'], ['1', '#8f2416']]); + gradDef('mtHub', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#c9c4b8'], ['1', '#6e685c']]); + /* bezel + knurled coin edge */ + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 47.5, fill: 'none', stroke: '#3a352c', 'stroke-width': 4, 'stroke-dasharray': '2 1.6' }); + svgEl(s, 'circle', { cx, cy, r: 44, fill: 'url(#mtFace)', stroke: '#0a0908', 'stroke-width': 1 }); + /* rotating scale: OFF at 0, minutes climb clockwise at 4.5 deg/min (60 at 270) */ + const rot = svgEl(s, 'g', {}); + for (let m = 0; m <= 60; m += 5) { + const major = m % 20 === 0; + const [x1, y1] = polar(cx, cy, 40, m * 4.5), [x2, y2] = polar(cx, cy, major ? 34 : 37, m * 4.5); + svgEl(rot, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': major ? 1.2 : .55, opacity: major ? .9 : .55 }); + } + for (const m of [20, 40, 60]) { + const [x, y] = polar(cx, cy, 27, m * 4.5); + svgEl(rot, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = String(m); + } + const [ox, oy] = polar(cx, cy, 27, 0); + svgEl(rot, 'text', { x: ox, y: oy + 3, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#e0523a' }).textContent = 'OFF'; + const [mx, my] = polar(cx, cy, 16, 180); + svgEl(rot, 'text', { x: mx, y: my + 2.5, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'MINUTES'; + /* hub with screw slot */ + svgEl(s, 'circle', { cx, cy, r: 9, fill: 'url(#mtHub)', stroke: '#3c382f', 'stroke-width': 1 }); + svgEl(s, 'line', { x1: cx - 5.5, y1: cy, x2: cx + 5.5, y2: cy, stroke: '#4a4438', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 2.2, fill: '#8f897b' }); + /* fixed red index at top + winding-direction arrow */ + svgEl(s, 'line', { x1: cx, y1: cy - 44, x2: cx, y2: cy - 32, stroke: '#e0523a', 'stroke-width': 1.6 }); + svgEl(s, 'path', { d: 'M 122 14 A 26 26 0 0 1 136 26', fill: 'none', stroke: 'var(--steel)', 'stroke-width': 1.6 }); + svgEl(s, 'polygon', { points: '136,26 130.5,24.5 134.5,19.5', fill: 'var(--steel)' }); + /* engraved plate captions */ + const cap = (t, y) => svgEl(s, 'text', { x: 4, y, 'font-size': 6.2, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = t; + cap('PUSH TO STOP', 118); cap('TURN TO START', 128); + let min; + const set = m => { + min = Math.max(0, Math.min(60, m)); + rot.setAttribute('transform', `rotate(${(-min * 4.5).toFixed(2)},${cx},${cy})`); + onChange(min, min > 0.5 ? 'T-' + Math.round(min) + ' MIN' : 'OFF'); + }; + /* dial drag surface (under the knob in DOM so the knob wins the overlap) */ + const hit = svgEl(s, 'circle', { cx, cy, r: 50, fill: 'transparent' }); + hit.style.cursor = 'grab'; + dragDelta(hit, () => min, set, { min: 0, max: 60 }); + /* red stop knob: fluted edge, domed center */ + const knob = svgEl(s, 'g', {}); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 20, fill: 'url(#mtRed)', stroke: '#5c150c', 'stroke-width': 1.5 }); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 18, fill: 'none', stroke: 'rgba(0,0,0,.4)', 'stroke-width': 3.4, 'stroke-dasharray': '3 2.4' }); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 8.5, fill: 'url(#mtRed)', stroke: 'rgba(0,0,0,.3)', 'stroke-width': .8 }); + svgEl(knob, 'ellipse', { cx: 121, cy: 98, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.18)', transform: 'rotate(-28,121,98)' }); + knob.style.cursor = 'pointer'; + knob.addEventListener('click', () => { set(0); onChange(0, 'STOP · OFF'); }); + set(opts.minutes !== undefined ? opts.minutes : 0); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { if (min > 0) { set(min - 1); if (min === 0) onChange(0, 'DING · OFF'); } }, 1000); + return { el: s, get: () => min, set }; +}; + +/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */ +GW.rockerPad = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55; + gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]); + gradDef('rk4Arr', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#e9e4d6'], ['1', '#9d988b']]); + gradDef('rk4Nub', 'radialGradient', { cx: '38%', cy: '30%', r: '85%' }, [['0', '#4a453e'], ['1', '#0d0c0a']]); + /* recessed plate + rubber pad */ + svgEl(s, 'rect', { x: 3, y: 3, width: 104, height: 104, rx: 18, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 }); + svgEl(s, 'circle', { cx, cy, r: 45, fill: '#0f0d0b' }); + svgEl(s, 'circle', { cx, cy, r: 41, fill: 'url(#rk4Pad)', stroke: '#060505', 'stroke-width': 1 }); + /* four arrows, pointing outward */ + const ARR = { up: '55,22 45,36 65,36', down: '55,88 45,74 65,74', left: '22,55 36,45 36,65', right: '88,55 74,45 74,65' }; + const arrows = {}; + for (const [dir, pts] of Object.entries(ARR)) + arrows[dir] = svgEl(s, 'polygon', { points: pts, fill: 'url(#rk4Arr)', stroke: '#3c382f', 'stroke-width': .7 }); + /* center pivot nub */ + svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#rk4Nub)', stroke: '#060505', 'stroke-width': .8 }); + svgEl(s, 'ellipse', { cx: cx - 2, cy: cy - 2.5, rx: 2.6, ry: 1.7, fill: 'rgba(255,255,255,.25)' }); + /* quadrant hit zones (drawn last so they win), press flash + tracked position */ + let rx = 0, ry = 0; + const press = (dir, dx, dy) => { + rx += dx; ry += dy; + onChange({ x: rx, y: ry, dir }, dir.toUpperCase() + ' · x ' + rx + ' y ' + ry); + arrows[dir].setAttribute('fill', 'var(--gold-hi)'); + setTimeout(() => arrows[dir].setAttribute('fill', 'url(#rk4Arr)'), 160); + }; + const zone = (dir, pts, dx, dy) => { + const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' }); + z.style.cursor = 'pointer'; z.addEventListener('click', () => press(dir, dx, dy)); + }; + zone('up', `${cx},${cy} 20,20 90,20`, 0, 1); + zone('down', `${cx},${cy} 20,90 90,90`, 0, -1); + zone('left', `${cx},${cy} 20,20 20,90`, -1, 0); + zone('right', `${cx},${cy} 90,20 90,90`, 1, 0); + onChange({ x: 0, y: 0, dir: null }, 'x 0 y 0'); + return { el: s, get: () => ({ x: rx, y: ry }) }; +}; + +/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */ +GW.fourWayToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; + gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]); + gradDef('fw4Ball', 'radialGradient', { cx: '36%', cy: '30%', r: '80%' }, [['0', '#f2f0ea'], ['.55', '#8e8a80'], ['1', '#3c3a34']]); + /* printed panel graphics: axes, circle, diagonal square, labels */ + svgEl(s, 'line', { x1: 8, y1: cy, x2: 122, y2: cy, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 }); + svgEl(s, 'line', { x1: cx, y1: 8, x2: cx, y2: 122, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 }); + svgEl(s, 'text', { x: cx + 2, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'Y'; + svgEl(s, 'text', { x: 116, y: cy - 4, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'X'; + svgEl(s, 'circle', { cx, cy, r: 42, fill: 'none', stroke: 'var(--steel)', 'stroke-width': .9, opacity: .8 }); + const QUAD = { A: 315, B: 45, C: 135, D: 225 }; + const diag = Object.values(QUAD).map(a => polar(cx, cy, 42, a)); + svgEl(s, 'polygon', { points: diag.map(p => p.join(',')).join(' '), fill: 'none', stroke: 'var(--steel)', 'stroke-width': .8, opacity: .8 }); + const LAMP_AT = { A: [14, 14], B: [116, 14], C: [116, 116], D: [14, 116] }; + const lamps = {}; + for (const [q, a] of Object.entries(QUAD)) { + const [lx, ly] = polar(cx, cy, 42, a); + const [px, py] = LAMP_AT[q]; + svgEl(s, 'line', { x1: lx, y1: ly, x2: px, y2: py, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .5 }); + svgEl(s, 'circle', { cx: lx, cy: ly, r: 6.5, fill: '#14110e', stroke: 'var(--steel)', 'stroke-width': .9 }); + svgEl(s, 'text', { x: lx, y: ly + 2.8, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = q; + svgEl(s, 'circle', { cx: px, cy: py, r: 5.5, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 }); + lamps[q] = svgEl(s, 'circle', { cx: px, cy: py, r: 3.6, fill: '#3a3426' }); + } + /* bezel + boot */ + svgEl(s, 'circle', { cx, cy, r: 20, fill: '#0d0c0a', stroke: '#2a2722', 'stroke-width': 1.5 }); + svgEl(s, 'circle', { cx, cy, r: 15, fill: '#17140f', stroke: '#060505', 'stroke-width': 1 }); + /* lever: shaft + grip rotate to the selected diagonal; ball pivot stays centered */ + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'polygon', { points: `${cx - 3},${cy} ${cx + 3},${cy} ${cx + 2},${cy - 26} ${cx - 2},${cy - 26}`, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 }); + svgEl(lever, 'rect', { x: cx - 3.4, y: cy - 40, width: 6.8, height: 16, rx: 3, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 }); + svgEl(s, 'circle', { cx, cy, r: 8.5, fill: 'url(#fw4Ball)', stroke: '#26241f', 'stroke-width': .8 }); + svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' }); + let pos = null; + const set = q => { + pos = q; + lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`); + for (const k of Object.keys(QUAD)) { + lamps[k].setAttribute('fill', k === q ? 'var(--jewel-a)' : '#3a3426'); + lamps[k].setAttribute('filter', k === q ? 'drop-shadow(0 0 4px rgba(255,180,58,.8))' : 'none'); + } + onChange(pos, 'POS ' + q); + }; + /* quadrant click zones (diagonal quadrants, drawn last) */ + const zone = (q, pts) => { + const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' }); + z.style.cursor = 'pointer'; z.addEventListener('click', () => set(q)); + }; + zone('A', `${cx},${cy} ${cx},5 5,5 5,${cy}`); + zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`); + zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`); + zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`); + set(opts.position || 'C'); + return { el: s, get: () => pos, set }; +}; + +/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */ +GW.pinMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV']; + const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD']; + const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */ + const s = stageSvg(host, 'rsvg press', 160, 110); + const X0 = 42, Y0 = 30, DX = 19, DY = 15.5; + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + COLS.forEach((c, j) => svgEl(s, 'text', { x: X0 + j * DX, y: 16, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = c); + ROWS.forEach((r, i) => svgEl(s, 'text', { x: 34, y: Y0 + i * DY + 2, 'text-anchor': 'end', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r); + ROWS.forEach((r, i) => COLS.forEach((c, j) => { + const cx = X0 + j * DX, cy = Y0 + i * DY, key = r + '>' + c; + svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 }); + const cap = svgEl(s, 'circle', { cx, cy, r: 4.4, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': 1, opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' }); + const hit = svgEl(s, 'circle', { cx, cy, r: 8, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { + const on = !pins.has(key); + if (on) pins.add(key); else pins.delete(key); + cap.setAttribute('opacity', on ? 1 : 0); + onChange([...pins], (on ? r + ' → ' + c : 'pulled ' + r + ' → ' + c) + ' · ' + pins.size + ' routes'); + }); + })); + onChange([...pins], pins.size + ' routes'); + return { el: s, get: () => [...pins] }; +}; + +/* R38 dead-man button — state exists only while the pointer holds it down */ +GW.deadMan = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58; + gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]); + svgEl(s, 'rect', { x: 4, y: 4, width: 112, height: 102, rx: 12, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 }); + svgEl(s, 'text', { x: cx, y: 18, 'text-anchor': 'middle', 'font-size': 6.2, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.caption || 'HOLD TO RUN'; + const lamp = svgEl(s, 'circle', { cx: 104, cy: 16, r: 4.5, fill: '#1e2a12' }); + const ring = svgEl(s, 'circle', { cx, cy, r: 33, fill: 'none', stroke: '#2c2820', 'stroke-width': 3 }); + const btn = svgEl(s, 'g', {}); + svgEl(btn, 'circle', { cx, cy, r: 28, fill: 'url(#dmBtn)', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(btn, 'circle', { cx, cy, r: 22, fill: 'none', stroke: 'rgba(255,255,255,.07)', 'stroke-width': 1 }); + svgEl(btn, 'text', { x: cx, y: cy + 2.5, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--silver)' }).textContent = opts.label || 'RUN'; + btn.style.cursor = 'pointer'; + let t0 = null, iv = null; + const stop = () => { + if (t0 === null) return; + const held = ((Date.now() - t0) / 1000).toFixed(1); + t0 = null; clearInterval(iv); + btn.removeAttribute('transform'); + ring.setAttribute('stroke', '#2c2820'); ring.removeAttribute('filter'); + lamp.setAttribute('fill', '#1e2a12'); lamp.removeAttribute('filter'); + onChange(false, 'SAFE (held ' + held + 's)'); + }; + btn.addEventListener('pointerdown', e => { + btn.setPointerCapture(e.pointerId); + t0 = Date.now(); + btn.setAttribute('transform', 'translate(0,1.5)'); + ring.setAttribute('stroke', 'var(--gold)'); ring.setAttribute('filter', 'drop-shadow(0 0 5px rgba(var(--glow-lo),.7))'); + lamp.setAttribute('fill', 'var(--pass)'); lamp.setAttribute('filter', 'drop-shadow(0 0 4px rgba(116,147,47,.8))'); + onChange(true, 'RUNNING 0.0s'); + iv = setInterval(() => { if (t0 !== null) onChange(true, 'RUNNING ' + ((Date.now() - t0) / 1000).toFixed(1) + 's'); }, 100); + e.preventDefault(); + }); + btn.addEventListener('pointerup', stop); + btn.addEventListener('pointercancel', stop); + onChange(false, 'SAFE'); + return { el: s, get: () => t0 !== null }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { -- cgit v1.2.3