mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
General Update
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user