aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-12 22:35:24 -0500
committerCraig Jennings <c@cjennings.net>2026-07-12 22:35:24 -0500
commit4064de53a9abfa7620d9d7e3f95e867ba0bf54e3 (patch)
tree1868a280bf3942eb178fbb0400ebd0f147af63bf /docs/prototypes
parent838065cea987a5364c7f4d771f9a2b873990918b (diff)
downloadarchsetup-4064de53a9abfa7620d9d7e3f95e867ba0bf54e3.tar.gz
archsetup-4064de53a9abfa7620d9d7e3f95e867ba0bf54e3.zip
refactor(gallery): extract indicators N20-N28 into GW builders
Diffstat (limited to 'docs/prototypes')
-rw-r--r--docs/prototypes/panel-widget-gallery.html137
-rw-r--r--docs/prototypes/widgets.js256
2 files changed, 276 insertions, 117 deletions
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)}),
'<b>a single warm-glowing numeral.</b> One lit digit per tube, leading zeros dark. Click to increment the count.');
card(I,'N20','Split-flap display',
- `<span class="flap" id="flap"><span class="flapd"><b>D</b></span><span class="flapd"><b>N</b></span><span class="flapd"><b>S</b></span></span>`,
+ (st,rd)=>GW.splitFlap(st,{animate:!reduced,onChange:(v,t)=>rd(t)}),
'<b>flips to the new value.</b> Each card drops to the next glyph with the mechanical clack. Auto-flips; click to flip now.');
card(I,'N21','Seven-segment display',
- `<span class="seven" id="seven"></span>`,
+ (st,rd)=>GW.sevenSeg(st,{onChange:(v,t)=>rd(t)}),
'<b>a lit-segment number.</b> Green segments, distinct from the nixie glow. Live countdown; click to add a minute.');
card(I,'N22','VFD marquee',
- `<span class="vfdm" id="vfdm"><span class="txt" id="vfdt">ARCHSETUP · NET OK · BT 2 · SND 62%</span><span class="mesh"></span></span>`,
+ (st,rd)=>GW.vfdMarquee(st,{onChange:(v,t)=>rd(t)}),
'<b>scrolling status line.</b> Teal glow, dot-matrix mesh, text tracks across. Scrolls live; click to change the message.');
card(I,'N23','Annunciator panel',
- `<div class="annwrap"><div class="annun" id="annun">
- <span class="acell">SYNC</span><span class="acell warn">LOW BATT</span><span class="acell">LINK</span>
- <span class="acell fault">NO DNS</span><span class="acell">VPN</span><span class="acell">DISK</span>
- </div><div class="annbar" id="annbar"><span class="mc" id="anmc">MSTR CAUTION</span>
- <button class="key" data-k="ACK">ACK</button><button class="key" data-k="TEST">TEST</button>
- <button class="key" data-k="RESET">RESET</button></div></div>`,
+ (st,rd)=>GW.annunciator(st,{onChange:(v,t)=>rd(t)}),
'<b>a grid of named alarms with the alarm lifecycle.</b> 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',
- `<span id="jewels" style="display:inline-flex;gap:10px;align-items:center"><span class="jewel" style="--jc:var(--jewel-r)"></span><span class="jewel" style="--jc:var(--jewel-a)"></span>
- <span class="jewel" style="--jc:var(--jewel-g)"></span><span class="jewel dim"></span></span>`,
+ (st,rd)=>GW.jewels(st,{onChange:(v,t)=>rd(t)}),
'<b>a faceted glass indicator.</b> The chunky bezel pilot lamp with a real bloom. Click a jewel to cycle red · amber · green · dark.');
card(I,'N25','Tape counter',
- `<span class="counter" id="counter"></span>`,
+ (st,rd)=>GW.tapeCounter(st,{onChange:(v,t)=>rd(t)}),
'<b>rolling odometer digits.</b> Numbered wheels roll to the next figure. Rolls live; readout shows the total.');
card(I,'N26','Analog clock',
- `<span class="clock" id="clock"><span class="hh" id="clkh"></span><span class="mh" id="clkm"></span><span class="sh" id="clks"></span><span class="pin"></span></span>`,
+ (st,rd)=>GW.analogClock(st,{onChange:(v,t)=>rd(t)}),
'<b>time on a real face.</b> Engraved ticks, three hands, live seconds sweep. Runs live; readout shows the time.');
card(I,'N27','Frequency-dial scale',
- `<span class="freqscale" id="fscale"><span class="band">MHz</span></span>`,
+ (st,rd)=>GW.freqScale(st,{onChange:(v,t)=>rd(t)}),
'<b>a printed log scale.</b> Marks crowd low, spread high — a real logarithmic axis. Drag the pointer; readout shows the frequency.');
card(I,'N28','Patch-bay jack field',
- `<div class="patch" id="patch"></div>`,
+ (st,rd)=>GW.patchBay(st,{onChange:(v,t)=>rd(t)}),
'<b>routing shown as patches.</b> 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',
`<svg id="dmx" class="rsvg press" viewBox="0 0 190 74" width="190" height="74"></svg>`,
@@ -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;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('http://www.w3.org/2000/svg','svg');patch.appendChild(svg);
- let conns=[];let pending=null;
- function key(a,b){return Math.min(a,b)+'-'+Math.max(a,b);}
- function 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('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])+'<span class="colon"><i></i><i></i></span>'+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 = '<b></b>'; 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]) + '<span class="colon"><i></i><i></i></span>' + 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 = '<span class="txt"></span><span class="mesh"></span>';
+ 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', '<span class="hh"></span><span class="mh"></span><span class="sh"></span><span class="pin"></span>');
+ 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() {