|
| 1 | +import { useEffect, useRef } from 'react' |
| 2 | + |
| 3 | +const NUM_BOIDS = 7 |
| 4 | +const MAX_SPEED = 1.0 |
| 5 | +const MIN_SPEED = 0.45 |
| 6 | + |
| 7 | +interface Boid { x: number; y: number; vx: number; vy: number } |
| 8 | + |
| 9 | +function initBoid(w: number, h: number): Boid { |
| 10 | + const angle = Math.random() * Math.PI * 2 |
| 11 | + const speed = MIN_SPEED + Math.random() * (MAX_SPEED - MIN_SPEED) |
| 12 | + return { x: Math.random() * w, y: Math.random() * h, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed } |
| 13 | +} |
| 14 | + |
| 15 | +function clamp(vx: number, vy: number): [number, number] { |
| 16 | + const mag = Math.hypot(vx, vy) |
| 17 | + if (mag === 0) return [MIN_SPEED, 0] |
| 18 | + const m = Math.max(MIN_SPEED, Math.min(MAX_SPEED, mag)) |
| 19 | + return [(vx / mag) * m, (vy / mag) * m] |
| 20 | +} |
| 21 | + |
| 22 | +function step(boids: Boid[], w: number, h: number): Boid[] { |
| 23 | + return boids.map(b => { |
| 24 | + const dvx = (Math.random() - 0.5) * 0.18 |
| 25 | + const dvy = (Math.random() - 0.5) * 0.18 |
| 26 | + let [vx, vy] = clamp(b.vx + dvx * 0.1, b.vy + dvy * 0.1) |
| 27 | + let x = b.x + vx, y = b.y + vy |
| 28 | + if (x < -20) x = w + 20; else if (x > w + 20) x = -20 |
| 29 | + if (y < -20) y = h + 20; else if (y > h + 20) y = -20 |
| 30 | + return { x, y, vx, vy } |
| 31 | + }) |
| 32 | +} |
| 33 | + |
| 34 | +const PLANE_PATH = new Path2D('M 66.362 90 c -0.062 0 -0.124 -0.006 -0.186 -0.018 c -0.325 -0.062 -0.599 -0.279 -0.73 -0.582 L 48.998 51.788 L 37.192 63.594 l 3.292 10.806 c 0.108 0.353 0.012 0.737 -0.25 0.998 l -6.436 6.436 c -0.23 0.23 -0.557 0.331 -0.88 0.278 c -0.321 -0.057 -0.594 -0.266 -0.732 -0.561 l -7.519 -16.035 L 8.383 57.88 c -0.295 -0.139 -0.504 -0.412 -0.561 -0.732 c -0.056 -0.321 0.047 -0.649 0.278 -0.88 l 6.436 -6.436 c 0.261 -0.261 0.644 -0.354 0.999 -0.25 l 10.805 3.291 l 11.872 -11.872 L 0.599 24.553 c -0.303 -0.132 -0.521 -0.406 -0.582 -0.73 c -0.062 -0.325 0.042 -0.659 0.275 -0.893 l 7.538 -7.538 c 0.239 -0.239 0.583 -0.34 0.914 -0.271 l 45.69 9.658 L 76.979 2.234 C 78.42 0.793 80.335 0 82.372 0 c 2.038 0 3.953 0.793 5.394 2.234 C 89.207 3.675 90 5.59 90 7.627 s -0.793 3.953 -2.234 5.394 L 65.223 35.565 l 9.657 45.69 c 0.069 0.331 -0.032 0.675 -0.271 0.914 l -7.539 7.538 C 66.88 89.896 66.625 90 66.362 90 z') |
| 35 | + |
| 36 | +function drawAirplane(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, color: string) { |
| 37 | + ctx.save() |
| 38 | + ctx.fillStyle = color |
| 39 | + ctx.globalAlpha = 0.6 |
| 40 | + ctx.translate(x, y) |
| 41 | + // SVG nose points upper-right (~45°); rotate +45° to align with +x travel direction |
| 42 | + ctx.rotate(angle + Math.PI / 4) |
| 43 | + ctx.scale(0.3, 0.3) |
| 44 | + ctx.translate(-45, -45) // center the 90×90 path at origin |
| 45 | + ctx.fill(PLANE_PATH) |
| 46 | + ctx.restore() |
| 47 | +} |
| 48 | + |
| 49 | +const Boids: React.FC = () => { |
| 50 | + const canvasRef = useRef<HTMLCanvasElement>(null) |
| 51 | + const state = useRef<{ boids: Boid[]; raf: number }>({ boids: [], raf: 0 }) |
| 52 | + |
| 53 | + useEffect(() => { |
| 54 | + const canvas = canvasRef.current |
| 55 | + if (!canvas) return |
| 56 | + const ctx = canvas.getContext('2d') |
| 57 | + if (!ctx) return |
| 58 | + |
| 59 | + const resize = () => { |
| 60 | + canvas.width = canvas.offsetWidth |
| 61 | + canvas.height = canvas.offsetHeight |
| 62 | + if (state.current.boids.length === 0) { |
| 63 | + state.current.boids = Array.from({ length: NUM_BOIDS }, () => |
| 64 | + initBoid(canvas.width, canvas.height) |
| 65 | + ) |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + resize() |
| 70 | + window.addEventListener('resize', resize) |
| 71 | + |
| 72 | + const loop = () => { |
| 73 | + const { width, height } = canvas |
| 74 | + ctx.clearRect(0, 0, width, height) |
| 75 | + const color = getComputedStyle(document.documentElement) |
| 76 | + .getPropertyValue('--accent').trim() || 'rgb(0,234,255)' |
| 77 | + state.current.boids = step(state.current.boids, width, height) |
| 78 | + for (const b of state.current.boids) { |
| 79 | + drawAirplane(ctx, b.x, b.y, Math.atan2(b.vy, b.vx), color) |
| 80 | + } |
| 81 | + state.current.raf = requestAnimationFrame(loop) |
| 82 | + } |
| 83 | + loop() |
| 84 | + |
| 85 | + return () => { |
| 86 | + cancelAnimationFrame(state.current.raf) |
| 87 | + window.removeEventListener('resize', resize) |
| 88 | + } |
| 89 | + }, []) |
| 90 | + |
| 91 | + return <canvas ref={canvasRef} className="hero-boids" /> |
| 92 | +} |
| 93 | + |
| 94 | +export default Boids |
0 commit comments