committing data
This commit is contained in:
527
halifax-explosion-viewer.html
Normal file
527
halifax-explosion-viewer.html
Normal 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: '© OpenStreetMap contributors © 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>
|
||||
Reference in New Issue
Block a user