Skip to content

Commit 35d300b

Browse files
Roméo MaignalRoméo Maignal
authored andcommitted
feat(HERO): boids added
1 parent 2a8a400 commit 35d300b

4 files changed

Lines changed: 110 additions & 0 deletions

File tree

src/components/Boids.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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

src/components/Hero.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
background-size: 28px 28px;
1010
}
1111

12+
.hero-boids {
13+
position: absolute;
14+
inset: 0;
15+
width: 100%;
16+
height: 100%;
17+
pointer-events: none;
18+
z-index: 0;
19+
}
20+
1221
.hero-content::before {
1322
content: '';
1423
position: absolute;

src/components/Hero.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect } from 'react'
22
import './Hero.css'
33
import { GithubIcon } from './icons/GithubIcon'
4+
import Boids from './Boids'
45

56
const stats = [
67
{ value: '...', label: 'Stat 1' },
@@ -32,6 +33,7 @@ const Hero: React.FC = () => {
3233

3334
return (
3435
<div className="hero-content">
36+
<Boids />
3537
<div className="hero-container">
3638
<h1 className="hero-title">
3739
{/* How safe do you think<br />
Lines changed: 5 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)