Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx
iris 0622736b30 dashboard: serve the dashboard at /, fold kept state in as a tab, add a pages menu
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.
2026-10-16 20:02:28 +02:00

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" />;
}