src/pages/murmurations.astro 9.7 K raw
1
---
2
import PageLayout from "@/layouts/Base.astro";
3
import Murmurations from "@/components/page/Murmurations.astro";
4
5
const meta = {
6
	title: "/murmurations",
7
	description: "Birds, math, and poetry in motion",
8
};
9
---
10
11
<PageLayout meta={meta}>
12
	<Murmurations />
13
	<style is:global>
14
		body.murmurations-page {
15
			padding: 0 !important;
16
			max-width: none !important;
17
		}
18
		body.murmurations-page #main-header,
19
		body.murmurations-page footer {
20
			display: none !important;
21
		}
22
		body.murmurations-page #main {
23
			flex: none;
24
		}
25
	</style>
26
	<script>
27
		document.body.classList.add("murmurations-page");
28
	</script>
29
	<a
30
		href="/"
31
		class="fixed bottom-4 left-4 z-10 text-sm underline"
32
		style="bottom: max(1rem, env(safe-area-inset-bottom)); left: max(1rem, env(safe-area-inset-left));"
33
	>
34
		Back
35
	</a>
36
	<audio id="murmuration-audio" src="/murmuration.mp3" preload="none"></audio>
37
	<div
38
		id="poem-overlay"
39
		class="pointer-events-none fixed inset-0 z-10 hidden items-center justify-center p-8 italic text-xl"
40
		aria-live="polite"
41
	>
42
		<div class="max-w-xl text-center text-sm leading-relaxed whitespace-pre-line text-textColor/90 opacity-0 transition-opacity duration-700 ease-in-out"></div>
43
	</div>
44
	<button
45
		id="audio-btn"
46
		class="fixed right-16 bottom-4 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-textColor/60 bg-bgColor/60 p-0 text-base hover:bg-textColor/15 active:bg-textColor/15"
47
		style="bottom: max(1rem, env(safe-area-inset-bottom)); right: calc(max(1rem, env(safe-area-inset-right)) + 3rem);"
48
		aria-label="Play audio"
49
		aria-pressed="false"
50
	>
51
		<span id="audio-icon" class="flex h-8 w-8 items-center justify-center"></span>
52
	</button>
53
	<button
54
		id="info-btn"
55
		class="fixed right-4 bottom-4 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-textColor/60 bg-bgColor/60 p-0 text-base hover:bg-textColor/15 active:bg-textColor/15"
56
		style="bottom: max(1rem, env(safe-area-inset-bottom)); right: max(1rem, env(safe-area-inset-right));"
57
		aria-label="About this simulation"
58
		aria-expanded="false"
59
	>
60
		i
61
	</button>
62
	<div
63
		id="info-panel"
64
		class="fixed right-4 left-4 z-10 ml-auto hidden max-w-sm border border-textColor/20 bg-bgColor/95 p-4 text-xs leading-relaxed"
65
		style="bottom: calc(max(1rem, env(safe-area-inset-bottom)) + 3.25rem); right: max(1rem, env(safe-area-inset-right)); left: max(1rem, env(safe-area-inset-left));"
66
		role="dialog"
67
		aria-label="About this simulation"
68
	>
69
		<h2 class="mb-2 text-sm font-semibold">Boids &amp; Starlings</h2>
70
		<p class="mb-2">
71
			A <em>murmuration</em> is the swirling, shape-shifting flight of thousands of starlings at dusk.
72
      There is no leader, rather coherence emerges from each bird reacting to its closest neighbors.
73
		</p>
74
		<p class="mb-2">
75
			This simulation uses <a class="underline" href="https://en.wikipedia.org/wiki/Boids" target="_blank" rel="noreferrer">Craig Reynolds&apos; boids algorithm</a>.
76
			Each agent follows three rules:
77
		</p>
78
		<ol class="mb-2 ml-5 list-decimal space-y-1">
79
			<li><strong>Separation</strong> (avoid crowding)</li>
80
			<li><strong>Alignment</strong> (match neighbor heading)</li>
81
			<li><strong>Cohesion</strong> (steer toward neighbor center)</li>
82
		</ol>
83
		<p>
84
			Real starlings track ~7 nearest neighbors regardless of distance.
85
			That fixed count is why turning waves cross the flock so fast and stay crisp at any density.
86
		</p>
87
    <br/>
88
    <p>
89
    This simulation doesn't come close to the real thing, and I would highly recommend <a target="_blank" rel="noreferrer" href="https://youtu.be/uV54oa0SyMc" class="style-link">taking a few minutes to see it</a>
90
    </p>
91
    <br/>
92
    <p class="italic">
93
      Audio credits: <a href="https://youtu.be/vj_zcAC2Y_0?si=zBdfwWx-a4N53VUv&t=345" target="_blank" rel="noreferrer" class="style-link">Starlings in Winter by Mary Oliver</a>
94
    </p>
95
	</div>
96
	<script>
97
		const audioBtn = document.getElementById("audio-btn") as HTMLButtonElement | null;
98
		const audioEl = document.getElementById("murmuration-audio") as HTMLAudioElement | null;
99
		const audioIcon = document.getElementById("audio-icon");
100
		const poemOverlay = document.getElementById("poem-overlay") as HTMLDivElement | null;
101
102
		// "Starlings in Winter" by Mary Oliver — read by Sean Bean.
103
		// Times in seconds from the start of /murmuration.mp3 (clip begins at 5:45 of source).
104
		const cues: { t: number; line: string }[] = [
105
			{ t: 13, line: "Starlings in Winter\nby Mary Oliver" },
106
			{ t: 18, line: "Chunky and noisy," },
107
			{ t: 20, line: "but with stars in their black feathers,\nthey spring from the telephone wire\nand instantly" },
108
			{ t: 27, line: "they are acrobats in the freezing wind.\nAnd now, in the theater of air,\nthey swing over buildings," },
109
			{ t: 36, line: "dipping and rising;\nthey float like one stippled star\nthat opens,\nbecomes for a moment fragmented," },
110
			{ t: 45, line: "then closes again;\nand you watch\nand you try\nbut you simply can't imagine" },
111
			{ t: 54, line: "how they do it\nwith no articulated instruction, no pause,\nonly the silent confirmation\nthat they are this notable thing," },
112
			{ t: 67, line: "this wheel of many parts, that can rise and spin\nover and over again,\nfull of gorgeous life." },
113
			{ t: 77, line: "Ah, world, what lessons you prepare for us,\neven in the leafless winter,\neven in the ashy city.\nI am thinking now\nof grief, and of getting past it;" },
114
			{ t: 92, line: "I feel my boots\ntrying to leave the ground,\nI feel my heart\npumping hard. I want" },
115
			{ t: 101, line: "to think again of dangerous and noble things.\nI want to be light and frolicsome.\nI want to be improbable beautiful and afraid of nothing,\nas though I had wings." },
116
		];
117
118
		const POEM_END_T = 118;
119
		const PLAY_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="h-4 w-4" viewBox="0 0 256 256"><path fill="currentColor" d="M231.36 116.19L87.28 28.06a14 14 0 0 0-14.18-.27A13.69 13.69 0 0 0 66 39.87v176.26a13.69 13.69 0 0 0 7.1 12.08a14 14 0 0 0 14.18-.27l144.08-88.13a13.82 13.82 0 0 0 0-23.62m-6.26 13.38L81 217.7a2 2 0 0 1-2.06 0a1.78 1.78 0 0 1-1-1.61V39.87a1.78 1.78 0 0 1 1-1.61A2.06 2.06 0 0 1 80 38a2 2 0 0 1 1 .31l144.1 88.12a1.82 1.82 0 0 1 0 3.14"/></svg>';
120
		const PAUSE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="h-4 w-4" viewBox="0 0 256 256"><path fill="currentColor" d="M200 34h-40a14 14 0 0 0-14 14v160a14 14 0 0 0 14 14h40a14 14 0 0 0 14-14V48a14 14 0 0 0-14-14m2 174a2 2 0 0 1-2 2h-40a2 2 0 0 1-2-2V48a2 2 0 0 1 2-2h40a2 2 0 0 1 2 2ZM96 34H56a14 14 0 0 0-14 14v160a14 14 0 0 0 14 14h40a14 14 0 0 0 14-14V48a14 14 0 0 0-14-14m2 174a2 2 0 0 1-2 2H56a2 2 0 0 1-2-2V48a2 2 0 0 1 2-2h40a2 2 0 0 1 2 2Z"/></svg>';
121
		if (audioIcon) audioIcon.innerHTML = PLAY_SVG;
122
123
		const poemText = poemOverlay?.firstElementChild as HTMLDivElement | null;
124
		let lastCueIdx = -1;
125
		let swapTimer: number | undefined;
126
		const FADE_MS = 300;
127
		const renderCue = (idx: number) => {
128
			if (!poemOverlay || !poemText) return;
129
			if (idx === lastCueIdx) return;
130
			window.clearTimeout(swapTimer);
131
			const wasHidden = poemOverlay.classList.contains("hidden");
132
			poemText.classList.remove("opacity-100");
133
			poemText.classList.add("opacity-0");
134
			const swap = () => {
135
				if (idx < 0) {
136
					poemOverlay.classList.add("hidden");
137
					poemOverlay.classList.remove("flex");
138
					poemText.textContent = "";
139
				} else {
140
					poemText.textContent = cues[idx].line;
141
					poemOverlay.classList.remove("hidden");
142
					poemOverlay.classList.add("flex");
143
					requestAnimationFrame(() => {
144
						requestAnimationFrame(() => {
145
							poemText.classList.remove("opacity-0");
146
							poemText.classList.add("opacity-100");
147
						});
148
					});
149
				}
150
			};
151
			if (wasHidden) swap();
152
			else swapTimer = window.setTimeout(swap, FADE_MS);
153
			lastCueIdx = idx;
154
		};
155
156
		const updateCue = () => {
157
			if (!audioEl) return;
158
			const t = audioEl.currentTime;
159
			if (t >= POEM_END_T) {
160
				renderCue(-1);
161
				return;
162
			}
163
			let idx = -1;
164
			for (let i = 0; i < cues.length; i++) {
165
				if (cues[i].t <= t) idx = i;
166
				else break;
167
			}
168
			renderCue(idx);
169
		};
170
171
		const resetBtn = () => {
172
			if (!audioBtn || !audioIcon) return;
173
			audioIcon.innerHTML = PLAY_SVG;
174
			audioBtn.setAttribute("aria-pressed", "false");
175
			audioBtn.setAttribute("aria-label", "Play audio");
176
		};
177
178
		if (audioBtn && audioEl && audioIcon) {
179
			audioEl.addEventListener("timeupdate", updateCue);
180
			audioEl.addEventListener("seeked", updateCue);
181
			audioEl.addEventListener("pause", () => {
182
				if (audioEl.currentTime === 0) renderCue(-1);
183
			});
184
			audioEl.addEventListener("ended", () => {
185
				renderCue(-1);
186
				audioEl.currentTime = 0;
187
				resetBtn();
188
			});
189
190
			audioBtn.addEventListener("click", async () => {
191
				if (audioEl.paused) {
192
					try {
193
						if (audioEl.ended) audioEl.currentTime = 0;
194
						await audioEl.play();
195
						audioIcon.innerHTML = PAUSE_SVG;
196
						audioBtn.setAttribute("aria-pressed", "true");
197
						audioBtn.setAttribute("aria-label", "Pause audio");
198
						updateCue();
199
					} catch (err) {
200
						console.error("Audio play failed", err);
201
					}
202
				} else {
203
					audioEl.pause();
204
					resetBtn();
205
				}
206
			});
207
		}
208
	</script>
209
	<script>
210
		const btn = document.getElementById("info-btn") as HTMLButtonElement | null;
211
		const panel = document.getElementById("info-panel") as HTMLDivElement | null;
212
		if (btn && panel) {
213
			btn.addEventListener("click", (e) => {
214
				e.stopPropagation();
215
				const willOpen = panel.classList.contains("hidden");
216
				panel.classList.toggle("hidden", !willOpen);
217
				btn.setAttribute("aria-expanded", String(willOpen));
218
			});
219
			document.addEventListener("click", (e) => {
220
				if (panel.classList.contains("hidden")) return;
221
				const target = e.target as Node;
222
				if (panel.contains(target) || btn.contains(target)) return;
223
				panel.classList.add("hidden");
224
				btn.setAttribute("aria-expanded", "false");
225
			});
226
		}
227
	</script>
228
</PageLayout>