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,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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user