feat: boids algo bf7a1dd3
Steve Simkins · 2026-05-02 23:29 5 file(s) · +422 −296
src/components/layout/Footer.astro +13 −6
12 12
  <a target="_blank" rel="noreferrer" class="underline" href="https://github.com/stevedylandev/stevedylan.dev">Source Code</a>
13 13
  <a class="underline" href="mailto:contact@stevedylan.dev">Contact</a>
14 14
  <a class="underline" href="/kill-your-lawn">Kill Your Lawn</a>
15 +
	<a href="/murmurations" class="flex items-center gap-1.5 murmuration-flock" aria-label="Murmurations">
16 +
		{Array.from({ length: 7 }).map(() => (
17 +
			<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256" class="murmuration-bird">
18 +
				<path fill="currentColor" d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/>
19 +
			</svg>
20 +
		))}
21 +
	</a>
22 +
	<script>
23 +
		document.querySelectorAll<SVGElement>('.murmuration-flock .murmuration-bird').forEach((el) => {
24 +
			const deg = Math.random() * 360;
25 +
			el.style.transform = `rotate(${deg}deg)`;
26 +
		});
27 +
	</script>
15 28
  <a href="https://blog.kagi.com/small-web-updates">
16 29
    <Image inferSize={true} src="https://kagifeedback.org/assets/files/2025-11-27/1764250950-635837-80x15-2.png" alt="kagi small web"/>
17 30
  </a>
18 -
	<a href="/googleballs" class="flex items-center gap-1" aria-label="Google Balls">
19 -
		<span style="background:#4285F4;width:6px;height:6px;border-radius:50%;display:inline-block;"></span>
20 -
		<span style="background:#EA4335;width:6px;height:6px;border-radius:50%;display:inline-block;"></span>
21 -
		<span style="background:#FBBC05;width:6px;height:6px;border-radius:50%;display:inline-block;"></span>
22 -
		<span style="background:#34A853;width:6px;height:6px;border-radius:50%;display:inline-block;"></span>
23 -
	</a>
24 31
</footer>
src/components/page/GoogleBalls.astro (deleted) +0 −262
1 -
---
2 -
---
3 -
4 -
<canvas id="googleballs-canvas" width="400" height="400"></canvas>
5 -
6 -
<script>
7 -
(function() {
8 -
	var canvas = document.getElementById("googleballs-canvas");
9 -
	var canvasHeight;
10 -
	var canvasWidth;
11 -
	var ctx;
12 -
	var lastFrameTime = 0;
13 -
	var animationId = null;
14 -
	var pointCollection;
15 -
16 -
	function init() {
17 -
		updateCanvasDimensions();
18 -
19 -
		var g = [new Point(202, 78, 0.0, 9, "#ed9d33"), new Point(348, 83, 0.0, 9, "#d44d61"), new Point(256, 69, 0.0, 9, "#4f7af2"), new Point(214, 59, 0.0, 9, "#ef9a1e"), new Point(265, 36, 0.0, 9, "#4976f3"), new Point(300, 78, 0.0, 9, "#269230"), new Point(294, 59, 0.0, 9, "#1f9e2c"), new Point(45, 88, 0.0, 9, "#1c48dd"), new Point(268, 52, 0.0, 9, "#2a56ea"), new Point(73, 83, 0.0, 9, "#3355d8"), new Point(294, 6, 0.0, 9, "#36b641"), new Point(235, 62, 0.0, 9, "#2e5def"), new Point(353, 42, 0.0, 8, "#d53747"), new Point(336, 52, 0.0, 8, "#eb676f"), new Point(208, 41, 0.0, 8, "#f9b125"), new Point(321, 70, 0.0, 8, "#de3646"), new Point(8, 60, 0.0, 8, "#2a59f0"), new Point(180, 81, 0.0, 8, "#eb9c31"), new Point(146, 65, 0.0, 8, "#c41731"), new Point(145, 49, 0.0, 8, "#d82038"), new Point(246, 34, 0.0, 8, "#5f8af8"), new Point(169, 69, 0.0, 8, "#efa11e"), new Point(273, 99, 0.0, 8, "#2e55e2"), new Point(248, 120, 0.0, 8, "#4167e4"), new Point(294, 41, 0.0, 8, "#0b991a"), new Point(267, 114, 0.0, 8, "#4869e3"), new Point(78, 67, 0.0, 8, "#3059e3"), new Point(294, 23, 0.0, 8, "#10a11d"), new Point(117, 83, 0.0, 8, "#cf4055"), new Point(137, 80, 0.0, 8, "#cd4359"), new Point(14, 71, 0.0, 8, "#2855ea"), new Point(331, 80, 0.0, 8, "#ca273c"), new Point(25, 82, 0.0, 8, "#2650e1"), new Point(233, 46, 0.0, 8, "#4a7bf9"), new Point(73, 13, 0.0, 8, "#3d65e7"), new Point(327, 35, 0.0, 6, "#f47875"), new Point(319, 46, 0.0, 6, "#f36764"), new Point(256, 81, 0.0, 6, "#1d4eeb"), new Point(244, 88, 0.0, 6, "#698bf1"), new Point(194, 32, 0.0, 6, "#fac652"), new Point(97, 56, 0.0, 6, "#ee5257"), new Point(105, 75, 0.0, 6, "#cf2a3f"), new Point(42, 4, 0.0, 6, "#5681f5"), new Point(10, 27, 0.0, 6, "#4577f6"), new Point(166, 55, 0.0, 6, "#f7b326"), new Point(266, 88, 0.0, 6, "#2b58e8"), new Point(178, 34, 0.0, 6, "#facb5e"), new Point(100, 65, 0.0, 6, "#e02e3d"), new Point(343, 32, 0.0, 6, "#f16d6f"), new Point(59, 5, 0.0, 6, "#507bf2"), new Point(27, 9, 0.0, 6, "#5683f7"), new Point(233, 116, 0.0, 6, "#3158e2"), new Point(123, 32, 0.0, 6, "#f0696c"), new Point(6, 38, 0.0, 6, "#3769f6"), new Point(63, 62, 0.0, 6, "#6084ef"), new Point(6, 49, 0.0, 6, "#2a5cf4"), new Point(108, 36, 0.0, 6, "#f4716e"), new Point(169, 43, 0.0, 6, "#f8c247"), new Point(137, 37, 0.0, 6, "#e74653"), new Point(318, 58, 0.0, 6, "#ec4147"), new Point(226, 100, 0.0, 5, "#4876f1"), new Point(101, 46, 0.0, 5, "#ef5c5c"), new Point(226, 108, 0.0, 5, "#2552ea"), new Point(17, 17, 0.0, 5, "#4779f7"), new Point(232, 93, 0.0, 5, "#4b78f1")];
20 -
21 -
		var gLength = g.length;
22 -
		for (var i = 0; i < gLength; i++) {
23 -
			g[i].curPos.x = (canvasWidth/2 - 180) + g[i].curPos.x;
24 -
			g[i].curPos.y = (canvasHeight/2 - 65) + g[i].curPos.y;
25 -
26 -
			g[i].originalPos.x = (canvasWidth/2 - 180) + g[i].originalPos.x;
27 -
			g[i].originalPos.y = (canvasHeight/2 - 65) + g[i].originalPos.y;
28 -
		}
29 -
30 -
		pointCollection = new PointCollection();
31 -
		pointCollection.points = g;
32 -
33 -
		window.addEventListener('resize', updateCanvasDimensions);
34 -
		window.addEventListener('mousemove', onMove);
35 -
36 -
		canvas.ontouchmove = function(e) {
37 -
			e.preventDefault();
38 -
			onTouchMove(e);
39 -
		};
40 -
41 -
		canvas.ontouchstart = function(e) {
42 -
			e.preventDefault();
43 -
		};
44 -
45 -
		lastFrameTime = 0;
46 -
		animationId = requestAnimationFrame(animateRAF);
47 -
	}
48 -
49 -
	function stop() {
50 -
		if (animationId) {
51 -
			cancelAnimationFrame(animationId);
52 -
			animationId = null;
53 -
		}
54 -
		window.removeEventListener('resize', updateCanvasDimensions);
55 -
		window.removeEventListener('mousemove', onMove);
56 -
		canvas.ontouchmove = null;
57 -
		canvas.ontouchstart = null;
58 -
		pointCollection = null;
59 -
		lastFrameTime = 0;
60 -
		if (ctx) {
61 -
			ctx.clearRect(0, 0, canvasWidth, canvasHeight);
62 -
		}
63 -
	}
64 -
65 -
	function animateRAF(timestamp) {
66 -
		if (!lastFrameTime) lastFrameTime = timestamp;
67 -
		var deltaTime = (timestamp - lastFrameTime) / 1000;
68 -
		lastFrameTime = timestamp;
69 -
70 -
		deltaTime = Math.min(deltaTime, 0.1);
71 -
72 -
		draw();
73 -
		update(deltaTime);
74 -
75 -
		animationId = requestAnimationFrame(animateRAF);
76 -
	}
77 -
78 -
	function updateCanvasDimensions() {
79 -
		var modal = document.getElementById('googleballs-modal');
80 -
		if (!modal) return;
81 -
82 -
		var dpr = window.devicePixelRatio || 1;
83 -
		var logicalWidth = modal.clientWidth;
84 -
		var logicalHeight = modal.clientHeight;
85 -
86 -
		canvas.width = logicalWidth * dpr;
87 -
		canvas.height = logicalHeight * dpr;
88 -
		canvas.style.width = logicalWidth + 'px';
89 -
		canvas.style.height = logicalHeight + 'px';
90 -
91 -
		canvasWidth = logicalWidth;
92 -
		canvasHeight = logicalHeight;
93 -
94 -
		if (canvas.getContext) {
95 -
			ctx = canvas.getContext('2d');
96 -
			ctx.scale(dpr, dpr);
97 -
		}
98 -
99 -
		recenterPoints();
100 -
		draw();
101 -
	}
102 -
103 -
	function recenterPoints() {
104 -
		if (!pointCollection) return;
105 -
106 -
		var offsetX = (canvasWidth / 2 - 180);
107 -
		var offsetY = (canvasHeight / 2 - 65);
108 -
109 -
		for (var i = 0; i < pointCollection.points.length; i++) {
110 -
			var point = pointCollection.points[i];
111 -
112 -
			var relX = point.originalPos.x - (canvasWidth / 2 - 180);
113 -
			var relY = point.originalPos.y - (canvasHeight / 2 - 65);
114 -
115 -
			point.originalPos.x = offsetX + relX;
116 -
			point.originalPos.y = offsetY + relY;
117 -
118 -
			point.curPos.x = point.originalPos.x;
119 -
			point.curPos.y = point.originalPos.y;
120 -
		}
121 -
	}
122 -
123 -
	function onMove(e) {
124 -
		if (pointCollection) {
125 -
			var modal = document.getElementById('googleballs-modal');
126 -
			var rect = modal.getBoundingClientRect();
127 -
			pointCollection.mousePos.set(e.clientX - rect.left, e.clientY - rect.top);
128 -
		}
129 -
	}
130 -
131 -
	function onTouchMove(e) {
132 -
		if (pointCollection) {
133 -
			var modal = document.getElementById('googleballs-modal');
134 -
			var rect = modal.getBoundingClientRect();
135 -
			pointCollection.mousePos.set(
136 -
				e.targetTouches[0].clientX - rect.left,
137 -
				e.targetTouches[0].clientY - rect.top
138 -
			);
139 -
		}
140 -
	}
141 -
142 -
	function draw() {
143 -
		if (!ctx) return;
144 -
		ctx.clearRect(0, 0, canvasWidth, canvasHeight);
145 -
		if (pointCollection) pointCollection.draw();
146 -
	}
147 -
148 -
	function update(dt) {
149 -
		if (pointCollection) pointCollection.update(dt);
150 -
	}
151 -
152 -
	function Vector(x, y, z) {
153 -
		this.x = x;
154 -
		this.y = y;
155 -
		this.z = z;
156 -
157 -
		this.addX = function(x) { this.x += x; };
158 -
		this.addY = function(y) { this.y += y; };
159 -
		this.addZ = function(z) { this.z += z; };
160 -
		this.set = function(x, y, z) { this.x = x; this.y = y; this.z = z; };
161 -
	}
162 -
163 -
	function PointCollection() {
164 -
		this.mousePos = new Vector(0, 0);
165 -
		this.points = [];
166 -
167 -
		this.update = function(dt) {
168 -
			var pointsLength = this.points.length;
169 -
			for (var i = 0; i < pointsLength; i++) {
170 -
				var point = this.points[i];
171 -
				if (point == null) continue;
172 -
173 -
				var dx = this.mousePos.x - point.curPos.x;
174 -
				var dy = this.mousePos.y - point.curPos.y;
175 -
				var dd = (dx * dx) + (dy * dy);
176 -
				var d = Math.sqrt(dd);
177 -
178 -
				if (d < 150) {
179 -
					point.targetPos.x = point.curPos.x - dx;
180 -
					point.targetPos.y = point.curPos.y - dy;
181 -
				} else {
182 -
					point.targetPos.x = point.originalPos.x;
183 -
					point.targetPos.y = point.originalPos.y;
184 -
				}
185 -
186 -
				point.update(dt);
187 -
			}
188 -
		};
189 -
190 -
		this.draw = function() {
191 -
			var pointsLength = this.points.length;
192 -
			for (var i = 0; i < pointsLength; i++) {
193 -
				var point = this.points[i];
194 -
				if (point == null) continue;
195 -
				point.draw();
196 -
			}
197 -
		};
198 -
	}
199 -
200 -
	function Point(x, y, z, size, colour) {
201 -
		this.colour = colour;
202 -
		this.curPos = new Vector(x, y, z);
203 -
		this.friction = 0.8;
204 -
		this.originalPos = new Vector(x, y, z);
205 -
		this.radius = size;
206 -
		this.size = size;
207 -
		this.springStrength = 0.1;
208 -
		this.targetPos = new Vector(x, y, z);
209 -
		this.velocity = new Vector(0.0, 0.0, 0.0);
210 -
211 -
		this.update = function(dt) {
212 -
			var targetFrameTime = 0.03;
213 -
			var timeScale = dt / targetFrameTime;
214 -
215 -
			var dx = this.targetPos.x - this.curPos.x;
216 -
			var ax = dx * this.springStrength * timeScale;
217 -
			this.velocity.x += ax;
218 -
			this.velocity.x *= Math.pow(this.friction, timeScale);
219 -
			this.curPos.x += this.velocity.x * timeScale;
220 -
221 -
			var dy = this.targetPos.y - this.curPos.y;
222 -
			var ay = dy * this.springStrength * timeScale;
223 -
			this.velocity.y += ay;
224 -
			this.velocity.y *= Math.pow(this.friction, timeScale);
225 -
			this.curPos.y += this.velocity.y * timeScale;
226 -
227 -
			var dox = this.originalPos.x - this.curPos.x;
228 -
			var doy = this.originalPos.y - this.curPos.y;
229 -
			var dd = (dox * dox) + (doy * doy);
230 -
			var d = Math.sqrt(dd);
231 -
232 -
			this.targetPos.z = d/100 + 1;
233 -
			var dz = this.targetPos.z - this.curPos.z;
234 -
			var az = dz * this.springStrength * timeScale;
235 -
			this.velocity.z += az;
236 -
			this.velocity.z *= Math.pow(this.friction, timeScale);
237 -
			this.curPos.z += this.velocity.z * timeScale;
238 -
239 -
			this.radius = this.size * this.curPos.z;
240 -
			if (this.radius < 1) this.radius = 1;
241 -
		};
242 -
243 -
		this.draw = function() {
244 -
			ctx.fillStyle = this.colour;
245 -
			ctx.beginPath();
246 -
			ctx.arc(this.curPos.x, this.curPos.y, this.radius, 0, Math.PI*2, true);
247 -
			ctx.fill();
248 -
		};
249 -
	}
250 -
251 -
	// Expose init/stop for the modal controller
252 -
	window.__googleballs = { init: init, stop: stop };
253 -
})();
254 -
</script>
255 -
256 -
<style>
257 -
#googleballs-canvas {
258 -
	position: absolute;
259 -
	top: 0;
260 -
	left: 0;
261 -
}
262 -
</style>
src/components/page/Murmurations.astro (added) +276 −0
1 +
---
2 +
---
3 +
4 +
<canvas id="murmurations-canvas"></canvas>
5 +
6 +
<script>
7 +
(() => {
8 +
	const canvas = document.getElementById('murmurations-canvas') as HTMLCanvasElement;
9 +
	const ctx = canvas.getContext('2d')!;
10 +
11 +
	let W: number, H: number;
12 +
	const DPR = Math.min(window.devicePixelRatio || 1, 2);
13 +
14 +
	function viewportSize() {
15 +
		const vv = window.visualViewport;
16 +
		return {
17 +
			w: vv ? vv.width : window.innerWidth,
18 +
			h: vv ? vv.height : window.innerHeight,
19 +
		};
20 +
	}
21 +
22 +
	function resize() {
23 +
		const { w, h } = viewportSize();
24 +
		W = w;
25 +
		H = h;
26 +
		canvas.width = W * DPR;
27 +
		canvas.height = H * DPR;
28 +
		canvas.style.width = W + 'px';
29 +
		canvas.style.height = H + 'px';
30 +
		ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
31 +
		ctx.lineCap = 'round';
32 +
		ctx.strokeStyle = 'rgba(245, 243, 238, 0.78)';
33 +
		ctx.lineWidth = 1.4;
34 +
	}
35 +
	resize();
36 +
	window.addEventListener('resize', resize);
37 +
	window.addEventListener('orientationchange', resize);
38 +
	if (window.visualViewport) {
39 +
		window.visualViewport.addEventListener('resize', resize);
40 +
	}
41 +
42 +
	const TOPOLOGICAL_K = 7;
43 +
	const PERCEPTION = 70;
44 +
	const SEP_RANGE = 14;
45 +
	const MAX_SPEED = 3.4;
46 +
	const MIN_SPEED = 2.2;
47 +
	const MAX_FORCE = 0.12;
48 +
49 +
	const W_SEP = 1.8;
50 +
	const W_ALIGN = 1.4;
51 +
	const W_COH = 0.9;
52 +
	const W_ATTRACT = 0.06;
53 +
	const W_NOISE = 0.015;
54 +
55 +
	const COUNT = Math.min(420, Math.floor((W * H) / 3200));
56 +
57 +
	const attractor = {
58 +
		x: W / 2, y: H / 2,
59 +
		t: Math.random() * 1000,
60 +
		update() {
61 +
			this.t += 0.003;
62 +
			const cx = W / 2, cy = H / 2;
63 +
			const rx = W * 0.32, ry = H * 0.28;
64 +
			this.x = cx + Math.sin(this.t * 1.3) * rx + Math.cos(this.t * 0.7) * rx * 0.3;
65 +
			this.y = cy + Math.cos(this.t * 1.1) * ry + Math.sin(this.t * 1.7) * ry * 0.25;
66 +
		}
67 +
	};
68 +
69 +
	class Boid {
70 +
		x: number; y: number; vx: number; vy: number; ax = 0; ay = 0;
71 +
		constructor() {
72 +
			this.x = Math.random() * W;
73 +
			this.y = Math.random() * H;
74 +
			const a = Math.random() * Math.PI * 2;
75 +
			const s = MIN_SPEED + Math.random() * (MAX_SPEED - MIN_SPEED);
76 +
			this.vx = Math.cos(a) * s;
77 +
			this.vy = Math.sin(a) * s;
78 +
		}
79 +
80 +
		edges() {
81 +
			const m = 20;
82 +
			if (this.x < -m) this.x = W + m;
83 +
			else if (this.x > W + m) this.x = -m;
84 +
			if (this.y < -m) this.y = H + m;
85 +
			else if (this.y > H + m) this.y = -m;
86 +
		}
87 +
88 +
		flock(candidates: Boid[]) {
89 +
			const dists: { o: Boid; d2: number; dx: number; dy: number }[] = [];
90 +
			for (let i = 0; i < candidates.length; i++) {
91 +
				const o = candidates[i];
92 +
				if (o === this) continue;
93 +
				const dx = o.x - this.x;
94 +
				const dy = o.y - this.y;
95 +
				const d2 = dx * dx + dy * dy;
96 +
				if (d2 < PERCEPTION * PERCEPTION && d2 > 0) {
97 +
					dists.push({ o, d2, dx, dy });
98 +
				}
99 +
			}
100 +
			dists.sort((a, b) => a.d2 - b.d2);
101 +
			const k = Math.min(TOPOLOGICAL_K, dists.length);
102 +
103 +
			let alignX = 0, alignY = 0;
104 +
			let cohX = 0, cohY = 0;
105 +
			let sepX = 0, sepY = 0;
106 +
			let sepCount = 0;
107 +
108 +
			for (let i = 0; i < k; i++) {
109 +
				const { o, d2, dx, dy } = dists[i];
110 +
				alignX += o.vx;
111 +
				alignY += o.vy;
112 +
				cohX += o.x;
113 +
				cohY += o.y;
114 +
115 +
				if (d2 < SEP_RANGE * SEP_RANGE) {
116 +
					const d = Math.sqrt(d2);
117 +
					const f = 1 / (d + 0.001);
118 +
					sepX -= (dx / d) * f;
119 +
					sepY -= (dy / d) * f;
120 +
					sepCount++;
121 +
				}
122 +
			}
123 +
124 +
			let fx = 0, fy = 0;
125 +
126 +
			if (k > 0) {
127 +
				alignX /= k; alignY /= k;
128 +
				const m = Math.hypot(alignX, alignY);
129 +
				if (m > 0) {
130 +
					alignX = alignX / m * MAX_SPEED - this.vx;
131 +
					alignY = alignY / m * MAX_SPEED - this.vy;
132 +
					const sm = Math.hypot(alignX, alignY);
133 +
					if (sm > MAX_FORCE) { alignX = alignX / sm * MAX_FORCE; alignY = alignY / sm * MAX_FORCE; }
134 +
					fx += alignX * W_ALIGN;
135 +
					fy += alignY * W_ALIGN;
136 +
				}
137 +
138 +
				cohX = cohX / k - this.x;
139 +
				cohY = cohY / k - this.y;
140 +
				const cm = Math.hypot(cohX, cohY);
141 +
				if (cm > 0) {
142 +
					cohX = cohX / cm * MAX_SPEED - this.vx;
143 +
					cohY = cohY / cm * MAX_SPEED - this.vy;
144 +
					const sm = Math.hypot(cohX, cohY);
145 +
					if (sm > MAX_FORCE) { cohX = cohX / sm * MAX_FORCE; cohY = cohY / sm * MAX_FORCE; }
146 +
					fx += cohX * W_COH;
147 +
					fy += cohY * W_COH;
148 +
				}
149 +
			}
150 +
151 +
			if (sepCount > 0) {
152 +
				const m = Math.hypot(sepX, sepY);
153 +
				if (m > 0) {
154 +
					sepX = sepX / m * MAX_SPEED - this.vx;
155 +
					sepY = sepY / m * MAX_SPEED - this.vy;
156 +
					const sm = Math.hypot(sepX, sepY);
157 +
					if (sm > MAX_FORCE * 2) { sepX = sepX / sm * MAX_FORCE * 2; sepY = sepY / sm * MAX_FORCE * 2; }
158 +
					fx += sepX * W_SEP;
159 +
					fy += sepY * W_SEP;
160 +
				}
161 +
			}
162 +
163 +
			const adx = attractor.x - this.x;
164 +
			const ady = attractor.y - this.y;
165 +
			const ad = Math.hypot(adx, ady);
166 +
			if (ad > 0) {
167 +
				fx += (adx / ad) * W_ATTRACT;
168 +
				fy += (ady / ad) * W_ATTRACT;
169 +
			}
170 +
171 +
			fx += (Math.random() - 0.5) * W_NOISE;
172 +
			fy += (Math.random() - 0.5) * W_NOISE;
173 +
174 +
			this.ax += fx;
175 +
			this.ay += fy;
176 +
		}
177 +
178 +
		update() {
179 +
			this.vx += this.ax;
180 +
			this.vy += this.ay;
181 +
			const sp = Math.hypot(this.vx, this.vy);
182 +
			if (sp > MAX_SPEED) {
183 +
				this.vx = this.vx / sp * MAX_SPEED;
184 +
				this.vy = this.vy / sp * MAX_SPEED;
185 +
			} else if (sp < MIN_SPEED && sp > 0) {
186 +
				this.vx = this.vx / sp * MIN_SPEED;
187 +
				this.vy = this.vy / sp * MIN_SPEED;
188 +
			}
189 +
			this.x += this.vx;
190 +
			this.y += this.vy;
191 +
			this.ax = 0;
192 +
			this.ay = 0;
193 +
		}
194 +
195 +
		draw() {
196 +
			const angle = Math.atan2(this.vy, this.vx);
197 +
			const cos = Math.cos(angle);
198 +
			const sin = Math.sin(angle);
199 +
			const len = 3.2;
200 +
			ctx.beginPath();
201 +
			ctx.moveTo(this.x - cos * len, this.y - sin * len);
202 +
			ctx.lineTo(this.x + cos * len * 0.6, this.y + sin * len * 0.6);
203 +
			ctx.stroke();
204 +
		}
205 +
	}
206 +
207 +
	const boids: Boid[] = [];
208 +
	const cx0 = W / 2, cy0 = H / 2;
209 +
	for (let i = 0; i < COUNT; i++) {
210 +
		const b = new Boid();
211 +
		const r = Math.random() * Math.min(W, H) * 0.25;
212 +
		const a = Math.random() * Math.PI * 2;
213 +
		b.x = cx0 + Math.cos(a) * r;
214 +
		b.y = cy0 + Math.sin(a) * r;
215 +
		boids.push(b);
216 +
	}
217 +
218 +
	const cellSize = PERCEPTION;
219 +
	const grid = new Map<string, Boid[]>();
220 +
	function buildGrid() {
221 +
		grid.clear();
222 +
		for (let i = 0; i < boids.length; i++) {
223 +
			const b = boids[i];
224 +
			const cx = Math.floor(b.x / cellSize);
225 +
			const cy = Math.floor(b.y / cellSize);
226 +
			const k = cx + ',' + cy;
227 +
			let arr = grid.get(k);
228 +
			if (!arr) { arr = []; grid.set(k, arr); }
229 +
			arr.push(b);
230 +
		}
231 +
	}
232 +
	function neighbors(b: Boid): Boid[] {
233 +
		const cx = Math.floor(b.x / cellSize);
234 +
		const cy = Math.floor(b.y / cellSize);
235 +
		const out: Boid[] = [];
236 +
		for (let dx = -1; dx <= 1; dx++) {
237 +
			for (let dy = -1; dy <= 1; dy++) {
238 +
				const arr = grid.get((cx + dx) + ',' + (cy + dy));
239 +
				if (arr) for (let i = 0; i < arr.length; i++) out.push(arr[i]);
240 +
			}
241 +
		}
242 +
		return out;
243 +
	}
244 +
245 +
	function loop() {
246 +
		ctx.fillStyle = 'rgba(18, 17, 19, 0.55)';
247 +
		ctx.fillRect(0, 0, W, H);
248 +
249 +
		attractor.update();
250 +
		buildGrid();
251 +
252 +
		for (let i = 0; i < boids.length; i++) {
253 +
			boids[i].flock(neighbors(boids[i]));
254 +
		}
255 +
		for (let i = 0; i < boids.length; i++) {
256 +
			boids[i].update();
257 +
			boids[i].edges();
258 +
			boids[i].draw();
259 +
		}
260 +
261 +
		requestAnimationFrame(loop);
262 +
	}
263 +
	loop();
264 +
})();
265 +
</script>
266 +
267 +
<style>
268 +
	#murmurations-canvas {
269 +
		position: fixed;
270 +
		inset: 0;
271 +
		width: 100%;
272 +
		height: 100%;
273 +
		display: block;
274 +
		touch-action: none;
275 +
	}
276 +
</style>
src/pages/googleballs.astro (deleted) +0 −28
1 -
---
2 -
import GoogleBalls from "@/components/page/GoogleBalls.astro";
3 -
---
4 -
5 -
<html lang="en">
6 -
	<head>
7 -
		<meta charset="utf-8" />
8 -
		<meta name="viewport" content="width=device-width, initial-scale=1" />
9 -
		<meta name="apple-mobile-web-app-capable" content="yes" />
10 -
		<meta name="theme-color" content="#121113" />
11 -
		<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
12 -
		<title>Google Balls</title>
13 -
		<style>
14 -
			* { margin: 0; padding: 0; box-sizing: border-box; }
15 -
			html, body { width: 100%; height: 100%; overflow: hidden; background: #121113; }
16 -
		</style>
17 -
	</head>
18 -
	<body>
19 -
		<div id="googleballs-modal" style="position:fixed;inset:0;background:#121113;">
20 -
			<a href="https://googleballs.com" target="_blank" rel="noreferrer" style="position:absolute;top:16px;left:16px;color:#fff;font-size:16px;cursor:pointer;z-index:10;text-decoration:underline;">Google Balls</a>
21 -
			<a href="/" style="position:absolute;top:16px;right:16px;z-index:10;color:#fff;font-size:16px;text-decoration:underline;">Back</a>
22 -
			<GoogleBalls />
23 -
		</div>
24 -
		<script>
25 -
			if (window.__googleballs) window.__googleballs.init();
26 -
		</script>
27 -
	</body>
28 -
</html>
src/pages/murmurations.astro (added) +133 −0
1 +
---
2 +
import Murmurations from "@/components/page/Murmurations.astro";
3 +
---
4 +
5 +
<html lang="en">
6 +
	<head>
7 +
		<meta charset="utf-8" />
8 +
		<meta name="viewport" content="width=device-width, initial-scale=1" />
9 +
		<meta name="apple-mobile-web-app-capable" content="yes" />
10 +
		<meta name="theme-color" content="#121113" />
11 +
		<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
12 +
		<title>Murmurations</title>
13 +
		<style>
14 +
			* { margin: 0; padding: 0; box-sizing: border-box; }
15 +
			html, body {
16 +
				width: 100%;
17 +
				height: 100vh;
18 +
				height: 100dvh;
19 +
				overflow: hidden;
20 +
				background: #121113;
21 +
				overscroll-behavior: none;
22 +
				touch-action: none;
23 +
				-webkit-tap-highlight-color: transparent;
24 +
			}
25 +
26 +
			.top-link {
27 +
				position: fixed;
28 +
				top: max(16px, env(safe-area-inset-top));
29 +
				color: #f5f3ee;
30 +
				font-size: 16px;
31 +
				z-index: 10;
32 +
				text-decoration: underline;
33 +
				font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
34 +
				padding: 8px;
35 +
				margin: -8px;
36 +
			}
37 +
			.top-link.title { left: max(16px, env(safe-area-inset-left)); }
38 +
			.top-link.back { right: max(16px, env(safe-area-inset-right)); }
39 +
40 +
			.info-btn {
41 +
				position: fixed;
42 +
				bottom: max(16px, env(safe-area-inset-bottom));
43 +
				right: max(16px, env(safe-area-inset-right));
44 +
				width: 40px;
45 +
				height: 40px;
46 +
				border-radius: 50%;
47 +
				border: 1px solid rgba(245, 243, 238, 0.6);
48 +
				background: rgba(18, 17, 19, 0.6);
49 +
				color: #f5f3ee;
50 +
				font-family: ui-serif, Georgia, serif;
51 +
				font-style: italic;
52 +
				font-size: 18px;
53 +
				cursor: pointer;
54 +
				z-index: 10;
55 +
				display: flex;
56 +
				align-items: center;
57 +
				justify-content: center;
58 +
				padding: 0;
59 +
				-webkit-tap-highlight-color: transparent;
60 +
			}
61 +
			.info-btn:hover, .info-btn:active { background: rgba(245, 243, 238, 0.15); }
62 +
63 +
			.info-panel {
64 +
				position: fixed;
65 +
				bottom: calc(max(16px, env(safe-area-inset-bottom)) + 52px);
66 +
				right: max(16px, env(safe-area-inset-right));
67 +
				left: max(16px, env(safe-area-inset-left));
68 +
				max-width: 340px;
69 +
				margin-left: auto;
70 +
				background: rgba(18, 17, 19, 0.94);
71 +
				border: 1px solid rgba(245, 243, 238, 0.2);
72 +
				color: #f5f3ee;
73 +
				padding: 16px 18px;
74 +
				font-size: 13px;
75 +
				line-height: 1.5;
76 +
				z-index: 10;
77 +
				display: none;
78 +
				font-family: ui-sans-serif, system-ui, sans-serif;
79 +
			}
80 +
			.info-panel.open { display: block; }
81 +
			.info-panel h2 {
82 +
				font-size: 14px;
83 +
				margin-bottom: 8px;
84 +
				font-weight: 600;
85 +
			}
86 +
			.info-panel p { margin-bottom: 8px; }
87 +
			.info-panel p:last-child { margin-bottom: 0; }
88 +
			.info-panel a { color: #f5f3ee; text-decoration: underline; }
89 +
90 +
			@media (max-width: 480px) {
91 +
				.top-link { font-size: 14px; }
92 +
				.info-panel { font-size: 12px; padding: 14px; }
93 +
			}
94 +
		</style>
95 +
	</head>
96 +
	<body>
97 +
		<span class="top-link title">Murmurations</span>
98 +
		<a href="/" class="top-link back">Back</a>
99 +
		<Murmurations />
100 +
		<button id="info-btn" class="info-btn" aria-label="About this simulation" aria-expanded="false">i</button>
101 +
		<div id="info-panel" class="info-panel" role="dialog" aria-label="About this simulation">
102 +
			<h2>Boids &amp; Starlings</h2>
103 +
			<p>
104 +
				A <em>murmuration</em> is the swirling, shape-shifting flight of thousands of starlings at dusk.
105 +
				No leader directs them — coherence emerges from each bird reacting to its closest neighbors.
106 +
			</p>
107 +
			<p>
108 +
				This simulation uses <a href="https://en.wikipedia.org/wiki/Boids" target="_blank" rel="noreferrer">Craig Reynolds&apos; boids algorithm</a>:
109 +
				each agent follows three rules — <strong>separation</strong> (avoid crowding),
110 +
				<strong>alignment</strong> (match neighbor heading), and <strong>cohesion</strong> (steer toward neighbor center).
111 +
			</p>
112 +
			<p>
113 +
				Real starlings track ~7 nearest neighbors regardless of distance — topological, not metric.
114 +
				That fixed count is why turning waves cross the flock so fast and stay crisp at any density.
115 +
			</p>
116 +
		</div>
117 +
		<script>
118 +
			const btn = document.getElementById('info-btn')!;
119 +
			const panel = document.getElementById('info-panel')!;
120 +
			btn.addEventListener('click', (e) => {
121 +
				e.stopPropagation();
122 +
				const open = panel.classList.toggle('open');
123 +
				btn.setAttribute('aria-expanded', String(open));
124 +
			});
125 +
			document.addEventListener('click', (e) => {
126 +
				if (!panel.classList.contains('open')) return;
127 +
				if (panel.contains(e.target as Node)) return;
128 +
				panel.classList.remove('open');
129 +
				btn.setAttribute('aria-expanded', 'false');
130 +
			});
131 +
		</script>
132 +
	</body>
133 +
</html>