import { useEffect, useRef } from 'react'; /* NeonField — the BooCode Override animated background. DISTINCT from the matrix rain glyph engine: a "neon grid horizon". A perspective floor grid recedes to a vanishing point at a high horizon, scrolling toward the viewer; a slow brightness pulse travels up the rows; sparse data-motes drift through the open "sky" above the horizon. Cyan grid, magenta vanishing-point bloom, violet accents — boolab's booops neon palette. This canvas is NET-NEW (boolab's neon was CSS, not a canvas), so it is the riskier of the two fields. It is kept deliberately CHEAP and LEGIBLE: - low primitive count: ROWS+COLS strokes + a handful of motes per frame; - NO per-frame shadowBlur (glow comes from a wide+thin double-stroke and the static CSS bloom layer in boocode-override-fx.css); - a full clearRect each frame (a deterministic redraw, no trail buffer); - dpr capped at 2, throttled to ~30fps, paused on visibilitychange; - the canvas is rendered at < 1 opacity so the field's brightest pixel can never lift the effective panel background enough to break AA text. Mounted by ThemeFx only when the bc-anim-on gate is on (toggle ON *and* not prefers-reduced-motion). */ const TARGET_FPS = 30; const FRAME_BUDGET = 1000 / TARGET_FPS; const ROWS = 18; // horizontal floor lines const COLS = 17; // vertical lines (odd → one passes through the vanishing point) const HORIZON_FRAC = 0.42; // horizon height from the top // Warm Classic palette (recoloured from the original neon). Names kept for // minimal churn: CYAN now holds amber, MAGENTA orange, VIOLET rust. const CYAN = '251, 191, 36'; // amber #fbbf24 const MAGENTA = '249, 115, 22'; // orange #f97316 const VIOLET = '194, 65, 12'; // rust #c2410c interface Mote { x: number; // 0..1 of width y: number; // 0..1 of height (above the horizon) vx: number; vy: number; r: number; hue: string; phase: number; } interface Props { enabled?: boolean; /* Global field dimmer. Caps how much the field can lift any panel background, which is the structural AA guarantee — keep ≤ 0.6. */ opacity?: number; /* Rows-per-second the grid scrolls toward the viewer. */ speed?: number; } export function NeonField({ enabled = true, opacity = 0.55, speed = 0.18 }: Props) { const canvasRef = useRef(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 horizonY = 0; let cx = 0; // vanishing-point x (centre) let motes: Mote[] = []; let scroll = 0; // grid scroll phase, fractional rows let wave = 0; // travelling brightness-pulse phase 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; horizonY = Math.round(height * HORIZON_FRAC); cx = width / 2; canvas.width = Math.max(1, Math.floor(width * dpr)); canvas.height = Math.max(1, Math.floor(height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Sky motes: count scales gently with viewport area, hard-capped low. const count = Math.min(16, Math.max(6, Math.round((width * height) / 90000))); const hues = [MAGENTA, CYAN, VIOLET]; motes = new Array(count); for (let i = 0; i < count; i++) { motes[i] = { x: Math.random(), y: Math.random(), vx: (Math.random() - 0.5) * 0.00006, vy: -(0.00004 + Math.random() * 0.00010), // drift upward r: 0.8 + Math.random() * 1.6, hue: hues[i % hues.length] ?? CYAN, phase: Math.random() * Math.PI * 2, }; } }; // Perspective Y for a floor row at fractional depth d (0 = horizon, 1 = // bottom edge). Quadratic bunches rows toward the horizon for a perspective // feel without trig. const rowY = (d: number): number => horizonY + (height - horizonY) * d * d; const drawGrid = () => { const floorH = height - horizonY; // ── Vertical lines: fan out from the vanishing point to the bottom edge. // Static (the motion lives in the horizontal rows), so a single thin // low-alpha stroke each. Spread is widened below the bottom so the outer // lines leave the frame at a steep, dramatic angle. ctx.lineWidth = 1; for (let i = 0; i < COLS; i++) { const t = COLS > 1 ? i / (COLS - 1) : 0.5; // 0..1 const spread = (t - 0.5) * 2; // -1..1 const xBottom = cx + spread * width * 0.95; // Dimmer toward the centre line, brighter at the dramatic outer rake. const a = 0.05 + Math.abs(spread) * 0.09; ctx.strokeStyle = `rgba(${CYAN}, ${a})`; ctx.beginPath(); ctx.moveTo(cx, horizonY); ctx.lineTo(xBottom, height); ctx.stroke(); } // ── Horizontal rows: scroll toward the viewer. Each row gets a wide faint // pass (the glow) and a thin bright pass (the core) — a cheap neon bloom // with no shadowBlur. A travelling sine pulse rides up the rows. for (let i = 0; i < ROWS; i++) { let d = (i + scroll) % ROWS / ROWS; // 0..1, wraps as it scrolls if (d <= 0) d += 1 / ROWS; const y = rowY(d); if (y <= horizonY || y > height + 1) continue; // Depth fade: near rows (large d) are brighter than far rows. const depth = 0.18 + d * 0.55; // Travelling brightness pulse along the floor. const pulse = 0.5 + 0.5 * Math.sin((d - wave) * Math.PI * 2); const a = Math.min(0.34, depth * (0.62 + pulse * 0.5)); // Width of the row segment narrows toward the horizon (perspective). const halfW = width * (0.5 + d * 0.5); // Glow pass (wide, faint, violet-shifted). ctx.lineWidth = 2.4; ctx.strokeStyle = `rgba(${VIOLET}, ${a * 0.4})`; ctx.beginPath(); ctx.moveTo(cx - halfW, y); ctx.lineTo(cx + halfW, y); ctx.stroke(); // Core pass (thin, bright, cyan). ctx.lineWidth = 1; ctx.strokeStyle = `rgba(${CYAN}, ${a})`; ctx.beginPath(); ctx.moveTo(cx - halfW, y); ctx.lineTo(cx + halfW, y); ctx.stroke(); } // ── Horizon line + vanishing-point magenta bloom. The horizon is the // theme's signature glow band; kept low-alpha and wide so it reads as // bloom, not a hard bright bar behind text. const grad = ctx.createLinearGradient(0, horizonY - floorH * 0.06, 0, horizonY + 2); grad.addColorStop(0, `rgba(${MAGENTA}, 0)`); grad.addColorStop(1, `rgba(${MAGENTA}, 0.10)`); ctx.fillStyle = grad; ctx.fillRect(0, horizonY - floorH * 0.06, width, floorH * 0.06 + 2); ctx.lineWidth = 1; ctx.strokeStyle = `rgba(${MAGENTA}, 0.22)`; ctx.beginPath(); ctx.moveTo(0, horizonY); ctx.lineTo(width, horizonY); ctx.stroke(); // Radial magenta pool at the vanishing point. const vp = ctx.createRadialGradient(cx, horizonY, 0, cx, horizonY, width * 0.22); vp.addColorStop(0, `rgba(${MAGENTA}, 0.16)`); vp.addColorStop(1, `rgba(${MAGENTA}, 0)`); ctx.fillStyle = vp; ctx.fillRect(0, Math.max(0, horizonY - width * 0.22), width, width * 0.44); }; const drawMotes = (dt: number) => { for (let i = 0; i < motes.length; i++) { const m = motes[i]; if (!m) continue; m.x += m.vx * dt; m.y += m.vy * dt; m.phase += dt * 0.0014; // Wrap within the sky band (above the horizon). if (m.y < -0.02) { m.y = 1; m.x = Math.random(); } if (m.x < -0.02) m.x = 1.02; else if (m.x > 1.02) m.x = -0.02; const px = m.x * width; const py = m.y * horizonY; // confine to the sky region const twinkle = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(m.phase)); const a = Math.min(0.5, twinkle * 0.5); const g = ctx.createRadialGradient(px, py, 0, px, py, m.r * 3.2); g.addColorStop(0, `rgba(${m.hue}, ${a})`); g.addColorStop(1, `rgba(${m.hue}, 0)`); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(px, py, m.r * 3.2, 0, Math.PI * 2); ctx.fill(); } }; const draw = (dt: number) => { ctx.clearRect(0, 0, width, height); const effSpeed = Math.max(0, speed); scroll = (scroll + (dt / 1000) * effSpeed * ROWS) % ROWS; wave = (wave + dt / 1000 / 6) % 1; // one pulse sweep every ~6s drawGrid(); drawMotes(dt); }; 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(Math.min(elapsed, 100)); // clamp dt so a backgrounded tab can't jump }; 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, speed]); if (!enabled) return null; return (