diff options
Diffstat (limited to 'docs/prototypes/widgets.js')
| -rw-r--r-- | docs/prototypes/widgets.js | 170 |
1 files changed, 148 insertions, 22 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index c5ce2c7..e11862c 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -3649,38 +3649,72 @@ GW.nixie = function (host, opts = {}) { /* N20 split-flap — an honest Solari mechanism. Each cell owns a stack of flaps in `chars` order (data, like R58's LAYOUT) and can only advance one flap at a time, so a changed reading cascades through intermediates and - cells arrive staggered by travel distance. set() mid-cascade re-aims the - running cells at the new target; animate:false (the page's reduced-motion - gate) collapses every move to an instant jump. One flip is two half-panel + cells arrive staggered by travel distance. setText() mid-cascade re-aims + the running cells; animate:false (the page's reduced-motion gate) + collapses every move to an instant jump. One flip is two half-panel animations: the current top falls (rotateX 0 -> -180) while the next - bottom lands (180 -> 0), backfaces hidden, crease preserved. The page - drives next() on its own cadence; first paint is silent like the original. */ + bottom lands (180 -> 0), backfaces hidden, crease preserved. First paint + is silent (no cascade on load), like the original. The page drives next() + on its own cadence. + + Contract (everything a consumer needs; no page globals touched): + opts: rows (1) x cells (4) grid; chars (flap order, default space+A-Z+0-9); + flapMs (70, per-cell rate jittered 0.8x-1.35x so letters finish + at different times, base mutable via setFlapMs); animate (true); + skin ('dark'|'white'|'light'|'paper'); font ('mono'|'helv'); + words (the demo pool, at least rows+1 entries: next() sends every + row to a different random word, mutually distinct; set(i) pins the + top row for determinism); onChange(idx|-1, top word) fires at + command time. get() is the last set() index, -1 after a scramble. + handle: el, get(), set(i), next(), setText(multi-line string), + reading() (displayed grid, rows joined with newline), + setStyle(axis, name), chars, flapMs()/setFlapMs(ms) (base flap + rate, clamped 20-400), onSettle(cb) — cb(reading) fires once per + command when every cell has arrived (dwell hook). + CSS lives in the "split-flap" block of GW_CSS; no other styles involved. */ 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 rows = opts.rows || 1; const cells = opts.cells || 4; const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - const flapMs = opts.flapMs || 70; - const f = document.createElement('span'); f.className = 'flap'; + let flapMs = Math.min(400, Math.max(20, opts.flapMs || 70)); + + const ci = ch => Math.max(0, chars.indexOf(ch)); + const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w); + const wordAt = k => words[((k % words.length) + words.length) % words.length]; + // Four stacked half-panels per cell, painting order = stacking order: // top-next (revealed as the current top falls), bottom-current (covered as // the next bottom lands), top-current (the falling flap), bottom-next (the // landing flap, resting folded away at rotateX(180) with its back hidden). - const st = []; - for (let i = 0; i < cells; i++) { + const st = []; // row-major cell states + const settleCbs = []; if (opts.onSettle) settleCbs.push(opts.onSettle); + let announced = true; // no announcement for the silent first paint + const allSettled = () => st.every(S => !S.running && S.cur === S.target); + const makeCell = () => { const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = '<b class="fh ftn"></b><b class="fh fbc"></b><b class="fh ftc"></b><b class="fh fbn"></b>'; - f.appendChild(d); st.push({ cur: 0, target: 0, running: false, + // Each cell's mechanism has its own character: a fixed per-cell jitter + // (0.8x-1.35x of the base rate) so letters finish at genuinely + // different times, beyond what travel distance alone staggers. + jitter: 0.8 + 0.55 * Math.random(), ftn: d.querySelector('.ftn'), fbc: d.querySelector('.fbc'), ftc: d.querySelector('.ftc'), fbn: d.querySelector('.fbn'), }); + return d; + }; + const f = document.createElement('span'); f.className = 'flap' + (rows > 1 ? ' flap-rows' : ''); + for (let r = 0; r < rows; r++) { + const line = rows > 1 ? document.createElement('span') : f; + if (rows > 1) { line.className = 'flapline'; f.appendChild(line); } + for (let c = 0; c < cells; c++) line.appendChild(makeCell()); } host.appendChild(f); - const ci = ch => Math.max(0, chars.indexOf(ch)); - const paintCell = (S, k) => { S.ftn.textContent = S.fbc.textContent = S.ftc.textContent = S.fbn.textContent = chars[k]; }; + // One cell's cascade: flip one place through the charset until the (live) // target is reached. A retarget while running just moves the goalpost. const run = S => { @@ -3694,9 +3728,9 @@ GW.splitFlap = function (host, opts = {}) { try { await Promise.all([ S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }], - { duration: flapMs, easing: 'ease-in' }).finished, + { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }).finished, S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }], - { duration: flapMs, easing: 'ease-in' }).finished, + { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }).finished, ]); } catch { break; /* cancelled (card torn down mid-flip) */ } } @@ -3704,22 +3738,99 @@ GW.splitFlap = function (host, opts = {}) { S.cur = nk; } S.running = false; + maybeSettle(); })(); }; + // Announce once per command, when the whole board has arrived. Consumers + // use it to dwell on the settled reading before commanding the next update. + const maybeSettle = () => { + if (announced || !allSettled()) return; + announced = true; + const r = reading(); + settleCbs.forEach(cb => cb(r)); + }; + + // Map a multi-line string onto the grid; missing lines/cells pad with space. + const gridTargets = str => { + const lines = String(str).split('\n'); + return st.map((S, i) => ci(fit(lines[(i / cells) | 0] || '', cells)[i % cells])); + }; const setText = str => { - const w = String(str).padEnd(cells, ' ').slice(0, cells); - st.forEach((S, i) => { S.target = ci(w[i]); run(S); }); + announced = false; + const ls = String(str).split('\n'); + lastGrid = Array.from({ length: rows }, (_, r) => fit(ls[r] || '', cells)).join('\n'); + // Assign every target before running any cell: a cell that finishes + // synchronously (animate:false) must not see its neighbours' stale, + // already-satisfied targets and announce a partial board as settled. + gridTargets(str).forEach((k, i) => { st[i].target = k; }); + st.forEach(run); + maybeSettle(); // covers a command that changes nothing }; + const silent = str => gridTargets(str).forEach((k, i) => { + const S = st[i]; S.cur = S.target = k; + S.ftn.textContent = S.fbc.textContent = S.ftc.textContent = S.fbn.textContent = chars[k]; + }); + + // The words demo: every advance sends each row to a completely different + // word, drawn at random from the pool; rows stay mutually distinct. set(i) + // pins the top row (deterministic callers, tests); next() scrambles all. let idx = 0; - const set = i => { idx = ((i % words.length) + words.length) % words.length; setText(words[idx]); onChange(idx, words[idx].trim()); }; - f.addEventListener('click', () => set(idx + 1)); - // first paint lands silently on word 0 — no cascade on load - words[0].padEnd(cells, ' ').slice(0, cells).split('').forEach((ch, i) => { st[i].cur = st[i].target = ci(ch); paintCell(st[i], st[i].cur); }); + let lastGrid = null; // the last commanded grid — scramble picks against + // commands, not in-flight glyphs mid-cascade + const rowWords = () => (lastGrid || reading()).split('\n'); + const pickNew = taken => { + const open = words.filter(w => !taken.includes(fit(w, cells))); + return fit(open[(Math.random() * open.length) | 0] || wordAt(0), cells); + }; + const gridFor = top => { + const out = []; + const prev = rowWords(); + for (let r = 0; r < rows; r++) + out.push(r === 0 && top !== null ? fit(top, cells) : pickNew(out.concat(prev[r]))); + return out.join('\n'); + }; + const set = i => { idx = ((i % words.length) + words.length) % words.length; setText(gridFor(wordAt(idx))); onChange(idx, wordAt(idx).trim()); }; + const scramble = () => { idx = -1; const g = gridFor(null); setText(g); onChange(-1, g.split('\n')[0].trim()); }; + f.addEventListener('click', scramble); + + // skin: flap polarity — dark (cream on black, the default) or the inverse + // light board (black on cream). A construction axis, not an accent. + const setStyle = (axis, name) => { + const ax = GW.splitFlap.STYLES[axis]; const o = ax && ax[name]; + if (!o) return; + Object.values(ax).forEach(v => { if (v.cls) f.classList.remove(v.cls); }); + if (o.cls) f.classList.add(o.cls); + }; + if (opts.skin) setStyle('skin', opts.skin); + if (opts.font) setStyle('font', opts.font); + + // first paint: consecutive pool words, silent (no cascade on load) + silent(Array.from({ length: rows }, (_, r) => fit(wordAt(r), cells)).join('\n')); + const reading = () => Array.from({ length: rows }, + (_, r) => st.slice(r * cells, (r + 1) * cells).map(S => chars[S.cur]).join('')).join('\n'); return { - el: f, get: () => idx, set, next: () => set(idx + 1), - setText, chars, reading: () => st.map(S => chars[S.cur]).join(''), + el: f, get: () => idx, set, next: scramble, setStyle, + setText, chars, reading, onSettle: cb => settleCbs.push(cb), + flapMs: () => flapMs, setFlapMs: ms => { flapMs = Math.min(400, Math.max(20, ms | 0)); }, }; }; +GW.splitFlap.STYLES = { + // board polarity + ink: dark board with the kit's cream ink (default), + // dark board with true-white ink, or the inverse ivory board + // dot: the board colour; the gallery card letters each chip in the skin's + // ink so the swatch is a miniature flap cell, not an ambiguous colour dot + skin: { + dark: { cls: '', dot: '#141210' }, + white: { cls: 'flap-white', dot: '#141210' }, + light: { cls: 'flap-light', dot: '#f3e7c5' }, + paper: { cls: 'flap-paper', dot: '#ffffff' }, + }, + // typeface: the kit's Berkeley Mono, or the grotesque real boards wore + font: { + mono: { cls: '', dot: '#0d0c0b' }, + helv: { cls: 'flap-helv', dot: '#0d0c0b' }, + }, +}; /* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); click adds a minute */ @@ -4927,6 +5038,21 @@ const GW_CSS = ` .flapd .fbn{transform:rotateX(180deg)} .flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;z-index:5; background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)} +/* multi-row board: column of flap lines */ +.flap-rows{flex-direction:column;gap:5px} +.flapline{display:inline-flex;gap:4px} +/* white skin — the dark board with true-white ink (the default ink is cream) */ +.flap-white .flapd .fh{color:#fff} +/* helvetica — the grotesque face real Solari boards wore */ +.flap-helv .flapd .fh{font-family:Helvetica,'Helvetica Neue',Arial,sans-serif;font-weight:600} +/* paper skin — true-white cards, dark lettering */ +.flap-paper .flapd{background:linear-gradient(180deg,#ffffff,#e6e6e3);border-color:#b6b6b2} +.flap-paper .flapd .fh{color:#17181a;text-shadow:0 1px 0 rgba(255,255,255,.5)} +.flap-paper .flapd::after{background:rgba(0,0,0,.28);box-shadow:0 1px 0 rgba(255,255,255,.35)} +/* light skin — the inverse board: ivory flaps, near-black ink, softer crease */ +.flap-light .flapd{background:linear-gradient(180deg,#f7eed3,#dccfa8);border-color:#a89c7e} +.flap-light .flapd .fh{color:#1b1813;text-shadow:0 1px 0 rgba(255,255,255,.45)} +.flap-light .flapd::after{background:rgba(0,0,0,.3);box-shadow:0 1px 0 rgba(255,255,255,.3)} /* seven-segment */ .seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer; |
