chore: updated /murmurations f8c7fa78
Steve · 2026-05-03 22:30 1 file(s) · +80 −0
src/pages/murmurations.astro +80 −0
34 34
		Back
35 35
	</a>
36 36
	<audio id="murmuration-audio" src="/murmuration.mp3" loop 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>
37 44
	<button
38 45
		id="audio-btn"
39 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"
77 84
			Real starlings track ~7 nearest neighbors regardless of distance: topological, not metric.
78 85
			That fixed count is why turning waves cross the flock so fast and stay crisp at any density.
79 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>
80 95
	</div>
81 96
	<script>
82 97
		const audioBtn = document.getElementById("audio-btn") as HTMLButtonElement | null;
83 98
		const audioEl = document.getElementById("murmuration-audio") as HTMLAudioElement | null;
84 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 poemText = poemOverlay?.firstElementChild as HTMLDivElement | null;
119 +
		let lastCueIdx = -1;
120 +
		let swapTimer: number | undefined;
121 +
		const FADE_MS = 300;
122 +
		const renderCue = (idx: number) => {
123 +
			if (!poemOverlay || !poemText) return;
124 +
			if (idx === lastCueIdx) return;
125 +
			window.clearTimeout(swapTimer);
126 +
			poemText.classList.remove("opacity-100");
127 +
			poemText.classList.add("opacity-0");
128 +
			swapTimer = window.setTimeout(() => {
129 +
				if (idx < 0) {
130 +
					poemOverlay.classList.add("hidden");
131 +
					poemOverlay.classList.remove("flex");
132 +
					poemText.textContent = "";
133 +
				} else {
134 +
					poemText.textContent = cues[idx].line;
135 +
					poemOverlay.classList.remove("hidden");
136 +
					poemOverlay.classList.add("flex");
137 +
					requestAnimationFrame(() => {
138 +
						poemText.classList.remove("opacity-0");
139 +
						poemText.classList.add("opacity-100");
140 +
					});
141 +
				}
142 +
			}, FADE_MS);
143 +
			lastCueIdx = idx;
144 +
		};
145 +
146 +
		const updateCue = () => {
147 +
			if (!audioEl) return;
148 +
			const t = audioEl.currentTime;
149 +
			let idx = -1;
150 +
			for (let i = 0; i < cues.length; i++) {
151 +
				if (cues[i].t <= t) idx = i;
152 +
				else break;
153 +
			}
154 +
			renderCue(idx);
155 +
		};
156 +
85 157
		if (audioBtn && audioEl && audioIcon) {
158 +
			audioEl.addEventListener("timeupdate", updateCue);
159 +
			audioEl.addEventListener("seeked", updateCue);
160 +
			audioEl.addEventListener("pause", () => {
161 +
				if (audioEl.currentTime === 0) renderCue(-1);
162 +
			});
163 +
			audioEl.addEventListener("ended", () => renderCue(-1));
164 +
86 165
			audioBtn.addEventListener("click", async () => {
87 166
				if (audioEl.paused) {
88 167
					try {
90 169
						audioIcon.textContent = "⏸";
91 170
						audioBtn.setAttribute("aria-pressed", "true");
92 171
						audioBtn.setAttribute("aria-label", "Pause audio");
172 +
						updateCue();
93 173
					} catch (err) {
94 174
						console.error("Audio play failed", err);
95 175
					}