From 4064de53a9abfa7620d9d7e3f95e867ba0bf54e3 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 22:35:24 -0500 Subject: refactor(gallery): extract indicators N20-N28 into GW builders --- docs/prototypes/panel-widget-gallery.html | 137 +++------------- docs/prototypes/widgets.js | 256 ++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 117 deletions(-) (limited to 'docs/prototypes') diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 0e10631..2d7bcb9 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -1823,37 +1823,31 @@ card(I,'26','Nixie tube', (st,rd)=>GW.nixie(st,{onChange:(v,t)=>rd(t)}), 'a single warm-glowing numeral. One lit digit per tube, leading zeros dark. Click to increment the count.'); card(I,'N20','Split-flap display', - `DNS`, + (st,rd)=>GW.splitFlap(st,{animate:!reduced,onChange:(v,t)=>rd(t)}), 'flips to the new value. Each card drops to the next glyph with the mechanical clack. Auto-flips; click to flip now.'); card(I,'N21','Seven-segment display', - ``, + (st,rd)=>GW.sevenSeg(st,{onChange:(v,t)=>rd(t)}), 'a lit-segment number. Green segments, distinct from the nixie glow. Live countdown; click to add a minute.'); card(I,'N22','VFD marquee', - `ARCHSETUP · NET OK · BT 2 · SND 62%`, + (st,rd)=>GW.vfdMarquee(st,{onChange:(v,t)=>rd(t)}), 'scrolling status line. Teal glow, dot-matrix mesh, text tracks across. Scrolls live; click to change the message.'); card(I,'N23','Annunciator panel', - `
- SYNCLOW BATTLINK - NO DNSVPNDISK -
MSTR CAUTION - -
`, + (st,rd)=>GW.annunciator(st,{onChange:(v,t)=>rd(t)}), 'a grid of named alarms with the alarm lifecycle. Cells light amber to warn, red to fault; any active alarm flashes MSTR CAUTION until ACK steadies it, a new alarm re-flashes it, RESET clears the board, TEST proves the bulbs. Click cells to raise alarms.'); card(I,'N24','Jewel pilot lamps', - ` - `, + (st,rd)=>GW.jewels(st,{onChange:(v,t)=>rd(t)}), 'a faceted glass indicator. The chunky bezel pilot lamp with a real bloom. Click a jewel to cycle red · amber · green · dark.'); card(I,'N25','Tape counter', - ``, + (st,rd)=>GW.tapeCounter(st,{onChange:(v,t)=>rd(t)}), 'rolling odometer digits. Numbered wheels roll to the next figure. Rolls live; readout shows the total.'); card(I,'N26','Analog clock', - ``, + (st,rd)=>GW.analogClock(st,{onChange:(v,t)=>rd(t)}), 'time on a real face. Engraved ticks, three hands, live seconds sweep. Runs live; readout shows the time.'); card(I,'N27','Frequency-dial scale', - `MHz`, + (st,rd)=>GW.freqScale(st,{onChange:(v,t)=>rd(t)}), 'a printed log scale. Marks crowd low, spread high — a real logarithmic axis. Drag the pointer; readout shows the frequency.'); card(I,'N28','Patch-bay jack field', - `
`, + (st,rd)=>GW.patchBay(st,{onChange:(v,t)=>rd(t)}), 'routing shown as patches. A grid of jacks with cables between connected pairs. Click one jack then another to add/remove a cable.'); card(I,'R10','Data matrix readout', ``, @@ -1901,70 +1895,7 @@ card(I,'R52','Blinkenlights front panel', /* ============ INDICATOR WIRING ============ */ /* 18-23, 26 extracted to widgets.js (GW.*) — wired at their card records */ -/* N23 annunciator with alarm lifecycle: raise -> MSTR CAUTION flashes -> ACK steadies -> RESET clears */ -(function(){const st=['acell','acell warn','acell fault'];const nm=['ok','warn','fault']; - const mc=$('anmc');let acked=false; - const active=()=>$('annun').querySelectorAll('.warn,.fault').length; - const refresh=()=>{const n=active(); - mc.classList.toggle('on',n>0); - mc.classList.toggle('fl',n>0&&!acked); - setRd('N23',n===0?'clear':n+' active · '+(acked?'ACK':'UNACK'));}; - $('annun').querySelectorAll('.acell').forEach(cell=>{const label=cell.textContent;cell.onclick=()=>{ - let cur=cell.className.trim();let i=st.indexOf(cur);if(i<0)i=0;i=(i+1)%st.length;cell.className=st[i]; - if(i>0)acked=false; // a new alarm re-arms the flasher - refresh();setRd('N23',label+' → '+nm[i]);};}); - $('annbar').querySelectorAll('.key').forEach(k=>k.addEventListener('click',()=>{ - const a=k.dataset.k; - if(a==='ACK'){if(active()>0)acked=true;refresh();} - else if(a==='RESET'){$('annun').querySelectorAll('.acell').forEach(c=>c.className='acell');acked=false;refresh();} - else if(a==='TEST'){const prev=[...$('annun').querySelectorAll('.acell')].map(c=>c.className); - $('annun').querySelectorAll('.acell').forEach(c=>c.className='acell fault'); - mc.classList.add('on','fl');setRd('N23','lamp test'); - setTimeout(()=>{[...$('annun').querySelectorAll('.acell')].forEach((c,i)=>c.className=prev[i]);refresh();},1100);}})); - refresh();})(); -/* N24 jewel */ -(function(){const cols=['var(--jewel-r)','var(--jewel-a)','var(--jewel-g)'];const nm=['red','amber','green']; - $('jewels').querySelectorAll('.jewel').forEach(j=>{j.onclick=()=>{ - if(j.classList.contains('dim')){j.classList.remove('dim');j.style.setProperty('--jc',cols[0]);setRd('N24','red');return;} - let cur=j.style.getPropertyValue('--jc').trim();let i=cols.indexOf(cur); - if(i>=cols.length-1||i<0){j.classList.add('dim');setRd('N24','dark');}else{j.style.setProperty('--jc',cols[i+1]);setRd('N24',nm[i+1]);}};});})(); -setRd('N24','click to cycle'); -/* N27 frequency-dial + pointer */ -(function(){const fs=$('fscale');const W=184;const marks=[0.5,1,2,3,5,7,10,14,20];const lo=Math.log10(0.5),hi=Math.log10(20); - marks.forEach(m=>{const x=6+((Math.log10(m)-lo)/(hi-lo))*(W-12); - const tk=document.createElement('span');tk.className='tick';tk.style.left=x+'px';tk.style.height='11px';fs.appendChild(tk); - const mk=document.createElement('span');mk.className='mk';mk.style.left=x+'px';mk.textContent=m;fs.appendChild(mk);}); - for(let m=0.5;m<=20;m+=(m<2?0.25:m<10?1:2)){const x=6+((Math.log10(m)-lo)/(hi-lo))*(W-12); - const tk=document.createElement('span');tk.className='tick';tk.style.left=x+'px';tk.style.height='6px';tk.style.opacity='.5';fs.appendChild(tk);} - const ptr=document.createElement('span');ptr.className='fptr';fs.appendChild(ptr); - function freqAt(p){return Math.pow(10,lo+(p/100)*(hi-lo));} - window.setFreq=function(p){ptr.style.left=(6+p/100*(W-12))+'px';const f=freqAt(p);setRd('N27',(f<10?f.toFixed(1):Math.round(f))+' MHz');}; - dragX(fs,window.setFreq);window.setFreq(45);})(); -/* N28 patch-bay */ -(function(){const patch=$('patch');const rows=2,cols=4;const jacks=[]; - for(let r=0;rj.classList.remove('hot')); - conns.forEach(pair=>{const[a,b]=pair.split('-').map(Number); - const ra=jacks[a].getBoundingClientRect(),rb=jacks[b].getBoundingClientRect(); - const x1=ra.left-pr.left+ra.width/2,y1=ra.top-pr.top+ra.height/2; - const x2=rb.left-pr.left+rb.width/2,y2=rb.top-pr.top+rb.height/2; - const p=document.createElementNS('http://www.w3.org/2000/svg','path'); - const my=Math.max(y1,y2)+16;p.setAttribute('d',`M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`);svg.appendChild(p); - jacks[a].classList.add('hot');jacks[b].classList.add('hot');}); - setRd('N28',conns.length?conns.map(c=>{const[a,b]=c.split('-');return a+'↔'+b;}).join(' '):'no cables');} - jacks.forEach(j=>{j.onclick=()=>{const id=+j.dataset.id; - if(pending==null){pending=id;j.classList.add('sel');return;} - if(pending===id){pending=null;j.classList.remove('sel');return;} - const k=key(pending,id);const i=conns.indexOf(k);if(i>=0)conns.splice(i,1);else conns.push(k); - jacks[pending].classList.remove('sel');pending=null;draw();};}); - conns=['0-5','2-7'];draw();window.addEventListener('resize',draw);})(); +/* N20-N28 extracted to widgets.js (GW.*) — wired at their card records */ /* ============ REFERENCE-BATCH (R) WIDGETS — SVG builders ============ */ /* svgEl, polar, VUDB live in widgets.js (GW) */ @@ -2199,14 +2130,8 @@ setRgraph(1200,6.5); /* extracted meters: the page keeps the clock + demo signal, drives card handles */ const MH={vu:$('card-11').gw,mini:$('card-12').gw,spark:$('card-16').gw,wave:$('card-17').gw, scope:$('card-N11').gw,eq:$('card-N12').gw,strip:$('card-N16').gw,mcvu:$('card-R01').gw}; -(function(){const cl=$('clock');for(let i=0;i<12;i++){const t=document.createElement('span');t.className='tk'; - t.style.transform=`rotate(${i*30}deg)`;cl.insertBefore(t,cl.firstChild);}})(); -const counterDigits=[4,7,1,3,0,0]; -(function(){const ct=$('counter');counterDigits.forEach((d,idx)=>{const w=document.createElement('span'); - w.className='cwheel'+(idx>=4?' redw':'');const col=document.createElement('span');col.className='col'; - for(let n=-1;n<=10;n++){const s=document.createElement('span');s.textContent=((n+10)%10);col.appendChild(s);} - w.appendChild(col);ct.appendChild(w);setWheel(w,d);});})(); -function setWheel(w,d){w.querySelector('.col').style.top=(-(d+1)*34)+'px';} +const IH={flap:$('card-N20').gw,seven:$('card-N21').gw,vfd:$('card-N22').gw, + counter:$('card-N25').gw,clock:$('card-N26').gw}; let ph=0; const eqBands=Array.from({length:11},(_,i)=>({v:0.4,ph:i*0.6})); @@ -2226,36 +2151,14 @@ function fastTick(){ MH.eq.set(eqBands.map(bd=>bd.v)); MH.strip.push((28+24*Math.sin(ph*0.7)*(0.6+0.4*Math.sin(ph*0.13))-(Math.random()-0.5)*4)/56); } -/* clock */ -function tickClock(){const d=new Date();const s=d.getSeconds(),m=d.getMinutes(),h=d.getHours()%12; - $('clks').style.transform=`rotate(${s*6}deg)`;$('clkm').style.transform=`rotate(${m*6+s*0.1}deg)`;$('clkh').style.transform=`rotate(${h*30+m*0.5}deg)`; - setRd('N26',String(d.getHours()).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'));} -/* split-flap */ -const flapWords=['DNS ','LINK','SYNC','WIFI','SCAN'];let flapI=0; -function tickFlap(){flapI=(flapI+1)%flapWords.length;const w=flapWords[flapI].padEnd(4,' '); - $('flap').querySelectorAll('.flapd').forEach((dd,i)=>{const ch=w[i]||' ';const bel=dd.querySelector('b'); - if(bel.textContent!==ch){bel.textContent=ch;if(!reduced){dd.classList.remove('flip');void dd.offsetWidth;dd.classList.add('flip');}}}); - setRd('N20',flapWords[flapI].trim());} -$('flap').onclick=tickFlap; -/* seven-segment countdown */ -let sevenSecs=24*60+10; -function renderSeven(){const mm=String(Math.floor(sevenSecs/60)).padStart(2,'0'),ss=String(sevenSecs%60).padStart(2,'0'); - $('seven').innerHTML=seg7(mm[0])+seg7(mm[1])+''+seg7(ss[0])+seg7(ss[1]);setRd('N21',mm+':'+ss);} -function tickSeven(){sevenSecs=(sevenSecs-1+3600)%3600;renderSeven();} -$('seven').onclick=()=>{sevenSecs=(sevenSecs+60)%3600;renderSeven();}; -renderSeven(); -/* tabular timer: builder owns the countdown; the page keeps the 1s clock */ +/* indicator loops: builders own state + clicks; the page keeps the clocks and + the time source (tick contract) */ +function tickClock(){const d=new Date();IH.clock.set(d.getHours(),d.getMinutes(),d.getSeconds());} +function tickFlap(){IH.flap.next();} +function tickSeven(){IH.seven.tick();} function tickT(){$('card-20').gw.tick();} -/* VFD marquee */ -const vfdMsgs=['ARCHSETUP · NET OK · BT 2 · SND 62%','WIFI @Hyatt · 300 Mbps · VPN UP','BATTERY 84% · DISK 61% · TEMP 47C'];let vfdMi=0,vfdX=176; -function tickVfd(){const t=$('vfdt');vfdX-=1.1;if(vfdX< -t.offsetWidth)vfdX=176;t.style.left=vfdX+'px';} -$('vfdm').onclick=()=>{vfdMi=(vfdMi+1)%vfdMsgs.length;$('vfdt').textContent=vfdMsgs[vfdMi];vfdX=176;setRd('N22','msg '+(vfdMi+1)+'/'+vfdMsgs.length);}; -setRd('N22','scrolling'); -/* tape counter */ -let ctrVal=471300; -function tickCounter(){ctrVal+=137;const s=String(ctrVal).padStart(6,'0'); - $('counter').querySelectorAll('.cwheel').forEach((w,i)=>setWheel(w,+s[i]));setRd('N25',s);} -setRd('N25',String(ctrVal).padStart(6,'0')); +function tickVfd(){IH.vfd.tick();} +function tickCounter(){IH.counter.set(IH.counter.get()+137);} /* reels spin keyframe */ (function(){const st=document.createElement('style');st.textContent='@keyframes reelspin{to{transform:rotate(360deg)}}';document.head.appendChild(st);})(); @@ -2270,7 +2173,7 @@ function paintStatic(){ MH.eq.set(eqBands.map(bd=>bd.v)); MH.scope.set(Array.from({length:60},(_,i)=>Math.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')); + tickClock();IH.flap.set(0); MH.mcvu.set(.5); } diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 3d915de..f3e1127 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -2951,6 +2951,262 @@ GW.nixie = function (host, opts = {}) { return { el: nx, get: () => v, set }; }; +/* N20 split-flap — windows flip to the next word with the mechanical clack; + the page drives next() on its own cadence; first paint is silent like the original */ +GW.splitFlap = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN']; + const animate = opts.animate !== undefined ? !!opts.animate : true; + const cells = opts.cells || 3; + const f = document.createElement('span'); f.className = 'flap'; + for (let i = 0; i < cells; i++) { + const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = ''; f.appendChild(d); + } + host.appendChild(f); + let idx = 0; + const paint = () => { + const w = words[idx].padEnd(cells + 1, ' '); + [...f.children].forEach((dd, i) => { + const ch = w[i] || ' ', bel = dd.querySelector('b'); + if (bel.textContent !== ch) { + bel.textContent = ch; + if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); } + } + }); + }; + const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); }; + f.addEventListener('click', () => set(idx + 1)); + paint(); + return { el: f, get: () => idx, set, next: () => set(idx + 1) }; +}; + +/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); + click adds a minute */ +GW.sevenSeg = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const sv = document.createElement('span'); sv.className = 'seven'; host.appendChild(sv); + let secs; + const set = v => { + secs = ((v % 3600) + 3600) % 3600; + const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0'); + sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '' + seg7(ss[0]) + seg7(ss[1]); + onChange(secs, mm + ':' + ss); + }; + sv.addEventListener('click', () => set(secs + 60)); + set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10); + return { el: sv, get: () => secs, set, tick: () => set(secs - 1) }; +}; + +/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message */ +GW.vfdMarquee = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C']; + const W = opts.width || 176; + const m = document.createElement('span'); m.className = 'vfdm'; + m.innerHTML = ''; + const t = m.querySelector('.txt'); t.textContent = msgs[0]; + host.appendChild(m); + let mi = 0, x = W; + m.addEventListener('click', () => { + mi = (mi + 1) % msgs.length; t.textContent = msgs[mi]; x = W; + onChange(mi, 'msg ' + (mi + 1) + '/' + msgs.length); + }); + onChange(0, 'scrolling'); + return { + el: m, get: () => mi, + tick: () => { x -= 1.1; if (x < -t.offsetWidth) x = W; t.style.left = x + 'px'; } + }; +}; + +/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET + lifecycle; TEST proves the bulbs then restores the board */ +GW.annunciator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const CLS = ['acell', 'acell warn', 'acell fault']; + const NAMES = ['ok', 'warn', 'fault']; + const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]]; + const wrap = document.createElement('div'); wrap.className = 'annwrap'; + const grid = document.createElement('div'); grid.className = 'annun'; wrap.appendChild(grid); + const bar = document.createElement('div'); bar.className = 'annbar'; + const mc = document.createElement('span'); mc.className = 'mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc); + ['ACK', 'TEST', 'RESET'].forEach(a => { + const b = document.createElement('button'); b.className = 'key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b); + }); + wrap.appendChild(bar); host.appendChild(wrap); + const els = cells.map(([label, st]) => { + const c = document.createElement('span'); c.className = CLS[st]; c.textContent = label; grid.appendChild(c); return c; + }); + let acked = false; + const active = () => grid.querySelectorAll('.warn,.fault').length; + const refresh = () => { + const n = active(); + mc.classList.toggle('on', n > 0); mc.classList.toggle('fl', n > 0 && !acked); + onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK')); + }; + els.forEach((c, i) => c.addEventListener('click', () => { + let j = CLS.indexOf(c.className.trim()); if (j < 0) j = 0; j = (j + 1) % CLS.length; c.className = CLS[j]; + if (j > 0) acked = false; /* a new alarm re-arms the flasher */ + refresh(); onChange(j, cells[i][0] + ' → ' + NAMES[j]); + })); + bar.querySelectorAll('.key').forEach(k => k.addEventListener('click', () => { + const a = k.dataset.k; + if (a === 'ACK') { if (active() > 0) acked = true; refresh(); } + else if (a === 'RESET') { els.forEach(c => c.className = 'acell'); acked = false; refresh(); } + else if (a === 'TEST') { + const prev = els.map(c => c.className); + els.forEach(c => c.className = 'acell fault'); + mc.classList.add('on', 'fl'); onChange(null, 'lamp test'); + setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100); + } + })); + refresh(); + return { + el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))), + set: (i, st) => { els[i].className = CLS[st]; if (st > 0) acked = false; refresh(); } + }; +}; + +/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */ +GW.jewels = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)']; + const NAMES = ['red', 'amber', 'green']; + const init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */ + const wrap = document.createElement('span'); + wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center'; + init.forEach(st => { + const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : ''); + if (st >= 0) j.style.setProperty('--jc', cols[st]); + j.addEventListener('click', () => { + if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; } + const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur); + if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); } + else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); } + }); + wrap.appendChild(j); + }); + host.appendChild(wrap); + onChange(null, 'click to cycle'); + return { el: wrap }; +}; + +/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */ +GW.tapeCounter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4; + const ct = document.createElement('span'); ct.className = 'counter'; host.appendChild(ct); + for (let idx = 0; idx < wheels; idx++) { + const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : ''); + const col = document.createElement('span'); col.className = 'col'; + for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); } + w.appendChild(col); ct.appendChild(w); + } + const max = Math.pow(10, wheels); + let v; + const set = x => { + v = ((Math.round(x) % max) + max) % max; + const s = String(v).padStart(wheels, '0'); + [...ct.children].forEach((w, i) => { w.querySelector('.col').style.top = (-(+s[i] + 1) * 34) + 'px'; }); + onChange(v, s); + }; + set(opts.value !== undefined ? opts.value : 471300); + return { el: ct, get: () => v, set }; +}; + +/* N26 analog clock — engraved ticks + three hands; the page owns the time source + and drives set(h, m, s); silent until the first set, like the live original */ +GW.analogClock = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const cl = document.createElement('span'); cl.className = 'clock'; + for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } + cl.insertAdjacentHTML('beforeend', ''); + host.appendChild(cl); + const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh'); + const set = (h, m, s) => { + sh.style.transform = `rotate(${s * 6}deg)`; + mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`; + hh.style.transform = `rotate(${(h % 12) * 30 + m * 0.5}deg)`; + onChange([h, m, s], String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')); + }; + return { el: cl, set }; +}; + +/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high; + drag the pointer to tune */ +GW.freqScale = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const W = opts.width || 184; + const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20]; + const unit = opts.unit || 'MHz'; + const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]); + const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12); + const fs = document.createElement('span'); fs.className = 'freqscale'; + const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band); + marks.forEach(m => { + const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk); + const mk = document.createElement('span'); mk.className = 'mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk); + }); + for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) { + const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk); + } + const ptr = document.createElement('span'); ptr.className = 'fptr'; fs.appendChild(ptr); + host.appendChild(fs); + let pct; + const set = p => { + pct = Math.max(0, Math.min(100, p)); + ptr.style.left = (6 + pct / 100 * (W - 12)) + 'px'; + const f = Math.pow(10, lo + (pct / 100) * (hi - lo)); + onChange(f, (f < 10 ? f.toFixed(1) : Math.round(f)) + ' ' + unit); + }; + dragX(fs, set); + set(opts.value !== undefined ? opts.value : 45); + return { el: fs, get: () => pct, set }; +}; + +/* N28 patch bay — jack grid with SVG cables; click one jack then another to + patch or unpatch the pair; redraws itself on window resize */ +GW.patchBay = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const rows = opts.rows || 2, cols = opts.cols || 4; + const patch = document.createElement('div'); patch.className = 'patch'; + const jacks = []; + for (let r = 0; r < rows; r++) { + const rowEl = document.createElement('div'); rowEl.className = 'row'; + for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); } + patch.appendChild(rowEl); + } + const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg); + host.appendChild(patch); + let conns = (opts.conns || ['0-5', '2-7']).slice(); let pending = null; + const key = (a, b) => Math.min(a, b) + '-' + Math.max(a, b); + const draw = () => { + while (svg.firstChild) svg.removeChild(svg.firstChild); + const pr = patch.getBoundingClientRect(); + jacks.forEach(j => j.classList.remove('hot')); + conns.forEach(pair => { + const [a, b] = pair.split('-').map(Number); + const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect(); + const x1 = ra.left - pr.left + ra.width / 2, y1 = ra.top - pr.top + ra.height / 2; + const x2 = rb.left - pr.left + rb.width / 2, y2 = rb.top - pr.top + rb.height / 2; + const p = document.createElementNS(SVGNS, 'path'); + const my = Math.max(y1, y2) + 16; p.setAttribute('d', `M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`); svg.appendChild(p); + jacks[a].classList.add('hot'); jacks[b].classList.add('hot'); + }); + onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables'); + }; + jacks.forEach(j => { + j.addEventListener('click', () => { + const id = +j.dataset.id; + if (pending == null) { pending = id; j.classList.add('sel'); return; } + if (pending === id) { pending = null; j.classList.remove('sel'); return; } + const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k); + jacks[pending].classList.remove('sel'); pending = null; draw(); + }); + }); + draw(); window.addEventListener('resize', draw); + return { el: patch, get: () => conns.slice(), set: c => { conns = c.slice(); draw(); } }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { -- cgit v1.2.3