Absolutely beatiful game of life hero background

This commit is contained in:
Timothy Pidashev
2025-01-08 10:47:46 -08:00
parent 42495f2316
commit 2519182e86
4 changed files with 412 additions and 330 deletions

View File

@@ -1,38 +1,98 @@
import React, { useState, useEffect } from "react";
// components/header/index.tsx
import React, { useState, useEffect, useRef } from "react";
import { Links } from "@/components/header/links";
export default function Header() {
const [isClient, setIsClient] = useState(false);
const [visible, setVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
const [currentPath, setCurrentPath] = useState("");
const [shouldAnimate, setShouldAnimate] = useState(false);
// Handle client-side initialization
useEffect(() => {
setIsClient(true);
setCurrentPath(document.location.pathname);
// Trigger initial animation after a brief delay
setTimeout(() => setShouldAnimate(true), 50);
}, []);
useEffect(() => {
if (!isClient) return;
const handleScroll = () => {
const currentScrollY = window.scrollY;
setVisible(currentScrollY < lastScrollY || currentScrollY < 10);
setLastScrollY(currentScrollY);
const currentScrollY = document.documentElement.scrollTop;
setVisible(currentScrollY < lastScrollY || currentScrollY < 10);
setLastScrollY(currentScrollY);
};
document.addEventListener("scroll", handleScroll);
return () => document.removeEventListener("scroll", handleScroll);
}, [lastScrollY, isClient]);
const checkIsActive = (linkHref: string): boolean => {
if (!isClient) return false;
const path = document.location.pathname;
if (linkHref === "/") return path === "/";
return linkHref !== "/" && path.startsWith(linkHref);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [lastScrollY]);
const headerLinks = Links.map((link) => (
<div
key={link.id}
className={`inline-block ${link.color}`}
>
<a href={link.href}>{link.label}</a>
</div>
));
const headerLinks = Links.map((link) => {
const isActive = checkIsActive(link.href);
return (
<div
key={link.id}
className={`relative inline-block ${link.color}`}
>
<a
href={link.href}
className="relative inline-block"
>
{link.label}
<div className="absolute -bottom-1 left-0 w-full overflow-hidden">
{isClient && isActive && (
<svg
className="w-full h-3 opacity-100"
preserveAspectRatio="none"
viewBox="0 0 100 12"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="stroke-current transition-[stroke-dashoffset] duration-[600ms] ease-out"
d="M0,6
C25,4 35,8 50,6
S75,4 100,6"
fill="none"
strokeWidth="2.5"
strokeLinecap="round"
style={{
strokeDasharray: 100,
strokeDashoffset: shouldAnimate ? 0 : 100
}}
/>
</svg>
)}
</div>
</a>
</div>
);
});
return (
<header className={`fixed z-50 top-0 left-0 right-0 font-bold transition-transform duration-300 ${
visible ? "translate-y-0" : "-translate-y-full"
}`}>
<div className="flex flex-row pt-1 px-2 bg-black text-lg lg:pt-2 lg:text-3xl md:text-2xl items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20">
<header
className={`
fixed z-50 top-0 left-0 right-0
bg-black font-bold
transition-transform duration-300
${visible ? "translate-y-0" : "-translate-y-full"}
`}
>
<div className="flex flex-row pt-1 px-2 text-lg lg:pt-2 lg:text-3xl md:text-2xl items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20">
{headerLinks}
</div>
</header>
);
};
}

View File

@@ -0,0 +1,328 @@
import { useEffect, useRef } from 'react';
interface Cell {
alive: boolean;
next: boolean;
color: [number, number, number];
currentX: number;
currentY: number;
targetX: number;
targetY: number;
opacity: number;
targetOpacity: number;
scale: number;
targetScale: number;
transitioning: boolean;
transitionComplete: boolean;
}
interface Grid {
cells: Cell[][];
cols: number;
rows: number;
offsetX: number;
offsetY: number;
}
const CELL_SIZE = 25;
const TRANSITION_SPEED = 0.1;
const SCALE_SPEED = 0.15;
const CYCLE_FRAMES = 120;
const INITIAL_DENSITY = 0.15;
const Background: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const gridRef = useRef<Grid>();
const animationFrameRef = useRef<number>();
const frameCount = useRef(0);
const isInitialized = useRef(false);
const randomColor = (): [number, number, number] => {
const colors = [
[204, 36, 29], // red
[152, 151, 26], // green
[215, 153, 33], // yellow
[69, 133, 136], // blue
[177, 98, 134], // purple
[104, 157, 106] // aqua
];
return colors[Math.floor(Math.random() * colors.length)];
};
const calculateGridDimensions = (width: number, height: number) => {
// Calculate number of complete cells that fit in the viewport
const cols = Math.floor(width / CELL_SIZE);
const rows = Math.floor(height / CELL_SIZE);
// Calculate offsets to center the grid
const offsetX = Math.floor((width - (cols * CELL_SIZE)) / 2);
const offsetY = Math.floor((height - (rows * CELL_SIZE)) / 2);
return { cols, rows, offsetX, offsetY };
};
const initGrid = (width: number, height: number): Grid => {
const { cols, rows, offsetX, offsetY } = calculateGridDimensions(width, height);
const cells = Array(cols).fill(0).map((_, i) =>
Array(rows).fill(0).map((_, j) => ({
alive: Math.random() < INITIAL_DENSITY,
next: false,
color: randomColor(),
currentX: i,
currentY: j,
targetX: i,
targetY: j,
opacity: 0,
targetOpacity: 0,
scale: 0,
targetScale: 0,
transitioning: false,
transitionComplete: false
}))
);
const grid = { cells, cols, rows, offsetX, offsetY };
computeNextState(grid);
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const cell = cells[i][j];
if (cell.next) {
cell.alive = true;
setTimeout(() => {
cell.targetOpacity = 1;
cell.targetScale = 1;
}, Math.random() * 1000);
} else {
cell.alive = false;
}
}
}
return grid;
};
const countNeighbors = (grid: Grid, x: number, y: number): { count: number, colors: [number, number, number][] } => {
const neighbors = { count: 0, colors: [] as [number, number, number][] };
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
if (i === 0 && j === 0) continue;
const col = (x + i + grid.cols) % grid.cols;
const row = (y + j + grid.rows) % grid.rows;
if (grid.cells[col][row].alive) {
neighbors.count++;
neighbors.colors.push(grid.cells[col][row].color);
}
}
}
return neighbors;
};
const averageColors = (colors: [number, number, number][]): [number, number, number] => {
if (colors.length === 0) return [0, 0, 0];
const sum = colors.reduce((acc, color) => [
acc[0] + color[0],
acc[1] + color[1],
acc[2] + color[2]
], [0, 0, 0]);
return [
Math.round(sum[0] / colors.length),
Math.round(sum[1] / colors.length),
Math.round(sum[2] / colors.length)
];
};
const computeNextState = (grid: Grid) => {
// First pass: compute next state without applying it
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const { count, colors } = countNeighbors(grid, i, j);
if (cell.alive) {
cell.next = count === 2 || count === 3;
} else {
cell.next = count === 3;
if (cell.next) {
cell.color = averageColors(colors);
}
}
// Mark cells that need to transition
if (cell.alive !== cell.next && !cell.transitioning) {
cell.transitioning = true;
cell.transitionComplete = false;
// For dying cells, start the shrinking animation
if (!cell.next) {
cell.targetScale = 0;
cell.targetOpacity = 0;
}
}
}
}
};
const updateCellAnimations = (grid: Grid) => {
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Update animation properties
cell.opacity += (cell.targetOpacity - cell.opacity) * TRANSITION_SPEED;
cell.scale += (cell.targetScale - cell.scale) * SCALE_SPEED;
// Handle transition states
if (cell.transitioning) {
// Check if shrinking animation is complete for dying cells
if (!cell.next && cell.scale < 0.05) {
cell.alive = false;
cell.transitioning = false;
cell.transitionComplete = true;
cell.scale = 0;
cell.opacity = 0;
}
// Check if growing animation is complete for new cells
else if (cell.next && !cell.alive && !cell.transitionComplete) {
cell.alive = true;
cell.transitioning = false;
cell.transitionComplete = true;
cell.targetScale = 1;
cell.targetOpacity = 1;
}
// Start growing animation for new cells once old cells have shrunk
else if (cell.next && !cell.alive && cell.transitionComplete) {
cell.transitioning = true;
cell.targetScale = 1;
cell.targetOpacity = 1;
}
}
}
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resizeCanvas = () => {
const dpr = window.devicePixelRatio || 1;
const displayWidth = window.innerWidth;
const displayHeight = window.innerHeight;
// Set canvas size accounting for device pixel ratio
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
// Scale the context to ensure correct drawing operations
ctx.scale(dpr, dpr);
// Set CSS size
canvas.style.width = `${displayWidth}px`;
canvas.style.height = `${displayHeight}px`;
if (!isInitialized.current) {
gridRef.current = initGrid(displayWidth, displayHeight);
isInitialized.current = true;
} else if (gridRef.current) {
// Update grid dimensions and offsets on resize
const { cols, rows, offsetX, offsetY } = calculateGridDimensions(displayWidth, displayHeight);
gridRef.current.cols = cols;
gridRef.current.rows = rows;
gridRef.current.offsetX = offsetX;
gridRef.current.offsetY = offsetY;
gridRef.current.cells = gridRef.current.cells.slice(0, cols).map(col => col.slice(0, rows));
}
};
const drawGrid = () => {
if (!ctx || !canvas || !gridRef.current) return;
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const grid = gridRef.current;
const cellSize = CELL_SIZE * 0.8;
const roundness = cellSize * 0.2;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.opacity > 0.01) {
const [r, g, b] = cell.color;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.globalAlpha = cell.opacity * 0.8;
const scaledSize = cellSize * cell.scale;
const xOffset = (cellSize - scaledSize) / 2;
const yOffset = (cellSize - scaledSize) / 2;
// Add grid offsets to center the animation
const x = grid.offsetX + i * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + xOffset;
const y = grid.offsetY + j * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + yOffset;
const scaledRoundness = roundness * cell.scale;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(x + scaledSize, y + scaledSize, x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
};
const animate = () => {
frameCount.current++;
if (gridRef.current) {
if (frameCount.current % CYCLE_FRAMES === 0) {
computeNextState(gridRef.current);
}
updateCellAnimations(gridRef.current);
}
drawGrid();
animationFrameRef.current = requestAnimationFrame(animate);
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
animate();
return () => {
window.removeEventListener('resize', resizeCanvas);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
return (
<div className="fixed inset-0 -z-10">
<canvas
ref={canvasRef}
className="w-full h-full bg-black"
/>
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
</div>
);
};
export default Background;

View File

@@ -1,305 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
const VineAnimation = ({ side }) => {
const [vines, setVines] = useState([]);
const VINE_COLOR = '#b8bb26'; // Gruvbox green
const VINE_LIFETIME = 8000; // Time before fade starts
const FADE_DURATION = 3000; // How long the fade takes
const MAX_VINES = Math.max(3, Math.floor(window.innerWidth / 400)); // Adjust to screen width
const isMobile = window.innerWidth <= 768;
const getDistance = (pointA, pointB) => {
return Math.sqrt(
Math.pow(pointA.x - pointB.x, 2) + Math.pow(pointA.y - pointB.y, 2)
);
};
// Function to create a new branch
const createBranch = (startX, startY, baseRotation) => ({
id: Date.now() + Math.random(),
points: [{
x: startX,
y: startY,
rotation: baseRotation
}],
leaves: [],
growing: true,
phase: Math.random() * Math.PI * 2,
amplitude: Math.random() * 0.5 + 1.2
});
// Function to create a new vine
const createNewVine = useCallback(() => ({
id: Date.now() + Math.random(),
mainBranch: createBranch(
side === 'left' ? 0 : window.innerWidth,
Math.random() * (window.innerHeight * 0.8) + (window.innerHeight * 0.1),
side === 'left' ? 0 : Math.PI
),
subBranches: [],
growing: true,
createdAt: Date.now(),
fadingOut: false,
opacity: 1
}), [side]);
// Update branch function
const updateBranch = (branch, isSubBranch = false) => {
if (!branch.growing) return branch;
const lastPoint = branch.points[branch.points.length - 1];
const progress = branch.points.length * 0.15;
const safetyMargin = window.innerWidth * 0.2;
const minX = safetyMargin;
const maxX = window.innerWidth - safetyMargin;
const baseAngle = side === 'left' ? 0 : Math.PI;
let curve = Math.sin(progress + branch.phase) * branch.amplitude;
const distanceFromCenter = Math.abs(window.innerWidth/2 - lastPoint.x);
const centerRepulsion = Math.max(0, 1 - (distanceFromCenter / (window.innerWidth/4)));
curve += (side === 'left' ? -1 : 1) * centerRepulsion * 0.5;
if (side === 'left' && lastPoint.x > minX) {
curve -= Math.pow((lastPoint.x - minX) / safetyMargin, 2);
} else if (side === 'right' && lastPoint.x < maxX) {
curve += Math.pow((maxX - lastPoint.x) / safetyMargin, 2);
}
const currentAngle = baseAngle + curve;
const distance = isSubBranch ? 12 : 18;
const newX = lastPoint.x + Math.cos(currentAngle) * distance;
const newY = lastPoint.y + Math.sin(currentAngle) * distance;
const newPoint = {
x: newX,
y: newY,
rotation: currentAngle
};
let newLeaves = [...branch.leaves];
if (Math.random() < 0.2 && branch.points.length > 2) {
const maxLength = isSubBranch ? 15 : 30;
const progress = branch.points.length / maxLength;
const baseSize = isSubBranch ? 20 : 35;
const sizeGradient = Math.pow(1 - progress, 2);
const leafSize = Math.max(8, baseSize * sizeGradient);
// Ensure leaves don't grow on the last point
const leafPosition = Math.min(branch.points.length - 2, Math.floor(Math.random() * branch.points.length));
newLeaves.push({
position: leafPosition,
size: leafSize,
side: Math.random() > 0.5 ? 'left' : 'right'
});
}
return {
...branch,
points: [...branch.points, newPoint],
leaves: newLeaves,
growing: branch.points.length < (isSubBranch ? 15 : 30)
};
};
// Update vine function
const updateVine = useCallback((vine) => {
const now = Date.now();
const age = now - vine.createdAt;
// Calculate opacity based on age
let newOpacity = vine.opacity;
if (age > VINE_LIFETIME) {
const fadeProgress = (age - VINE_LIFETIME) / FADE_DURATION;
newOpacity = Math.max(0, 1 - fadeProgress);
}
// Update main branch
const newMainBranch = updateBranch(vine.mainBranch);
let newSubBranches = [...vine.subBranches];
// Add new branches with random probability
if (
!vine.fadingOut &&
age < VINE_LIFETIME &&
Math.random() < 0.05 &&
newMainBranch.points.length > 4
) {
// Choose a random point, excluding the last few points of any branch
const allBranches = [newMainBranch, ...newSubBranches];
const sourceBranch = allBranches[Math.floor(Math.random() * allBranches.length)];
// Calculate the valid range for branching
const minPoints = 4; // Minimum points needed before branching
const reservedTipPoints = 5; // Points to reserve at the tip
const maxBranchPoint = Math.max(
minPoints,
sourceBranch.points.length - reservedTipPoints
);
// Only create new branch if there's a valid spot
if (maxBranchPoint > minPoints) {
const branchPointIndex = Math.floor(
Math.random() * (maxBranchPoint - minPoints) + minPoints
);
const branchPoint = sourceBranch.points[branchPointIndex];
// Add some randomness to the branching angle
const rotationOffset = (Math.random() * 0.8 - 0.4) +
(Math.random() > 0.5 ? Math.PI/4 : -Math.PI/4);
newSubBranches.push(
createBranch(
branchPoint.x,
branchPoint.y,
branchPoint.rotation + rotationOffset
)
);
}
}
// Update existing branches
newSubBranches = newSubBranches.map(branch => updateBranch(branch, true));
return {
...vine,
mainBranch: newMainBranch,
subBranches: newSubBranches,
growing: newMainBranch.growing || newSubBranches.some(b => b.growing),
opacity: newOpacity,
fadingOut: age > VINE_LIFETIME
};
}, [side]);
// [Rest of the component code remains the same...]
const renderLeaf = (point, size, leafSide, parentOpacity = 1) => {
const sideMultiplier = leafSide === 'left' ? -1 : 1;
const angle = point.rotation + (Math.PI / 3) * sideMultiplier;
const tipX = point.x + Math.cos(angle) * size * 2;
const tipY = point.y + Math.sin(angle) * size * 2;
const ctrl1X = point.x + Math.cos(angle - Math.PI/8) * size * 1.8;
const ctrl1Y = point.y + Math.sin(angle - Math.PI/8) * size * 1.8;
const ctrl2X = point.x + Math.cos(angle + Math.PI/8) * size * 1.8;
const ctrl2Y = point.y + Math.sin(angle + Math.PI/8) * size * 1.8;
const baseCtrl1X = point.x + Math.cos(angle - Math.PI/4) * size * 0.5;
const baseCtrl1Y = point.y + Math.sin(angle - Math.PI/4) * size * 0.5;
const baseCtrl2X = point.x + Math.cos(angle + Math.PI/4) * size * 0.5;
const baseCtrl2Y = point.y + Math.sin(angle + Math.PI/4) * size * 0.5;
return (
<path
d={`
M ${point.x} ${point.y}
C ${baseCtrl1X} ${baseCtrl1Y} ${ctrl1X} ${ctrl1Y} ${tipX} ${tipY}
C ${ctrl2X} ${ctrl2Y} ${baseCtrl2X} ${baseCtrl2Y} ${point.x} ${point.y}
`}
fill={VINE_COLOR}
opacity={parentOpacity * 0.8}
/>
);
};
const renderBranch = (branch, parentOpacity = 1) => {
if (branch.points.length < 2) return null;
const points = branch.points;
const getStrokeWidth = (index) => {
const maxWidth = 5;
const progress = index / (points.length - 1);
const startTaper = Math.min(1, index / 3);
const endTaper = Math.pow(1 - progress, 1.5);
return maxWidth * startTaper * endTaper;
};
return (
<g key={branch.id}>
{points.map((point, i) => {
if (i === 0) return null;
const prev = points[i - 1];
const dx = point.x - prev.x;
const dy = point.y - prev.y;
const controlX = prev.x + dx * 0.7;
const controlY = prev.y + dy * 0.7;
return (
<path
key={`segment-${i}`}
d={`M ${prev.x} ${prev.y} Q ${controlX} ${controlY} ${point.x} ${point.y}`}
fill="none"
stroke={VINE_COLOR}
strokeWidth={getStrokeWidth(i)}
strokeLinecap="round"
strokeLinejoin="round"
opacity={parentOpacity * 0.9}
/>
);
})}
{branch.leaves.map((leaf, i) => {
const point = points[Math.floor(leaf.position)];
if (!point) return null;
return (
<g key={`${branch.id}-leaf-${i}`}>
{renderLeaf(point, leaf.size, leaf.side, parentOpacity)}
</g>
);
})}
</g>
);
};
useEffect(() => {
if (isMobile) return;
if (vines.length === 0) {
setVines([
createNewVine(),
{ ...createNewVine(), createdAt: Date.now() - 2000 },
{ ...createNewVine(), createdAt: Date.now() - 4000 }
]);
}
const interval = setInterval(() => {
setVines(currentVines => {
const updatedVines = currentVines
.map(vine => updateVine(vine))
.filter(vine => vine.opacity > 0.01);
if (updatedVines.length < 3) {
return [...updatedVines, createNewVine()];
}
return updatedVines;
});
}, 40);
return () => clearInterval(interval);
}, [createNewVine, updateVine]);
return (
<div className="fixed top-0 left-0 w-full h-full pointer-events-none">
<svg
className="w-full h-full"
viewBox={`0 0 ${window.innerWidth} ${window.innerHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{vines.map(vine => (
<g key={vine.id}>
{renderBranch(vine.mainBranch, vine.opacity)}
{vine.subBranches.map(branch => renderBranch(branch, vine.opacity))}
</g>
))}
</svg>
</div>
);
};
export default VineAnimation;

View File

@@ -5,7 +5,7 @@ import "@/style/globals.css";
import Header from "@/components/header";
import Footer from "@/components/footer";
import VineAnimation from "@/components/vines";
import Background from "@/components/hero/background";
---
<html lang="en">
@@ -17,9 +17,8 @@ import VineAnimation from "@/components/vines";
</head>
<body class="bg-background text-foreground">
<Header client:load />
<VineAnimation side="left" client:only="react" />
<VineAnimation side="right" client:only="react" />
<main>
<Background client:only="react" />
<slot />
</main>
<Footer client:load fixed=true />