src/components/page/Murmurations.astro 6.8 K raw
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>