Drop the H0M3 tile hub and the core page. The dashboard is now the index; kept state is its fifth tab. A pages menu in the chrome links flow, stats, API docs and the swarm UI. The swarm apex URL comes from services.hyperhive.swarm.domain via HIVE_SWARM_PUBLIC_URL and the new swarm_public_url field of the state snapshot.
132 lines
4.4 KiB
TypeScript
132 lines
4.4 KiB
TypeScript
// <MatrixRain> — the falling-glyph "digital rain" backdrop, drawn on a canvas
|
|
// that fills its positioned parent. Purely decorative, so it is hidden from
|
|
// assistive tech and takes no pointer events. The glyphs take the live
|
|
// `--green` and the fade trail the live `--bg`, so a theme swap recolours it.
|
|
// Stepped on an interval (the steppy cadence is the look, and it costs
|
|
// little), paused while the tab is hidden, and stopped (and cleared) when
|
|
// motion is reduced: the settings menu's `data-motion` override wins over
|
|
// `prefers-reduced-motion`, and is followed live.
|
|
import { useEffect, useRef } from "preact/hooks";
|
|
import {
|
|
CELL,
|
|
columnsFor,
|
|
fadeColorOf,
|
|
glyphFor,
|
|
rainAllowed,
|
|
startDrops,
|
|
stepDrops,
|
|
} from "./rain.js";
|
|
import "./MatrixRain.css";
|
|
|
|
const STEP_MS = 55;
|
|
|
|
// `getComputedStyle` on a custom property returns its declared value
|
|
// unresolved (`var(--x)`), so resolve a concrete rgb() by computing a real
|
|
// `color` on a probe element instead.
|
|
function resolveColor(varName: string, fallback: string): string {
|
|
const probe = document.createElement("span");
|
|
probe.style.color = `var(${varName})`;
|
|
probe.style.display = "none";
|
|
document.body.appendChild(probe);
|
|
const c = getComputedStyle(probe).color;
|
|
probe.remove();
|
|
return /^rgb/.test(c) ? c : fallback;
|
|
}
|
|
|
|
export function MatrixRain() {
|
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = ref.current;
|
|
const ctx = canvas?.getContext("2d");
|
|
if (!canvas || !ctx) return;
|
|
|
|
// Resolved again whenever the theme or motion setting can have changed:
|
|
// the shell applies its overrides (`data-theme`, `data-motion`) from an
|
|
// effect that runs after this one, and a later switch from the settings
|
|
// menu changes them too.
|
|
let glyphColor = "";
|
|
let fadeColor = "";
|
|
function resolveColors() {
|
|
glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
|
|
fadeColor = fadeColorOf(resolveColor("--bg", "rgb(30, 30, 46)"));
|
|
}
|
|
resolveColors();
|
|
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
const schemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
|
|
let cols = 0;
|
|
let drops: number[] = [];
|
|
|
|
function resize() {
|
|
if (!canvas || !ctx) return;
|
|
canvas.width = canvas.offsetWidth;
|
|
canvas.height = canvas.offsetHeight;
|
|
cols = columnsFor(canvas.width);
|
|
drops = startDrops(cols, Math.random);
|
|
ctx.font = `${CELL}px monospace`; // a canvas resize resets the font
|
|
}
|
|
|
|
function tick() {
|
|
if (!canvas || !ctx) return;
|
|
ctx.fillStyle = fadeColor;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = glyphColor;
|
|
for (let i = 0; i < cols; i++) {
|
|
ctx.fillText(glyphFor(Math.random), i * CELL, (drops[i] ?? 0) * CELL);
|
|
}
|
|
drops = stepDrops(drops, canvas.height, Math.random);
|
|
}
|
|
|
|
const allowed = () =>
|
|
rainAllowed(document.documentElement.dataset.motion, motionQuery.matches);
|
|
|
|
let timer: number | null = null;
|
|
const play = () => {
|
|
if (timer === null) timer = window.setInterval(tick, STEP_MS);
|
|
};
|
|
const pause = () => {
|
|
if (timer !== null) {
|
|
window.clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
};
|
|
|
|
// Runs the rain when motion is allowed and the tab is visible; otherwise
|
|
// stops it and clears what was drawn. Safe to call on any change.
|
|
function sync() {
|
|
if (allowed() && !document.hidden) {
|
|
play();
|
|
} else {
|
|
pause();
|
|
if (!allowed() && canvas && ctx) {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
}
|
|
}
|
|
const onSettingsChange = () => {
|
|
resolveColors();
|
|
sync();
|
|
};
|
|
|
|
const watch = new MutationObserver(onSettingsChange);
|
|
watch.observe(document.documentElement, { attributes: true });
|
|
schemeQuery.addEventListener("change", onSettingsChange);
|
|
motionQuery.addEventListener("change", onSettingsChange);
|
|
|
|
resize();
|
|
window.addEventListener("resize", resize);
|
|
document.addEventListener("visibilitychange", sync);
|
|
sync();
|
|
return () => {
|
|
pause();
|
|
watch.disconnect();
|
|
schemeQuery.removeEventListener("change", onSettingsChange);
|
|
motionQuery.removeEventListener("change", onSettingsChange);
|
|
window.removeEventListener("resize", resize);
|
|
document.removeEventListener("visibilitychange", sync);
|
|
};
|
|
}, []);
|
|
|
|
return <canvas ref={ref} class="matrix-rain" aria-hidden="true" />;
|
|
}
|