From a95200c188327dc17da23710a65d2d19a3c6b485 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 22:43:53 -0500 Subject: refactor(gallery): extract indicators R35, R36, R45-R47, R52 into GW builders --- docs/prototypes/panel-widget-gallery.html | 188 ++------------------- docs/prototypes/widgets.js | 260 ++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 176 deletions(-) (limited to 'docs/prototypes') diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index f81b60b..2eae646 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -1868,22 +1868,22 @@ card(I,'R31','Radar sweep', (st,rd)=>GW.radarSweep(st,{onChange:(v,t)=>rd(t)}), 'the rotating scan. A sweep beam circles the bearing ring, contacts bloom as it passes and fade after. Click to mark the current bearing. After a radar PPI scope.'); card(I,'R35','Day-date disc calendar', - ``, + (st,rd)=>GW.dayDateCal(st,{onChange:(v,t)=>rd(t)}), 'two coaxial discs, one index. The outer ring carries the date, the inner disc the weekday; both read where the yellow hand points. Starts on today; click to roll midnight forward and watch the discs step. After a watch day-date movement.'); card(I,'R36','LED dot matrix', - ``, + (st,rd)=>GW.dotMatrix(st,{onChange:(v,t)=>rd(t)}), 'a bitmap, one red dot at a time. 64 discrete LEDs behind a tinted window — segments draw digits, but a matrix draws anything. Click dots to paint the pattern; the readout counts what is lit. After the K4816 pattern generator faceplate.'); card(I,'R45','Flip-disc tile array', - ``, + (st,rd)=>GW.flipDisc(st,{onChange:(v,t)=>rd(t)}), 'the mechanical pixel. Each disc flips between its yellow face and its black back and then STAYS — no power, no glow, state that persists. Click a disc and watch it flip. The dot matrix glows; this one clacks. After transit flip-dot signs.'); card(I,'R46','Dekatron counting ring', - ``, + (st,rd)=>GW.dekatron(st,{onChange:(v,t)=>rd(t)}), 'a count as a position, and the counting is visible. Each click is a pulse; the neon glow steps one cathode around the ring and carries on wrap. Nixies show the digit — the Dekatron shows the arithmetic. After glow-transfer counter tubes.'); card(I,'R47','Landing gear indicator', - ``, + (st,rd)=>GW.gearIndicator(st,{onChange:(v,t)=>rd(t)}), 'three greens or nothing. A spatial lamp panel: nose and mains each get a light, green only when down and locked; the amber transit pulse means neither state. Click the wheel lever to cycle the gear. After a cockpit gear panel.'); card(I,'R52','Blinkenlights front panel', - ``, + (st,rd)=>GW.blinkenlights(st,{onChange:(v,t)=>rd(t)}), 'the machine thinking, out loud. Address and data lamps ripple with the live computation; the switch register below is yours — flip bits and watch the pattern change. DIP switches hold config; this shows work happening. After a PDP-11/70 front panel.'); /* ============ CONTROL WIRING ============ */ @@ -1994,179 +1994,15 @@ if(!reduced){ document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='paused'); } -/* R35 day-date disc calendar: coaxial printed discs rotate so today reads under the fixed hand */ -const DD_DAYS=['SUN','MON','TUE','WED','THU','FRI','SAT']; -let ddDate=1,ddDay=0,ddDateG=null,ddDayG=null; -(function(){const s=$('daydate'),cx=65,cy=65,HAND=135; - const defs=svgEl(s,'defs',{}); - const fc=svgEl(defs,'radialGradient',{id:'ddFace',cx:'46%',cy:'40%',r:'75%'}); - svgEl(fc,'stop',{offset:'0','stop-color':'#efe9da'}); - svgEl(fc,'stop',{offset:'1','stop-color':'#cfc8b6'}); - // movement plate + white calendar disc - svgEl(s,'circle',{cx,cy,r:62,fill:'#17140f',stroke:'#060505','stroke-width':2}); - for(let k=0;k<5;k++){const [x,y]=polar(cx,cy,58,20+k*72); - svgEl(s,'circle',{cx:x,cy:y,r:1.8,fill:'#060505'});} - svgEl(s,'circle',{cx,cy,r:54,fill:'url(#ddFace)',stroke:'#8f897b','stroke-width':.8}); - // date ring: 31 radial numerals on its own group - ddDateG=svgEl(s,'g',{}); - for(let d=1;d<=31;d++){const a=(d-1)*(360/31); - const g=svgEl(ddDateG,'g',{transform:`rotate(${a},${cx},${cy})`}); - svgEl(g,'text',{x:cx,y:cy-46,'text-anchor':'middle','font-size':7.5,'font-weight':700, - 'font-family':'var(--mono)',fill:'#14110e'}).textContent=String(d);} - // inner weekday disc: 7 names twice around, separated by a hairline ring - svgEl(s,'circle',{cx,cy,r:38,fill:'none',stroke:'#8f897b','stroke-width':.7}); - ddDayG=svgEl(s,'g',{}); - for(let i=0;i<14;i++){const a=i*(360/14); - const g=svgEl(ddDayG,'g',{transform:`rotate(${a},${cx},${cy})`}); - svgEl(g,'text',{x:cx,y:cy-28,'text-anchor':'middle','font-size':6.8,'font-weight':700, - 'font-family':'var(--mono)',fill:'#14110e'}).textContent=DD_DAYS[i%7];} - // fixed yellow hand from hub toward the read index, and the hub - const [hx,hy]=polar(cx,cy,44,HAND); - svgEl(s,'line',{x1:cx,y1:cy,x2:hx,y2:hy,stroke:'#e8cf4a','stroke-width':3,'stroke-linecap':'round'}); - svgEl(s,'line',{x1:cx,y1:cy,x2:hx,y2:hy,stroke:'rgba(0,0,0,.25)','stroke-width':.8}); - svgEl(s,'circle',{cx,cy,r:6,fill:'#b8b2a4',stroke:'#5c574c','stroke-width':1.2}); - svgEl(s,'circle',{cx,cy,r:2.4,fill:'#7a7466'}); - s.style.cursor='pointer'; - s.addEventListener('click',()=>{setDdcal(ddDate%31+1,(ddDay+1)%7);});})(); -function setDdcal(d,w){ddDate=d;ddDay=w;const HAND=135; - ddDateG.setAttribute('transform',`rotate(${(HAND-(d-1)*(360/31)).toFixed(2)},65,65)`); - ddDayG.setAttribute('transform',`rotate(${(HAND-w*(360/14)).toFixed(2)},65,65)`); - setRd('R35',DD_DAYS[w]+' '+d);} -(function(){const now=new Date();setDdcal(now.getDate(),now.getDay());})(); - -/* R36 LED dot matrix: 8x8 paintable bitmap, starts on the reference K */ -(function(){const s=$('dotmx'),N=8,STEP=11,X0=10.5,Y0=10.5; - const K8=['10000100','10001000','10010000','11100000','10100000','10010000','10001000','10000100']; - svgEl(s,'rect',{x:2,y:2,width:104,height:104,rx:8,fill:'#17140f',stroke:'#060505','stroke-width':1.5}); - svgEl(s,'rect',{x:6,y:6,width:96,height:96,rx:5,fill:'#0d0505',stroke:'#241010','stroke-width':1}); - const dots=[];let lit=0; - const paint=(d,on)=>{d.on=on; - d.el.setAttribute('fill',on?'#ff4a30':'#2a1210'); - d.el.setAttribute('filter',on?'drop-shadow(0 0 3.5px rgba(255,74,48,.85))':'none');}; - for(let r=0;r{paint(d,!d.on);lit+=d.on?1:-1; - setRd('R36',lit+' / 64 lit');});} - setRd('R36',lit+' / 64 lit');})(); +/* R35, R36 extracted to widgets.js (GW.*) */ + /* 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; - const GLYPH=['0000100','0000010','1111111','0000010','0000100']; // arrow right - svgEl(s,'rect',{x:2,y:2,width:116,height:88,rx:7,fill:'#17140f',stroke:'#060505','stroke-width':1.5}); - let lit=0; - const discs=[]; - for(let r=0;r{d.on=!d.on;lit+=d.on?1:-1; - if(matchMedia('(prefers-reduced-motion: reduce)').matches){el.setAttribute('fill',d.on?'#e3d44f':'#141210');} - else{el.style.transform='scaleX(0)'; - setTimeout(()=>{el.setAttribute('fill',d.on?'#e3d44f':'#141210');el.style.transform='scaleX(1)';},115);} - setRd('R45',lit+' discs showing');}); - discs.push(d);} - setRd('R45',lit+' discs showing');})(); - -/* R46 dekatron: click pulses the neon glow one cathode around; wrap = carry */ -let dkPos=0,dkCarry=0; -(function(){const s=$('dekatron'),cx=60,cy=60; - svgEl(s,'circle',{cx,cy,r:52,fill:'#171310',stroke:'#060505','stroke-width':2}); - svgEl(s,'circle',{cx,cy,r:46,fill:'#0d0a08',stroke:'#2a221c','stroke-width':1}); - svgEl(s,'ellipse',{cx:cx-14,cy:cy-18,rx:20,ry:11,fill:'rgba(255,255,255,.05)',transform:`rotate(-24,${cx-14},${cy-18})`}); - const dots=[]; - for(let i=0;i<10;i++){const [x,y]=polar(cx,cy,34,i*36); - svgEl(s,'text',{x:polar(cx,cy,43,i*36)[0],y:polar(cx,cy,43,i*36)[1]+2,'text-anchor':'middle','font-size':5.2, - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent=String(i); - dots.push(svgEl(s,'circle',{cx:x,cy:y,r:3.4,fill:'#3a2a20'}));} - const carry=svgEl(s,'circle',{cx,cy:cy-6,r:3,fill:'#2a1c14'}); - svgEl(s,'text',{x:cx,y:cy+8,'text-anchor':'middle','font-size':4.6,'letter-spacing':'.12em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='CARRY'; - const draw=()=>{dots.forEach((d,i)=>{const on=i===dkPos; - d.setAttribute('fill',on?'#ff9a4c':'#3a2a20'); - d.setAttribute('filter',on?'drop-shadow(0 0 5px rgba(255,154,76,.9))':'none');}); - setRd('R46','COUNT '+dkPos+(dkCarry?' · CARRY x'+dkCarry:''));}; - s.style.cursor='pointer'; - s.addEventListener('click',()=>{dkPos=(dkPos+1)%10; - if(dkPos===0){dkCarry++;carry.setAttribute('fill','#ff9a4c'); - carry.setAttribute('filter','drop-shadow(0 0 4px rgba(255,154,76,.9))'); - setTimeout(()=>{carry.setAttribute('fill','#2a1c14');carry.removeAttribute('filter');},220);} - draw();}); - draw();})(); - -/* R47 landing gear: three greens or nothing; lever cycles down -> transit -> up */ -(function(){const s=$('gearind'); - svgEl(s,'rect',{x:2,y:2,width:136,height:106,rx:9,fill:'#1c1916',stroke:'#060505','stroke-width':1.5}); - const POS=[['NOSE',48,28],['L',24,74],['R',72,74]]; - const lamps={}; - POS.forEach(([n,x,y])=>{ - svgEl(s,'circle',{cx:x,cy:y,r:9,fill:'#0d0b09',stroke:'#2c2820','stroke-width':1.5}); - lamps[n]=svgEl(s,'circle',{cx:x,cy:y,r:6.2,fill:'#16240f'}); - svgEl(s,'text',{x,y:y+18,'text-anchor':'middle','font-size':5.2,'letter-spacing':'.08em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent=n;}); - // gear lever: slot + wheel-shaped knob - svgEl(s,'rect',{x:106,y:22,width:8,height:66,rx:4,fill:'#0d0b09',stroke:'#2c2820','stroke-width':1}); - const lever=svgEl(s,'g',{}); - svgEl(lever,'rect',{x:107.5,y:36,width:5,height:26,fill:'#8f8a7e'}); - svgEl(lever,'circle',{cx:110,cy:32,r:8,fill:'#26221c',stroke:'#4a463e','stroke-width':2.4}); - svgEl(lever,'circle',{cx:110,cy:32,r:3,fill:'#4a463e'}); - svgEl(s,'text',{x:110,y:100,'text-anchor':'middle','font-size':5.2,'letter-spacing':'.08em', - 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='GEAR'; - let state='down',busy=false; - const paint=()=>{const c=state==='down'?'var(--pass)':state==='transit'?'var(--amber-warn)':'#16240f'; - for(const n of Object.keys(lamps)){lamps[n].setAttribute('fill',c); - lamps[n].setAttribute('filter',state==='down'?'drop-shadow(0 0 4px rgba(116,147,47,.8))': - state==='transit'?'drop-shadow(0 0 4px rgba(var(--glow-lo),.7))':'none'); - lamps[n].style.animation=state==='transit'?'pulse var(--pulse-rate) ease-in-out infinite':'none';} - lever.setAttribute('transform',state==='up'?'translate(0,-8)':state==='transit'?'translate(0,-4)':''); - setRd('R47',state==='down'?'GEAR DOWN · 3 GREEN':state==='up'?'GEAR UP':'IN TRANSIT');}; - s.style.cursor='pointer'; - s.addEventListener('click',()=>{if(busy)return; - const target=state==='down'?'up':'down'; - if(matchMedia('(prefers-reduced-motion: reduce)').matches){state=target;paint();return;} - busy=true;state='transit';paint(); - setTimeout(()=>{state=target;busy=false;paint();},1500);}); - paint();})(); - -/* R52 blinkenlights: live register lamps driven by a pseudo-PC that folds in the switch register */ -(function(){const s=$('blinken'); - svgEl(s,'rect',{x:2,y:2,width:166,height:96,rx:8,fill:'#3a1420',stroke:'#060505','stroke-width':1.5}); - svgEl(s,'rect',{x:8,y:8,width:154,height:62,rx:5,fill:'#14090e'}); - const mkRow=(y,lbl)=>{const row=[]; - svgEl(s,'text',{x:12,y:y+2.6,'font-size':4.6,'letter-spacing':'.08em','font-family':'var(--mono)', - fill:'#b08a96'}).textContent=lbl; - for(let i=0;i<12;i++)row.push(svgEl(s,'circle',{cx:46+i*10,cy:y,r:3,fill:'#2a1210'})); - return row;}; - const addr=mkRow(24,'ADDR'),data=mkRow(46,'DATA'); - const paint=(row,val)=>row.forEach((d,i)=>{const on=(val>>(11-i))&1; - d.setAttribute('fill',on?'#ffb43a':'#2a1210'); - d.setAttribute('filter',on?'drop-shadow(0 0 3px rgba(255,180,58,.8))':'none');}); - // switch register: 8 mini toggles - let sr=0b10100101; - const toggles=[]; - for(let i=0;i<8;i++){const x=46+i*14; - svgEl(s,'rect',{x:x-3,y:76,width:6,height:16,rx:3,fill:'#0d0b09',stroke:'#4a2432','stroke-width':1}); - const t=svgEl(s,'circle',{cx:x,cy:80,r:4,fill:'#8f8a7e',stroke:'#3c382f','stroke-width':1}); - t.style.cursor='pointer'; - const hit=svgEl(s,'rect',{x:x-7,y:72,width:14,height:24,fill:'transparent'}); - hit.style.cursor='pointer'; - const flip=()=>{sr^=(1<<(7-i));draw();}; - hit.addEventListener('click',flip); - toggles.push(t);} - svgEl(s,'text',{x:12,y:83,'font-size':4.6,'letter-spacing':'.08em','font-family':'var(--mono)', - fill:'#b08a96'}).textContent='SR'; - let pc=0o1234; - const draw=()=>{toggles.forEach((t,i)=>t.setAttribute('cy',((sr>>(7-i))&1)?76:84)); - setRd('R52','SR '+sr.toString(8).padStart(3,'0')+' (octal)');}; - const tick=()=>{pc=((pc*5+sr+1)&0xFFF);paint(addr,pc);paint(data,(pc^(pc<<3)^sr)&0xFFF);}; - draw();tick(); - if(!matchMedia('(prefers-reduced-motion: reduce)').matches)setInterval(tick,250);})(); +/* R45-R47, R52 extracted to widgets.js (GW.*) */ + + + /* R53-R56 extracted to widgets.js (GW.*) */ diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 4cd25eb..2c57935 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -3521,6 +3521,266 @@ GW.radarSweep = function (host, opts = {}) { return { el: s, get: () => a, tick }; }; +/* R35 day-date disc calendar — coaxial printed discs rotate so today reads + under the fixed hand; starts on today; click rolls midnight forward */ +GW.dayDateCal = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135; + gradDef('ddFace', 'radialGradient', { cx: '46%', cy: '40%', r: '75%' }, [['0', '#efe9da'], ['1', '#cfc8b6']]); + /* movement plate + white calendar disc */ + svgEl(s, 'circle', { cx, cy, r: 62, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + for (let k = 0; k < 5; k++) { + const [x, y] = polar(cx, cy, 58, 20 + k * 72); + svgEl(s, 'circle', { cx: x, cy: y, r: 1.8, fill: '#060505' }); + } + svgEl(s, 'circle', { cx, cy, r: 54, fill: 'url(#ddFace)', stroke: '#8f897b', 'stroke-width': .8 }); + /* date ring: 31 radial numerals on its own group */ + const dateG = svgEl(s, 'g', {}); + for (let d = 1; d <= 31; d++) { + const a = (d - 1) * (360 / 31); + const g = svgEl(dateG, 'g', { transform: `rotate(${a},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 46, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#14110e' + }).textContent = String(d); + } + /* inner weekday disc: 7 names twice around, separated by a hairline ring */ + svgEl(s, 'circle', { cx, cy, r: 38, fill: 'none', stroke: '#8f897b', 'stroke-width': .7 }); + const dayG = svgEl(s, 'g', {}); + for (let i = 0; i < 14; i++) { + const a = i * (360 / 14); + const g = svgEl(dayG, 'g', { transform: `rotate(${a},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 6.8, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#14110e' + }).textContent = DAYS[i % 7]; + } + /* fixed yellow hand from hub toward the read index, and the hub */ + const [hx, hy] = polar(cx, cy, 44, HAND); + svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: '#e8cf4a', 'stroke-width': 3, 'stroke-linecap': 'round' }); + svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#b8b2a4', stroke: '#5c574c', 'stroke-width': 1.2 }); + svgEl(s, 'circle', { cx, cy, r: 2.4, fill: '#7a7466' }); + s.style.cursor = 'pointer'; + let date, day; + const set = (d, w) => { + date = d; day = w; + dateG.setAttribute('transform', `rotate(${(HAND - (d - 1) * (360 / 31)).toFixed(2)},${cx},${cy})`); + dayG.setAttribute('transform', `rotate(${(HAND - w * (360 / 14)).toFixed(2)},${cx},${cy})`); + onChange([d, w], DAYS[w] + ' ' + d); + }; + s.addEventListener('click', () => set(date % 31 + 1, (day + 1) % 7)); + const now = new Date(); + set(opts.date !== undefined ? opts.date : now.getDate(), + opts.day !== undefined ? opts.day : now.getDay()); + return { el: s, get: () => [date, day], set }; +}; + +/* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots + to paint; starts on the reference K */ +GW.dotMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5; + const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100']; + const s = stageSvg(host, 'rsvg press', 108, 108); + svgEl(s, 'rect', { x: 2, y: 2, width: 104, height: 104, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 6, y: 6, width: 96, height: 96, rx: 5, fill: '#0d0505', stroke: '#241010', 'stroke-width': 1 }); + let lit = 0; + const paint = (d, on) => { + d.on = on; + d.el.setAttribute('fill', on ? '#ff4a30' : '#2a1210'); + d.el.setAttribute('filter', on ? 'drop-shadow(0 0 3.5px rgba(255,74,48,.85))' : 'none'); + }; + for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) { + const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2, cy: Y0 + r * STEP + STEP / 2, r: 4.1, fill: '#2a1210' }); + el.style.cursor = 'pointer'; + const d = { el, on: false }; + paint(d, glyph[r][c] === '1'); if (d.on) lit++; + el.addEventListener('click', () => { + paint(d, !d.on); lit += d.on ? 1 : -1; + onChange(lit, lit + ' / 64 lit'); + }); + } + onChange(lit, lit + ' / 64 lit'); + return { el: s, get: () => lit }; +}; + +/* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the + color swap at the midpoint; click a disc to flip it */ +GW.flipDisc = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13; + const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */ + const s = stageSvg(host, 'rsvg press', 120, 92); + svgEl(s, 'rect', { x: 2, y: 2, width: 116, height: 88, rx: 7, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); + let lit = 0; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2 - 2, cy: Y0 + r * STEP + STEP / 2 - 2, r: 5.6, stroke: '#060505', 'stroke-width': .8 }); + el.style.transformBox = 'fill-box'; el.style.transformOrigin = 'center'; el.style.transition = 'transform .11s ease-in'; + el.style.cursor = 'pointer'; + const d = { el, on: glyph[r][c] === '1' }; + el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); if (d.on) lit++; + el.addEventListener('click', () => { + d.on = !d.on; lit += d.on ? 1 : -1; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); } + else { + el.style.transform = 'scaleX(0)'; + setTimeout(() => { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); el.style.transform = 'scaleX(1)'; }, 115); + } + onChange(lit, lit + ' discs showing'); + }); + } + onChange(lit, lit + ' discs showing'); + return { el: s, get: () => lit }; +}; + +/* R46 dekatron counting ring — each click pulses the neon glow one cathode + around; the wrap flashes the carry dot */ +GW.dekatron = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60; + svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 46, fill: '#0d0a08', stroke: '#2a221c', 'stroke-width': 1 }); + svgEl(s, 'ellipse', { cx: cx - 14, cy: cy - 18, rx: 20, ry: 11, fill: 'rgba(255,255,255,.05)', transform: `rotate(-24,${cx - 14},${cy - 18})` }); + const dots = []; + for (let i = 0; i < 10; i++) { + const [x, y] = polar(cx, cy, 34, i * 36); + svgEl(s, 'text', { + x: polar(cx, cy, 43, i * 36)[0], y: polar(cx, cy, 43, i * 36)[1] + 2, 'text-anchor': 'middle', 'font-size': 5.2, + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = String(i); + dots.push(svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: '#3a2a20' })); + } + const carry = svgEl(s, 'circle', { cx, cy: cy - 6, r: 3, fill: '#2a1c14' }); + svgEl(s, 'text', { + x: cx, y: cy + 8, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.12em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'CARRY'; + let pos = opts.count || 0, carries = 0; + const draw = () => { + dots.forEach((d, i) => { + const on = i === pos; + d.setAttribute('fill', on ? '#ff9a4c' : '#3a2a20'); + d.setAttribute('filter', on ? 'drop-shadow(0 0 5px rgba(255,154,76,.9))' : 'none'); + }); + onChange(pos, 'COUNT ' + pos + (carries ? ' · CARRY x' + carries : '')); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => { + pos = (pos + 1) % 10; + if (pos === 0) { + carries++; carry.setAttribute('fill', '#ff9a4c'); + carry.setAttribute('filter', 'drop-shadow(0 0 4px rgba(255,154,76,.9))'); + setTimeout(() => { carry.setAttribute('fill', '#2a1c14'); carry.removeAttribute('filter'); }, 220); + } + draw(); + }); + draw(); + return { el: s, get: () => pos }; +}; + +/* R47 landing gear indicator — three greens or nothing; the lever cycles + down → transit (amber pulse) → up */ +GW.gearIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 140, 110); + svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + const POS = [['NOSE', 48, 28], ['L', 24, 74], ['R', 72, 74]]; + const lamps = {}; + POS.forEach(([n, x, y]) => { + svgEl(s, 'circle', { cx: x, cy: y, r: 9, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1.5 }); + lamps[n] = svgEl(s, 'circle', { cx: x, cy: y, r: 6.2, fill: '#16240f' }); + svgEl(s, 'text', { + x, y: y + 18, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = n; + }); + /* gear lever: slot + wheel-shaped knob */ + svgEl(s, 'rect', { x: 106, y: 22, width: 8, height: 66, rx: 4, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 }); + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'rect', { x: 107.5, y: 36, width: 5, height: 26, fill: '#8f8a7e' }); + svgEl(lever, 'circle', { cx: 110, cy: 32, r: 8, fill: '#26221c', stroke: '#4a463e', 'stroke-width': 2.4 }); + svgEl(lever, 'circle', { cx: 110, cy: 32, r: 3, fill: '#4a463e' }); + svgEl(s, 'text', { + x: 110, y: 100, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'GEAR'; + let state = opts.state || 'down', busy = false; + const paint = () => { + const c = state === 'down' ? 'var(--pass)' : state === 'transit' ? 'var(--amber-warn)' : '#16240f'; + for (const n of Object.keys(lamps)) { + lamps[n].setAttribute('fill', c); + lamps[n].setAttribute('filter', state === 'down' ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : + state === 'transit' ? 'drop-shadow(0 0 4px rgba(var(--glow-lo),.7))' : 'none'); + lamps[n].style.animation = state === 'transit' ? 'pulse var(--pulse-rate) ease-in-out infinite' : 'none'; + } + lever.setAttribute('transform', state === 'up' ? 'translate(0,-8)' : state === 'transit' ? 'translate(0,-4)' : ''); + onChange(state, state === 'down' ? 'GEAR DOWN · 3 GREEN' : state === 'up' ? 'GEAR UP' : 'IN TRANSIT'); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => { + if (busy) return; + const target = state === 'down' ? 'up' : 'down'; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { state = target; paint(); return; } + busy = true; state = 'transit'; paint(); + setTimeout(() => { state = target; busy = false; paint(); }, 1500); + }); + paint(); + return { el: s, get: () => state }; +}; + +/* R52 blinkenlights front panel — address/data lamps ripple with a live + pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the + switch register; flip the SR bits and the pattern changes */ +GW.blinkenlights = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 170, 100); + svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 8, y: 8, width: 154, height: 62, rx: 5, fill: '#14090e' }); + const mkRow = (y, lbl) => { + const row = []; + svgEl(s, 'text', { + x: 12, y: y + 2.6, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', + fill: '#b08a96' + }).textContent = lbl; + for (let i = 0; i < 12; i++) row.push(svgEl(s, 'circle', { cx: 46 + i * 10, cy: y, r: 3, fill: '#2a1210' })); + return row; + }; + const addr = mkRow(24, 'ADDR'), data = mkRow(46, 'DATA'); + const paint = (row, val) => row.forEach((d, i) => { + const on = (val >> (11 - i)) & 1; + d.setAttribute('fill', on ? '#ffb43a' : '#2a1210'); + d.setAttribute('filter', on ? 'drop-shadow(0 0 3px rgba(255,180,58,.8))' : 'none'); + }); + /* switch register: 8 mini toggles */ + let sr = opts.sr !== undefined ? opts.sr : 0b10100101; + const toggles = []; + for (let i = 0; i < 8; i++) { + const x = 46 + i * 14; + svgEl(s, 'rect', { x: x - 3, y: 76, width: 6, height: 16, rx: 3, fill: '#0d0b09', stroke: '#4a2432', 'stroke-width': 1 }); + const t = svgEl(s, 'circle', { cx: x, cy: 80, r: 4, fill: '#8f8a7e', stroke: '#3c382f', 'stroke-width': 1 }); + t.style.cursor = 'pointer'; + const hit = svgEl(s, 'rect', { x: x - 7, y: 72, width: 14, height: 24, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { sr ^= (1 << (7 - i)); draw(); }); + toggles.push(t); + } + svgEl(s, 'text', { + x: 12, y: 83, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', + fill: '#b08a96' + }).textContent = 'SR'; + let pc = 0o1234; + const draw = () => { + toggles.forEach((t, i) => t.setAttribute('cy', ((sr >> (7 - i)) & 1) ? 76 : 84)); + onChange(sr, 'SR ' + sr.toString(8).padStart(3, '0') + ' (octal)'); + }; + const tick = () => { pc = ((pc * 5 + sr + 1) & 0xFFF); paint(addr, pc); paint(data, (pc ^ (pc << 3) ^ sr) & 0xFFF); }; + draw(); tick(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 250); + return { el: s, get: () => sr, tick }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { -- cgit v1.2.3