209 lines
5.6 KiB
TypeScript
209 lines
5.6 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef } from "react"
|
|
|
|
interface Hypha {
|
|
x: number
|
|
y: number
|
|
angle: number
|
|
speed: number
|
|
age: number
|
|
maxAge: number
|
|
parentX: number
|
|
parentY: number
|
|
depth: number
|
|
branchCount: number
|
|
}
|
|
|
|
export function MycelialCanvas() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const hyphaeRef = useRef<Hypha[]>([])
|
|
const frameRef = useRef<number>(0)
|
|
const trailCanvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const ctx = canvas.getContext("2d")
|
|
if (!ctx) return
|
|
|
|
// Create offscreen trail canvas for persistence
|
|
const trailCanvas = document.createElement("canvas")
|
|
const trailCtx = trailCanvas.getContext("2d")!
|
|
trailCanvasRef.current = trailCanvas
|
|
|
|
const resize = () => {
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
|
const w = window.innerWidth
|
|
const h = window.innerHeight
|
|
canvas.width = w * dpr
|
|
canvas.height = h * dpr
|
|
canvas.style.width = `${w}px`
|
|
canvas.style.height = `${h}px`
|
|
ctx.scale(dpr, dpr)
|
|
|
|
trailCanvas.width = w * dpr
|
|
trailCanvas.height = h * dpr
|
|
trailCtx.scale(dpr, dpr)
|
|
}
|
|
resize()
|
|
window.addEventListener("resize", resize)
|
|
|
|
const w = () => canvas.width / (Math.min(window.devicePixelRatio || 1, 2))
|
|
const h = () => canvas.height / (Math.min(window.devicePixelRatio || 1, 2))
|
|
|
|
const createHypha = (
|
|
x: number,
|
|
y: number,
|
|
angle: number,
|
|
depth: number
|
|
): Hypha => ({
|
|
x,
|
|
y,
|
|
angle,
|
|
speed: 1.2 + Math.random() * 0.8 - depth * 0.15,
|
|
age: 0,
|
|
maxAge: 200 + Math.random() * 150 - depth * 20,
|
|
parentX: x,
|
|
parentY: y,
|
|
depth,
|
|
branchCount: 0,
|
|
})
|
|
|
|
// Seed initial hyphae from bottom
|
|
const seedCount = Math.max(3, Math.floor(w() / 250))
|
|
for (let i = 0; i < seedCount; i++) {
|
|
const x =
|
|
(w() / (seedCount + 1)) * (i + 1) + (Math.random() - 0.5) * 80
|
|
hyphaeRef.current.push(
|
|
createHypha(
|
|
x,
|
|
h() + 10,
|
|
-Math.PI / 2 + (Math.random() - 0.5) * 0.5,
|
|
0
|
|
)
|
|
)
|
|
}
|
|
|
|
const getAccentColor = () => {
|
|
return (
|
|
getComputedStyle(document.documentElement)
|
|
.getPropertyValue("--scroll-accent")
|
|
.trim() || "oklch(0.55 0.18 155)"
|
|
)
|
|
}
|
|
|
|
let lastSeed = 0
|
|
|
|
const animate = () => {
|
|
const width = w()
|
|
const height = h()
|
|
|
|
// Clear main canvas
|
|
ctx.clearRect(0, 0, width, height)
|
|
|
|
// Fade the trail canvas slowly
|
|
trailCtx.fillStyle = "rgba(10, 8, 5, 0.008)"
|
|
trailCtx.fillRect(0, 0, width, height)
|
|
|
|
const accent = getAccentColor()
|
|
const alive: Hypha[] = []
|
|
|
|
for (const hypha of hyphaeRef.current) {
|
|
hypha.age++
|
|
if (hypha.age >= hypha.maxAge) continue
|
|
|
|
// Save previous position
|
|
const prevX = hypha.x
|
|
const prevY = hypha.y
|
|
|
|
// Random walk with upward bias
|
|
hypha.angle += (Math.random() - 0.5) * 0.12
|
|
// Gentle gravitropism (slightly toward vertical)
|
|
hypha.angle += (-Math.PI / 2 - hypha.angle) * 0.003
|
|
hypha.x += Math.cos(hypha.angle) * hypha.speed
|
|
hypha.y += Math.sin(hypha.angle) * hypha.speed
|
|
|
|
// Boundary wrapping
|
|
if (hypha.x < -20) hypha.x = width + 20
|
|
if (hypha.x > width + 20) hypha.x = -20
|
|
|
|
// Opacity based on age and depth
|
|
const ageRatio = hypha.age / hypha.maxAge
|
|
const opacity = Math.max(
|
|
0.05,
|
|
(1 - ageRatio * 0.9) * (0.6 - hypha.depth * 0.08)
|
|
)
|
|
const lineWidth = Math.max(0.3, 2.5 - hypha.depth * 0.4 - ageRatio)
|
|
|
|
// Draw on trail canvas for persistence
|
|
trailCtx.strokeStyle = accent.replace(")", ` / ${opacity * 0.5})`)
|
|
trailCtx.lineWidth = lineWidth
|
|
trailCtx.lineCap = "round"
|
|
trailCtx.beginPath()
|
|
trailCtx.moveTo(prevX, prevY)
|
|
trailCtx.lineTo(hypha.x, hypha.y)
|
|
trailCtx.stroke()
|
|
|
|
// Draw tip glow on main canvas
|
|
if (ageRatio < 0.7) {
|
|
ctx.fillStyle = accent.replace(")", ` / ${opacity * 0.4})`)
|
|
ctx.beginPath()
|
|
ctx.arc(hypha.x, hypha.y, lineWidth + 1, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
}
|
|
|
|
// Branching
|
|
if (
|
|
hypha.age > 25 &&
|
|
hypha.age < hypha.maxAge * 0.7 &&
|
|
hypha.branchCount < 3 &&
|
|
hypha.depth < 5 &&
|
|
Math.random() > 0.975
|
|
) {
|
|
const branchAngle =
|
|
hypha.angle + (Math.random() > 0.5 ? 1 : -1) * (0.4 + Math.random() * 0.5)
|
|
alive.push(createHypha(hypha.x, hypha.y, branchAngle, hypha.depth + 1))
|
|
hypha.branchCount++
|
|
}
|
|
|
|
alive.push(hypha)
|
|
}
|
|
|
|
// Draw trail canvas behind
|
|
ctx.drawImage(trailCanvas, 0, 0, width, height)
|
|
|
|
// Cap and manage
|
|
hyphaeRef.current = alive.length > 500 ? alive.slice(-400) : alive
|
|
|
|
// Periodically seed new roots
|
|
const now = Date.now()
|
|
if (now - lastSeed > 3000 && alive.length < 350) {
|
|
lastSeed = now
|
|
const x = Math.random() * width
|
|
hyphaeRef.current.push(
|
|
createHypha(x, height + 10, -Math.PI / 2 + (Math.random() - 0.5) * 0.6, 0)
|
|
)
|
|
}
|
|
|
|
frameRef.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
animate()
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", resize)
|
|
cancelAnimationFrame(frameRef.current)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="fixed inset-0 pointer-events-none"
|
|
style={{ zIndex: 0, opacity: 0.55 }}
|
|
/>
|
|
)
|
|
}
|