Files
boocode/apps/web/src/components/fx/MatrixRain.tsx
indifferentketchup f42c673881 feat: futuristic theme ladder + stacked landing banner
Add three opt-in dark themes (BooCode+, BooCode Classic, BooCode
Override) plus an in-place Ember polish, on a class-scoped effects
engine: matrix rain, a neon grid field, and frosted glass, all gated
by a localStorage "Animated background" toggle and prefers-reduced-
motion. Extend the server theme_id whitelist so the new ids persist,
and replace the Home landing wordmark with the stacked mascot +
wordmark banner.
2026-06-03 14:16:59 +00:00

190 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef } from 'react';
/* Matrix code-rain canvas — faithful port of /opt/boolab MatrixRain.jsx.
Amber head (#fbbf24) → orange (#f97316) → deep-rust (#7a3d14) trail, COL_WIDTH
14, throttled to ~24fps, dpr capped at 2, visibilitychange-paused, and
composited with destination-out fade so the canvas stays transparent and
overlays the UI without dimming it. Mounted by ThemeFx only when the
bc-anim-on gate is on (toggle ON *and* not prefers-reduced-motion). */
const CHARSET = (() => {
let s = '';
for (let c = 0x41; c <= 0x5a; c++) s += String.fromCharCode(c); // AZ
for (let c = 0x30; c <= 0x39; c++) s += String.fromCharCode(c); // 09
for (let c = 0xff66; c <= 0xff9d; c++) s += String.fromCharCode(c); // half-width katakana
return s;
})();
const COL_WIDTH = 14;
const TARGET_FPS = 24;
const FRAME_BUDGET = 1000 / TARGET_FPS;
const HEAD_COLOR = '#fbbf24'; // amber
const SECONDARY_COLOR = '#f97316'; // orange
const DEEP_COLOR = '#7a3d14'; // deep rust
function randChar(): string {
return CHARSET.charAt((Math.random() * CHARSET.length) | 0);
}
interface Column {
y: number;
step: number;
}
interface Props {
enabled?: boolean;
density?: number;
speed?: number;
opacity?: number;
}
export function MatrixRain({
enabled = true,
density = 0.35,
speed = 0.7,
opacity = 0.6,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!enabled) return undefined;
const canvas = canvasRef.current;
if (!canvas) return undefined;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return undefined;
let width = 0;
let height = 0;
let columns = 0;
let cols: Column[] = [];
let rafId: number | null = null;
let lastTime = performance.now();
let resizeTimer: number | null = null;
const setupCanvas = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
// Measure the element's own CSS box (width:100% + fixed inset:0) rather
// than window.innerWidth — innerWidth includes the scrollbar gutter and
// would over-size the bitmap on scrollbar-present layouts.
width = canvas.clientWidth || window.innerWidth;
height = canvas.clientHeight || window.innerHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = `${COL_WIDTH}px monospace`;
ctx.textBaseline = 'top';
columns = Math.max(1, Math.floor((width / COL_WIDTH) * density));
cols = new Array(columns);
const rowsApprox = Math.ceil(height / COL_WIDTH) + 2;
for (let i = 0; i < columns; i++) {
cols[i] = {
y: -Math.floor(Math.random() * rowsApprox),
step: 0.5 + Math.random() * 0.8,
};
}
// Keep the canvas transparent so it overlays the UI without dimming it.
ctx.clearRect(0, 0, width, height);
};
const draw = () => {
// Fade older chars toward transparent (destination-out) instead of
// filling with a solid trail colour — keeps the canvas alpha-composited.
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
ctx.fillRect(0, 0, width, height);
ctx.globalCompositeOperation = 'source-over';
const effSpeed = Math.max(0.1, speed);
const colPitch = columns > 0 ? width / columns : COL_WIDTH;
for (let i = 0; i < columns; i++) {
const c = cols[i];
if (!c) continue;
const x = Math.floor(i * colPitch);
const yPx = Math.floor(c.y) * COL_WIDTH;
if (c.y >= 0) {
ctx.fillStyle = HEAD_COLOR;
ctx.fillText(randChar(), x, yPx);
if (c.y >= 1) {
ctx.fillStyle = SECONDARY_COLOR;
ctx.fillText(randChar(), x, yPx - COL_WIDTH);
}
if (c.y >= 2) {
ctx.fillStyle = DEEP_COLOR;
ctx.fillText(randChar(), x, yPx - COL_WIDTH * 2);
}
}
c.y += c.step * effSpeed;
const passedBottom = c.y * COL_WIDTH > height;
if (passedBottom && (Math.random() < 0.025 || c.y * COL_WIDTH > height + COL_WIDTH * 20)) {
c.y = -1 - Math.floor(Math.random() * 8);
c.step = 0.5 + Math.random() * 0.8;
}
}
};
const loop = (now: number) => {
rafId = window.requestAnimationFrame(loop);
if (document.hidden) return;
const elapsed = now - lastTime;
if (elapsed < FRAME_BUDGET) return;
lastTime = now - (elapsed % FRAME_BUDGET);
draw();
};
const start = () => {
if (rafId != null) return;
lastTime = performance.now();
rafId = window.requestAnimationFrame(loop);
};
const stop = () => {
if (rafId != null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
};
const onVisibility = () => {
if (document.hidden) stop();
else start();
};
const onResize = () => {
if (resizeTimer) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(setupCanvas, 150);
};
setupCanvas();
if (!document.hidden) start();
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', onResize);
return () => {
stop();
if (resizeTimer) window.clearTimeout(resizeTimer);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', onResize);
};
}, [enabled, density, speed]);
if (!enabled) return null;
return (
<canvas
ref={canvasRef}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
width: '100%',
height: '100%',
zIndex: 0,
pointerEvents: 'none',
display: 'block',
opacity,
}}
/>
);
}