chore: updated /murmurations
f8c7fa78
1 file(s) · +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 | } |
|