Files
com.gregkilmer.www/src/components/mystic/ConstellationField.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

247 lines
7.8 KiB
TypeScript

/**
* 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. The .astro call site is responsible for
* including `vt-sky` when this element should carry the view-transition
* morph. We do NOT compute the transition class here: conditional className
* interpolation triggers a server/client hydration attribute mismatch under
* Astro's client:idle. A static, fully-formed string keeps SSR and hydration
* byte-identical.
*/
className?: string;
/** Star color theme. 'gold' leans warm, 'violet' leans mystic. */
tint?: 'gold' | 'violet' | 'teal';
}
export default function ConstellationField({
density = 1,
className = '',
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 = '#c9a24a';
const violet = '#6a2fe0';
const teal = '#2bc2b7';
const white = '#f4f2ed';
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);
// Pivot = top-right corner. The disk is centered there so the field
// rotates around that corner; maxR reaches the far (bottom-left) corner.
const cx = width;
const cy = 0;
const maxR = Math.sqrt(cx * cx + height * height) + 40;
// Ultra-dense: ~5x. One star per ~300 px^2, scaled by density; ~17%
// anchors for a very dense constellation backbone.
const count = Math.min(
3000,
Math.floor(((Math.PI * maxR * maxR) / 300) * 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);
// Pivot = top-right corner (matches the disk center used in resize()).
const cx = width;
const cy = 0;
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(201, 162, 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]);
return (
<canvas
ref={canvasRef}
className={`pointer-events-none absolute inset-0 h-full w-full ${className}`}
aria-hidden="true"
/>
);
}