mycofi-earth-website/components/hyphal-canvas.tsx

353 lines
11 KiB
TypeScript

"use client"
import { useEffect, useRef } from "react"
interface HyphalNode {
x: number
y: number
vx: number
vy: number
age: number
maxAge: number
branches: HyphalNode[]
parent: HyphalNode | null
angle: number
angleChangeRate: number
connections: HyphalNode[]
opacity: number
isPersistent: boolean
createdAt: number
connectionDensity: number
}
export function HyphalCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const nodesRef = useRef<HyphalNode[]>([])
const mouseRef = useRef({ x: 0, y: 0, isMoving: false })
const animationRef = useRef<number>()
const getDensityColor = (density: number, opacity: number): string => {
// Map density (0-10+) to hue (0-300 degrees)
// Low density = red/orange (0-60), high density = blue/violet (240-300)
const normalizedDensity = Math.min(density / 10, 1)
const hue = normalizedDensity * 300
// Muted colors: lower saturation (40-50%) and medium lightness (50-60%)
const saturation = 40 + normalizedDensity * 10
const lightness = 50 + normalizedDensity * 10
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`
}
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const resizeCanvas = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resizeCanvas()
window.addEventListener("resize", resizeCanvas)
let mouseMoveTimeout: NodeJS.Timeout
const handleMouseMove = (e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY, isMoving: true }
clearTimeout(mouseMoveTimeout)
mouseMoveTimeout = setTimeout(() => {
mouseRef.current.isMoving = false
}, 100)
}
window.addEventListener("mousemove", handleMouseMove)
const createNode = (x: number, y: number, parent: HyphalNode | null = null): HyphalNode => {
const angle = parent ? parent.angle + (Math.random() - 0.5) * 1.2 : Math.random() * Math.PI * 2
const speed = parent ? 1.5 + Math.random() * 1 : 2 + Math.random() * 2
return {
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
age: 0,
maxAge: 80 + Math.random() * 60,
branches: [],
parent,
angle,
angleChangeRate: (Math.random() - 0.5) * 0.2,
connections: [],
opacity: 1,
isPersistent: false,
createdAt: Date.now(),
connectionDensity: 0,
}
}
const findNearbyPersistentNodes = (x: number, y: number, radius: number): HyphalNode[] => {
return nodesRef.current.filter((node) => {
if (!node.isPersistent) return false
const dx = node.x - x
const dy = node.y - y
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < radius
})
}
const createMeshConnections = () => {
nodesRef.current.forEach((node, i) => {
const maturityFactor = node.age / node.maxAge
const maxConnections = node.isPersistent ? 7 : maturityFactor > 0.6 ? 4 : 2
const connectionDistance = maturityFactor > 0.6 ? 80 : 50
const connectionProbability = maturityFactor > 0.6 ? 0.97 : 0.99
if (node.age < 20 || node.connections.length >= maxConnections) return
for (let j = i + 1; j < nodesRef.current.length; j++) {
const other = nodesRef.current[j]
if (other === node.parent || node === other.parent) continue
const otherMaturityFactor = other.age / other.maxAge
const otherMaxConnections = other.isPersistent ? 7 : otherMaturityFactor > 0.6 ? 4 : 2
if (other.age < 20 || other.connections.length >= otherMaxConnections) continue
const dx = other.x - node.x
const dy = other.y - node.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < connectionDistance && Math.random() > connectionProbability) {
if (!node.connections.includes(other)) {
node.connections.push(other)
other.connections.push(node)
}
}
}
})
}
const drawBranchingConnection = (
ctx: CanvasRenderingContext2D,
x1: number,
y1: number,
x2: number,
y2: number,
opacity: number,
color: string,
fragmentationFactor: number,
) => {
const dx = x2 - x1
const dy = y2 - y1
const distance = Math.sqrt(dx * dx + dy * dy)
if (fragmentationFactor > 0.5 && Math.random() > fragmentationFactor) {
// Skip drawing some connections to create fragmentation effect
return
}
if (distance < 30) {
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
return
}
const numBranches = distance > 60 ? 2 : 1
const points = [{ x: x1, y: y1 }]
for (let i = 0; i < numBranches; i++) {
const t = (i + 1) / (numBranches + 1)
const midX = x1 + dx * t
const midY = y1 + dy * t
const perpX = -dy / distance
const perpY = dx / distance
const offset = (Math.random() - 0.5) * distance * 0.3
points.push({
x: midX + perpX * offset,
y: midY + perpY * offset,
})
}
points.push({ x: x2, y: y2 })
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length - 1; i++) {
const xc = (points[i].x + points[i + 1].x) / 2
const yc = (points[i].y + points[i + 1].y) / 2
ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc)
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y)
ctx.stroke()
}
const animate = () => {
ctx.fillStyle = "rgba(250, 248, 245, 0.08)"
ctx.fillRect(0, 0, canvas.width, canvas.height)
const currentTime = Date.now()
if (mouseRef.current.isMoving && Math.random() > 0.65) {
const burstCount = Math.floor(Math.random() * 2) + 1
const nearbyPersistent = findNearbyPersistentNodes(mouseRef.current.x, mouseRef.current.y, 100)
for (let i = 0; i < burstCount; i++) {
const node = createNode(mouseRef.current.x, mouseRef.current.y)
nodesRef.current.push(node)
if (nearbyPersistent.length > 0) {
const connectCount = Math.min(3, nearbyPersistent.length)
for (let j = 0; j < connectCount; j++) {
const target = nearbyPersistent[Math.floor(Math.random() * nearbyPersistent.length)]
if (!node.connections.includes(target)) {
node.connections.push(target)
target.connections.push(node)
}
}
}
}
}
if (Math.random() > 0.85) {
createMeshConnections()
}
nodesRef.current.forEach((node) => {
node.connectionDensity = node.connections.length
})
nodesRef.current = nodesRef.current.filter((node) => {
node.age++
const timeSinceCreation = currentTime - node.createdAt
const fadeOutDuration = 30000 // 30 seconds
const fadeOutFactor = Math.min(timeSinceCreation / fadeOutDuration, 1)
if (!node.isPersistent) {
node.angle += node.angleChangeRate
if (Math.random() > 0.92) {
node.angleChangeRate = (Math.random() - 0.5) * 0.25
}
if (Math.random() > 0.96) {
node.angle += (Math.random() - 0.5) * 0.8
}
const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
node.vx = Math.cos(node.angle) * speed
node.vy = Math.sin(node.angle) * speed
node.x += node.vx
node.y += node.vy
node.vx *= 0.96
node.vy *= 0.96
}
const ageFactor = node.age / node.maxAge
if (node.isPersistent) {
node.opacity = Math.max(0, 0.15 * (1 - fadeOutFactor))
} else if (ageFactor >= 1) {
node.isPersistent = true
node.opacity = 0.15 * (1 - fadeOutFactor)
node.vx = 0
node.vy = 0
} else {
node.opacity = Math.max(0, (1 - ageFactor * 0.85) * (1 - fadeOutFactor * 0.5))
}
if (fadeOutFactor >= 1) {
return false
}
if (
node.age > 8 &&
node.age < node.maxAge - 30 &&
Math.random() > 0.92 &&
node.branches.length < 4 &&
!node.isPersistent
) {
const branch = createNode(node.x, node.y, node)
node.branches.push(branch)
nodesRef.current.push(branch)
}
if (node.parent) {
const avgDensity = (node.connectionDensity + node.parent.connectionDensity) / 2
const parentOpacity = node.parent.opacity * 0.5
const nodeOpacity = node.opacity * 0.5
const gradient = ctx.createLinearGradient(node.parent.x, node.parent.y, node.x, node.y)
gradient.addColorStop(0, getDensityColor(node.parent.connectionDensity, parentOpacity))
gradient.addColorStop(0.5, getDensityColor(avgDensity, (parentOpacity + nodeOpacity) / 2))
gradient.addColorStop(1, getDensityColor(node.connectionDensity, nodeOpacity))
ctx.strokeStyle = gradient
ctx.lineWidth = 2.5 + Math.random() * 1.5
ctx.lineCap = "round"
ctx.beginPath()
ctx.moveTo(node.parent.x, node.parent.y)
ctx.lineTo(node.x, node.y)
ctx.stroke()
}
node.connections.forEach((connected) => {
if (nodesRef.current.includes(connected)) {
const avgDensity = (node.connectionDensity + connected.connectionDensity) / 2
const avgOpacity = ((node.opacity + connected.opacity) / 2) * 0.35
// Calculate fragmentation based on time
const nodeFragmentation = Math.min((currentTime - node.createdAt) / fadeOutDuration, 1)
const connectedFragmentation = Math.min((currentTime - connected.createdAt) / fadeOutDuration, 1)
const avgFragmentation = (nodeFragmentation + connectedFragmentation) / 2
ctx.strokeStyle = getDensityColor(avgDensity, avgOpacity)
ctx.lineWidth = 1.5
ctx.lineCap = "round"
drawBranchingConnection(
ctx,
node.x,
node.y,
connected.x,
connected.y,
avgOpacity,
ctx.strokeStyle,
avgFragmentation,
)
}
})
ctx.fillStyle = getDensityColor(node.connectionDensity, node.opacity * 0.7)
ctx.beginPath()
ctx.arc(node.x, node.y, 2.5, 0, Math.PI * 2)
ctx.fill()
return node.isPersistent || node.age < node.maxAge
})
if (nodesRef.current.length > 800) {
const persistentNodes = nodesRef.current.filter((n) => n.isPersistent)
const activeNodes = nodesRef.current.filter((n) => !n.isPersistent)
if (persistentNodes.length > 500) {
nodesRef.current = [...persistentNodes.slice(-400), ...activeNodes]
}
}
animationRef.current = requestAnimationFrame(animate)
}
animate()
return () => {
window.removeEventListener("resize", resizeCanvas)
window.removeEventListener("mousemove", handleMouseMove)
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [])
return <canvas ref={canvasRef} className="fixed inset-0 pointer-events-none z-10" style={{ opacity: 0.6 }} />
}