committing data

This commit is contained in:
nathan
2026-06-01 19:49:01 -04:00
parent 17be980aed
commit 3481094f8e
2 changed files with 536 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"type": "FeatureCollection",
"name": "halifax-explosion-animated",
"features": [
{ "type": "Feature", "properties": { "name": "SS Mont-Blanc", "vessel": "SS Mont-Blanc", "role": "munitions ship (inbound)", "stroke": "#d35400", "stroke-width": 3, "coordTimes": [ "1917-12-06T07:15:00", "1917-12-06T07:35:00", "1917-12-06T07:55:00", "1917-12-06T08:12:00", "1917-12-06T08:25:00", "1917-12-06T08:37:00", "1917-12-06T08:45:00", "1917-12-06T08:55:00", "1917-12-06T09:04:35" ], "events": { "08:45": "collision", "09:04:35": "EXPLOSION" } }, "geometry": { "type": "LineString", "coordinates": [ [ -63.54, 44.595 ], [ -63.547, 44.615 ], [ -63.548539, 44.635109 ], [ -63.557534, 44.648309 ], [ -63.573, 44.658 ], [ -63.585, 44.6655 ], [ -63.593, 44.67 ], [ -63.594557914791778, 44.669291630501128 ], [ -63.59608214055303, 44.668833295936217 ] ] } },
{ "type": "Feature", "properties": { "name": "SS Imo", "vessel": "SS Imo", "role": "relief ship (outbound)", "stroke": "#2980b9", "stroke-width": 3, "coordTimes": [ "1917-12-06T07:55:00", "1917-12-06T08:08:00", "1917-12-06T08:20:00", "1917-12-06T08:32:00", "1917-12-06T08:41:00", "1917-12-06T09:04:35", "1917-12-06T09:04:40" ], "events": { "08:45": "collision", "09:04:35": "thrown across harbour by blast" } }, "geometry": { "type": "LineString", "coordinates": [ [ -63.632868, 44.683271 ], [ -63.620881, 44.681208 ], [ -63.612, 44.679 ], [ -63.603668, 44.674875 ], [ -63.598033, 44.672254 ], [ -63.593, 44.67 ], [ -63.591929, 44.670091 ] ] } },
{ "type": "Feature", "properties": { "name": "Explosion", "category": "epicentre", "time": "09:04:35", "marker-color": "#c0392b", "marker-symbol": "star" }, "geometry": { "type": "Point", "coordinates": [ -63.596112829662317, 44.668818745573212 ] } }
]
}

View File

@@ -0,0 +1,527 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>The Halifax Explosion — 6 December 1917</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
<style>
:root{
--ink:#15181c;
--panel:#1b1f24;
--panel-edge:#2c333b;
--paper:#f4f1ea;
--muted:#8d97a3;
--mb:#d35400; /* Mont-Blanc */
--imo:#2563a8; /* Imo */
--blast:#c0392b;
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;background:var(--ink);font-family:"Space Mono",monospace;}
#map{position:absolute;inset:0;}
/* ---- Title placard ---- */
.placard{
position:absolute;top:18px;left:18px;z-index:5;max-width:330px;
background:rgba(27,31,36,.92);color:var(--paper);
border:1px solid var(--panel-edge);border-radius:4px;
padding:16px 18px 14px;backdrop-filter:blur(3px);
box-shadow:0 10px 30px rgba(0,0,0,.35);
}
.placard h1{
font-family:"Fraunces",serif;font-weight:600;font-size:23px;line-height:1.05;
margin:0 0 4px;letter-spacing:.2px;
}
.placard .date{
font-size:11px;letter-spacing:2.5px;text-transform:uppercase;color:var(--muted);
}
.placard .legend{margin-top:12px;display:flex;flex-direction:column;gap:7px;font-size:11px;}
.placard .legend .row{display:flex;align-items:center;gap:9px;color:#d7dce2;}
.swatch{width:22px;height:3px;border-radius:2px;flex:none;}
.swatch.dot{width:10px;height:10px;border-radius:50%;}
.placard .note{margin-top:12px;font-size:9.5px;line-height:1.5;color:var(--muted);border-top:1px solid var(--panel-edge);padding-top:9px;}
/* ---- Ship markers ---- */
.ship{position:relative;transform:translate(-50%,-50%);will-change:transform;}
.ship .disc{
width:14px;height:14px;border-radius:50%;border:2px solid #fff;
box-shadow:0 0 0 2px rgba(0,0,0,.25), 0 2px 6px rgba(0,0,0,.4);
}
.ship .arrow{
position:absolute;top:50%;left:50%;width:0;height:0;
border-left:5px solid transparent;border-right:5px solid transparent;
border-bottom:9px solid #fff;
transform-origin:50% 130%;transform:translate(-50%,-150%);
filter:drop-shadow(0 1px 1px rgba(0,0,0,.4));
}
.ship .tag{
position:absolute;top:16px;left:50%;transform:translateX(-50%);
white-space:nowrap;font-size:10px;font-weight:700;letter-spacing:.3px;
color:#fff;padding:2px 7px;border-radius:3px;box-shadow:0 2px 6px rgba(0,0,0,.35);
}
.ship .pulse{
position:absolute;top:50%;left:50%;width:14px;height:14px;border-radius:50%;
transform:translate(-50%,-50%);animation:pulse 2.6s ease-out infinite;
}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(255,255,255,.5);}70%,100%{box-shadow:0 0 0 16px rgba(255,255,255,0);}}
/* epicentre marker (appears after blast) */
.epi{transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;gap:4px;opacity:0;transition:opacity .4s;}
.epi.show{opacity:1;}
.epi .star{width:16px;height:16px;background:var(--blast);transform:rotate(45deg);box-shadow:0 0 0 3px rgba(192,57,43,.3),0 0 14px rgba(192,57,43,.7);}
.epi .lbl{font-size:10px;font-weight:700;color:#fff;background:var(--blast);padding:2px 7px;border-radius:3px;letter-spacing:.4px;}
/* ---- Event banner ---- */
.banner{
position:absolute;top:18px;left:50%;transform:translateX(-50%) translateY(-16px);
z-index:6;background:var(--blast);color:#fff;font-weight:700;font-size:13px;letter-spacing:.4px;
padding:8px 18px;border-radius:4px;box-shadow:0 8px 24px rgba(0,0,0,.4);
opacity:0;transition:opacity .35s, transform .35s;pointer-events:none;
}
.banner.show{opacity:1;transform:translateX(-50%) translateY(0);}
/* ---- Control deck ---- */
.deck{
position:absolute;left:50%;bottom:20px;transform:translateX(-50%);z-index:5;
width:min(680px,calc(100% - 36px));
background:rgba(27,31,36,.94);border:1px solid var(--panel-edge);border-radius:6px;
padding:14px 18px 16px;backdrop-filter:blur(4px);box-shadow:0 14px 40px rgba(0,0,0,.4);color:var(--paper);
}
.deck .topline{display:flex;align-items:center;gap:16px;}
.play{
flex:none;width:46px;height:46px;border-radius:50%;border:none;cursor:pointer;
background:var(--paper);color:var(--ink);display:flex;align-items:center;justify-content:center;
transition:transform .12s, background .2s;
}
.play:hover{transform:scale(1.06);}
.play:active{transform:scale(.96);}
.play svg{width:18px;height:18px;}
.clockwrap{display:flex;flex-direction:column;line-height:1;}
.clock{font-size:30px;font-weight:700;letter-spacing:1px;}
.clocksub{font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);margin-top:4px;}
.speeds{margin-left:auto;display:flex;gap:5px;}
.speeds button{
background:transparent;border:1px solid var(--panel-edge);color:var(--muted);
font-family:inherit;font-size:11px;padding:5px 8px;border-radius:4px;cursor:pointer;transition:all .15s;
}
.speeds button:hover{color:var(--paper);border-color:var(--muted);}
.speeds button.active{background:var(--paper);color:var(--ink);border-color:var(--paper);font-weight:700;}
.scrub{position:relative;margin-top:14px;padding-bottom:22px;}
.scrub input[type=range]{width:100%;-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;background:#3a424c;outline:none;cursor:pointer;position:relative;z-index:2;}
.scrub input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--paper);border:2px solid var(--ink);cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.4);}
.scrub input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--paper);border:2px solid var(--ink);cursor:pointer;}
.marks{position:absolute;left:0;right:0;top:0;height:4px;z-index:1;pointer-events:none;}
.marks .m{position:absolute;top:-3px;width:2px;height:10px;background:var(--blast);transform:translateX(-50%);border-radius:1px;}
.ticks{position:absolute;left:0;right:0;top:16px;height:14px;font-size:9px;letter-spacing:.5px;color:var(--muted);}
.ticks .t{position:absolute;transform:translateX(-50%);white-space:nowrap;}
.ticks .t.start{transform:translateX(0);}
.ticks .t.end{transform:translateX(-100%);}
.ticks .t.evt{color:#cc7066;}
/* ---- Flash + replay ---- */
.flash{position:absolute;inset:0;background:#fff;opacity:0;z-index:7;pointer-events:none;}
.flash.go{animation:flash 1.3s ease-out forwards;}
@keyframes flash{0%{opacity:0;}6%{opacity:.92;}100%{opacity:0;}}
.replay{position:absolute;inset:0;display:none;z-index:8;align-items:center;justify-content:center;}
.replay.show{display:flex;}
.replay button{
background:rgba(27,31,36,.94);color:var(--paper);border:1px solid var(--panel-edge);
font-family:"Fraunces",serif;font-size:16px;padding:12px 22px;border-radius:5px;cursor:pointer;
display:flex;align-items:center;gap:9px;box-shadow:0 10px 30px rgba(0,0,0,.4);
}
.replay button:hover{border-color:var(--muted);}
</style>
</head>
<body>
<div id="map"></div>
<div class="placard">
<h1>The Halifax Explosion</h1>
<div class="date">6 December 1917 · 9:04:35 a.m.</div>
<div class="legend">
<div class="row"><span class="swatch dot" style="background:var(--mb)"></span> SS Mont-Blanc — inbound, munitions</div>
<div class="row"><span class="swatch dot" style="background:var(--imo)"></span> SS Imo — outbound, relief ship</div>
<div class="row"><span class="swatch" style="background:var(--blast)"></span> Detonation at Pier 6</div>
</div>
<div class="note">Ship courses are an approximate reconstruction based on the post-explosion inquiry — illustrative, not surveyed positions. Times are approximate apart from the collision (8:45) and detonation (9:04:35).</div>
</div>
<div class="banner" id="banner"></div>
<div class="flash" id="flash"></div>
<div class="replay" id="replay"><button id="replayBtn">↻ Replay</button></div>
<div class="deck">
<div class="topline">
<button class="play" id="play" aria-label="Play / pause">
<svg id="playIcon" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div class="clockwrap">
<div class="clock" id="clock">07:15:00</div>
<div class="clocksub">Halifax Harbour · local time</div>
</div>
<div class="speeds" id="speeds">
<button data-s="60">60×</button>
<button data-s="120" class="active">120×</button>
<button data-s="300">300×</button>
<button data-s="600">600×</button>
</div>
</div>
<div class="scrub">
<input type="range" id="scrub" min="26100" max="33060" step="1" value="26100" />
<div class="marks">
<span class="m" style="left:77.6%"></span>
<span class="m" style="left:94.5%"></span>
</div>
<div class="ticks">
<span class="t start" style="left:0%">07:15</span>
<span class="t" style="left:38.8%">08:00</span>
<span class="t evt" style="left:77.6%">08:45 · collision</span>
<span class="t evt end" style="left:94.5%">09:04:35 · blast</span>
</div>
</div>
</div>
<script>
/* ============================================================
RESILIENT LIBRARY LOADER
Tries CDNs in order; falls through if one is blocked/unreachable.
============================================================ */
const CDNS = [
"https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl",
"https://cdnjs.cloudflare.com/ajax/libs/maplibre-gl/4.7.1/maplibre-gl",
"https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl"
];
function loadCDN(i){
if (i >= CDNS.length){ showFatal(); return; }
const base = CDNS[i];
const css = document.createElement("link");
css.rel = "stylesheet"; css.href = base + ".css";
document.head.appendChild(css);
const js = document.createElement("script");
js.src = base + ".js";
js.onload = () => { if (window.maplibregl) initApp(); else loadCDN(i+1); };
js.onerror = () => loadCDN(i+1);
document.head.appendChild(js);
}
function showFatal(){
document.getElementById("map").innerHTML =
'<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;'+
'padding:30px;text-align:center;color:#f4f1ea;font-family:monospace;font-size:14px;line-height:1.6;">'+
'Could not load the map library from any CDN.<br>This page needs internet access '+
'(jsDelivr, cdnjs, or unpkg) to load MapLibre and the basemap tiles.<br>'+
'If you are on a restricted network, that is the likely cause.</div>';
}
window.addEventListener("DOMContentLoaded", () => loadCDN(0));
/* ============================================================
APP — runs once the MapLibre library is available
============================================================ */
function initApp(){
/* ============================================================
DATA — corrected geography (lng, lat) + approximate timeline
times are seconds from local midnight, 6 Dec 1917
============================================================ */
const EPICENTRE = [-63.5980, 44.6672];
const START = 26100; // 07:15:00
const END = 33060; // 09:11:00
const COLLISION_T = 31500; // 08:45:00
const BLAST_T = 32675; // 09:04:35
const SHIPS = {
montblanc: {
name: "SS Mont-Blanc",
color: "#d35400",
hideAfter: BLAST_T,
track: [
{ t: 26100, c: [-63.5400, 44.5950] }, // 07:15 underway from harbour-mouth anchorage
{ t: 27300, c: [-63.5470, 44.6150] }, // 07:35
{ t: 28500, c: [-63.5550, 44.6330] }, // 07:55
{ t: 29520, c: [-63.5630, 44.6470] }, // 08:12
{ t: 30300, c: [-63.5730, 44.6580] }, // 08:25
{ t: 31020, c: [-63.5850, 44.6655] }, // 08:37
{ t: 31500, c: [-63.5930, 44.6700] }, // 08:45 COLLISION
{ t: 32100, c: [-63.5955, 44.6685] }, // 08:55 burning, drifting to shore
{ t: 32675, c: [-63.5980, 44.6672] } // 09:04:35 detonation at Pier 6
]
},
imo: {
name: "SS Imo",
color: "#2563a8",
hideAfter: null,
track: [
{ t: 28500, c: [-63.6300, 44.6920] }, // 07:55 underway from Bedford Basin
{ t: 29280, c: [-63.6200, 44.6850] }, // 08:08
{ t: 30000, c: [-63.6120, 44.6790] }, // 08:20
{ t: 30720, c: [-63.6040, 44.6745] }, // 08:32
{ t: 31260, c: [-63.5980, 44.6710] }, // 08:41
{ t: 31500, c: [-63.5930, 44.6700] }, // 08:45 COLLISION
{ t: 32675, c: [-63.5930, 44.6700] }, // held at collision until the blast
{ t: 32680, c: [-63.5850, 44.6755] } // 09:04:40 thrown to Dartmouth shore
]
}
};
/* ============================================================
MAP
============================================================ */
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
basemap: {
type: "raster",
tiles: [
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"
],
tileSize: 256,
attribution: '&copy; OpenStreetMap contributors &copy; CARTO'
}
},
layers: [{ id: "basemap", type: "raster", source: "basemap" }]
},
center: [-63.585, 44.655],
zoom: 12.2,
attributionControl: { compact: true }
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
function fullLine(track){ return track.map(p => p.c); }
/* linear interpolation of a ship position at time `clock`
returns { lng, lat, bearing, visible } */
function interp(ship, clock){
const tr = ship.track;
if (ship.hideAfter !== null && clock > ship.hideAfter) {
const last = tr[tr.length-1].c;
return { lng:last[0], lat:last[1], bearing:0, visible:false };
}
if (clock <= tr[0].t){ const c=tr[0].c; return {lng:c[0],lat:c[1],bearing:headingAt(tr,0),visible:true}; }
if (clock >= tr[tr.length-1].t){ const c=tr[tr.length-1].c; return {lng:c[0],lat:c[1],bearing:headingAt(tr,tr.length-2),visible:true}; }
for (let i=0;i<tr.length-1;i++){
if (clock>=tr[i].t && clock<tr[i+1].t){
const f=(clock-tr[i].t)/(tr[i+1].t-tr[i].t);
const a=tr[i].c, b=tr[i+1].c;
return {
lng:a[0]+(b[0]-a[0])*f,
lat:a[1]+(b[1]-a[1])*f,
bearing:bearing(a,b),
visible:true
};
}
}
const c=tr[tr.length-1].c; return {lng:c[0],lat:c[1],bearing:0,visible:true};
}
function bearing(a,b){
const dx=(b[0]-a[0])*Math.cos(a[1]*Math.PI/180);
const dy=(b[1]-a[1]);
return Math.atan2(dx,dy)*180/Math.PI;
}
function headingAt(tr,i){ return bearing(tr[i].c, tr[Math.min(i+1,tr.length-1)].c); }
/* portion of the track already travelled, up to `clock` + current point */
function progress(ship, clock){
const tr=ship.track, pts=[];
for (const p of tr){ if (p.t<=clock) pts.push(p.c); else break; }
const pos=interp(ship,clock);
if (pos.visible) pts.push([pos.lng,pos.lat]);
if (pts.length<2 && pts.length>0) return [pts[0],pts[0]];
return pts;
}
/* build a ship DOM marker */
function makeShipEl(ship){
const el=document.createElement("div"); el.className="ship";
el.innerHTML =
`<div class="pulse"></div>
<div class="arrow"></div>
<div class="disc" style="background:${ship.color}"></div>
<div class="tag" style="background:${ship.color}">${ship.name}</div>`;
return el;
}
let markers={}, epiMarker, exploded=false;
map.on("load", ()=>{
// full faint tracks + travelled tracks
for (const key of Object.keys(SHIPS)){
const ship=SHIPS[key];
map.addSource("full-"+key,{type:"geojson",data:lineFeature(fullLine(ship.track))});
map.addLayer({id:"full-"+key,type:"line",source:"full-"+key,
paint:{"line-color":ship.color,"line-width":2,"line-opacity":0.22,"line-dasharray":[2,2]}});
map.addSource("prog-"+key,{type:"geojson",data:lineFeature([ship.track[0].c,ship.track[0].c])});
map.addLayer({id:"prog-"+key,type:"line",source:"prog-"+key,
layout:{"line-cap":"round","line-join":"round"},
paint:{"line-color":ship.color,"line-width":3.5,"line-opacity":0.95}});
const el=makeShipEl(ship);
markers[key]=new maplibregl.Marker({element:el,anchor:"center"})
.setLngLat(ship.track[0].c).addTo(map);
el._arrow=el.querySelector(".arrow");
}
// blast shockwave source
map.addSource("blast",{type:"geojson",data:{type:"Feature",geometry:{type:"Point",coordinates:EPICENTRE}}});
map.addLayer({id:"blast-ring",type:"circle",source:"blast",
paint:{"circle-radius":0,"circle-color":"rgba(0,0,0,0)","circle-stroke-color":"#c0392b","circle-stroke-width":3,"circle-stroke-opacity":0}});
map.addLayer({id:"blast-fill",type:"circle",source:"blast",
paint:{"circle-radius":0,"circle-color":"#e67e22","circle-opacity":0}});
// epicentre marker (hidden until blast)
const epi=document.createElement("div"); epi.className="epi";
epi.innerHTML=`<div class="star"></div><div class="lbl">EXPLOSION · 9:04:35</div>`;
epiMarker=new maplibregl.Marker({element:epi,anchor:"center"}).setLngLat(EPICENTRE).addTo(map);
epiMarker._el=epi;
render(START);
});
function lineFeature(coords){
return {type:"Feature",geometry:{type:"LineString",coordinates:coords}};
}
/* ============================================================
RENDER one frame at a given clock
============================================================ */
function render(clock){
for (const key of Object.keys(SHIPS)){
const ship=SHIPS[key], pos=interp(ship,clock), m=markers[key];
if (!m) continue;
const el=m.getElement();
if (pos.visible){
el.style.display="";
m.setLngLat([pos.lng,pos.lat]);
if (el._arrow) el._arrow.style.transform=`translate(-50%,-150%) rotate(${pos.bearing}deg)`;
} else {
el.style.display="none";
}
if (map.getSource("prog-"+key)) map.getSource("prog-"+key).setData(lineFeature(progress(ship,clock)));
}
// clock + scrubber
clockEl.textContent=fmt(clock);
if (document.activeElement!==scrub) scrub.value=clock;
// explosion state (handles scrubbing back and forth)
if (clock>=BLAST_T && !exploded) triggerBlast();
if (clock<BLAST_T && exploded) resetBlast();
}
function fmt(s){
s=Math.max(0,Math.round(s));
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), sec=s%60;
return String(h).padStart(2,"0")+":"+String(m).padStart(2,"0")+":"+String(sec).padStart(2,"0");
}
/* ============================================================
EXPLOSION
============================================================ */
function triggerBlast(){
exploded=true;
flash.classList.remove("go"); void flash.offsetWidth; flash.classList.add("go");
if (epiMarker) epiMarker._el.classList.add("show");
showBanner("9:04:35 — Detonation at Pier 6");
// animate the shockwave rings
const t0=performance.now(), dur=1700;
function ring(now){
const k=Math.min(1,(now-t0)/dur);
const r=k*150;
if (map.getLayer("blast-ring")){
map.setPaintProperty("blast-ring","circle-radius",r);
map.setPaintProperty("blast-ring","circle-stroke-opacity",(1-k)*0.9);
map.setPaintProperty("blast-fill","circle-radius",r*0.85);
map.setPaintProperty("blast-fill","circle-opacity",(1-k)*0.5);
}
if (k<1 && exploded) requestAnimationFrame(ring);
}
requestAnimationFrame(ring);
}
function resetBlast(){
exploded=false;
if (epiMarker) epiMarker._el.classList.remove("show");
if (map.getLayer("blast-ring")){
map.setPaintProperty("blast-ring","circle-radius",0);
map.setPaintProperty("blast-ring","circle-stroke-opacity",0);
map.setPaintProperty("blast-fill","circle-radius",0);
map.setPaintProperty("blast-fill","circle-opacity",0);
}
}
/* one-shot event banner */
let bannerTimer;
function showBanner(text){
banner.textContent=text; banner.classList.add("show");
clearTimeout(bannerTimer);
bannerTimer=setTimeout(()=>banner.classList.remove("show"),3200);
}
/* ============================================================
PLAYBACK
============================================================ */
let clock=START, playing=false, speed=120, lastNow=null, collisionFlagged=false;
const playBtn=document.getElementById("play");
const playIcon=document.getElementById("playIcon");
const clockEl=document.getElementById("clock");
const scrub=document.getElementById("scrub");
const banner=document.getElementById("banner");
const flash=document.getElementById("flash");
const replay=document.getElementById("replay");
function setPlaying(v){
playing=v;
playIcon.innerHTML = v ? '<path d="M6 5h4v14H6zM14 5h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
if (v){
if (clock>=END){ clock=START; collisionFlagged=false; }
replay.classList.remove("show");
lastNow=performance.now();
requestAnimationFrame(loop);
}
}
function loop(now){
if (!playing) return;
const dt=(now-lastNow)/1000; lastNow=now;
clock+=dt*speed;
if (!collisionFlagged && clock>=COLLISION_T){ collisionFlagged=true; showBanner("8:45 — Collision in the Narrows"); }
if (clock>=END){
clock=END; render(clock); setPlaying(false);
replay.classList.add("show");
return;
}
render(clock);
requestAnimationFrame(loop);
}
playBtn.addEventListener("click",()=>setPlaying(!playing));
document.getElementById("replayBtn").addEventListener("click",()=>{ clock=START; collisionFlagged=false; resetBlast(); render(clock); setPlaying(true); });
scrub.addEventListener("input",()=>{
setPlaying(false);
clock=parseFloat(scrub.value);
collisionFlagged = clock>=COLLISION_T;
replay.classList.remove("show");
render(clock);
});
document.getElementById("speeds").addEventListener("click",e=>{
const b=e.target.closest("button"); if(!b) return;
speed=parseInt(b.dataset.s,10);
[...document.querySelectorAll("#speeds button")].forEach(x=>x.classList.toggle("active",x===b));
});
/* spacebar = play/pause */
window.addEventListener("keydown",e=>{ if(e.code==="Space"){ e.preventDefault(); setPlaying(!playing);} });
} /* ===== end initApp ===== */
</script>
</body>
</html>