diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-12 22:35:24 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-12 22:35:24 -0500 |
| commit | 4064de53a9abfa7620d9d7e3f95e867ba0bf54e3 (patch) | |
| tree | 1868a280bf3942eb178fbb0400ebd0f147af63bf /docs/prototypes/widgets.js | |
| parent | 838065cea987a5364c7f4d771f9a2b873990918b (diff) | |
| download | archsetup-4064de53a9abfa7620d9d7e3f95e867ba0bf54e3.tar.gz archsetup-4064de53a9abfa7620d9d7e3f95e867ba0bf54e3.zip | |
refactor(gallery): extract indicators N20-N28 into GW builders
Diffstat (limited to 'docs/prototypes/widgets.js')
| -rw-r--r-- | docs/prototypes/widgets.js | 256 |
1 files changed, 256 insertions, 0 deletions
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() { |
