/** * CircuitField.tsx * --------------------------------------------------------------------------- * The Professional page's counterpart to ConstellationField. It draws a faint * circuit-board pattern — orthogonal traces connecting node pads, with a slow * "signal pulse" traveling the traces — instead of stars and constellations. * * Crucially it carries the SAME `view-transition-name` ("sky") as the star * fields on other pages. So when you navigate from, say, the Home hero * (ConstellationField / AnimatedBackground) to the Professional page, the * browser's View Transition morphs the star field into the circuit field as * one element — constellations visibly transform into circuitry. * * Honors prefers-reduced-motion (renders a static board) and pauses off-screen. */ import { useEffect, useRef } from 'react'; interface Props { /** * Tailwind/className passthrough. The .astro call site includes `vt-sky` when * this element should carry the view-transition morph. Not computed here to * avoid a server/client hydration mismatch (see AnimatedBackground). */ className?: string; } interface Trace { x1: number; y1: number; x2: number; y2: number; } interface Node { x: number; y: number; } export default function CircuitField({ className = '', }: Props) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const dpr = Math.min(window.devicePixelRatio || 1, 2); let raf = 0; let width = 0; let height = 0; let t0 = performance.now(); let traces: Trace[] = []; let nodes: Node[] = []; const gold = '#c9a24a'; const violet = '#6a2fe0'; const teal = '#2bc2b7'; function build() { if (!canvas || !ctx) return; const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height; canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Build a clean, ALIGNED orthogonal circuit on a grid: pads sit exactly at // grid intersections (no jitter) and traces connect them in straight // right-angle lines. Reads as an orderly schematic / PCB layout. const step = 64; const cols = Math.ceil(width / step) + 1; const rows = Math.ceil(height / step) + 1; const present: boolean[][] = []; nodes = []; for (let r = 0; r < rows; r++) { present[r] = []; for (let c = 0; c < cols; c++) { // ~62% of intersections get a pad. present[r][c] = Math.random() < 0.62; if (present[r][c]) { // Snapped to the grid — no jitter, so traces land flush on pads. nodes.push({ x: c * step, y: r * step }); } } } traces = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (!present[r][c]) continue; const here = { x: c * step, y: r * step }; // Right neighbor. if (c + 1 < cols && present[r][c + 1] && Math.random() < 0.7) { traces.push({ x1: here.x, y1: here.y, x2: (c + 1) * step, y2: r * step }); } // Down neighbor. if (r + 1 < rows && present[r + 1][c] && Math.random() < 0.7) { traces.push({ x1: here.x, y1: here.y, x2: c * step, y2: (r + 1) * step }); } } } } function frame(now: number) { if (!ctx) return; const dt = Math.min(0.05, (now - t0) / 1000); t0 = now; ctx.clearRect(0, 0, width, height); // Traces. A signal pulse brightens a moving window along each trace, with // traces staggered so pulses are distributed across the board over time. for (let i = 0; i < traces.length; i++) { const t = traces[i]; // Base trace — clearly visible violet conductor line. ctx.strokeStyle = 'rgba(43, 194, 183, 0.55)'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(t.x1, t.y1); ctx.lineTo(t.x2, t.y2); ctx.stroke(); // Pulse: a bright gold "signal" traveling the trace. position 0..1, // looping, with traces staggered so pulses spread across the board. const period = 6 + (i % 7); const phase = ((now / 1000) % period) / period; const px = t.x1 + (t.x2 - t.x1) * phase; const py = t.y1 + (t.y2 - t.y1) * phase; ctx.strokeStyle = gold; ctx.globalAlpha = 0.95; ctx.lineWidth = 2.2; ctx.shadowBlur = 10; ctx.shadowColor = gold; ctx.beginPath(); ctx.moveTo(px - (t.x2 - t.x1) * 0.08, py - (t.y2 - t.y1) * 0.08); ctx.lineTo(px, py); ctx.stroke(); // Bright pulse head dot. ctx.globalAlpha = 1; ctx.fillStyle = '#fbf3dc'; ctx.beginPath(); ctx.arc(px, py, 1.8, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; ctx.shadowBlur = 0; } // Node pads — solid teal fill with a brighter violet ring (solder pad). for (const n of nodes) { ctx.beginPath(); ctx.fillStyle = teal; ctx.globalAlpha = 0.95; ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 0.85; ctx.strokeStyle = violet; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.arc(n.x, n.y, 4.5, 0, Math.PI * 2); ctx.stroke(); } ctx.globalAlpha = 1; void dt; raf = requestAnimationFrame(frame); } function drawStatic() { if (!ctx) return; ctx.clearRect(0, 0, width, height); for (const t of traces) { ctx.strokeStyle = 'rgba(43, 194, 183, 0.5)'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(t.x1, t.y1); ctx.lineTo(t.x2, t.y2); ctx.stroke(); } for (const n of nodes) { ctx.fillStyle = teal; ctx.globalAlpha = 0.95; ctx.beginPath(); ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } build(); if (reduce) { drawStatic(); } else { t0 = performance.now(); raf = requestAnimationFrame(frame); } const onResize = () => { build(); if (reduce) drawStatic(); }; window.addEventListener('resize', onResize); const io = new IntersectionObserver((entries) => { if (reduce) return; for (const e of entries) { if (e.isIntersecting) { if (!raf) { t0 = performance.now(); raf = requestAnimationFrame(frame); } } else { cancelAnimationFrame(raf); raf = 0; } } }); io.observe(canvas); return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', onResize); io.disconnect(); }; }, []); return ( // Root container mirrors AnimatedBackground's structure (gradient layers + // canvas all INSIDE the named element) so the view-transition morph from a // star hero into this circuit field is element-to-element equivalent and // nothing flashes separately. The `vt-sky`/opacity classes are passed via // className from the .astro call site.