swarm-ui: test the rain arithmetic and the swarm name loader
The rain's column maths and fade colour move from MatrixRain into shell/rain.ts, and the request-once swarm name loader from useSwarmName into shell/swarm-name.ts. Both are plain functions with their input passed in, so tests cover them; the components keep the DOM and canvas work.
This commit is contained in:
parent
01e58219cf
commit
e32b4172e4
6 changed files with 237 additions and 42 deletions
|
|
@ -7,10 +7,16 @@
|
|||
// `prefers-reduced-motion`. The same effect as the hive dashboard's home
|
||||
// page, which is plain JS over a fixed canvas.
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import {
|
||||
CELL,
|
||||
columnsFor,
|
||||
fadeColorOf,
|
||||
glyphFor,
|
||||
startDrops,
|
||||
stepDrops,
|
||||
} from "./rain.js";
|
||||
import "./MatrixRain.css";
|
||||
|
||||
const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
|
||||
const CELL = 16; // glyph cell size, in px
|
||||
const STEP_MS = 55;
|
||||
|
||||
// `getComputedStyle` on a custom property returns its declared value
|
||||
|
|
@ -42,15 +48,7 @@ export function MatrixRain() {
|
|||
let fadeColor = "";
|
||||
function resolveColors() {
|
||||
glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
|
||||
// The fade trail is the background at low alpha. Its channels are
|
||||
// pulled out numerically so the trail fades whatever spacing
|
||||
// rgb()/rgba() comes back in, and never fills solid.
|
||||
const bg = resolveColor("--bg", "rgb(30, 30, 46)").match(
|
||||
/(\d+)[^\d]+(\d+)[^\d]+(\d+)/,
|
||||
);
|
||||
fadeColor = bg
|
||||
? `rgba(${bg[1]}, ${bg[2]}, ${bg[3]}, 0.09)`
|
||||
: "rgba(30, 30, 46, 0.09)";
|
||||
fadeColor = fadeColorOf(resolveColor("--bg", "rgb(30, 30, 46)"));
|
||||
}
|
||||
resolveColors();
|
||||
const themeWatch = new MutationObserver(resolveColors);
|
||||
|
|
@ -65,10 +63,8 @@ export function MatrixRain() {
|
|||
if (!canvas || !ctx) return;
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
cols = Math.max(1, Math.ceil(canvas.width / CELL));
|
||||
drops = Array.from({ length: cols }, () =>
|
||||
Math.floor(Math.random() * -40),
|
||||
);
|
||||
cols = columnsFor(canvas.width);
|
||||
drops = startDrops(cols, Math.random);
|
||||
ctx.font = `${CELL}px monospace`; // a canvas resize resets the font
|
||||
}
|
||||
|
||||
|
|
@ -78,13 +74,9 @@ export function MatrixRain() {
|
|||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = glyphColor;
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const ch = GLYPHS[Math.floor(Math.random() * GLYPHS.length)] ?? "0";
|
||||
ctx.fillText(ch, i * CELL, (drops[i] ?? 0) * CELL);
|
||||
if ((drops[i] ?? 0) * CELL > canvas.height && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
}
|
||||
drops[i] = (drops[i] ?? 0) + 1;
|
||||
ctx.fillText(glyphFor(Math.random), i * CELL, (drops[i] ?? 0) * CELL);
|
||||
}
|
||||
drops = stepDrops(drops, canvas.height, Math.random);
|
||||
}
|
||||
|
||||
let timer: number | null = null;
|
||||
|
|
|
|||
61
frontend/packages/swarm-ui/src/shell/rain.ts
Normal file
61
frontend/packages/swarm-ui/src/shell/rain.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// The digital rain's arithmetic, apart from the canvas: the glyph cell, how a
|
||||
// column starts and steps, which glyph a column draws, and the translucent
|
||||
// fade colour that leaves each glyph's trail. Plain functions, with the random
|
||||
// source passed in, so they can be tested.
|
||||
|
||||
export const GLYPHS =
|
||||
"アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
|
||||
|
||||
/** The glyph cell size, in px. */
|
||||
export const CELL = 16;
|
||||
|
||||
/** How far above the top a column may start, in cells. */
|
||||
const MAX_HEAD_START = 40;
|
||||
|
||||
/** The chance, once a column has fallen past the bottom, that it restarts at
|
||||
* the top on a given step. */
|
||||
const RESTART_ABOVE = 0.975;
|
||||
|
||||
/** The fade trail's alpha. */
|
||||
const FADE_ALPHA = 0.09;
|
||||
|
||||
/** How many columns fill a canvas this wide. */
|
||||
export function columnsFor(width: number): number {
|
||||
return Math.max(1, Math.ceil(width / CELL));
|
||||
}
|
||||
|
||||
/** Each column's starting row: up to `MAX_HEAD_START` cells above the top, so
|
||||
* the columns do not all start falling together. */
|
||||
export function startDrops(cols: number, rand: () => number): number[] {
|
||||
return Array.from({ length: cols }, () =>
|
||||
Math.floor(rand() * -MAX_HEAD_START),
|
||||
);
|
||||
}
|
||||
|
||||
/** One step: every column moves down a row, and one that has fallen past the
|
||||
* bottom of a canvas `height` tall goes back to the top on a small chance. */
|
||||
export function stepDrops(
|
||||
drops: number[],
|
||||
height: number,
|
||||
rand: () => number,
|
||||
): number[] {
|
||||
return drops.map((row) => {
|
||||
const restarted = row * CELL > height && rand() > RESTART_ABOVE ? 0 : row;
|
||||
return restarted + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** The glyph a column draws this step. */
|
||||
export function glyphFor(rand: () => number): string {
|
||||
return GLYPHS[Math.floor(rand() * GLYPHS.length)] ?? "0";
|
||||
}
|
||||
|
||||
/** The fade colour for a computed background colour: the same channels at a low
|
||||
* alpha. They are read out numerically, so the trail fades whatever spacing
|
||||
* `rgb()` or `rgba()` comes back in; a colour with no channels gets the
|
||||
* default background's, so the trail never fills solid. */
|
||||
export function fadeColorOf(background: string): string {
|
||||
const m = background.match(/(\d+)[^\d]+(\d+)[^\d]+(\d+)/);
|
||||
const [r, g, b] = m ? [m[1], m[2], m[3]] : [30, 30, 46];
|
||||
return `rgba(${r}, ${g}, ${b}, ${FADE_ALPHA})`;
|
||||
}
|
||||
28
frontend/packages/swarm-ui/src/shell/swarm-name.ts
Normal file
28
frontend/packages/swarm-ui/src/shell/swarm-name.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Loading the swarm's display name from `GET /api/swarm`, once. The header and
|
||||
// the start page both ask for it and it does not change within a page visit,
|
||||
// so the loader remembers the first request and every caller shares it. The
|
||||
// label is cosmetic, so a failed request, an error response or an unconfigured
|
||||
// name all answer `null` instead of throwing: callers fall back to their own
|
||||
// default, as an operator who never set a name sees.
|
||||
|
||||
type Fetch = (url: string) => Promise<{
|
||||
ok: boolean;
|
||||
json: () => Promise<unknown>;
|
||||
}>;
|
||||
|
||||
export function createSwarmNameLoader(
|
||||
fetchImpl: Fetch,
|
||||
): () => Promise<string | null> {
|
||||
let pending: Promise<string | null> | null = null;
|
||||
return () => {
|
||||
if (pending === null) {
|
||||
pending = (async () => {
|
||||
const r = await fetchImpl("/api/swarm");
|
||||
if (!r.ok) return null;
|
||||
const data = (await r.json()) as { name?: string | null };
|
||||
return data.name || null;
|
||||
})().catch(() => null);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,31 +1,16 @@
|
|||
// useSwarmName — the swarm's configured display name from `GET /api/swarm`,
|
||||
// or `null` until it arrives, when none is configured, or when the fetch
|
||||
// fails. The label is cosmetic, so a failure is swallowed rather than shown:
|
||||
// callers fall back to their own default, as an operator who never set a
|
||||
// name sees. One request per page load, shared by every caller, because the
|
||||
// header and the start page both ask for it and the name does not change
|
||||
// within a visit.
|
||||
// useSwarmName — the swarm's configured display name, or `null` until it
|
||||
// arrives, when none is configured, or when the request fails. One request per
|
||||
// page load, shared by every caller; see `swarm-name.ts`.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { createSwarmNameLoader } from "./swarm-name.js";
|
||||
|
||||
let pending: Promise<string | null> | null = null;
|
||||
|
||||
function fetchSwarmName(): Promise<string | null> {
|
||||
if (pending === null) {
|
||||
pending = (async () => {
|
||||
const r = await fetch("/api/swarm");
|
||||
if (!r.ok) return null;
|
||||
const data = (await r.json()) as { name: string | null };
|
||||
return data.name || null;
|
||||
})().catch(() => null);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
const loadSwarmName = createSwarmNameLoader((url) => fetch(url));
|
||||
|
||||
export function useSwarmName(): string | null {
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchSwarmName().then((n) => {
|
||||
void loadSwarmName().then((n) => {
|
||||
if (!cancelled && n !== null) setName(n);
|
||||
});
|
||||
return () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue