committing data
This commit is contained in:
9
halifax-explosion-animated.geojson
Normal file
9
halifax-explosion-animated.geojson
Normal 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 ] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
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