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:
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* AnimatedBackground.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* The hero background island. Three layers:
|
||||
* The hero background island. Layers:
|
||||
*
|
||||
* 1. A CSS-driven mesh-gradient layer (always on, GPU-friendly, animated via
|
||||
* the `animate-gradient-shift` keyframes in global.css). Tuned to the
|
||||
* night-sky palette: midnight indigo with violet + gold nebular edges.
|
||||
* 2. A faint static SVG ornament layer (astrolabe + sacred geometry) baked
|
||||
* in as absolutely-positioned decorations for esoteric texture.
|
||||
* 3. An optional lightweight <canvas> star field: dozens of twinkling stars
|
||||
* with thin constellation lines drawn between near neighbors, plus the
|
||||
* occasional slow shooting-star streak. 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.
|
||||
* 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. A faint static SVG ornament layer (astrolabe + sacred geometry) for
|
||||
* esoteric texture.
|
||||
* 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.
|
||||
@@ -24,9 +25,21 @@ interface Props {
|
||||
particles?: boolean;
|
||||
/** Tailwind/className passthrough for the root. */
|
||||
className?: string;
|
||||
/**
|
||||
* Opt-in View Transition name. Unset by default. The View Transitions API
|
||||
* requires this name to be UNIQUE within a page snapshot, so the page must
|
||||
* opt in on AT MOST ONE field (typically the hero). Applied via the `.vt-sky`
|
||||
* CSS class rather than React's inline style to avoid an SSR/hydration
|
||||
* attribute mismatch.
|
||||
*/
|
||||
transitionName?: 'sky';
|
||||
}
|
||||
|
||||
export default function AnimatedBackground({ particles = true, className = '' }: Props) {
|
||||
export default function AnimatedBackground({
|
||||
particles = true,
|
||||
className = '',
|
||||
transitionName,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,15 +58,15 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
interface Star {
|
||||
x: number;
|
||||
y: number;
|
||||
// Position in sky coordinates (pre-rotation). The whole field rotates.
|
||||
rx: number; // offset from field center (cx, cy)
|
||||
ry: number;
|
||||
r: number; // base radius
|
||||
vx: number; // slow drift
|
||||
vy: number;
|
||||
tw: number; // twinkle phase
|
||||
tws: number; // twinkle speed
|
||||
tws: number; // twinkle speed (slow)
|
||||
baseA: number; // base alpha
|
||||
anchor: boolean; // brighter "anchor" star used for constellations
|
||||
hue: 'white' | 'gold' | 'violet' | 'teal';
|
||||
}
|
||||
|
||||
interface Shooter {
|
||||
@@ -61,15 +74,15 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
y: number;
|
||||
len: number;
|
||||
speed: number;
|
||||
angle: number; // direction of travel (radians)
|
||||
life: number; // 0..1 remaining
|
||||
maxLife: 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 =
|
||||
@@ -85,10 +98,25 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-cream').trim() ||
|
||||
'#f4f1e8';
|
||||
|
||||
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 = 132;
|
||||
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();
|
||||
@@ -98,97 +126,117 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Cap density: one star per ~9k px^2, max 140. Anchor stars are rarer.
|
||||
const count = Math.min(140, Math.floor((width * height) / 9000));
|
||||
// Dense field tuned for ~40% constellation coverage on any slice. Stars
|
||||
// live in a sky disk larger than the viewport so rotation never reveals
|
||||
// an empty edge. Radius = distance to the far viewport corner.
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const maxR = Math.sqrt(cx * cx + cy * cy) + 40;
|
||||
|
||||
// One star per ~4000 px^2 of the disk, capped for perf (~12% anchors).
|
||||
const diskArea = Math.PI * maxR * maxR;
|
||||
const count = Math.min(420, Math.floor(diskArea / 4000));
|
||||
stars = Array.from({ length: count }, (_, i) => {
|
||||
const anchor = i % 11 === 0; // ~9% anchors -> the brighter backbone stars
|
||||
const r = anchor ? 1.4 + Math.random() * 1.3 : 0.4 + Math.random() * 1.1;
|
||||
const anchor = i % 8 === 0; // ~12% anchors -> denser 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 {
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
rx: cx + rr * Math.cos(a),
|
||||
ry: cy + rr * Math.sin(a),
|
||||
r,
|
||||
vx: (Math.random() - 0.5) * 0.06,
|
||||
vy: (Math.random() - 0.5) * 0.06,
|
||||
tw: Math.random() * Math.PI * 2,
|
||||
tws: 0.4 + Math.random() * 1.1, // twinkle cycles/sec-ish
|
||||
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.18 + Math.random() * 0.4,
|
||||
// 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) {
|
||||
// Diagonal streak starting somewhere in the top half.
|
||||
const startX = Math.random() * width * 0.8;
|
||||
const startY = Math.random() * height * 0.5;
|
||||
const angle = Math.PI * (0.18 + Math.random() * 0.12); // ~32-54deg down-right
|
||||
const speed = 5.5 + Math.random() * 3.5;
|
||||
const a = Math.PI * (0.18 + Math.random() * 0.12);
|
||||
shooters.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
len: 90 + Math.random() * 120,
|
||||
speed,
|
||||
angle,
|
||||
speed: 5.5 + Math.random() * 3.5,
|
||||
angle: a,
|
||||
life: 1,
|
||||
maxLife: 1,
|
||||
});
|
||||
// Next shooter in 5-13s.
|
||||
nextShooterAt = now + 5000 + Math.random() * 8000;
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!ctx) return;
|
||||
const dt = Math.min(0.05, (now - t0) / 1000); // seconds, clamped
|
||||
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);
|
||||
|
||||
// 1) Constellation lines (drawn first, behind stars). Connect near anchors.
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
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 < stars.length; i++) {
|
||||
const a = stars[i];
|
||||
if (!a.anchor) continue; // only anchors backbone the constellations
|
||||
for (let j = i + 1; j < stars.length; j++) {
|
||||
const b = stars[j];
|
||||
if (!b.anchor) continue;
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
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;
|
||||
// Alpha falls off with distance; gentle pulse tied to time.
|
||||
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(232, 181, 74, ${alpha.toFixed(3)})`; // gold, rgb fixed for perf
|
||||
ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.moveTo(A.x, A.y);
|
||||
ctx.lineTo(B.x, B.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Stars (twinkle + drift).
|
||||
for (const s of stars) {
|
||||
s.x += s.vx;
|
||||
s.y += s.vy;
|
||||
// 2) Stars (slow twinkle; positions already rotated).
|
||||
for (const p of proj) {
|
||||
const s = p.s;
|
||||
s.tw += s.tws * dt;
|
||||
// Wrap softly at edges.
|
||||
if (s.x < -4) s.x = width + 4;
|
||||
else if (s.x > width + 4) s.x = -4;
|
||||
if (s.y < -4) s.y = height + 4;
|
||||
else if (s.y > height + 4) s.y = -4;
|
||||
// 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 = s.anchor ? gold : s.r > 1 ? white : s.r > 0.7 ? violet : teal;
|
||||
const color = hueColor(s.hue);
|
||||
|
||||
if (s.anchor) {
|
||||
// Glow halo for anchors.
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = alpha * 0.5;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = color;
|
||||
ctx.arc(s.x, s.y, s.r * 1.8, 0, Math.PI * 2);
|
||||
ctx.arc(p.x, p.y, s.r * 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
@@ -197,19 +245,19 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.shadowBlur = s.anchor ? 6 : 3;
|
||||
ctx.shadowColor = color;
|
||||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 3) Shooting stars.
|
||||
// 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; // fade over ~2s
|
||||
sh.life -= dt * 0.5;
|
||||
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
|
||||
shooters.splice(i, 1);
|
||||
continue;
|
||||
@@ -218,8 +266,8 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
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)})`); // star-white head
|
||||
grad.addColorStop(0.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`); // gold mid
|
||||
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`);
|
||||
grad.addColorStop(0.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`);
|
||||
grad.addColorStop(1, 'rgba(232, 181, 74, 0)');
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 1.6;
|
||||
@@ -228,7 +276,6 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
ctx.lineTo(tailX, tailY);
|
||||
ctx.stroke();
|
||||
|
||||
// Bright head dot.
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
|
||||
ctx.shadowBlur = 8;
|
||||
@@ -271,26 +318,49 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
};
|
||||
}, [particles]);
|
||||
|
||||
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
|
||||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${skyClass} ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Layer 1: animated mesh gradient (midnight indigo -> violet + gold edges). */}
|
||||
{/* 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="animate-gradient-shift absolute inset-0 opacity-95"
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, #04061a 0%, #070b24 32%, #0a1230 58%, #0e1a3e 82%, #12224a 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={{
|
||||
backgroundColor: 'var(--color-surface-sunken)',
|
||||
backgroundImage: `
|
||||
radial-gradient(60% 80% at 15% 10%, color-mix(in srgb, var(--color-amber) 18%, transparent), transparent 60%),
|
||||
radial-gradient(55% 70% at 85% 0%, color-mix(in srgb, var(--color-violet, var(--color-secondary)) 30%, transparent), transparent 60%),
|
||||
radial-gradient(70% 90% at 50% 110%, color-mix(in srgb, var(--color-amber) 14%, transparent), transparent 60%)
|
||||
radial-gradient(38% 70% at 68% 38%, rgba(111, 182, 173, 0.22), transparent 62%),
|
||||
radial-gradient(30% 55% at 72% 44%, rgba(139, 123, 216, 0.26), transparent 60%),
|
||||
radial-gradient(22% 40% at 76% 50%, rgba(232, 181, 74, 0.12), transparent 60%),
|
||||
linear-gradient(115deg, transparent 30%, rgba(236, 233, 255, 0.06) 45%, rgba(236, 233, 255, 0.09) 52%, rgba(236, 233, 255, 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(139, 123, 216, 0.18), transparent 60%),
|
||||
radial-gradient(40% 50% at 88% 88%, rgba(111, 182, 173, 0.16), transparent 62%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{/* Faint astrolabe + sacred-geometry ornaments for esoteric texture. */}
|
||||
<svg
|
||||
className="absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow text-(--color-amber) opacity-[0.08]"
|
||||
className="absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow text-(--color-amber) opacity-[0.11]"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -317,7 +387,7 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
})}
|
||||
</svg>
|
||||
<svg
|
||||
className="absolute -right-44 bottom-[-12rem] h-[30rem] w-[30rem] text-(--color-violet) opacity-[0.08]"
|
||||
className="absolute -right-44 bottom-[-12rem] h-[30rem] w-[30rem] text-(--color-violet) opacity-[0.11]"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -343,10 +413,10 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(120% 80% at 50% 30%, transparent 40%, color-mix(in srgb, var(--color-surface-sunken) 70%, transparent) 100%)',
|
||||
'radial-gradient(120% 80% at 50% 30%, transparent 40%, rgba(4, 6, 26, 0.7) 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Layer 3: canvas star field (skipped under reduced motion). */}
|
||||
{/* Canvas star field (skipped under reduced motion). */}
|
||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user