Files
com.gregkilmer.www/src/components/mystic/CircuitField.tsx
T
Claude 35c09f2854 Brand audit fixes: domain casing, radii, violet, seal size, page seals
- Store domain shown as Store.GregKilmer.com (no uppercase transform)
- Cards and panels 10px corners; buttons pill-shaped
- Canvas star/circuit violet switched to brand violet #6A2FE0
- Nav, store nav and footer seal at the brand's 72px minimum (nav 80px tall)
- Faint seals on contact, store cards, deck boxes, order and professional

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 02:45:49 +00:00

254 lines
8.1 KiB
TypeScript

/**
* 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<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 = '#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.
<div
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
aria-hidden="true"
>
{/* Base gradient — IDENTICAL colors to AnimatedBackground's night sky so
the morph between star field and circuit field is color-consistent.
No nebula radials here (the user asked to keep the circuit style only);
just the clean deep-blue sky base. */}
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(180deg, #080c14 0%, #0b111c 32%, #0e1522 58%, #131c2b 82%, #1a2a3a 100%)',
}}
/>
{/* Circuit traces + nodes + signal pulses. */}
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
</div>
);
}