General Update

This commit is contained in:
Greg Kilmer
2026-07-04 10:53:14 -04:00
parent 71bffad507
commit 33fa312645
18 changed files with 921 additions and 346 deletions
+240
View File
@@ -0,0 +1,240 @@
/**
* 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 {
className?: string;
/**
* Opt-in View Transition name. Unset by default. The page should set this on
* AT MOST ONE element per page (the hero field). Applied via the `.vt-sky`
* CSS class to avoid an SSR/hydration mismatch with React's inline style.
*/
transitionName?: 'sky';
}
interface Trace {
x1: number;
y1: number;
x2: number;
y2: number;
}
interface Node {
x: number;
y: number;
}
export default function CircuitField({
className = '',
transitionName,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(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 = '#e8b54a';
const violet = '#8b7bd8';
const teal = '#6fb6ad';
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 an orthogonal circuit on a grid: place nodes at grid intersections
// (with jitter + random omissions), then connect neighbors with right-angle
// traces. This reads as a PCB / schematic, not a star map.
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]) {
const jx = (Math.random() - 0.5) * step * 0.25;
const jy = (Math.random() - 0.5) * step * 0.25;
nodes.push({ x: c * step + jx, y: r * step + jy });
}
}
}
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(168, 158, 220, 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 = '#fff4d6';
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(168, 158, 220, 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();
};
}, []);
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return (
<canvas
ref={canvasRef}
className={`pointer-events-none absolute inset-0 h-full w-full ${skyClass} ${className}`}
aria-hidden="true"
/>
);
}