mycostack-website/components/mycelial-canvas.tsx

393 lines
11 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
}
interface Spore {
x: number
y: number
vx: number
vy: number
life: number
maxLife: number
radius: number
}
export function MycelialCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const hyphaeRef = useRef<Hypha[]>([])
const sporesRef = useRef<Spore[]>([])
const frameRef = useRef<number>(0)
const trailCanvasRef = useRef<HTMLCanvasElement | null>(null)
const mouseRef = useRef<{ x: number; y: number; active: boolean }>({
x: 0,
y: 0,
active: false,
})
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)
// Mouse/touch tracking for interactive effects
const onMouseMove = (e: MouseEvent) => {
mouseRef.current.x = e.clientX
mouseRef.current.y = e.clientY
mouseRef.current.active = true
}
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
mouseRef.current.x = e.touches[0].clientX
mouseRef.current.y = e.touches[0].clientY
mouseRef.current.active = true
}
}
const onMouseLeave = () => {
mouseRef.current.active = false
}
window.addEventListener("mousemove", onMouseMove)
window.addEventListener("touchmove", onTouchMove, { passive: true })
window.addEventListener("mouseleave", onMouseLeave)
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.0 + Math.random() * 0.6 - depth * 0.1,
age: 0,
maxAge: 250 + Math.random() * 200 - depth * 15,
parentX: x,
parentY: y,
depth,
branchCount: 0,
})
const createSpore = (x: number, y: number): Spore => ({
x,
y,
vx: (Math.random() - 0.5) * 1.5,
vy: (Math.random() - 0.5) * 1.5 - 0.3,
life: 0,
maxLife: 60 + Math.random() * 80,
radius: 1 + Math.random() * 2,
})
// Seed initial hyphae from bottom and sides
const seedCount = Math.max(4, Math.floor(w() / 200))
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
)
)
}
// Seed a couple from sides
for (let i = 0; i < 2; i++) {
const fromLeft = Math.random() > 0.5
hyphaeRef.current.push(
createHypha(
fromLeft ? -10 : w() + 10,
h() * (0.4 + Math.random() * 0.4),
fromLeft ? -0.2 + Math.random() * 0.4 : Math.PI - 0.2 + Math.random() * 0.4,
0
)
)
}
const getAccentColor = () => {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--scroll-accent")
.trim() || "oklch(0.55 0.13 155)"
)
}
let lastSeed = 0
let lastSporeEmit = 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(8, 18, 10, 0.006)"
trailCtx.fillRect(0, 0, width, height)
const accent = getAccentColor()
const mouse = mouseRef.current
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
// Mouse attraction: hyphae gently curve toward cursor
if (mouse.active) {
const dx = mouse.x - hypha.x
const dy = mouse.y - hypha.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 300 && dist > 20) {
const targetAngle = Math.atan2(dy, dx)
let angleDiff = targetAngle - hypha.angle
if (angleDiff > Math.PI) angleDiff -= Math.PI * 2
if (angleDiff < -Math.PI) angleDiff += Math.PI * 2
hypha.angle += angleDiff * 0.015 * (1 - dist / 300)
}
}
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.06)
)
const lineWidth = Math.max(0.3, 2.5 - hypha.depth * 0.35 - 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)
}
// Connection lines + energy pulse at interconnected nodes
const time = Date.now() * 0.002
for (let i = 0; i < alive.length; i++) {
const hypha = alive[i]
const ageRatio = hypha.age / hypha.maxAge
if (ageRatio > 0.5 || hypha.depth >= 3) continue
let connectionCount = 0
for (let j = i + 1; j < alive.length; j++) {
const other = alive[j]
const otherAge = other.age / other.maxAge
if (otherAge > 0.5 || other.depth >= 3) continue
const dx = other.x - hypha.x
const dy = other.y - hypha.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 80 && dist > 10) {
connectionCount++
const connOpacity = 0.06 * (1 - dist / 80)
ctx.strokeStyle = accent.replace(")", ` / ${connOpacity})`)
ctx.lineWidth = 0.5
ctx.beginPath()
ctx.moveTo(hypha.x, hypha.y)
ctx.lineTo(other.x, other.y)
ctx.stroke()
}
}
// Pulse glow at nodes with multiple connections
if (connectionCount > 0) {
const pulse = 0.5 + 0.5 * Math.sin(time + hypha.x * 0.01 + hypha.y * 0.008)
const intensity = Math.min(connectionCount / 4, 1) * pulse
const radius = 6 + connectionCount * 3
const gradient = ctx.createRadialGradient(
hypha.x, hypha.y, 0,
hypha.x, hypha.y, radius
)
gradient.addColorStop(0, accent.replace(")", ` / ${intensity * 0.15})`))
gradient.addColorStop(1, accent.replace(")", ` / 0)`))
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(hypha.x, hypha.y, radius, 0, Math.PI * 2)
ctx.fill()
}
}
// Spore particles
const now = Date.now()
// Emit spores near mouse
if (mouse.active && now - lastSporeEmit > 150) {
lastSporeEmit = now
sporesRef.current.push(
createSpore(
mouse.x + (Math.random() - 0.5) * 40,
mouse.y + (Math.random() - 0.5) * 40
)
)
}
// Emit ambient spores occasionally
if (now - lastSporeEmit > 800 && sporesRef.current.length < 30) {
lastSporeEmit = now
sporesRef.current.push(
createSpore(Math.random() * width, Math.random() * height)
)
}
// Update and draw spores
const aliveSpores: Spore[] = []
for (const spore of sporesRef.current) {
spore.life++
if (spore.life >= spore.maxLife) continue
spore.x += spore.vx
spore.y += spore.vy
spore.vx *= 0.98
spore.vy *= 0.98
const lifeRatio = spore.life / spore.maxLife
const sporeOpacity = Math.sin(lifeRatio * Math.PI) * 0.3
const sporeRadius = spore.radius * (1 - lifeRatio * 0.5)
ctx.fillStyle = accent.replace(")", ` / ${sporeOpacity})`)
ctx.beginPath()
ctx.arc(spore.x, spore.y, sporeRadius, 0, Math.PI * 2)
ctx.fill()
aliveSpores.push(spore)
}
sporesRef.current = aliveSpores
// Mouse glow effect
if (mouse.active) {
const gradient = ctx.createRadialGradient(
mouse.x, mouse.y, 0,
mouse.x, mouse.y, 120
)
gradient.addColorStop(0, accent.replace(")", " / 0.06)"))
gradient.addColorStop(1, accent.replace(")", " / 0)"))
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(mouse.x, mouse.y, 120, 0, Math.PI * 2)
ctx.fill()
}
// 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
if (now - lastSeed > 2500 && alive.length < 400) {
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)
window.removeEventListener("mousemove", onMouseMove)
window.removeEventListener("touchmove", onTouchMove)
window.removeEventListener("mouseleave", onMouseLeave)
cancelAnimationFrame(frameRef.current)
}
}, [])
return (
<canvas
ref={canvasRef}
className="fixed inset-0"
style={{ zIndex: 0, opacity: 0.6, pointerEvents: "none" }}
/>
)
}