aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/prototypes/panel-widget-gallery.html247
-rw-r--r--docs/prototypes/widgets.js361
2 files changed, 369 insertions, 239 deletions
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)}),
'<b>a needle on a vertical scale.</b> 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',
- `<svg id="attind" class="rsvg" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (st,rd)=>GW.attitudeIndicator(st,{onChange:(v,t)=>rd(t)}),
'<b>orientation, not a number.</b> 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',
- `<svg id="hdgbug" class="rsvg" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (st,rd)=>GW.headingBug(st,{value:90,onChange:(v,t)=>rd(t)}),
'<b>commanded versus actual on one dial.</b> 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',
- `<svg id="vtape" class="rsvg" viewBox="0 0 70 130" width="70" height="130"></svg>`,
+ (st,rd)=>GW.verticalTape(st,{value:24,onChange:(v,t)=>rd(t)}),
'<b>the scale moves, the index does not.</b> 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',
- `<svg id="twinng" class="rsvg" viewBox="0 0 120 110" width="120" height="110"></svg>`,
+ (st,rd)=>GW.twinNeedle(st,{fuel:2.4,oil:3.1,onChange:(v,t)=>rd(t)}),
'<b>two values, one housing, mirrored scales.</b> 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',
- `<svg id="comfort" class="rsvg" viewBox="0 0 130 122" width="130" height="122"></svg>`,
+ (st,rd)=>GW.comfortMeter(st,{temp:72,humidity:45,onChange:(v,t)=>rd(t)}),
'<b>two values, one verdict.</b> 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',
- `<svg id="circhart" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (st,rd)=>GW.chartRecorder(st,{onChange:(v,t)=>rd(t)}),
'<b>cyclic time on round paper.</b> 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() {