// DotMatrixHeading — the page H1 rendered as a 5x7 (uppercase) / 5x9 // (mixed-case) LED dot-matrix panel on a canvas. Replaces DS.displayH1Sx as // the page title. Dots are ink in light mode and paper in dark mode; when the // text changes, dots flicker randomly and settle into the new word over // TRANSITION_S seconds. // // Load order (webpage/index.html): javascript/dot_matrix_font.js must load // before this file. // // Usage inside a page component: // // // The component reads the active MUI theme for its mode, so it needs no props // beyond the text. const DotMatrixHeading = ({ text, ariaLevel }) => { const { useTheme } = MaterialUI; const DS = window.DesignSystem; const { FONT, FONT_LOWER } = window.DotMatrixFont; const theme = useTheme(); const darkMode = theme.palette.mode === 'dark'; const T = DS.tokens(darkMode); // ---- tuning (values signed off in the design) --------------------------- const MIXED_CASE = false; // true renders lowercase in the taller 9-row cell const DOT_PITCH = 6; // px between dot centres at full size const DOT_PITCH_MIN = 4.0; // ...and on a phone. Ramps between the two across // DS.metrics.fluidStart..fluidEnd, the same // viewport band the page margins ramp over, so // the heading and the margins resize together. const DOT_RATIO = 0.78; // dot side / pitch const CORNER = 0.3; // dot corner radius, as a fraction of dot side const LETTER_GAP = 0.25; // gap between letters, in dot columns const TRANSITION_S = 0.5; // scramble → resolved const FLICKER = 0.18; // share of unresolved dots lit at any instant const FLICKER_MS = 55; // how often the noise is resampled const FRAME_MS = 33; // animation tick const canvasRef = React.useRef(null); const anim = React.useRef({}); const animate = React.useContext(window.HeadingAnimateContext); const mountedRef = React.useRef(false); // Build the dot grid for a string. Letters advance by 5 dot columns plus a // fractional gap, so cells are positioned on a fractional column axis rather // than a shared integer grid — that is what makes a half-dot gap possible. const buildGrid = React.useCallback((str) => { const rows = MIXED_CASE ? 9 : 7; const chars = String(MIXED_CASE ? str : String(str).toUpperCase()).split(''); const bits = []; const cellX = []; const cellRow = []; let x = 0; chars.forEach((c) => { if (c === ' ') { x += 5 + LETTER_GAP; return; } // space = full cell const glyph = ((MIXED_CASE && FONT_LOWER[c]) || FONT[c.toUpperCase()] || FONT['?']).split('/'); for (let r = 0; r < rows; r++) { for (let k = 0; k < 5; k++) { const row = glyph[r]; bits.push(row && row[k] === '1' ? 1 : 0); cellX.push(x + k); cellRow.push(r); } } x += 5 + LETTER_GAP; }); return { rows: rows, width: Math.max(1, x - LETTER_GAP), bits: Uint8Array.from(bits), cellX: Float32Array.from(cellX), cellRow: Uint8Array.from(cellRow) }; }, []); const paint = React.useCallback((now) => { const a = anim.current; const cv = canvasRef.current; if (!cv || !a.grid) return true; // The canvas sizes itself to its parent; bail (without sizing) until the // parent has actually been laid out, or the canvas collapses to 1x0. const availW = (cv.parentElement && cv.parentElement.clientWidth) || 0; if (availW <= 0) return false; const g = a.grid; // Two independent caps: the fluid one scales the panel down on small // screens, and availW/width still shrinks it further when a long title // would not otherwise fit on one line. const maxPitch = DS.fluidValue(DOT_PITCH_MIN, DOT_PITCH, window.innerWidth); const pitch = Math.min(maxPitch, availW / g.width); const size = pitch * DOT_RATIO; const w = Math.max(1, Math.round(g.width * pitch)); const h = Math.round(g.rows * pitch); const dpr = window.devicePixelRatio || 1; if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); } cv.style.width = w + 'px'; cv.style.height = h + 'px'; const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); const p = Math.min(1, (now - a.t0) / (TRANSITION_S * 1000)); if (now - a.lastFlick > FLICKER_MS) { a.lastFlick = now; const live = FLICKER * (1 - p * 0.55); // noise thins out as it resolves for (let i = 0; i < a.flick.length; i++) a.flick[i] = Math.random() < live ? 1 : 0; } ctx.fillStyle = T.text; const radius = size * CORNER; for (let i = 0; i < g.bits.length; i++) { const lit = p >= a.resolveAt[i] ? g.bits[i] : a.flick[i]; if (!lit) continue; ctx.beginPath(); ctx.roundRect( g.cellX[i] * pitch + (pitch - size) / 2, g.cellRow[i] * pitch + (pitch - size) / 2, size, size, radius ); ctx.fill(); } return p >= 1; }, [T.text]); // Start a scramble→resolve run for `str`. Every dot gets its own resolve // time, uniformly spread across the run, so the whole word settles at once // rather than sweeping left to right. const transitionTo = React.useCallback((str, instant) => { const a = anim.current; a.grid = buildGrid(str); a.resolveAt = new Float32Array(a.grid.bits.length); for (let i = 0; i < a.resolveAt.length; i++) a.resolveAt[i] = 0.15 + Math.random() * 0.85; a.flick = new Uint8Array(a.grid.bits.length); a.lastFlick = 0; // `instant` back-dates t0 past the end of the run, so the first paint // already has p >= 1 and the word lands resolved. a.t0 = performance.now() - (instant ? TRANSITION_S * 1000 + 1 : 0); if (a.timer) { clearInterval(a.timer); a.timer = null; } if (paint(performance.now())) return; a.timer = setInterval(() => { if (paint(performance.now())) { clearInterval(a.timer); a.timer = null; } }, FRAME_MS); }, [buildGrid, paint]); // Run on mount and on every text change (i.e. every page navigation). React.useEffect(() => { // Only the mount may skip the scramble, and only when the browser already // animated the traversal. Later re-runs — a theme flip, a title change — // always play it. `animate` is intentionally not a dependency: it is read // on the mount pass alone, and listing it would replay the scramble on // every navigation. const instant = !mountedRef.current && !animate; mountedRef.current = true; transitionTo(text, instant); const a = anim.current; return () => { if (a.timer) { clearInterval(a.timer); a.timer = null; } }; }, [text, transitionTo]); // Redraw the settled frame when the ink color flips with the theme. React.useEffect(() => { const a = anim.current; if (!a.timer && a.grid) paint(a.t0 + TRANSITION_S * 1000 + 1); }, [T.text, paint]); // The first layout pass usually lands after mount, so observe the parent // rather than relying on window resize alone. The window listener is still // needed alongside it: the pitch cap reads window.innerWidth, so a viewport // change that leaves the content column the same width must still repaint. React.useEffect(() => { const cv = canvasRef.current; if (!cv || !cv.parentElement) return undefined; const repaint = () => { const a = anim.current; if (!a.grid) return; paint(a.timer ? performance.now() : a.t0 + TRANSITION_S * 1000 + 1); }; const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(repaint) : null; if (ro) ro.observe(cv.parentElement); window.addEventListener('resize', repaint); return () => { if (ro) ro.disconnect(); window.removeEventListener('resize', repaint); }; }, [paint]); return ( // The canvas sizes itself from this wrapper, so the wrapper must take its // width from the layout and never from the canvas: as a shrink-to-fit flex // item (Live Data, Plants — the H1 sits in a row beside a StatusBadge) it // would wrap the canvas, and each ResizeObserver pass would ratchet the // pitch down. `flex` is ignored in a block parent, where a div already // fills the column; `minWidth: 0` lets it shrink on narrow viewports.
); }; // Export to global scope for browser-based JSX compilation window.DotMatrixHeading = DotMatrixHeading; // False when the browser already animated the traversal that mounted the // heading — see the predictive-back block in app.jsx. window.HeadingAnimateContext = React.createContext(true);