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
@@ -0,0 +1,255 @@
/**
* ConstellationField.tsx
* ---------------------------------------------------------------------------
* A calmer, reusable star-field island for SECTION backgrounds (everything that
* isn't the hero). Compared to AnimatedBackground it is:
* - sparser and dimmer (so foreground content stays readable),
* - slow-blink only (no shooting stars / nebula),
* - the whole field rotates together very slowly, reading as a slice of sky
* turning as the planet rotates.
*
* Carries a `view-transition-name` (default "sky") so that when the browser
* morphs between pages via Astro's <ClientRouter />, the hero's star field and
* a section's constellation field cross-fade/translate as one continuous sky.
* On the Professional page we render CircuitField instead, which shares the
* same transition name — so constellations visibly MORPH into circuit traces.
*
* Honors prefers-reduced-motion (renders a static field) and pauses when the
* section is off-screen.
*/
import { useEffect, useRef } from 'react';
interface Props {
/** Density multiplier. 1 = default; lower for quieter sections. */
density?: number;
/** Tailwind/className passthrough for the root. */
className?: string;
/**
* Opt-in View Transition name. DEFAULT IS UNSET so the field carries NO
* view-transition-name. The View Transitions API requires the name to be
* UNIQUE within a single page snapshot; assigning "sky" to every instance
* caused "Unexpected duplicate view-transition-name: sky" + transition
* aborts. The page should set this on AT MOST ONE field per page (typically
* the hero), and it must match the corresponding element on the destination
* page for the morph (e.g. constellations -> circuitry) to play.
*
* Set via the `data-sky` attribute + the `.vt-sky` CSS class (see global.css)
* rather than React's inline style, which avoids an SSR/hydration mismatch.
*/
transitionName?: 'sky';
/** Star color theme. 'gold' leans warm, 'violet' leans mystic. */
tint?: 'gold' | 'violet' | 'teal';
}
export default function ConstellationField({
density = 1,
className = '',
transitionName,
tint = 'gold',
}: 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 angle = 0;
let t0 = performance.now();
interface Star {
rx: number;
ry: number;
r: number;
tw: number;
tws: number;
baseA: number;
anchor: boolean;
}
let stars: Star[] = [];
const gold = '#e8b54a';
const violet = '#8b7bd8';
const teal = '#6fb6ad';
const white = '#f4f1e8';
const accent = tint === 'violet' ? violet : tint === 'teal' ? teal : gold;
// One full rotation every ~10 minutes — barely perceptible, "planet turning".
const ROT_SPEED = (Math.PI * 2) / (10 * 60);
const LINK_DIST = 175;
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
function resize() {
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);
const cx = width / 2;
const cy = height / 2;
const maxR = Math.sqrt(cx * cx + cy * cy) + 40;
// Tuned for ~40% constellation coverage. One star per ~6500 px^2, scaled
// by density; ~14% anchors for a rich constellation backbone.
const count = Math.min(
260,
Math.floor(((Math.PI * maxR * maxR) / 6500) * density),
);
stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 7 === 0;
const rr = maxR * Math.sqrt(Math.random());
const a = Math.random() * Math.PI * 2;
return {
rx: cx + rr * Math.cos(a),
ry: cy + rr * Math.sin(a),
r: anchor ? 1.1 + Math.random() * 1.0 : 0.3 + Math.random() * 0.9,
tw: Math.random() * Math.PI * 2,
tws: 0.1 + Math.random() * 0.25,
baseA: anchor ? 0.4 + Math.random() * 0.3 : 0.1 + Math.random() * 0.3,
anchor,
};
});
}
function drawStatic() {
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
for (const s of stars) {
ctx.beginPath();
ctx.fillStyle = s.anchor ? accent : white;
ctx.globalAlpha = s.baseA;
ctx.arc(s.rx, s.ry, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
void cx;
void cy;
}
function frame(now: number) {
if (!ctx) return;
const dt = Math.min(0.05, (now - t0) / 1000);
t0 = now;
angle += ROT_SPEED * dt;
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
const project = (rx: number, ry: number): [number, number] => [
cx + (rx - cx) * cosA - (ry - cy) * sinA,
cy + (rx - cx) * sinA + (ry - cy) * cosA,
];
const proj = stars.map((s) => {
const [x, y] = project(s.rx, s.ry);
return { s, x, y };
});
// Constellation lines between near anchors.
ctx.lineWidth = 0.5;
for (let i = 0; i < proj.length; i++) {
const A = proj[i];
if (!A.s.anchor) continue;
for (let j = i + 1; j < proj.length; j++) {
const B = proj[j];
if (!B.s.anchor) continue;
const ddx = A.x - B.x;
const ddy = A.y - B.y;
const d2 = ddx * ddx + ddy * ddy;
if (d2 > LINK_DIST_SQ) continue;
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
const pulse = 0.5 + 0.5 * Math.sin(now / 1600 + (i + j) * 0.7);
const alpha = proximity * 0.24 * (0.55 + 0.45 * pulse);
ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(A.x, A.y);
ctx.lineTo(B.x, B.y);
ctx.stroke();
}
}
// Slow-blink stars.
for (const p of proj) {
const s = p.s;
s.tw += s.tws * dt;
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
const blink = 0.55 + 0.45 * Math.sin(s.tw);
const alpha = Math.max(0, Math.min(1, s.baseA * blink));
ctx.beginPath();
ctx.fillStyle = s.anchor ? accent : white;
ctx.globalAlpha = alpha;
if (s.anchor) {
ctx.shadowBlur = 6;
ctx.shadowColor = accent;
} else {
ctx.shadowBlur = 0;
}
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
raf = requestAnimationFrame(frame);
}
resize();
if (reduce) {
drawStatic();
} else {
t0 = performance.now();
raf = requestAnimationFrame(frame);
}
const onResize = () => {
resize();
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();
};
}, [density, tint]);
// Apply the view-transition-name via a CSS class (global.css `.vt-sky`) rather
// than React's inline style: avoids an SSR/hydration attribute mismatch and
// keeps the name off by default (only one field per page should opt in).
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"
/>
);
}