| 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 & 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' 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> |