From 145f86882d2a692e9049b0c7fd16f8a83303c326 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 22:19:10 -0500 Subject: refactor(gallery): extract meters R43, R44, R53-R56 into GW builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heading-bug servo chase and the chart recorder's day clock are widget-owned animations, so they move into their builders with the reduced-motion gates intact (reduced motion paints the recorder's full day once). This completes the Meters section — all 28 extracted. --- docs/prototypes/panel-widget-gallery.html | 247 +------------------- docs/prototypes/widgets.js | 361 ++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+), 239 deletions(-) (limited to 'docs') diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 6bbe3d9..0065645 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -1778,22 +1778,22 @@ card(M,'R13','Edgewise strip meter', (st,rd)=>GW.edgeMeter(st,{value:.62,onChange:(v,t)=>rd(t)}), 'a needle on a vertical scale. The parchment strip reads attenuation down a compressed log scale; the bar rides its edge. Drag up/down; readout shows dB. After a channel-strip gain-reduction meter.'); card(M,'R43','Attitude indicator', - ``, + (st,rd)=>GW.attitudeIndicator(st,{onChange:(v,t)=>rd(t)}), 'orientation, not a number. The horizon rolls and shifts behind the fixed amber airplane — two axes in one read. Drag it: left/right banks, up/down pitches. The first two-axis instrument in the kit. After a cockpit artificial horizon.'); card(M,'R44','Heading bug + servo needle', - ``, + (st,rd)=>GW.headingBug(st,{value:90,onChange:(v,t)=>rd(t)}), 'commanded versus actual on one dial. Drag to park the amber bug where you want to go; the needle chases it with honest servo lag. The gap between them IS the information. After an HSI heading bug and synchro repeaters.'); card(M,'R54','Vertical tape instrument', - ``, + (st,rd)=>GW.verticalTape(st,{value:24,onChange:(v,t)=>rd(t)}), 'the scale moves, the index does not. A scrolling tape behind a fixed amber index — your value is always at eye level and the neighborhood above and below stays visible. Drag to spin the tape. After the Ki-57 remote tachometer; modern cockpits tape everything.'); card(M,'R55','Twin-needle gauge', - ``, + (st,rd)=>GW.twinNeedle(st,{fuel:2.4,oil:3.1,onChange:(v,t)=>rd(t)}), 'two values, one housing, mirrored scales. Fuel presses up the left arc, oil up the right, each with its own needle from the shared hub. Drag either half to set that side. Not the crossed-needle (N13) — these are read separately, not at their intersection. After the Ki-57 fuel/oil pressure gauge.'); card(M,'R56','Comfort-zone crossed needles', - ``, + (st,rd)=>GW.comfortMeter(st,{temp:72,humidity:45,onChange:(v,t)=>rd(t)}), 'two values, one verdict. Temperature and humidity needles cross over a face printed with judgments — the crossing point lands in TOO WARM, TOO DRY, or JUST RIGHT. N13 derives a number from the crossing; this one derives an opinion. Drag each half. After a brass weather-station comfort meter.'); card(M,'R53','Circular chart recorder', - ``, + (st,rd)=>GW.chartRecorder(st,{onChange:(v,t)=>rd(t)}), 'cyclic time on round paper. The pen sweeps a full day per revolution, so "same time yesterday" is the same angle and daily rhythm draws itself as a flower. Click for fresh paper. The strip chart shows a window; this shows the cycle. After a Partlow round-chart recorder.'); card(M,'R17','Round CRT scope', (st,rd)=>GW.roundCrt(st,{onChange:(v,t)=>rd(t)}), @@ -2380,76 +2380,7 @@ function setDdcal(d,w){ddDate=d;ddDay=w;const HAND=135; setRd('R36',lit+' / 64 lit');});} setRd('R36',lit+' / 64 lit');})(); -/* R43 attitude indicator: sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */ -(function(){const s=$('attind'),cx=65,cy=65; - const defs=svgEl(s,'defs',{}); - const clip=svgEl(defs,'clipPath',{id:'aiClip'}); - svgEl(clip,'circle',{cx,cy,r:48}); - const sky=svgEl(defs,'linearGradient',{id:'aiSky',x1:0,y1:0,x2:0,y2:1}); - svgEl(sky,'stop',{offset:'0','stop-color':'#54677d'}); - svgEl(sky,'stop',{offset:'1','stop-color':'#3a4c60'}); - const gnd=svgEl(defs,'linearGradient',{id:'aiGnd',x1:0,y1:0,x2:0,y2:1}); - svgEl(gnd,'stop',{offset:'0','stop-color':'#5c4630'}); - svgEl(gnd,'stop',{offset:'1','stop-color':'#3c2e1e'}); - svgEl(s,'circle',{cx,cy,r:56,fill:'#14110e',stroke:'#060505','stroke-width':2}); - const g=svgEl(s,'g',{'clip-path':'url(#aiClip)'}); - const ball=svgEl(g,'g',{}); - svgEl(ball,'rect',{x:cx-90,y:cy-180,width:180,height:180,fill:'url(#aiSky)'}); - svgEl(ball,'rect',{x:cx-90,y:cy,width:180,height:180,fill:'url(#aiGnd)'}); - svgEl(ball,'line',{x1:cx-90,y1:cy,x2:cx+90,y2:cy,stroke:'#f3e7c5','stroke-width':1.4}); - for(const p of [-20,-10,10,20]){const y=cy-p*1.6,w=p%20===0?16:10; - svgEl(ball,'line',{x1:cx-w,y1:y,x2:cx+w,y2:y,stroke:'#f3e7c5','stroke-width':.8,opacity:.85}); - svgEl(ball,'text',{x:cx+w+3,y:y+2,'font-size':4.6,'font-family':'var(--mono)', - fill:'#f3e7c5',opacity:.85}).textContent=String(Math.abs(p));} - // fixed bank scale + pointer + miniature aircraft - for(const b of [-60,-45,-30,-20,-10,0,10,20,30,45,60]){ - const [x1,y1]=polar(cx,cy,47,b),[x2,y2]=polar(cx,cy,b%30===0?42:44,b); - svgEl(s,'line',{x1,y1,x2,y2,stroke:'var(--cream)','stroke-width':b===0?1.6:.9,opacity:.9});} - svgEl(s,'polygon',{points:`${cx-3.5},${cy-38} ${cx+3.5},${cy-38} ${cx},${cy-44}`,fill:'var(--gold-hi)'}); - svgEl(s,'path',{d:`M ${cx-22} ${cy} L ${cx-7} ${cy} L ${cx-4} ${cy+4} M ${cx+22} ${cy} L ${cx+7} ${cy} L ${cx+4} ${cy+4}`, - fill:'none',stroke:'var(--gold-hi)','stroke-width':2.4,'stroke-linecap':'round'}); - svgEl(s,'circle',{cx,cy,r:2,fill:'var(--gold-hi)'}); - let aiBank=0,aiPitch=0; - const setAi=(b,p)=>{aiBank=Math.max(-60,Math.min(60,b));aiPitch=Math.max(-20,Math.min(20,p)); - ball.setAttribute('transform',`rotate(${(-aiBank).toFixed(1)},${cx},${cy}) translate(0,${(aiPitch*1.6).toFixed(1)})`); - setRd('R43','BANK '+(aiBank<0?'L':aiBank>0?'R':'')+Math.abs(Math.round(aiBank))+' · PITCH '+(aiPitch>=0?'+':'-')+String(Math.abs(Math.round(aiPitch))).padStart(2,'0'));}; - s.style.touchAction='none';s.style.cursor='move'; - s.addEventListener('pointerdown',e=>{s.setPointerCapture(e.pointerId); - const x0=e.clientX,y0=e.clientY,b0=aiBank,p0=aiPitch; - const mv=ev=>setAi(b0+(ev.clientX-x0)*0.4,p0+(ev.clientY-y0)*0.25); - const up=()=>{s.removeEventListener('pointermove',mv);s.removeEventListener('pointerup',up);s.removeEventListener('pointercancel',up);}; - s.addEventListener('pointermove',mv);s.addEventListener('pointerup',up);s.addEventListener('pointercancel',up);e.preventDefault();}); - setAi(0,0);})(); - -/* R44 heading bug + servo needle: drag sets the command; the needle chases with lag */ -let hbCmd=90,hbAct=90,hbBug=null,hbNeedle=null; -(function(){const s=$('hdgbug'),cx=65,cy=65; - svgEl(s,'circle',{cx,cy,r:56,fill:'#14110e',stroke:'#060505','stroke-width':2}); - svgEl(s,'circle',{cx,cy,r:50,fill:'#1a1714',stroke:'#2c2820','stroke-width':1}); - for(let d=0;d<360;d+=10){const maj=d%30===0; - const [x1,y1]=polar(cx,cy,48,d),[x2,y2]=polar(cx,cy,maj?42:45,d); - svgEl(s,'line',{x1,y1,x2,y2,stroke:'var(--cream)','stroke-width':maj?1:.5,opacity:maj?.9:.5});} - for(let d=0;d<360;d+=30){const lbl=d===0?'N':d===90?'E':d===180?'S':d===270?'W':String(d/10); - const [x,y]=polar(cx,cy,35,d); - svgEl(s,'text',{x,y:y+2.6,'text-anchor':'middle','font-size':d%90===0?8:6.4,'font-weight':700, - 'font-family':'var(--mono)',fill:d%90===0?'var(--cream)':'var(--steel)'}).textContent=lbl;} - svgEl(s,'line',{x1:cx,y1:cy-56,x2:cx,y2:cy-48,stroke:'var(--fail)','stroke-width':2}); - hbBug=svgEl(s,'path',{d:`M ${cx-6} ${cy-49} L ${cx-6} ${cy-44} L ${cx-2.5} ${cy-44} L ${cx} ${cy-47} L ${cx+2.5} ${cy-44} L ${cx+6} ${cy-44} L ${cx+6} ${cy-49} Z`, - fill:'var(--gold-hi)',stroke:'#7d5c16','stroke-width':.7}); - hbNeedle=svgEl(s,'g',{}); - svgEl(hbNeedle,'polygon',{points:`${cx-2},${cy+10} ${cx+2},${cy+10} ${cx+1.2},${cy-40} ${cx},${cy-44} ${cx-1.2},${cy-40}`, - fill:'#bfc4d0',stroke:'#4a4e58','stroke-width':.6}); - svgEl(s,'circle',{cx,cy,r:4.5,fill:'#3a3631',stroke:'#060505','stroke-width':1}); - const draw=()=>{hbBug.setAttribute('transform',`rotate(${hbCmd.toFixed(1)},${cx},${cy})`); - hbNeedle.setAttribute('transform',`rotate(${hbAct.toFixed(1)},${cx},${cy})`); - setRd('R44','CMD '+String(Math.round((hbCmd%360+360)%360)).padStart(3,'0')+' · ACT '+String(Math.round((hbAct%360+360)%360)).padStart(3,'0'));}; - dragDelta(s,()=>hbCmd,v=>{hbCmd=(v%360+360)%360;draw();},{min:-100000,max:100000,sens:1.2}); - draw(); - if(!matchMedia('(prefers-reduced-motion: reduce)').matches) - setInterval(()=>{const diff=((hbCmd-hbAct+540)%360)-180; - if(Math.abs(diff)<0.4){if(hbAct!==hbCmd){hbAct=hbCmd;draw();}return;} - hbAct=(hbAct+diff*0.07+360)%360;draw();},80); - else{hbAct=hbCmd;}})(); +/* R43, R44 extracted to widgets.js (GW.*) */ /* R45 flip-disc: bistable mechanical pixels; scaleX flip with color swap at the midpoint */ (function(){const s=$('flipdisc'),COLS=7,ROWS=5,STEP=14,X0=13,Y0=13; @@ -2565,169 +2496,7 @@ let dkPos=0,dkCarry=0; draw();tick(); if(!matchMedia('(prefers-reduced-motion: reduce)').matches)setInterval(tick,250);})(); -/* R53 circular chart recorder: a day per revolution; the pen draws the cycle */ -(function(){const s=$('circhart'),cx=65,cy=65; - svgEl(s,'circle',{cx,cy,r:60,fill:'#17140f',stroke:'#060505','stroke-width':2}); - svgEl(s,'circle',{cx,cy,r:54,fill:'#efe9da',stroke:'#8f897b','stroke-width':.8}); - for(const r of [20,31,42]) - svgEl(s,'circle',{cx,cy,r,fill:'none',stroke:'#b8ae98','stroke-width':.5}); - for(let h=0;h<24;h++){const a=h*15; - const [x1,y1]=polar(cx,cy,54,a),[x2,y2]=polar(cx,cy,h%6===0?12:48,a); - svgEl(s,'line',{x1,y1,x2,y2,stroke:'#b8ae98','stroke-width':h%6===0?.6:.35,opacity:.8}); - if(h%6===0){const [tx,ty]=polar(cx,cy,50,a+5); - svgEl(s,'text',{x:tx,y:ty+2,'text-anchor':'middle','font-size':4.6,'font-family':'var(--mono)', - fill:'#6e6552'}).textContent=String(h).padStart(2,'0');}} - const trace=svgEl(s,'path',{d:'',fill:'none',stroke:'#8f2416','stroke-width':1.1,'stroke-linejoin':'round'}); - const pen=svgEl(s,'line',{x1:cx,y1:cy,x2:cx,y2:cy-54,stroke:'#4a463e','stroke-width':1}); - const penDot=svgEl(s,'circle',{cx,cy:cy-30,r:2,fill:'#8f2416'}); - svgEl(s,'circle',{cx,cy,r:4,fill:'#4a463e'}); - const val=h=>52+30*Math.sin(h/24*2*Math.PI-2.1)+12*Math.sin(h/24*6*Math.PI)+4*Math.sin(h*2.7); - const rOf=v=>12+Math.max(0,Math.min(100,v))*0.36; - let pts=[],hour=0; - const draw=()=>{const a=hour*15,r=rOf(val(hour)); - pts.push(polar(cx,cy,r,a)); - if(pts.length>1)trace.setAttribute('d','M '+pts.map(p=>p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' L ')); - pen.setAttribute('transform',`rotate(${a},${cx},${cy})`); - const [px,py]=polar(cx,cy,r,a); - penDot.setAttribute('cx',px);penDot.setAttribute('cy',py); - setRd('R53',String(Math.floor(hour)).padStart(2,'0')+':'+(hour%1>=0.5?'30':'00')+' · '+Math.round(val(hour))+'%');}; - const reset=()=>{pts=[];hour=0;trace.setAttribute('d','');draw();}; - s.style.cursor='pointer'; - s.addEventListener('click',reset); - if(matchMedia('(prefers-reduced-motion: reduce)').matches){ - for(hour=0;hour<24;hour+=0.25)draw();hour=23.75;} - else{reset();setInterval(()=>{hour+=0.25;if(hour>=24){pts=[];hour=0;}draw();},375);}})(); - -/* R54 vertical tape: the scale scrolls behind a fixed index; drag to drive the value */ -(function(){const s=$('vtape'),W=70,H=130,CX=38,CY=65; - const MIN=5,MAX=35,PPU=5.5; // rpm x100, px per unit - const defs=svgEl(s,'defs',{}); - const clip=svgEl(defs,'clipPath',{id:'vtClip'}); - svgEl(clip,'rect',{x:14,y:8,width:48,height:114,rx:4}); - svgEl(s,'rect',{x:2,y:2,width:66,height:126,rx:7,fill:'#1c1916',stroke:'#060505','stroke-width':1.5}); - svgEl(s,'rect',{x:14,y:8,width:48,height:114,rx:4,fill:'#0d0c0a',stroke:'#2c2820','stroke-width':1}); - const tapeG=svgEl(s,'g',{'clip-path':'url(#vtClip)'}); - const tape=svgEl(tapeG,'g',{}); - for(let u=MIN;u<=MAX;u++){const y=-u*PPU; // higher value further up the tape - const maj=u%5===0; - svgEl(tape,'line',{x1:maj?20:26,y1:y,x2:34,y2:y,stroke:'var(--cream)','stroke-width':maj?1.1:.55,opacity:maj?.95:.6}); - if(maj)svgEl(tape,'text',{x:48,y:y+3,'text-anchor':'middle','font-size':9,'font-weight':700, - 'font-family':'var(--mono)',fill:'var(--cream)'}).textContent=String(u);} - // fixed index: side pointer + line across the window at center height - svgEl(s,'polygon',{points:`8,${CY} 15,${CY-4} 15,${CY+4}`,fill:'var(--gold-hi)'}); - svgEl(s,'line',{x1:15,y1:CY,x2:62,y2:CY,stroke:'var(--gold-hi)','stroke-width':1,opacity:.85}); - svgEl(s,'text',{x:CX,y:126,'text-anchor':'middle','font-size':5,'letter-spacing':'.1em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='RPM x100'; - let vt=24; - const draw=()=>{tape.setAttribute('transform',`translate(0,${(CY+vt*PPU).toFixed(1)})`); - setRd('R54','RPM '+Math.round(vt*100));}; - s.style.cursor='ns-resize'; - dragDelta(s,()=>vt,v=>{vt=Math.max(MIN,Math.min(MAX,v));draw();},{min:MIN,max:MAX,sens:(MAX-MIN)/140}); - draw();})(); - -/* R55 twin-needle gauge: mirrored half-scales, one hub, two independent needles */ -(function(){const s=$('twinng'),cx=60,cy=62,R=44; - svgEl(s,'circle',{cx,cy,r:52,fill:'#14110e',stroke:'#060505','stroke-width':2}); - svgEl(s,'circle',{cx,cy,r:46,fill:'#1a1714',stroke:'#2c2820','stroke-width':1}); - // mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL - const angL=v=>-170+v/4*160; // 0..4 -> -170..-10 (left side, cw from top) - const angR=v=>170-v/4*160; // 0..4 -> 170..10 (right side) - for(let v=0;v<=4;v++){ - for(const [ang,anchor] of [[angL(v),'end'],[angR(v),'start']]){ - const [x1,y1]=polar(cx,cy,R,ang),[x2,y2]=polar(cx,cy,R-5,ang); - svgEl(s,'line',{x1,y1,x2,y2,stroke:'var(--cream)','stroke-width':1,opacity:.9}); - const [tx,ty]=polar(cx,cy,R-11,ang); - svgEl(s,'text',{x:tx,y:ty+2.4,'text-anchor':'middle','font-size':6,'font-family':'var(--mono)', - fill:'var(--cream)'}).textContent=String(v);}} - for(let v=0.5;v<4;v+=0.5){if(v%1===0)continue; - for(const ang of [angL(v),angR(v)]){ - const [x1,y1]=polar(cx,cy,R,ang),[x2,y2]=polar(cx,cy,R-3,ang); - svgEl(s,'line',{x1,y1,x2,y2,stroke:'var(--cream)','stroke-width':.5,opacity:.55});}} - svgEl(s,'text',{x:cx-20,y:cy+34,'text-anchor':'middle','font-size':5.4,'letter-spacing':'.08em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='FUEL'; - svgEl(s,'text',{x:cx+20,y:cy+34,'text-anchor':'middle','font-size':5.4,'letter-spacing':'.08em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='OIL'; - svgEl(s,'text',{x:cx,y:cy-26,'text-anchor':'middle','font-size':4.6,'letter-spacing':'.06em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='kg/cm2'; - const needle=color=>{const g=svgEl(s,'g',{}); - svgEl(g,'polygon',{points:`${cx-1.6},${cy+8} ${cx+1.6},${cy+8} ${cx+1},${cy-R+8} ${cx},${cy-R+4} ${cx-1},${cy-R+8}`, - fill:color,stroke:'rgba(0,0,0,.4)','stroke-width':.5});return g;}; - const nF=needle('#e0523a'),nO=needle('#bfc4d0'); - svgEl(s,'circle',{cx,cy,r:5,fill:'#3a3631',stroke:'#060505','stroke-width':1}); - let vF=2.4,vO=3.1; - const draw=()=>{nF.setAttribute('transform',`rotate(${angL(vF).toFixed(1)},${cx},${cy})`); - nO.setAttribute('transform',`rotate(${angR(vO).toFixed(1)},${cx},${cy})`); - setRd('R55','FUEL '+vF.toFixed(1)+' · OIL '+vO.toFixed(1));}; - // each half is its own drag surface - const half=(x,get,set)=>{const hit=svgEl(s,'rect',{x,y:10,width:60,height:104,fill:'transparent'}); - hit.style.cursor='ns-resize'; - dragDelta(hit,get,v=>{set(Math.max(0,Math.min(4,v)));draw();},{min:0,max:4,sens:4/110});}; - half(0,()=>vF,v=>vF=v); - half(60,()=>vO,v=>vO=v); - draw();})(); - -/* R56 comfort meter: temp and humidity needles cross over printed zone verdicts */ -(function(){const s=$('comfort'),cx=65,cy=60; - const defs=svgEl(s,'defs',{}); - const br=svgEl(defs,'linearGradient',{id:'cmBrass',x1:0,y1:0,x2:0,y2:1}); - svgEl(br,'stop',{offset:'0','stop-color':'#d8b25c'}); - svgEl(br,'stop',{offset:'.5','stop-color':'#a07c30'}); - svgEl(br,'stop',{offset:'1','stop-color':'#6e5218'}); - svgEl(s,'circle',{cx,cy,r:56,fill:'url(#cmBrass)',stroke:'#4a3610','stroke-width':2}); - svgEl(s,'circle',{cx,cy,r:53,fill:'none',stroke:'#7a5c1e','stroke-width':2.5,'stroke-dasharray':'2 1.4'}); - svgEl(s,'circle',{cx,cy,r:49,fill:'#efe9da',stroke:'#8f897b','stroke-width':1}); - const PT=[38,96],PH=[92,96]; // temp pivot (BL), humidity pivot (BR) - const angT=t=>105-(t-40)/60*70; // 40..100F -> bearing 105..35 from PT - const angH=h=>-105+h/100*70; // 0..100% -> bearing -105..-35 from PH - // scale ticks along each sweep, near the rim - for(let t=40;t<=100;t+=10){const a=angT(t); - const [x1,y1]=polar(PT[0],PT[1],86,a),[x2,y2]=polar(PT[0],PT[1],82,a); - if(Math.hypot(x1-cx,y1-cy)<48){svgEl(s,'line',{x1,y1,x2,y2,stroke:'#3c382f','stroke-width':t%20===0?1:.5}); - if(t%20===0){const [tx,ty]=polar(PT[0],PT[1],78,a); - svgEl(s,'text',{x:tx,y:ty+2,'text-anchor':'middle','font-size':4.6,'font-family':'var(--mono)', - fill:'#3c382f'}).textContent=String(t);}}} - for(let h=0;h<=100;h+=10){const a=angH(h); - const [x1,y1]=polar(PH[0],PH[1],86,a),[x2,y2]=polar(PH[0],PH[1],82,a); - if(Math.hypot(x1-cx,y1-cy)<48){svgEl(s,'line',{x1,y1,x2,y2,stroke:'#3c382f','stroke-width':h%20===0?1:.5}); - if(h%20===0){const [tx,ty]=polar(PH[0],PH[1],78,a); - svgEl(s,'text',{x:tx,y:ty+2,'text-anchor':'middle','font-size':4.6,'font-family':'var(--mono)', - fill:'#3c382f'}).textContent=String(h);}}} - // printed zone verdicts; the active one goes red - const zones={}; - const zone=(key,txt,x,y,rot,size)=>{zones[key]=svgEl(s,'text',{x,y,'text-anchor':'middle', - 'font-size':size||5.2,'font-weight':700,'letter-spacing':'.06em','font-family':'var(--mono)', - fill:'#8f897b',transform:`rotate(${rot},${x},${y})`});zones[key].textContent=txt;}; - zone('humid','TOO HUMID',42,34,-32); - zone('warm','TOO WARM',89,34,32); - zone('right','JUST RIGHT',65,58,0,6.2); - zone('dry','TOO DRY',38,74,28); - zone('cold','TOO COLD',93,74,-28); - svgEl(s,'text',{x:cx,y:92,'text-anchor':'middle','font-size':5.6,'letter-spacing':'.18em', - 'font-family':'var(--mono)',fill:'#3c382f'}).textContent='WEATHER STATION'; - // needles: temp from bottom-left, humidity from bottom-right — clipped to the face - const nclip=svgEl(defs,'clipPath',{id:'cmClip'}); - svgEl(nclip,'circle',{cx,cy,r:49}); - const ng=svgEl(s,'g',{'clip-path':'url(#cmClip)'}); - const needle=()=>{const g=svgEl(ng,'g',{}); - svgEl(g,'line',{x1:0,y1:0,x2:0,y2:-84,stroke:'#c23a28','stroke-width':1.6,'stroke-linecap':'round'}); - svgEl(g,'circle',{cx:0,cy:0,r:2.6,fill:'#8f2416'});return g;}; - const nT=needle(),nH=needle(); - nT.setAttribute('transform',`translate(${PT[0]},${PT[1]})`); - nH.setAttribute('transform',`translate(${PH[0]},${PH[1]})`); - let vT=72,vH=45; - const verdict=()=>vT>78?'warm':vT<62?'cold':vH>60?'humid':vH<30?'dry':'right'; - const draw=()=>{ - nT.setAttribute('transform',`translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`); - nH.setAttribute('transform',`translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`); - const v=verdict(); - for(const k of Object.keys(zones))zones[k].setAttribute('fill',k===v?'#c23a28':'#8f897b'); - setRd('R56',Math.round(vT)+'F · '+Math.round(vH)+'% RH · '+zones[v].textContent);}; - const half=(x,get,set,min,max)=>{const hit=svgEl(s,'rect',{x,y:8,width:65,height:106,fill:'transparent'}); - hit.style.cursor='ns-resize'; - dragDelta(hit,get,v=>{set(Math.max(min,Math.min(max,v)));draw();},{min,max,sens:(max-min)/110});}; - half(0,()=>vT,v=>vT=v,40,100); - half(65,()=>vH,v=>vH=v,0,100); - draw();})(); +/* R53-R56 extracted to widgets.js (GW.*) */ /* ---- screen-family chips: first round, the clearly-screen widgets ---- Defaults match each widget's shipped color (var fallbacks), so no family is applied until a chip is clicked. diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 49991bf..89425f1 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -2443,6 +2443,367 @@ GW.roundCrt = function (host, opts = {}) { return { el: s, tick }; }; +/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */ +GW.attitudeIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 130); + const cx = 65, cy = 65; + const clipId = uid('aiClip'); + gradDef('aiSky', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#54677d'], ['1', '#3a4c60']]); + gradDef('aiGnd', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#5c4630'], ['1', '#3c2e1e']]); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 48 }); + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const ball = svgEl(g, 'g', {}); + svgEl(ball, 'rect', { x: cx - 90, y: cy - 180, width: 180, height: 180, fill: 'url(#aiSky)' }); + svgEl(ball, 'rect', { x: cx - 90, y: cy, width: 180, height: 180, fill: 'url(#aiGnd)' }); + svgEl(ball, 'line', { x1: cx - 90, y1: cy, x2: cx + 90, y2: cy, stroke: '#f3e7c5', 'stroke-width': 1.4 }); + for (const p of [-20, -10, 10, 20]) { + const y = cy - p * 1.6, w = p % 20 === 0 ? 16 : 10; + svgEl(ball, 'line', { x1: cx - w, y1: y, x2: cx + w, y2: y, stroke: '#f3e7c5', 'stroke-width': .8, opacity: .85 }); + svgEl(ball, 'text', { + x: cx + w + 3, y: y + 2, 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#f3e7c5', opacity: .85 + }).textContent = String(Math.abs(p)); + } + /* fixed bank scale + pointer + miniature aircraft */ + for (const b of [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60]) { + const [x1, y1] = polar(cx, cy, 47, b), [x2, y2] = polar(cx, cy, b % 30 === 0 ? 42 : 44, b); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': b === 0 ? 1.6 : .9, opacity: .9 }); + } + svgEl(s, 'polygon', { points: `${cx - 3.5},${cy - 38} ${cx + 3.5},${cy - 38} ${cx},${cy - 44}`, fill: 'var(--gold-hi)' }); + svgEl(s, 'path', { + d: `M ${cx - 22} ${cy} L ${cx - 7} ${cy} L ${cx - 4} ${cy + 4} M ${cx + 22} ${cy} L ${cx + 7} ${cy} L ${cx + 4} ${cy + 4}`, + fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' + }); + svgEl(s, 'circle', { cx, cy, r: 2, fill: 'var(--gold-hi)' }); + let bank = 0, pitch = 0; + function set(b, p) { + bank = Math.max(-60, Math.min(60, b)); pitch = Math.max(-20, Math.min(20, p)); + ball.setAttribute('transform', `rotate(${(-bank).toFixed(1)},${cx},${cy}) translate(0,${(pitch * 1.6).toFixed(1)})`); + onChange({ bank, pitch }, 'BANK ' + (bank < 0 ? 'L' : bank > 0 ? 'R' : '') + Math.abs(Math.round(bank)) + + ' · PITCH ' + (pitch >= 0 ? '+' : '-') + String(Math.abs(Math.round(pitch))).padStart(2, '0')); + } + s.style.touchAction = 'none'; s.style.cursor = 'move'; + s.addEventListener('pointerdown', e => { + s.setPointerCapture(e.pointerId); + const x0 = e.clientX, y0 = e.clientY, b0 = bank, p0 = pitch; + const mv = ev => set(b0 + (ev.clientX - x0) * 0.4, p0 + (ev.clientY - y0) * 0.25); + const up = () => { s.removeEventListener('pointermove', mv); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); }; + s.addEventListener('pointermove', mv); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); e.preventDefault(); + }); + set(opts.bank ?? 0, opts.pitch ?? 0); + return { el: s, get: () => ({ bank, pitch }), set }; +}; + +/* R44 heading bug + servo needle — drag parks the command; the needle chases + with honest servo lag (widget-owned animation, reduced-motion gated) */ +GW.headingBug = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 130); + const cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 }); + for (let d = 0; d < 360; d += 10) { + const maj = d % 30 === 0; + const [x1, y1] = polar(cx, cy, 48, d), [x2, y2] = polar(cx, cy, maj ? 42 : 45, d); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': maj ? 1 : .5, opacity: maj ? .9 : .5 }); + } + for (let d = 0; d < 360; d += 30) { + const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : String(d / 10); + const [x, y] = polar(cx, cy, 35, d); + svgEl(s, 'text', { + x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': d % 90 === 0 ? 8 : 6.4, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: d % 90 === 0 ? 'var(--cream)' : 'var(--steel)' + }).textContent = lbl; + } + svgEl(s, 'line', { x1: cx, y1: cy - 56, x2: cx, y2: cy - 48, stroke: 'var(--fail)', 'stroke-width': 2 }); + const bug = svgEl(s, 'path', { + d: `M ${cx - 6} ${cy - 49} L ${cx - 6} ${cy - 44} L ${cx - 2.5} ${cy - 44} L ${cx} ${cy - 47} L ${cx + 2.5} ${cy - 44} L ${cx + 6} ${cy - 44} L ${cx + 6} ${cy - 49} Z`, + fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': .7 + }); + const needle = svgEl(s, 'g', {}); + svgEl(needle, 'polygon', { + points: `${cx - 2},${cy + 10} ${cx + 2},${cy + 10} ${cx + 1.2},${cy - 40} ${cx},${cy - 44} ${cx - 1.2},${cy - 40}`, + fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6 + }); + svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let cmd = opts.value ?? 90, act = cmd; + const draw = () => { + bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`); + needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`); + onChange({ cmd, act }, 'CMD ' + String(Math.round((cmd % 360 + 360) % 360)).padStart(3, '0') + + ' · ACT ' + String(Math.round((act % 360 + 360) % 360)).padStart(3, '0')); + }; + function set(v) { cmd = (v % 360 + 360) % 360; draw(); } + dragDelta(s, () => cmd, set, { min: -100000, max: 100000, sens: 1.2 }); + draw(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { + const diff = ((cmd - act + 540) % 360) - 180; + if (Math.abs(diff) < 0.4) { if (act !== cmd) { act = cmd; draw(); } return; } + act = (act + diff * 0.07 + 360) % 360; draw(); + }, 80); + return { el: s, get: () => ({ cmd, act }), set }; +}; + +/* R53 circular chart recorder — a day per revolution; the pen draws the cycle + (widget-owned clock, reduced-motion paints the full day once); click for fresh paper */ +GW.chartRecorder = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130); + const cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 54, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': .8 }); + for (const r of [20, 31, 42]) + svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: '#b8ae98', 'stroke-width': .5 }); + for (let h = 0; h < 24; h++) { + const a = h * 15; + const [x1, y1] = polar(cx, cy, 54, a), [x2, y2] = polar(cx, cy, h % 6 === 0 ? 12 : 48, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#b8ae98', 'stroke-width': h % 6 === 0 ? .6 : .35, opacity: .8 }); + if (h % 6 === 0) { + const [tx, ty] = polar(cx, cy, 50, a + 5); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#6e6552' + }).textContent = String(h).padStart(2, '0'); + } + } + const trace = svgEl(s, 'path', { d: '', fill: 'none', stroke: '#8f2416', 'stroke-width': 1.1, 'stroke-linejoin': 'round' }); + const pen = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 54, stroke: '#4a463e', 'stroke-width': 1 }); + const penDot = svgEl(s, 'circle', { cx, cy: cy - 30, r: 2, fill: '#8f2416' }); + svgEl(s, 'circle', { cx, cy, r: 4, fill: '#4a463e' }); + const val = h => 52 + 30 * Math.sin(h / 24 * 2 * Math.PI - 2.1) + 12 * Math.sin(h / 24 * 6 * Math.PI) + 4 * Math.sin(h * 2.7); + const rOf = v => 12 + Math.max(0, Math.min(100, v)) * 0.36; + let pts = [], hour = 0; + const draw = () => { + const a = hour * 15, r = rOf(val(hour)); + pts.push(polar(cx, cy, r, a)); + if (pts.length > 1) trace.setAttribute('d', 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L ')); + pen.setAttribute('transform', `rotate(${a},${cx},${cy})`); + const [px, py] = polar(cx, cy, r, a); + penDot.setAttribute('cx', px); penDot.setAttribute('cy', py); + onChange(hour, String(Math.floor(hour)).padStart(2, '0') + ':' + (hour % 1 >= 0.5 ? '30' : '00') + ' · ' + Math.round(val(hour)) + '%'); + }; + const reset = () => { pts = []; hour = 0; trace.setAttribute('d', ''); draw(); }; + s.style.cursor = 'pointer'; + s.addEventListener('click', reset); + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (hour = 0; hour < 24; hour += 0.25) draw(); + hour = 23.75; + } else { + reset(); + setInterval(() => { hour += 0.25; if (hour >= 24) { pts = []; hour = 0; } draw(); }, 375); + } + return { el: s, get: () => hour, reset }; +}; + +/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */ +GW.verticalTape = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 70, 130); + const CX = 38, CY = 65; + const MIN = 5, MAX = 35, PPU = 5.5; /* rpm x100, px per unit */ + const clipId = uid('vtClip'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4 }); + svgEl(s, 'rect', { x: 2, y: 2, width: 66, height: 126, rx: 7, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4, fill: '#0d0c0a', stroke: '#2c2820', 'stroke-width': 1 }); + const tapeG = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const tape = svgEl(tapeG, 'g', {}); + for (let u = MIN; u <= MAX; u++) { + const y = -u * PPU; /* higher value further up the tape */ + const maj = u % 5 === 0; + svgEl(tape, 'line', { x1: maj ? 20 : 26, y1: y, x2: 34, y2: y, stroke: 'var(--cream)', 'stroke-width': maj ? 1.1 : .55, opacity: maj ? .95 : .6 }); + if (maj) svgEl(tape, 'text', { + x: 48, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: 'var(--cream)' + }).textContent = String(u); + } + /* fixed index: side pointer + line across the window at center height */ + svgEl(s, 'polygon', { points: `8,${CY} 15,${CY - 4} 15,${CY + 4}`, fill: 'var(--gold-hi)' }); + svgEl(s, 'line', { x1: 15, y1: CY, x2: 62, y2: CY, stroke: 'var(--gold-hi)', 'stroke-width': 1, opacity: .85 }); + svgEl(s, 'text', { + x: CX, y: 126, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.1em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'RPM x100'; + let v = opts.value ?? 24; + function set(nv) { + v = Math.max(MIN, Math.min(MAX, nv)); + tape.setAttribute('transform', `translate(0,${(CY + v * PPU).toFixed(1)})`); + onChange(v, 'RPM ' + Math.round(v * 100)); + } + s.style.cursor = 'ns-resize'; + dragDelta(s, () => v, set, { min: MIN, max: MAX, sens: (MAX - MIN) / 140 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */ +GW.twinNeedle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 120, 110); + const cx = 60, cy = 62, R = 44; + svgEl(s, 'circle', { cx, cy, r: 52, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 46, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 }); + /* mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL */ + const angL = v => -170 + v / 4 * 160; /* 0..4 -> -170..-10 (left side, cw from top) */ + const angR = v => 170 - v / 4 * 160; /* 0..4 -> 170..10 (right side) */ + for (let v = 0; v <= 4; v++) { + for (const ang of [angL(v), angR(v)]) { + const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 5, ang); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': 1, opacity: .9 }); + const [tx, ty] = polar(cx, cy, R - 11, ang); + svgEl(s, 'text', { + x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', + fill: 'var(--cream)' + }).textContent = String(v); + } + } + for (let v = 0.5; v < 4; v += 0.5) { + if (v % 1 === 0) continue; + for (const ang of [angL(v), angR(v)]) { + const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 3, ang); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .5, opacity: .55 }); + } + } + svgEl(s, 'text', { + x: cx - 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'FUEL'; + svgEl(s, 'text', { + x: cx + 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'OIL'; + svgEl(s, 'text', { + x: cx, y: cy - 26, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.06em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'kg/cm2'; + const needle = color => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'polygon', { + points: `${cx - 1.6},${cy + 8} ${cx + 1.6},${cy + 8} ${cx + 1},${cy - R + 8} ${cx},${cy - R + 4} ${cx - 1},${cy - R + 8}`, + fill: color, stroke: 'rgba(0,0,0,.4)', 'stroke-width': .5 + }); return g; + }; + const nF = needle('#e0523a'), nO = needle('#bfc4d0'); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let vF = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1; + const draw = () => { + nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`); + nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`); + onChange([vF, vO], 'FUEL ' + vF.toFixed(1) + ' · OIL ' + vO.toFixed(1)); + }; + function set(f, o) { vF = Math.max(0, Math.min(4, f)); vO = Math.max(0, Math.min(4, o)); draw(); } + /* each half is its own drag surface */ + const half = (x, get, setV) => { + const hit = svgEl(s, 'rect', { x, y: 10, width: 60, height: 104, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, get, v => { setV(Math.max(0, Math.min(4, v))); draw(); }, { min: 0, max: 4, sens: 4 / 110 }); + }; + half(0, () => vF, v => vF = v); + half(60, () => vO, v => vO = v); + draw(); + return { el: s, get: () => [vF, vO], set }; +}; + +/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */ +GW.comfortMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 122); + const cx = 65, cy = 60; + const clipId = uid('cmClip'); + gradDef('cmBrass', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8b25c'], ['.5', '#a07c30'], ['1', '#6e5218']]); + const defs = svgEl(s, 'defs', {}); + svgEl(s, 'circle', { cx, cy, r: 56, fill: 'url(#cmBrass)', stroke: '#4a3610', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 53, fill: 'none', stroke: '#7a5c1e', 'stroke-width': 2.5, 'stroke-dasharray': '2 1.4' }); + svgEl(s, 'circle', { cx, cy, r: 49, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': 1 }); + const PT = [38, 96], PH = [92, 96]; /* temp pivot (BL), humidity pivot (BR) */ + const angT = t => 105 - (t - 40) / 60 * 70; /* 40..100F -> bearing 105..35 from PT */ + const angH = h => -105 + h / 100 * 70; /* 0..100% -> bearing -105..-35 from PH */ + /* scale ticks along each sweep, near the rim */ + for (let t = 40; t <= 100; t += 10) { + const a = angT(t); + const [x1, y1] = polar(PT[0], PT[1], 86, a), [x2, y2] = polar(PT[0], PT[1], 82, a); + if (Math.hypot(x1 - cx, y1 - cy) < 48) { + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': t % 20 === 0 ? 1 : .5 }); + if (t % 20 === 0) { + const [tx, ty] = polar(PT[0], PT[1], 78, a); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#3c382f' + }).textContent = String(t); + } + } + } + for (let h = 0; h <= 100; h += 10) { + const a = angH(h); + const [x1, y1] = polar(PH[0], PH[1], 86, a), [x2, y2] = polar(PH[0], PH[1], 82, a); + if (Math.hypot(x1 - cx, y1 - cy) < 48) { + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': h % 20 === 0 ? 1 : .5 }); + if (h % 20 === 0) { + const [tx, ty] = polar(PH[0], PH[1], 78, a); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#3c382f' + }).textContent = String(h); + } + } + } + /* printed zone verdicts; the active one goes red */ + const zones = {}; + const zone = (key, txt, x, y, rot, size) => { + zones[key] = svgEl(s, 'text', { + x, y, 'text-anchor': 'middle', + 'font-size': size || 5.2, 'font-weight': 700, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', + fill: '#8f897b', transform: `rotate(${rot},${x},${y})` + }); zones[key].textContent = txt; + }; + zone('humid', 'TOO HUMID', 42, 34, -32); + zone('warm', 'TOO WARM', 89, 34, 32); + zone('right', 'JUST RIGHT', 65, 58, 0, 6.2); + zone('dry', 'TOO DRY', 38, 74, 28); + zone('cold', 'TOO COLD', 93, 74, -28); + svgEl(s, 'text', { + x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.6, 'letter-spacing': '.18em', + 'font-family': 'var(--mono)', fill: '#3c382f' + }).textContent = 'WEATHER STATION'; + /* needles: temp from bottom-left, humidity from bottom-right — clipped to the face */ + const nclip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(nclip, 'circle', { cx, cy, r: 49 }); + const ng = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const needle = () => { + const g = svgEl(ng, 'g', {}); + svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: -84, stroke: '#c23a28', 'stroke-width': 1.6, 'stroke-linecap': 'round' }); + svgEl(g, 'circle', { cx: 0, cy: 0, r: 2.6, fill: '#8f2416' }); return g; + }; + const nT = needle(), nH = needle(); + nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`); + nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`); + let vT = opts.temp ?? 72, vH = opts.humidity ?? 45; + const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right'; + const draw = () => { + nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`); + nH.setAttribute('transform', `translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`); + const v = verdict(); + for (const k of Object.keys(zones)) zones[k].setAttribute('fill', k === v ? '#c23a28' : '#8f897b'); + onChange([vT, vH], Math.round(vT) + 'F · ' + Math.round(vH) + '% RH · ' + zones[v].textContent); + }; + function set(t, h) { vT = Math.max(40, Math.min(100, t)); vH = Math.max(0, Math.min(100, h)); draw(); } + const half = (x, get, setV, min, max) => { + const hit = svgEl(s, 'rect', { x, y: 8, width: 65, height: 106, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, get, v => { setV(Math.max(min, Math.min(max, v))); draw(); }, { min, max, sens: (max - min) / 110 }); + }; + half(0, () => vT, v => vT = v, 40, 100); + half(65, () => vH, v => vH = v, 0, 100); + draw(); + return { el: s, get: () => [vT, vH], set }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { -- cgit v1.2.3