From 63577703fe59f03e98a039fec8be56c2fc32826b Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 22:15:12 -0500 Subject: refactor(gallery): extract meters R01, R07-R09, R13, R17 into GW builders The VU ballistics chase stays page-side and drives mcVu.set(t); the R17 trace animation is widget-owned so it moves into the builder with its reduced-motion gate. The R17 face gradient keeps instance-local defs because its stops read screen-family vars from the widget subtree; probe-fams selectors follow the retired rcrt ids to the card handle. --- docs/prototypes/panel-widget-gallery.html | 211 ++------------------- docs/prototypes/widgets.js | 296 ++++++++++++++++++++++++++++++ tests/gallery-probes/probe-fams.mjs | 4 +- 3 files changed, 311 insertions(+), 200 deletions(-) diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 7862c5e..6bbe3d9 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -1763,19 +1763,19 @@ card(M,'N18','Battery-cell gauge', (st,rd)=>GW.battCells(st,{value:62,onChange:(v,t)=>rd(t)}), 'charge as discrete cells. Filled cells count up, the last warn in terracotta. Drag left/right to set; readout shows %.'); card(M,'R01','Moving-coil VU meter', - ``, + (st,rd)=>GW.mcVu(st,{onChange:(v,t)=>rd(t)}), 'the classic cream-faced VU. Printed dB arc, red zone past 0, ballistic needle. Runs the live signal; readout shows VU. After a vintage console meter pair.'); card(M,'R07','Round panel meter', - ``, + (st,rd)=>GW.roundMeter(st,{value:50,onChange:(v,t)=>rd(t)}), 'the porthole lab meter. Cream dial in a round black bezel, printed dB arc, black needle. Drag up/down to drive it; readout shows dB. After a modulator front meter.'); card(M,'R08','Chrome MIN/MAX indicator', - ``, + (st,rd)=>GW.chromeMinMax(st,{value:50,onChange:(v,t)=>rd(t)}), 'the hi-fi trim window. Polished chrome ring, brushed dome, a dark pointer sweeping MIN to MAX over the metal. Drag up/down; readout shows the position. After a hi-fi amplifier level window.'); card(M,'R09','Black-face aviation gauge', - ``, + (st,rd)=>GW.aviationGauge(st,{value:52,onChange:(v,t)=>rd(t)}), 'amber on black, zone-marked. The cockpit round: amber ticks and numerals on a black face, painted caution and limit sectors, amber needle. Drag up/down; readout tracks. After a flight-instrument cluster.'); 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', ``, @@ -1796,7 +1796,7 @@ card(M,'R53','Circular chart recorder', ``, '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)}), 'the round-tube scope. Pale phosphor face in a screwed porthole bezel, dark graticule, a bright live trace. Runs live; readout shows Vpp. After a 1950s bench oscilloscope.'); /* ============ INDICATORS & READOUTS ============ */ @@ -1998,42 +1998,9 @@ setRd('N24','click to cycle'); /* ============ REFERENCE-BATCH (R) WIDGETS — SVG builders ============ */ /* svgEl, polar, VUDB live in widgets.js (GW) */ -/* R01 moving-coil VU: pivot below the face, authentic nonlinear dB scale */ -const mcvu={t:.35,needle:null}; -(function(){const s=$('mcvu'),cx=75,cy=112,sweep=t=>-43+t*86; - const defs=svgEl(s,'defs',{}); - const g1=svgEl(defs,'linearGradient',{id:'vuGlass',x1:0,y1:0,x2:0,y2:1}); - svgEl(g1,'stop',{offset:'0','stop-color':'rgba(255,255,255,.13)'}); - svgEl(g1,'stop',{offset:'.35','stop-color':'rgba(255,255,255,0)'}); - const clip=svgEl(defs,'clipPath',{id:'vuFace'}); - svgEl(clip,'rect',{x:8,y:8,width:134,height:74,rx:3}); - svgEl(s,'rect',{x:.5,y:.5,width:149,height:95,rx:7,fill:'#16130f',stroke:'#060505'}); - svgEl(s,'rect',{x:8,y:8,width:134,height:74,rx:3,fill:'var(--cream)'}); - const face=svgEl(s,'g',{'clip-path':'url(#vuFace)'}); - const arcPt=t=>polar(cx,cy,58,sweep(t)); - const [ax1,ay1]=arcPt(0),[ax2,ay2]=arcPt(1); - svgEl(face,'path',{d:`M ${ax1} ${ay1} A 58 58 0 0 1 ${ax2} ${ay2}`,fill:'none',stroke:'#3a3128','stroke-width':1.2}); - const [rx1,ry1]=arcPt(.75); - svgEl(face,'path',{d:`M ${rx1} ${ry1} A 58 58 0 0 1 ${ax2} ${ay2}`,fill:'none',stroke:'var(--fail)','stroke-width':3.5}); - for(const [db,t] of VUDB){const a=sweep(t),[x1,y1]=polar(cx,cy,55,a),[x2,y2]=polar(cx,cy,63,a); - svgEl(face,'line',{x1,y1,x2,y2,stroke:db>0?'var(--fail)':'#3a3128','stroke-width':1.1});} - for(const [db,t] of VUDB){if(![-20,-10,-5,-3,0,1,2,3].includes(db))continue; - const [x,y]=polar(cx,cy,69,sweep(t)); - svgEl(face,'text',{x,y:y+2,'text-anchor':'middle','font-size':5.8,'font-family':'var(--mono)', - fill:db>0?'var(--fail)':'#3a3128'}).textContent=Math.abs(db);} - svgEl(face,'text',{x:75,y:78,'text-anchor':'middle','font-size':11,'font-weight':700, - 'font-family':'var(--mono)',fill:'#3a3128'}).textContent='VU'; - mcvu.needle=svgEl(face,'line',{x1:cx,y1:cy,x2:cx,y2:cy-62,stroke:'#1a1613','stroke-width':1.6, - transform:`rotate(${sweep(.35)},${cx},${cy})`}); - svgEl(face,'circle',{cx,cy:88,r:9,fill:'#16130f'}); - svgEl(s,'rect',{x:8,y:8,width:134,height:74,rx:3,fill:'url(#vuGlass)'}); - mcvu.sweep=sweep;})(); -function setMcVu(t){t=Math.max(0,Math.min(1.02,t));mcvu.t=t; - mcvu.needle.setAttribute('transform',`rotate(${mcvu.sweep(Math.min(1,t))},75,112)`); - const db=vuDb(Math.min(1,t)); - setRd('R01',(db>0?'+':'')+db.toFixed(1)+' VU');} +/* R01 ballistics: the page owns the signal chase; the meter just takes set(t) */ function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12); - setMcVu(mcvu.t+(target-mcvu.t)*(target>mcvu.t?0.32:0.10));} + const t=MH.mcvu.get();MH.mcvu.set(t+(target-t)*(target>t?0.32:0.10));} /* R02-R06, R12, R14, R15 extracted to widgets.js (GW.*) */ @@ -2045,100 +2012,7 @@ function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12); -/* R07 round panel meter: porthole bezel, same VU law as R01 */ -let rndT=.5,rndNeedle=null; -(function(){const s=$('rndm'),cx=55,fy=54,py=74,sweep=t=>-40+t*80; - const defs=svgEl(s,'defs',{}); - const clip=svgEl(defs,'clipPath',{id:'rndFace'}); - svgEl(clip,'circle',{cx,cy:fy,r:40}); - svgEl(s,'circle',{cx,cy:fy,r:48,fill:'#141110',stroke:'#060505','stroke-width':2}); - svgEl(s,'circle',{cx,cy:fy,r:40,fill:'var(--cream)'}); - const g=svgEl(s,'g',{'clip-path':'url(#rndFace)'}); - const pt=(r,t)=>polar(cx,py,r,sweep(t)); - const [ax1,ay1]=pt(44,0),[ax2,ay2]=pt(44,1); - svgEl(g,'path',{d:`M ${ax1} ${ay1} A 44 44 0 0 1 ${ax2} ${ay2}`,fill:'none',stroke:'#3a3128','stroke-width':1.1}); - const [rzx,rzy]=pt(44,.75); - svgEl(g,'path',{d:`M ${rzx} ${rzy} A 44 44 0 0 1 ${ax2} ${ay2}`,fill:'none',stroke:'var(--fail)','stroke-width':3}); - for(const [db,t] of VUDB){const [x1,y1]=pt(41.5,t),[x2,y2]=pt(47,t); - svgEl(g,'line',{x1,y1,x2,y2,stroke:db>0?'var(--fail)':'#3a3128','stroke-width':1});} - for(const [db,t] of VUDB){if(![-20,-10,-5,-3,0,3].includes(db))continue; - const [x,y]=pt(52,t); - svgEl(g,'text',{x,y:y+2,'text-anchor':'middle','font-size':5.5,'font-family':'var(--mono)', - fill:db>0?'var(--fail)':'#3a3128'}).textContent=Math.abs(db);} - svgEl(g,'text',{x:cx,y:fy+14,'text-anchor':'middle','font-size':8,'font-weight':700, - 'font-family':'var(--mono)',fill:'#3a3128'}).textContent='dB'; - rndNeedle=svgEl(g,'line',{x1:cx,y1:py,x2:cx,y2:py-46,stroke:'#1a1613','stroke-width':1.5, - transform:`rotate(0,${cx},${py})`}); - svgEl(g,'circle',{cx,cy:py,r:5,fill:'#141110'}); - svgEl(s,'ellipse',{cx:cx-8,cy:fy-22,rx:20,ry:9,fill:'rgba(255,255,255,.07)',transform:`rotate(-18,${cx-8},${fy-22})`});})(); -function setRnd(v){const t=Math.max(0,Math.min(1,v/100));rndT=t; - rndNeedle.setAttribute('transform',`rotate(${-40+t*80},55,74)`); - const db=vuDb(t);setRd('R07',(db>0?'+':'')+db.toFixed(1)+' dB');} -dragDelta($('rndm'),()=>rndT*100,setRnd,{min:0,max:100});setRnd(50); - -/* R08 chrome MIN/MAX indicator */ -let chrV=50,chrPtr=null; -(function(){const s=$('chrmm'),cx=55,cy=58; - const defs=svgEl(s,'defs',{}); - const ring=svgEl(defs,'linearGradient',{id:'chrRing',x1:0,y1:0,x2:0,y2:1}); - svgEl(ring,'stop',{offset:'0','stop-color':'#eceef0'}); - svgEl(ring,'stop',{offset:'.5','stop-color':'#9aa0a8'}); - svgEl(ring,'stop',{offset:'1','stop-color':'#54585e'}); - const dome=svgEl(defs,'radialGradient',{id:'chrDome',cx:'48%',cy:'20%',r:'85%'}); - svgEl(dome,'stop',{offset:'0','stop-color':'#e6e8ec'}); - svgEl(dome,'stop',{offset:'1','stop-color':'#82878f'}); - svgEl(s,'circle',{cx,cy:54,r:47,fill:'url(#chrRing)'}); - svgEl(s,'circle',{cx,cy:54,r:40,fill:'#101214',stroke:'#2c2f33','stroke-width':1}); - svgEl(s,'path',{d:`M 21 60 A 34 34 0 0 1 89 60 Z`,fill:'url(#chrDome)',stroke:'#55595f','stroke-width':.5}); - for(const r of [14,22,30]) - svgEl(s,'path',{d:`M ${cx-r} 60 A ${r} ${r} 0 0 1 ${cx+r} 60`,fill:'none',stroke:'rgba(255,255,255,.13)','stroke-width':.7}); - chrPtr=svgEl(s,'line',{x1:cx,y1:cy-10,x2:cx,y2:cy-31,stroke:'#17191b','stroke-width':2.2,'stroke-linecap':'round'}); - svgEl(s,'circle',{cx,cy,r:6,fill:'#23262a'}); - for(let k=0;k<8;k++){const [x,y]=polar(cx,cy,6,k*45); - svgEl(s,'circle',{cx:x,cy:y,r:1.3,fill:'#23262a'});} - svgEl(s,'circle',{cx,cy,r:1.6,fill:'#0c0d0e'}); - svgEl(s,'text',{x:24,y:66,'text-anchor':'middle','font-size':6,'letter-spacing':'.05em', - 'font-family':'var(--mono)',fill:'#d7dade'}).textContent='MIN'; - svgEl(s,'text',{x:86,y:66,'text-anchor':'middle','font-size':6,'letter-spacing':'.05em', - 'font-family':'var(--mono)',fill:'#d7dade'}).textContent='MAX'; - svgEl(s,'text',{x:cx,y:84,'text-anchor':'middle','font-size':7,'font-style':'italic', - 'font-family':'var(--mono)',fill:'#787c82'}).textContent='dupre.'; - svgEl(s,'ellipse',{cx:47,cy:34,rx:22,ry:9,fill:'rgba(255,255,255,.10)',transform:'rotate(-16,47,34)'});})(); -function setChr(v){chrV=Math.max(0,Math.min(100,v)); - chrPtr.setAttribute('transform',`rotate(${-55+chrV/100*110},55,58)`); - setRd('R08',chrV<=2?'MIN':chrV>=98?'MAX':Math.round(chrV)+'%');} -dragDelta($('chrmm'),()=>chrV,setChr,{min:0,max:100});setChr(50); - -/* R09 black-face aviation gauge: zone arcs per the airspeed-indicator scheme */ -let avV=52,avNeedle=null; -(function(){const s=$('avgauge'),cx=55,cy=52,sweep=t=>-135+t*270; - const defs=svgEl(s,'defs',{}); - const fl=svgEl(defs,'filter',{id:'avGlow',x:'-60%',y:'-60%',width:'220%',height:'220%'}); - svgEl(fl,'feGaussianBlur',{in:'SourceGraphic',stdDeviation:1.4}); - svgEl(s,'circle',{cx,cy,r:47,fill:'#1b1917',stroke:'#060505','stroke-width':2}); - svgEl(s,'circle',{cx,cy,r:41,fill:'#0a0908',stroke:'#3a352c','stroke-width':1}); - const zone=(t1,t2,color)=>{const [x1,y1]=polar(cx,cy,37,sweep(t1)),[x2,y2]=polar(cx,cy,37,sweep(t2)); - svgEl(s,'path',{d:`M ${x1} ${y1} A 37 37 0 ${(t2-t1)>.5?1:0} 1 ${x2} ${y2}`,fill:'none',stroke:color,'stroke-width':3,opacity:.85});}; - zone(.15,.6,'var(--pass)');zone(.6,.85,'var(--gold)');zone(.85,1,'var(--fail)'); - for(let i=0;i<=40;i++){const t=i/40,major=i%5===0,a=sweep(t); - const [x1,y1]=polar(cx,cy,40,a),[x2,y2]=polar(cx,cy,major?32:36,a); - svgEl(s,'line',{x1,y1,x2,y2,stroke:'var(--gold)','stroke-width':major?1.6:.8,opacity:major?1:.7}); - if(major){const [nx,ny]=polar(cx,cy,25,a); - svgEl(s,'text',{x:nx,y:ny+2.4,'text-anchor':'middle','font-size':6.5,'font-family':'var(--mono)', - fill:'var(--gold-hi)'}).textContent=i/5;}} - svgEl(s,'text',{x:cx,y:cy+16,'text-anchor':'middle','font-size':5.5,'letter-spacing':'.14em', - 'font-family':'var(--mono)',fill:'var(--gold)',opacity:.8}).textContent='×100 RPM'; - avNeedle=svgEl(s,'g',{}); - svgEl(avNeedle,'line',{x1:cx,y1:cy+8,x2:cx,y2:cy-30,stroke:'var(--gold-hi)','stroke-width':4, - 'stroke-linecap':'round',opacity:.5,filter:'url(#avGlow)'}); - svgEl(avNeedle,'line',{x1:cx,y1:cy+8,x2:cx,y2:cy-30,stroke:'var(--gold-hi)','stroke-width':2.2, - 'stroke-linecap':'round'}); - svgEl(s,'circle',{cx,cy,r:5,fill:'#1b1917',stroke:'#3a352c'}); - svgEl(s,'ellipse',{cx:cx-10,cy:cy-24,rx:18,ry:8,fill:'rgba(255,255,255,.05)',transform:`rotate(-18,${cx-10},${cy-24})`});})(); -function setAv(v){avV=Math.max(0,Math.min(100,v)); - avNeedle.setAttribute('transform',`rotate(${-135+avV/100*270},55,52)`); - setRd('R09',(avV/100*8).toFixed(1)+' ×100 rpm');} -dragDelta($('avgauge'),()=>avV,setAv,{min:0,max:100});setAv(52); +/* R07-R09 extracted to widgets.js (GW.*) */ /* R10 data matrix readout */ const DMX_PAGES=[ @@ -2194,66 +2068,7 @@ setFlag(false); -/* R13 edgewise strip meter: compressed log scale, bar rides the edge */ -const EDGE_SCALE=[[0,.08],[3,.26],[6,.44],[12,.62],[20,.78],[40,.94]]; -let edgeFrac=.62,edgeBar=null; -(function(){const s=$('edgem'),wy0=12,wy1=118; - const defs=svgEl(s,'defs',{}); - const pg=svgEl(defs,'linearGradient',{id:'edgeFace',x1:0,y1:0,x2:1,y2:0}); - svgEl(pg,'stop',{offset:'0','stop-color':'#efe6ca'}); - svgEl(pg,'stop',{offset:'1','stop-color':'#d9cfae'}); - svgEl(s,'rect',{x:8,y:6,width:54,height:118,rx:8,fill:'#171412',stroke:'#060505','stroke-width':1.5}); - svgEl(s,'rect',{x:14,y:wy0,width:42,height:wy1-wy0,fill:'url(#edgeFace)',stroke:'#0a0908'}); - const yOf=f=>wy0+f*(wy1-wy0); - for(const [db,f] of EDGE_SCALE){const y=yOf(f); - svgEl(s,'line',{x1:19,y1:y,x2:40,y2:y,stroke:'#3a3128','stroke-width':1}); - svgEl(s,'text',{x:52,y:y+2.4,'text-anchor':'end','font-size':7,'font-family':'var(--mono)', - fill:'#3a3128'}).textContent=db;} - for(let i=0;isetEdge(.08+(100-pct)/100*.86));})(); -function setEdge(f){edgeFrac=Math.max(.08,Math.min(.94,f)); - edgeBar.setAttribute('transform',`translate(0,${12+edgeFrac*106})`); - let i=0;while(iMath.sin(i*0.5)*18/22),0.74); MH.strip.set(Array.from({length:60},(_,i)=>0.5+Math.sin(i*0.3)*16/56),0.5); tickClock();renderSeven();setRd('N20','DNS');setRd('N22','scrolling');setRd('N25',String(ctrVal).padStart(6,'0')); - setMcVu(.5); + MH.mcvu.set(.5); } if(!reduced){ @@ -2918,7 +2733,7 @@ let dkPos=0,dkCarry=0; Defaults match each widget's shipped color (var fallbacks), so no family is applied until a chip is clicked. The nixie stays chipless on purpose: neon is only ever orange, so alternates would be historically false. */ screenChips('R10',$('dmx'),['amber','green','red','blue','vfd'],'amber'); -screenChips('R17',$('rcrt'),['green','amber','red','blue','vfd'],'green'); +screenChips('R17',$('card-R17').gw.el,['green','amber','red','blue','vfd'],'green'); screenChips('R19',$('card-R19').gw.el,['white','green','amber','blue','vfd'],'white'); screenChips('R31',$('radar'),['amber','green','red','blue','vfd'],'amber'); screenChips('N11',$('card-N11').gw.el,['green','amber','red','blue','vfd'],'green'); diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index d7b66f3..49991bf 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -2147,6 +2147,302 @@ GW.battCells = function (host, opts = {}) { return { el, get: () => p, set }; }; +/* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale; + set(t) positions instantly (0..1.02); ballistics belong to the signal owner */ +GW.mcVu = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 150, 96); + const cx = 75, cy = 112, sweep = t => -43 + t * 86; + const glassId = uid('vuGlass'), clipId = uid('vuFace'); + const defs = svgEl(s, 'defs', {}); + const g1 = svgEl(defs, 'linearGradient', { id: glassId, x1: 0, y1: 0, x2: 0, y2: 1 }); + svgEl(g1, 'stop', { offset: '0', 'stop-color': 'rgba(255,255,255,.13)' }); + svgEl(g1, 'stop', { offset: '.35', 'stop-color': 'rgba(255,255,255,0)' }); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3 }); + svgEl(s, 'rect', { x: .5, y: .5, width: 149, height: 95, rx: 7, fill: '#16130f', stroke: '#060505' }); + svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: 'var(--cream)' }); + const face = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const arcPt = t => polar(cx, cy, 58, sweep(t)); + const [ax1, ay1] = arcPt(0), [ax2, ay2] = arcPt(1); + svgEl(face, 'path', { d: `M ${ax1} ${ay1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.2 }); + const [rx1, ry1] = arcPt(.75); + svgEl(face, 'path', { d: `M ${rx1} ${ry1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3.5 }); + for (const [db, t] of VUDB) { + const a = sweep(t), [x1, y1] = polar(cx, cy, 55, a), [x2, y2] = polar(cx, cy, 63, a); + svgEl(face, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1.1 }); + } + for (const [db, t] of VUDB) { + if (![-20, -10, -5, -3, 0, 1, 2, 3].includes(db)) continue; + const [x, y] = polar(cx, cy, 69, sweep(t)); + svgEl(face, 'text', { + x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.8, 'font-family': 'var(--mono)', + fill: db > 0 ? 'var(--fail)' : '#3a3128' + }).textContent = Math.abs(db); + } + svgEl(face, 'text', { + x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#3a3128' + }).textContent = 'VU'; + const needle = svgEl(face, 'line', { + x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6, + transform: `rotate(${sweep(.35)},${cx},${cy})` + }); + svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' }); + svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` }); + let t = opts.value ?? .35; + function set(nt) { + t = Math.max(0, Math.min(1.02, nt)); + needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`); + const db = vuDb(Math.min(1, t)); + onChange(t, (db > 0 ? '+' : '') + db.toFixed(1) + ' VU'); + } + return { el: s, get: () => t, set }; +}; + +/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */ +GW.roundMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80; + const clipId = uid('rndFace'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy: fy, r: 40 }); + svgEl(s, 'circle', { cx, cy: fy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy: fy, r: 40, fill: 'var(--cream)' }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const pt = (r, t) => polar(cx, py, r, sweep(t)); + const [ax1, ay1] = pt(44, 0), [ax2, ay2] = pt(44, 1); + svgEl(g, 'path', { d: `M ${ax1} ${ay1} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.1 }); + const [rzx, rzy] = pt(44, .75); + svgEl(g, 'path', { d: `M ${rzx} ${rzy} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3 }); + for (const [db, t] of VUDB) { + const [x1, y1] = pt(41.5, t), [x2, y2] = pt(47, t); + svgEl(g, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1 }); + } + for (const [db, t] of VUDB) { + if (![-20, -10, -5, -3, 0, 3].includes(db)) continue; + const [x, y] = pt(52, t); + svgEl(g, 'text', { + x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', + fill: db > 0 ? 'var(--fail)' : '#3a3128' + }).textContent = Math.abs(db); + } + svgEl(g, 'text', { + x: cx, y: fy + 14, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#3a3128' + }).textContent = 'dB'; + const needle = svgEl(g, 'line', { + x1: cx, y1: py, x2: cx, y2: py - 46, stroke: '#1a1613', 'stroke-width': 1.5, + transform: `rotate(0,${cx},${py})` + }); + svgEl(g, 'circle', { cx, cy: py, r: 5, fill: '#141110' }); + svgEl(s, 'ellipse', { cx: cx - 8, cy: fy - 22, rx: 20, ry: 9, fill: 'rgba(255,255,255,.07)', transform: `rotate(-18,${cx - 8},${fy - 22})` }); + let v = opts.value ?? 50; + function set(nv) { + v = nv; + const t = Math.max(0, Math.min(1, v / 100)); + needle.setAttribute('transform', `rotate(${-40 + t * 80},${cx},${py})`); + const db = vuDb(t); + onChange(v, (db > 0 ? '+' : '') + db.toFixed(1) + ' dB'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */ +GW.chromeMinMax = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, cy = 58; + gradDef('chrRing', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#eceef0'], ['.5', '#9aa0a8'], ['1', '#54585e']]); + gradDef('chrDome', 'radialGradient', { cx: '48%', cy: '20%', r: '85%' }, [['0', '#e6e8ec'], ['1', '#82878f']]); + svgEl(s, 'circle', { cx, cy: 54, r: 47, fill: 'url(#chrRing)' }); + svgEl(s, 'circle', { cx, cy: 54, r: 40, fill: '#101214', stroke: '#2c2f33', 'stroke-width': 1 }); + svgEl(s, 'path', { d: `M 21 60 A 34 34 0 0 1 89 60 Z`, fill: 'url(#chrDome)', stroke: '#55595f', 'stroke-width': .5 }); + for (const r of [14, 22, 30]) + svgEl(s, 'path', { d: `M ${cx - r} 60 A ${r} ${r} 0 0 1 ${cx + r} 60`, fill: 'none', stroke: 'rgba(255,255,255,.13)', 'stroke-width': .7 }); + const ptr = svgEl(s, 'line', { x1: cx, y1: cy - 10, x2: cx, y2: cy - 31, stroke: '#17191b', 'stroke-width': 2.2, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#23262a' }); + for (let k = 0; k < 8; k++) { + const [x, y] = polar(cx, cy, 6, k * 45); + svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: '#23262a' }); + } + svgEl(s, 'circle', { cx, cy, r: 1.6, fill: '#0c0d0e' }); + svgEl(s, 'text', { + x: 24, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em', + 'font-family': 'var(--mono)', fill: '#d7dade' + }).textContent = 'MIN'; + svgEl(s, 'text', { + x: 86, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em', + 'font-family': 'var(--mono)', fill: '#d7dade' + }).textContent = 'MAX'; + svgEl(s, 'text', { + x: cx, y: 84, 'text-anchor': 'middle', 'font-size': 7, 'font-style': 'italic', + 'font-family': 'var(--mono)', fill: '#787c82' + }).textContent = 'dupre.'; + svgEl(s, 'ellipse', { cx: 47, cy: 34, rx: 22, ry: 9, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-16,47,34)' }); + let v = opts.value ?? 50; + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + ptr.setAttribute('transform', `rotate(${-55 + v / 100 * 110},${cx},${cy})`); + onChange(v, v <= 2 ? 'MIN' : v >= 98 ? 'MAX' : Math.round(v) + '%'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */ +GW.aviationGauge = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, cy = 52, sweep = t => -135 + t * 270; + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + svgEl(s, 'circle', { cx, cy, r: 47, fill: '#1b1917', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 41, fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1 }); + const zone = (t1, t2, color) => { + const [x1, y1] = polar(cx, cy, 37, sweep(t1)), [x2, y2] = polar(cx, cy, 37, sweep(t2)); + svgEl(s, 'path', { d: `M ${x1} ${y1} A 37 37 0 ${(t2 - t1) > .5 ? 1 : 0} 1 ${x2} ${y2}`, fill: 'none', stroke: color, 'stroke-width': 3, opacity: .85 }); + }; + zone(.15, .6, 'var(--pass)'); zone(.6, .85, 'var(--gold)'); zone(.85, 1, 'var(--fail)'); + for (let i = 0; i <= 40; i++) { + const t = i / 40, major = i % 5 === 0, a = sweep(t); + const [x1, y1] = polar(cx, cy, 40, a), [x2, y2] = polar(cx, cy, major ? 32 : 36, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--gold)', 'stroke-width': major ? 1.6 : .8, opacity: major ? 1 : .7 }); + if (major) { + const [nx, ny] = polar(cx, cy, 25, a); + svgEl(s, 'text', { + x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)', + fill: 'var(--gold-hi)' + }).textContent = i / 5; + } + } + svgEl(s, 'text', { + x: cx, y: cy + 16, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.14em', + 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .8 + }).textContent = '×100 RPM'; + const needle = svgEl(s, 'g', {}); + svgEl(needle, 'line', { + x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 4, + 'stroke-linecap': 'round', opacity: .5, filter: 'url(#avGlow)' + }); + svgEl(needle, 'line', { + x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 2.2, + 'stroke-linecap': 'round' + }); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#1b1917', stroke: '#3a352c' }); + svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 24, rx: 18, ry: 8, fill: 'rgba(255,255,255,.05)', transform: `rotate(-18,${cx - 10},${cy - 24})` }); + let v = opts.value ?? 52; + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + needle.setAttribute('transform', `rotate(${-135 + v / 100 * 270},${cx},${cy})`); + onChange(v, (v / 100 * 8).toFixed(1) + ' ×100 rpm'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R13 edgewise strip meter — compressed log scale, the bar rides the edge */ +GW.edgeMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]]; + const s = stageSvg(host, 'rsvg drag', 70, 130); + const wy0 = 12, wy1 = 118; + const faceId = uid('edgeFace'); + const defs = svgEl(s, 'defs', {}); + const pg = svgEl(defs, 'linearGradient', { id: faceId, x1: 0, y1: 0, x2: 1, y2: 0 }); + svgEl(pg, 'stop', { offset: '0', 'stop-color': '#efe6ca' }); + svgEl(pg, 'stop', { offset: '1', 'stop-color': '#d9cfae' }); + svgEl(s, 'rect', { x: 8, y: 6, width: 54, height: 118, rx: 8, fill: '#171412', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: wy1 - wy0, fill: `url(#${faceId})`, stroke: '#0a0908' }); + const yOf = f => wy0 + f * (wy1 - wy0); + for (const [db, f] of SCALE) { + const y = yOf(f); + svgEl(s, 'line', { x1: 19, y1: y, x2: 40, y2: y, stroke: '#3a3128', 'stroke-width': 1 }); + svgEl(s, 'text', { + x: 52, y: y + 2.4, 'text-anchor': 'end', 'font-size': 7, 'font-family': 'var(--mono)', + fill: '#3a3128' + }).textContent = db; + } + for (let i = 0; i < SCALE.length - 1; i++) { + const y = yOf((SCALE[i][1] + SCALE[i + 1][1]) / 2); + svgEl(s, 'line', { x1: 19, y1: y, x2: 32, y2: y, stroke: '#3a3128', 'stroke-width': .6, opacity: .7 }); + } + const bar = svgEl(s, 'polygon', { points: '56,0 24,-1.4 24,1.4', fill: '#1a1613' }); + svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: 24, fill: 'rgba(255,255,255,.08)' }); + let frac = opts.value ?? .62; + function set(f) { + frac = Math.max(.08, Math.min(.94, f)); + bar.setAttribute('transform', `translate(0,${wy0 + frac * (wy1 - wy0)})`); + let i = 0; while (i < SCALE.length - 2 && SCALE[i + 1][1] < frac) i++; + const [d1, f1] = SCALE[i], [d2, f2] = SCALE[i + 1]; + const db = d1 + (d2 - d1) * Math.max(0, Math.min(1, (frac - f1) / (f2 - f1))); + onChange(frac, '−' + db.toFixed(1) + ' dB'); + } + dragY(s, pct => set(.08 + (100 - pct) / 100 * .86)); + set(frac); + return { el: s, get: () => frac, set }; +}; + +/* R17 round CRT scope — pale phosphor face; the trace is the widget's own + animation, so it lives here with its reduced-motion gate */ +GW.roundCrt = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 110, 104); + const cx = 55, cy = 52; + const faceId = uid('rcrtFace'), clipId = uid('rcrtClip'); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + /* the face gradient reads screen-family vars from this widget's subtree, + so it must live in the local defs, not the shared def sink */ + const defs = svgEl(s, 'defs', {}); + const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' }); + svgEl(ph, 'stop', { offset: '0', 'stop-color': 'var(--crt-face1,#b9d8c0)' }); + svgEl(ph, 'stop', { offset: '1', 'stop-color': 'var(--crt-face2,#8bb296)' }); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 38 }); + svgEl(s, 'circle', { cx, cy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 }); + for (let k = 0; k < 4; k++) { + const [x, y] = polar(cx, cy, 43, 45 + k * 90); + svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 }); + } + svgEl(s, 'circle', { cx, cy, r: 38, fill: `url(#${faceId})` }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + for (let i = -4; i <= 4; i++) { + svgEl(g, 'line', { x1: cx + i * 9, y1: cy - 38, x2: cx + i * 9, y2: cy + 38, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 }); + svgEl(g, 'line', { x1: cx - 38, y1: cy + i * 9, x2: cx + 38, y2: cy + i * 9, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 }); + } + const glow = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: 'var(--crt-glow,#eef7ee)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' }); + const trace = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: '#f4fcf4', 'stroke-width': 1.3 }); + svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 16, rx: 20, ry: 10, fill: 'rgba(255,255,255,.10)', transform: `rotate(-20,${cx - 10},${cy - 16})` }); + let tph = 0; + function tick() { + tph += 0.12; + const amp = 8 + 3 * Math.sin(tph * 0.35); + let pts = ''; + for (let x = -38; x <= 38; x += 2) { + const y = Math.sin(x * 0.55 + tph * 2.5) * amp; + pts += `${cx + x},${(cy + y).toFixed(1)} `; + } + trace.setAttribute('points', pts.trim()); + glow.setAttribute('points', pts.trim()); + onChange(amp, 'Vpp ' + Math.round(amp * 2 * 4.5)); + } + tick(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 80); + return { el: s, tick }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs index 63f9dc3..35671db 100644 --- a/tests/gallery-probes/probe-fams.mjs +++ b/tests/gallery-probes/probe-fams.mjs @@ -48,8 +48,8 @@ const barFill = await evl(`(()=>{const b=document.querySelectorAll('#card-R19 sv ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill); // R17: face gradient stop resolves to amber face after chip -await evl(`document.querySelector('#rcrt').closest('.card').querySelector('.fc[title="amber"]').click()`); -const face = await evl(`getComputedStyle(document.querySelector('#rcrtFace stop')).stopColor`); +await evl(`document.querySelector('#card-R17 .fc[title="amber"]').click()`); +const face = await evl(`getComputedStyle(document.querySelector('#card-R17 svg radialGradient stop')).stopColor`); ok('R17 amber chip retints face', face === 'rgb(216, 203, 166)', face); ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0); -- cgit v1.2.3