mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
35c09f2854
- 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
247 lines
7.8 KiB
TypeScript
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"
|
|
/>
|
|
);
|
|
}
|