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.
190 lines
5.8 KiB
TypeScript
190 lines
5.8 KiB
TypeScript
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); // A–Z
|
||
for (let c = 0x30; c <= 0x39; c++) s += String.fromCharCode(c); // 0–9
|
||
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,
|
||
}}
|
||
/>
|
||
);
|
||
}
|