mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
9ecd4d0e59
- Icons: Patiun stroke icon set (13 approved + 73 new) in src/lib/brand-icons.ts; Icon.astro and a React BrandIcon resolve mdi names to brand icons, keeping mdi only for third-party logos. Inline island icons (mobile menu, carousel, form success, board game shelf, product tile) switched too. - Motifs: layered seal turns in the hero corner; astrolabe on the Professional hero; Starlight recolors of the transmutation seal and tria prima array behind light sections (outlined ring text, no fonts needed). Removed the generic sacred-geometry and astrolabe drawings. - Gold accents: image frames and corner stars, quote bars, dividers (new star-line divider), nav group divider, scrolled nav rule, footer rule. - Nav and footer use the Patiun seal; nav tagline back to "Developer. Maker. Storyteller."; "Web Development · a Patiun LLC company" moved to the footer copyright line. - Mobile menu: Midnight drawer, visible close button. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
/**
|
||
* AnimatedBackground.tsx
|
||
* ---------------------------------------------------------------------------
|
||
* The hero background island. Layers:
|
||
*
|
||
* 1. A CSS-driven night-sky gradient (deep blue, darker at top → lighter near
|
||
* horizon) plus a diagonal Milky Way galaxy band and teal/violet nebula
|
||
* clouds. GPU-friendly, animated via `animate-gradient-shift` keyframes.
|
||
* 2. The Patiun layered magic seal, faint and slowly turning in the top-left
|
||
* corner (the surface's one focal motif).
|
||
* 3. An optional <canvas> star field: a dense field of slowly-twinkling stars
|
||
* with thin constellation lines between near neighbors, plus the occasional
|
||
* shooting star. The WHOLE field rotates together very slowly around the
|
||
* field center — reading as a slice of sky turning as the planet rotates.
|
||
* We render at devicePixelRatio and cap the count for perf. It pauses when
|
||
* off-screen and is fully skipped when prefers-reduced-motion is set.
|
||
*
|
||
* The component is presentational only; it reads CSS variables for colors so it
|
||
* adapts to the section surface tone automatically.
|
||
*/
|
||
import { useEffect, useRef } from 'react';
|
||
|
||
interface Props {
|
||
/** Show the canvas star field. Off on interest pages for a calmer feel. */
|
||
particles?: boolean;
|
||
/**
|
||
* Tailwind/className passthrough for the root. The CALL SITE (in .astro) is
|
||
* responsible for including `vt-sky` here when this element should carry the
|
||
* `view-transition-name: sky` morph. We intentionally do NOT compute the
|
||
* transition class inside this React component: any conditional className
|
||
* interpolation causes a server/client hydration attribute mismatch under
|
||
* Astro's client:idle. Passing a static, fully-formed string guarantees the
|
||
* server-rendered and hydrated class attributes are byte-identical.
|
||
*/
|
||
className?: string;
|
||
}
|
||
|
||
export default function AnimatedBackground({
|
||
particles = true,
|
||
className = '',
|
||
}: Props) {
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
|
||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
if (reduce || !particles) return;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
|
||
let raf = 0;
|
||
let width = 0;
|
||
let height = 0;
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
|
||
interface Star {
|
||
// Position in sky coordinates (pre-rotation). The whole field rotates.
|
||
rx: number; // offset from field center (cx, cy)
|
||
ry: number;
|
||
r: number; // base radius
|
||
tw: number; // twinkle phase
|
||
tws: number; // twinkle speed (slow)
|
||
baseA: number; // base alpha
|
||
anchor: boolean; // brighter "anchor" star used for constellations
|
||
hue: 'white' | 'gold' | 'violet' | 'teal';
|
||
}
|
||
|
||
interface Shooter {
|
||
x: number;
|
||
y: number;
|
||
len: number;
|
||
speed: number;
|
||
angle: number;
|
||
life: number;
|
||
}
|
||
|
||
let stars: Star[] = [];
|
||
let shooters: Shooter[] = [];
|
||
let nextShooterAt = 0;
|
||
let t0 = performance.now();
|
||
let angle = 0; // current field rotation (radians)
|
||
|
||
// Resolve star colors from CSS variables so the field matches the theme.
|
||
const gold =
|
||
getComputedStyle(document.documentElement).getPropertyValue('--color-gold').trim() ||
|
||
'#c9a24a';
|
||
// Nebula Violet is background light only, so violet stars use a lifted
|
||
// tint of it; teal stars use the on-dark teal so they read at dot size.
|
||
const violet = '#8a63ee';
|
||
const teal =
|
||
getComputedStyle(document.documentElement).getPropertyValue('--color-teal-bright').trim() ||
|
||
'#2bc2b7';
|
||
const white =
|
||
getComputedStyle(document.documentElement).getPropertyValue('--color-starlight').trim() ||
|
||
'#f4f2ed';
|
||
|
||
const hueColor = (h: Star['hue']) => (h === 'gold' ? gold : h === 'violet' ? violet : h === 'teal' ? teal : white);
|
||
|
||
// Max distance for two stars to be connected by a constellation line.
|
||
const LINK_DIST = 168;
|
||
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
||
|
||
// Rotation speed: a full turn takes ~6 minutes, so the drift is felt but
|
||
// never distracting — "as the planet rotates".
|
||
const ROT_SPEED = (Math.PI * 2) / (6 * 60); // radians per second
|
||
|
||
function pickHue(): Star['hue'] {
|
||
const n = Math.random();
|
||
// Mostly white stars, with occasional colored ones for richness.
|
||
if (n < 0.7) return 'white';
|
||
if (n < 0.85) return 'gold';
|
||
if (n < 0.94) return 'violet';
|
||
return 'teal';
|
||
}
|
||
|
||
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);
|
||
|
||
// Dense field tuned for ~40% constellation coverage on any slice. Stars
|
||
// live in a sky disk large enough that rotation never reveals an empty
|
||
// edge. The disk is centered on the PIVOT (the top-right corner) so the
|
||
// whole field rotates around that corner like a star chart turning about
|
||
// its corner mount; maxR reaches the far (bottom-left) corner.
|
||
const cx = width;
|
||
const cy = 0;
|
||
const maxR = Math.sqrt(cx * cx + height * height) + 40;
|
||
|
||
// Ultra-dense starfield: ~5x the previous density for a richly packed sky.
|
||
// One star per ~220 px^2 of the disk, capped for perf (~17% anchors).
|
||
const diskArea = Math.PI * maxR * maxR;
|
||
const count = Math.min(3000, Math.floor(diskArea / 220));
|
||
stars = Array.from({ length: count }, (_, i) => {
|
||
const anchor = i % 6 === 0; // ~17% anchors -> very dense constellation backbone
|
||
// Uniform-ish distribution across the disk via sqrt on radius.
|
||
const rr = maxR * Math.sqrt(Math.random());
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = anchor ? 1.4 + Math.random() * 1.4 : 0.35 + Math.random() * 1.1;
|
||
return {
|
||
rx: cx + rr * Math.cos(a),
|
||
ry: cy + rr * Math.sin(a),
|
||
r,
|
||
tw: Math.random() * Math.PI * 2,
|
||
// Slow twinkle: 0.15–0.5 cycles/sec.
|
||
tws: 0.15 + Math.random() * 0.35,
|
||
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.16 + Math.random() * 0.4,
|
||
anchor,
|
||
hue: anchor ? (Math.random() < 0.5 ? 'gold' : 'white') : pickHue(),
|
||
};
|
||
});
|
||
}
|
||
|
||
function spawnShooter(now: number) {
|
||
const startX = Math.random() * width * 0.8;
|
||
const startY = Math.random() * height * 0.5;
|
||
const a = Math.PI * (0.18 + Math.random() * 0.12);
|
||
shooters.push({
|
||
x: startX,
|
||
y: startY,
|
||
len: 90 + Math.random() * 120,
|
||
speed: 5.5 + Math.random() * 3.5,
|
||
angle: a,
|
||
life: 1,
|
||
});
|
||
nextShooterAt = now + 5000 + Math.random() * 8000;
|
||
}
|
||
|
||
function frame(now: number) {
|
||
if (!ctx) return;
|
||
const dt = Math.min(0.05, (now - t0) / 1000);
|
||
t0 = now;
|
||
angle += ROT_SPEED * dt; // whole sky rotates together
|
||
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);
|
||
|
||
// Rotate each sky point about the field center into screen space.
|
||
const project = (rx: number, ry: number): [number, number] => [
|
||
cx + (rx - cx) * cosA - (ry - cy) * sinA,
|
||
cy + (rx - cx) * sinA + (ry - cy) * cosA,
|
||
];
|
||
|
||
// Project every star's sky position into screen space once.
|
||
// Each entry carries the source star + its rotated (x, y).
|
||
const proj = stars.map((s) => {
|
||
const [x, y] = project(s.rx, s.ry);
|
||
return { s, x, y };
|
||
});
|
||
|
||
// 1) Constellation lines (behind stars) — connect near anchor pairs.
|
||
ctx.lineWidth = 0.6;
|
||
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 / 1400 + (i + j) * 0.7);
|
||
const alpha = proximity * 0.22 * (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();
|
||
}
|
||
}
|
||
|
||
// 2) Stars (slow twinkle; positions already rotated).
|
||
for (const p of proj) {
|
||
const s = p.s;
|
||
s.tw += s.tws * dt;
|
||
// Cull stars rotated off-screen for perf.
|
||
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
|
||
|
||
const twinkle = 0.6 + 0.4 * Math.sin(s.tw);
|
||
const alpha = Math.max(0, Math.min(1, s.baseA * twinkle));
|
||
const color = hueColor(s.hue);
|
||
|
||
if (s.anchor) {
|
||
ctx.beginPath();
|
||
ctx.fillStyle = color;
|
||
ctx.globalAlpha = alpha * 0.5;
|
||
ctx.shadowBlur = 10;
|
||
ctx.shadowColor = color;
|
||
ctx.arc(p.x, p.y, s.r * 1.8, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
ctx.beginPath();
|
||
ctx.fillStyle = color;
|
||
ctx.globalAlpha = alpha;
|
||
ctx.shadowBlur = s.anchor ? 6 : 3;
|
||
ctx.shadowColor = color;
|
||
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.shadowBlur = 0;
|
||
|
||
// 3) Shooting stars (screen-space; not part of the rotating sky).
|
||
if (now >= nextShooterAt) spawnShooter(now);
|
||
for (let i = shooters.length - 1; i >= 0; i--) {
|
||
const sh = shooters[i];
|
||
sh.x += Math.cos(sh.angle) * sh.speed;
|
||
sh.y += Math.sin(sh.angle) * sh.speed;
|
||
sh.life -= dt * 0.5;
|
||
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
|
||
shooters.splice(i, 1);
|
||
continue;
|
||
}
|
||
const tailX = sh.x - Math.cos(sh.angle) * sh.len;
|
||
const tailY = sh.y - Math.sin(sh.angle) * sh.len;
|
||
const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);
|
||
const headA = Math.max(0, Math.min(1, sh.life));
|
||
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`);
|
||
grad.addColorStop(0.4, `rgba(201, 162, 74, ${(headA * 0.6).toFixed(3)})`);
|
||
grad.addColorStop(1, 'rgba(201, 162, 74, 0)');
|
||
ctx.strokeStyle = grad;
|
||
ctx.lineWidth = 1.6;
|
||
ctx.beginPath();
|
||
ctx.moveTo(sh.x, sh.y);
|
||
ctx.lineTo(tailX, tailY);
|
||
ctx.stroke();
|
||
|
||
ctx.beginPath();
|
||
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
|
||
ctx.shadowBlur = 8;
|
||
ctx.shadowColor = gold;
|
||
ctx.arc(sh.x, sh.y, 1.4, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
raf = requestAnimationFrame(frame);
|
||
}
|
||
|
||
resize();
|
||
t0 = performance.now();
|
||
nextShooterAt = t0 + 3000 + Math.random() * 4000;
|
||
raf = requestAnimationFrame(frame);
|
||
const onResize = () => resize();
|
||
window.addEventListener('resize', onResize);
|
||
|
||
// Pause when off-screen to save cycles.
|
||
const io = new IntersectionObserver((entries) => {
|
||
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();
|
||
};
|
||
}, [particles]);
|
||
|
||
return (
|
||
<div
|
||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
|
||
aria-hidden="true"
|
||
>
|
||
{/* Layer 1: night-sky gradient — deep blue, darker at top, lighter toward
|
||
a low horizon. Evokes a true clear night sky rather than a flat color. */}
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
background:
|
||
'linear-gradient(180deg, #080c14 0%, #0b111c 32%, #0e1522 58%, #131c2b 82%, #1a2a3a 100%)',
|
||
}}
|
||
/>
|
||
{/* Milky Way galaxy band — a soft diagonal luminous streak with a denser
|
||
core, blending teal + violet + faint gold. Sits over the base sky. */}
|
||
<div
|
||
className="animate-gradient-shift absolute inset-0 opacity-90"
|
||
style={{
|
||
backgroundImage: `
|
||
radial-gradient(38% 70% at 68% 38%, rgba(20, 163, 154, 0.22), transparent 62%),
|
||
radial-gradient(30% 55% at 72% 44%, rgba(106, 47, 224, 0.26), transparent 60%),
|
||
radial-gradient(22% 40% at 76% 50%, rgba(201, 162, 74, 0.12), transparent 60%),
|
||
linear-gradient(115deg, transparent 30%, rgba(236, 233, 226, 0.06) 45%, rgba(236, 233, 226, 0.09) 52%, rgba(236, 233, 226, 0.05) 60%, transparent 72%)
|
||
`,
|
||
backgroundSize: '200% 200%',
|
||
}}
|
||
/>
|
||
{/* Nebula clouds near the corners for color depth. */}
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundImage: `
|
||
radial-gradient(45% 55% at 12% 12%, rgba(106, 47, 224, 0.18), transparent 60%),
|
||
radial-gradient(40% 50% at 88% 88%, rgba(20, 163, 154, 0.16), transparent 62%)
|
||
`,
|
||
}}
|
||
/>
|
||
{/* The layered magic seal, turning slowly in the top-left corner: the
|
||
one focal motif on the night sky. Its ring text and symbols are
|
||
outlined to paths (public/motifs/), so no symbol font is needed. */}
|
||
<img
|
||
src="/motifs/layered-seal.svg"
|
||
alt=""
|
||
aria-hidden="true"
|
||
decoding="async"
|
||
draggable={false}
|
||
className="pointer-events-none absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow select-none opacity-[0.2]"
|
||
/>
|
||
|
||
{/* Subtle vignette for text legibility. */}
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
background:
|
||
'radial-gradient(120% 80% at 50% 30%, transparent 40%, rgba(8, 12, 20, 0.7) 100%)',
|
||
}}
|
||
/>
|
||
{/* Canvas star field (skipped under reduced motion). */}
|
||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||
</div>
|
||
);
|
||
}
|