Files
animation-viewr/halifax-explosion-viewer.html
2026-06-01 19:49:01 -04:00

528 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>