// 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);