The rain read only the OS reduced-motion query, once, at mount. The settings menu's motion override (data-motion on the document) wins over the OS in either direction everywhere else, and the shell applies it from an effect that runs after this one. Whether the rain may move is now rainAllowed(), and the component re-evaluates it on mount, on every document attribute change and on the media query's change, starting or stopping the interval and clearing the canvas.
74 lines
2.7 KiB
TypeScript
74 lines
2.7 KiB
TypeScript
// 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;
|
|
|
|
/** Whether the rain may move. The app's own motion setting (`data-motion` on
|
|
* the document, from the settings menu) wins over the OS preference in either
|
|
* direction: `reduce` stops it, `allow` lets it run on a reduce-motion OS, and
|
|
* anything else follows the OS. */
|
|
export function rainAllowed(
|
|
dataMotion: string | undefined,
|
|
osReduces: boolean,
|
|
): boolean {
|
|
if (dataMotion === "reduce") return false;
|
|
if (dataMotion === "allow") return true;
|
|
return !osReduces;
|
|
}
|
|
|
|
/** 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})`;
|
|
}
|