phase 0-2 complete
This commit is contained in:
414
web/templates/index.html
Normal file
414
web/templates/index.html
Normal file
@@ -0,0 +1,414 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Heloha — NEXRAD Radar</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0d1117; color: #e6edf3; font-family: 'SF Mono', 'Fira Mono', monospace; }
|
||||
#map { width: 100vw; height: 100vh; }
|
||||
|
||||
/* ── shared panel style ── */
|
||||
.panel {
|
||||
background: rgba(13,17,23,0.90);
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Top HUD ── */
|
||||
#hud {
|
||||
position: absolute; top: 14px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
padding: 7px 18px;
|
||||
font-size: 12px; color: #8b949e;
|
||||
white-space: nowrap; letter-spacing: 0.02em;
|
||||
display: flex; align-items: center; gap: 0;
|
||||
}
|
||||
#hud .site { color: #58a6ff; font-weight: 700; font-size: 13px; }
|
||||
#hud .label { color: #c9d1d9; }
|
||||
#hud .hi { color: #3fb950; font-weight: 600; }
|
||||
#hud .sep { color: #30363d; margin: 0 10px; user-select: none; }
|
||||
|
||||
/* ── Loop player ── */
|
||||
#player {
|
||||
position: absolute; bottom: 36px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
padding: 8px 14px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
pointer-events: all;
|
||||
}
|
||||
#player button {
|
||||
background: none; border: 1px solid #30363d;
|
||||
color: #c9d1d9; border-radius: 5px;
|
||||
padding: 4px 10px; font: 12px monospace;
|
||||
cursor: pointer; transition: border-color .15s, color .15s;
|
||||
}
|
||||
#player button:hover { border-color: #58a6ff; color: #58a6ff; }
|
||||
#player button.active { border-color: #58a6ff; color: #58a6ff; background: rgba(88,166,255,0.08); }
|
||||
#player button:disabled { opacity: 0.35; cursor: default; }
|
||||
#scrubber {
|
||||
width: 180px; accent-color: #58a6ff; cursor: pointer;
|
||||
}
|
||||
#frame-time { font-size: 11px; color: #8b949e; min-width: 60px; }
|
||||
#live-badge {
|
||||
font-size: 10px; font-weight: 700; letter-spacing: .06em;
|
||||
color: #3fb950; border: 1px solid #3fb950;
|
||||
border-radius: 4px; padding: 1px 5px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
#live-badge.hidden { display: none; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||
|
||||
/* ── dBZ Legend ── */
|
||||
#legend, #vel-legend {
|
||||
position: absolute; bottom: 36px; left: 16px;
|
||||
z-index: 1000;
|
||||
padding: 10px 14px;
|
||||
font-size: 11px; color: #8b949e;
|
||||
pointer-events: none; min-width: 108px;
|
||||
}
|
||||
#vel-legend { display: none; }
|
||||
.legend-title {
|
||||
color: #c9d1d9; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: .08em; text-transform: uppercase; margin-bottom: 7px;
|
||||
}
|
||||
.legend-row { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
|
||||
.swatch { width: 22px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
||||
.lbl { color: #8b949e; font-size: 10px; }
|
||||
|
||||
/* ── NWS Warning tooltips ── */
|
||||
.warn-tip {
|
||||
background: rgba(13,17,23,0.92) !important;
|
||||
border: 1px solid #30363d !important;
|
||||
border-radius: 6px !important; color: #c9d1d9 !important;
|
||||
font: 11px/1.5 monospace !important;
|
||||
box-shadow: none !important; padding: 5px 9px !important;
|
||||
}
|
||||
.warn-tip::before { display: none !important; }
|
||||
|
||||
/* ── Radar site tooltips ── */
|
||||
.radar-tip {
|
||||
background: rgba(13,17,23,0.92) !important;
|
||||
border: 1px solid #30363d !important;
|
||||
border-radius: 6px !important; color: #c9d1d9 !important;
|
||||
font: 11px/1.5 monospace !important;
|
||||
box-shadow: none !important; padding: 5px 9px !important;
|
||||
}
|
||||
.radar-tip b { color: #58a6ff; }
|
||||
.radar-tip::before { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top HUD -->
|
||||
<div id="hud" class="panel">
|
||||
<span class="site">15 NEXRAD</span>
|
||||
<span class="sep">|</span>
|
||||
<span class="label" id="product-label">Base Reflectivity 0.5°</span>
|
||||
<span class="sep">|</span>
|
||||
<span class="label">KTLX </span><span class="hi" id="scan-time">—</span>
|
||||
<span class="sep">|</span>
|
||||
<span class="hi" id="utc-clock">--:-- UTC</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="label" id="cst-clock">--:-- CDT</span>
|
||||
</div>
|
||||
|
||||
<!-- Loop player -->
|
||||
<div id="player" class="panel">
|
||||
<button id="btn-refl" class="active" onclick="setProduct('reflectivity')">REFL</button>
|
||||
<button id="btn-vel" onclick="setProduct('velocity')">VEL</button>
|
||||
<span class="sep" style="margin:0 2px"></span>
|
||||
<button id="btn-play" onclick="playToggle()">▶</button>
|
||||
<input id="scrubber" type="range" min="0" max="0" step="1" value="0"
|
||||
oninput="onScrub(this.value)">
|
||||
<span id="live-badge">LIVE</span>
|
||||
<span id="frame-time"></span>
|
||||
</div>
|
||||
|
||||
<!-- Reflectivity legend -->
|
||||
<div id="legend" class="panel">
|
||||
<div class="legend-title">dBZ</div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#028e00"></div><span class="lbl">20 – 25</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#01b44c"></div><span class="lbl">25 – 30</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#9ce400"></div><span class="lbl">30 – 35</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#d8d800"></div><span class="lbl">35 – 40</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#ffaa00"></div><span class="lbl">40 – 45</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#ff0000"></div><span class="lbl">45 – 50</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#d00000"></div><span class="lbl">50 – 55</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#c00080"></div><span class="lbl">55 – 60</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#fff700"></div><span class="lbl">> 60</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Velocity legend -->
|
||||
<div id="vel-legend" class="panel">
|
||||
<div class="legend-title">m/s</div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#ff0000"></div><span class="lbl">≤ −27 inbound</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#ff6666"></div><span class="lbl">−27 – −14</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#ffaaaa"></div><span class="lbl">−14 – −1</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#333;border:1px solid #444"></div><span class="lbl">near zero</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#aaffaa"></div><span class="lbl">+1 – +14</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#00cc00"></div><span class="lbl">+14 – +27</span></div>
|
||||
<div class="legend-row"><div class="swatch" style="background:#006600"></div><span class="lbl">≥ +27 outbound</span></div>
|
||||
</div>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
// ── NEXRAD site list ──────────────────────────────────────────────────────
|
||||
const nexradSites = [
|
||||
{ id: 'KTLX', name: 'Oklahoma City, OK', lat: 35.3331, lon: -97.2778 },
|
||||
{ id: 'KINX', name: 'Tulsa, OK', lat: 36.1750, lon: -95.5647 },
|
||||
{ id: 'KVNX', name: 'Vance AFB, OK', lat: 36.7408, lon: -98.1278 },
|
||||
{ id: 'KFDR', name: 'Frederick, OK', lat: 34.3622, lon: -98.9764 },
|
||||
{ id: 'KSRX', name: 'Fort Smith, AR', lat: 35.2906, lon: -94.3619 },
|
||||
{ id: 'KLZK', name: 'Little Rock, AR', lat: 34.8364, lon: -92.2622 },
|
||||
{ id: 'KSHV', name: 'Shreveport, LA', lat: 32.4508, lon: -93.8411 },
|
||||
{ id: 'KFWS', name: 'Fort Worth, TX', lat: 32.5728, lon: -97.3028 },
|
||||
{ id: 'KDYX', name: 'Dyess AFB, TX', lat: 32.5386, lon: -99.2539 },
|
||||
{ id: 'KAMA', name: 'Amarillo, TX', lat: 35.2333, lon: -101.709 },
|
||||
{ id: 'KLBB', name: 'Lubbock, TX', lat: 33.6542, lon: -101.814 },
|
||||
{ id: 'KICT', name: 'Wichita, KS', lat: 37.6545, lon: -97.4433 },
|
||||
{ id: 'KDDC', name: 'Dodge City, KS', lat: 37.7608, lon: -99.9689 },
|
||||
{ id: 'KSGF', name: 'Springfield, MO', lat: 37.2353, lon: -93.4008 },
|
||||
{ id: 'KEAX', name: 'Kansas City, MO', lat: 38.8100, lon: -94.2644 },
|
||||
];
|
||||
|
||||
// ── Map init ─────────────────────────────────────────────────────────────
|
||||
const map = L.map('map', { center: [36.0, -97.5], zoom: 7, zoomControl: true });
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const state = {
|
||||
product: 'reflectivity',
|
||||
frameCount: 0,
|
||||
currentFrame: -1, // -1 = live (always latest)
|
||||
playing: false,
|
||||
playTimer: null,
|
||||
frames: [], // [{index, time, age_seconds}] oldest→newest
|
||||
};
|
||||
|
||||
// ── Radar tile layer ──────────────────────────────────────────────────────
|
||||
let radarLayer = null;
|
||||
|
||||
function tileUrl() {
|
||||
const frame = state.currentFrame === -1 ? 'latest' : state.currentFrame;
|
||||
return `/api/v1/tile/composite/${state.product}/${frame}/{z}/{x}/{y}.png`;
|
||||
}
|
||||
|
||||
function refreshLayer() {
|
||||
if (radarLayer) map.removeLayer(radarLayer);
|
||||
radarLayer = L.tileLayer(tileUrl(), {
|
||||
opacity: 0.85, maxZoom: 19, updateWhenIdle: false,
|
||||
}).addTo(map);
|
||||
updateFrameTime();
|
||||
}
|
||||
|
||||
function updateFrameTime() {
|
||||
const el = document.getElementById('frame-time');
|
||||
const badge = document.getElementById('live-badge');
|
||||
if (state.currentFrame === -1 || state.frames.length === 0) {
|
||||
el.textContent = '';
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
const f = state.frames[state.currentFrame];
|
||||
if (f) {
|
||||
const t = new Date(f.time);
|
||||
el.textContent = t.toLocaleTimeString('en-US', {
|
||||
timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}) + ' UTC';
|
||||
}
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Product toggle ────────────────────────────────────────────────────────
|
||||
function setProduct(p) {
|
||||
state.product = p;
|
||||
state.currentFrame = -1;
|
||||
stopPlay();
|
||||
document.getElementById('btn-refl').classList.toggle('active', p === 'reflectivity');
|
||||
document.getElementById('btn-vel').classList.toggle('active', p === 'velocity');
|
||||
document.getElementById('product-label').textContent =
|
||||
p === 'reflectivity' ? 'Base Reflectivity 0.5°' : 'Base Velocity 0.5°';
|
||||
document.getElementById('legend').style.display = p === 'reflectivity' ? '' : 'none';
|
||||
document.getElementById('vel-legend').style.display = p === 'velocity' ? '' : 'none';
|
||||
fetchFrames().then(() => refreshLayer());
|
||||
}
|
||||
|
||||
// ── Scrubber / frame nav ──────────────────────────────────────────────────
|
||||
function onScrub(val) {
|
||||
stopPlay();
|
||||
const i = parseInt(val);
|
||||
state.currentFrame = i < state.frameCount ? i : -1;
|
||||
refreshLayer();
|
||||
}
|
||||
|
||||
function updateScrubber() {
|
||||
const s = document.getElementById('scrubber');
|
||||
s.max = Math.max(0, state.frameCount - 1);
|
||||
s.value = state.currentFrame === -1 ? s.max : state.currentFrame;
|
||||
}
|
||||
|
||||
// ── Playback ──────────────────────────────────────────────────────────────
|
||||
function playToggle() {
|
||||
state.playing ? stopPlay() : startPlay();
|
||||
}
|
||||
|
||||
function startPlay() {
|
||||
if (state.frameCount < 2) return;
|
||||
state.playing = true;
|
||||
document.getElementById('btn-play').textContent = '⏸';
|
||||
// Start from oldest if currently live
|
||||
if (state.currentFrame === -1) state.currentFrame = 0;
|
||||
state.playTimer = setInterval(() => {
|
||||
state.currentFrame = (state.currentFrame + 1) % state.frameCount;
|
||||
updateScrubber();
|
||||
refreshLayer();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function stopPlay() {
|
||||
state.playing = false;
|
||||
document.getElementById('btn-play').textContent = '▶';
|
||||
if (state.playTimer) { clearInterval(state.playTimer); state.playTimer = null; }
|
||||
}
|
||||
|
||||
// ── Frame metadata ────────────────────────────────────────────────────────
|
||||
async function fetchFrames() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/frames?product=${state.product}`);
|
||||
const d = await r.json();
|
||||
state.frameCount = d.frame_count || 0;
|
||||
state.frames = d.frames || [];
|
||||
updateScrubber();
|
||||
const velBtn = document.getElementById('btn-vel');
|
||||
if (state.product === 'reflectivity') {
|
||||
// check if vel has any frames
|
||||
const vr = await fetch('/api/v1/frames?product=velocity');
|
||||
const vd = await vr.json();
|
||||
velBtn.disabled = (vd.frame_count || 0) === 0;
|
||||
velBtn.title = velBtn.disabled ? 'Velocity not available from this source' : '';
|
||||
}
|
||||
} catch(e) { console.warn('frames fetch failed', e); }
|
||||
}
|
||||
|
||||
// ── Scan time ─────────────────────────────────────────────────────────────
|
||||
async function fetchScanTime() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/scan-time');
|
||||
document.getElementById('scan-time').textContent = await r.text();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── UTC / CDT clock ───────────────────────────────────────────────────────
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
document.getElementById('utc-clock').textContent =
|
||||
now.toLocaleTimeString('en-US', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) + ' UTC';
|
||||
const tzName = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Chicago', timeZoneName: 'short' })
|
||||
.formatToParts(now).find(p => p.type === 'timeZoneName')?.value || 'CT';
|
||||
document.getElementById('cst-clock').textContent =
|
||||
now.toLocaleTimeString('en-US', { timeZone: 'America/Chicago', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) + ' ' + tzName;
|
||||
}
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
|
||||
// ── NWS Active Warnings ───────────────────────────────────────────────────
|
||||
const alertColors = {
|
||||
'Tornado Warning': '#FF0000',
|
||||
'Severe Thunderstorm Warning': '#FFA500',
|
||||
'Tornado Watch': '#FFFF00',
|
||||
'Severe Thunderstorm Watch': '#DB7093',
|
||||
'Flash Flood Warning': '#00FF00',
|
||||
'Flash Flood Watch': '#2E8B57',
|
||||
'Special Weather Statement': '#FFE4B5',
|
||||
};
|
||||
|
||||
function alertColor(event) {
|
||||
for (const [k, v] of Object.entries(alertColors)) {
|
||||
if (event && event.includes(k)) return v;
|
||||
}
|
||||
return '#AAAAAA';
|
||||
}
|
||||
|
||||
// Dedicated pane above radar tiles (default overlay pane is z=400; we use 450)
|
||||
map.createPane('warnings');
|
||||
map.getPane('warnings').style.zIndex = 450;
|
||||
map.getPane('warnings').style.pointerEvents = 'none';
|
||||
|
||||
let warningLayer = null;
|
||||
async function fetchWarnings() {
|
||||
try {
|
||||
const r = await fetch('https://api.weather.gov/alerts/active?area=OK,TX,KS,AR,MO,LA,CO,NM');
|
||||
const d = await r.json();
|
||||
if (warningLayer) map.removeLayer(warningLayer);
|
||||
warningLayer = L.geoJSON(d, {
|
||||
filter: f => f.geometry !== null,
|
||||
pane: 'warnings',
|
||||
style: f => {
|
||||
const c = alertColor(f.properties.event);
|
||||
return { color: c, weight: 2.5, opacity: 1, fillColor: c, fillOpacity: 0.30, dashArray: '6 4' };
|
||||
},
|
||||
onEachFeature: (f, layer) => {
|
||||
layer.bindTooltip(
|
||||
`<b>${f.properties.event}</b><br>${f.properties.areaDesc || ''}`,
|
||||
{ sticky: true, opacity: 0.95, className: 'warn-tip' }
|
||||
);
|
||||
},
|
||||
}).addTo(map);
|
||||
} catch(e) { console.warn('warnings fetch failed', e); }
|
||||
}
|
||||
|
||||
// ── Range rings + site markers ────────────────────────────────────────────
|
||||
nexradSites.forEach(site => {
|
||||
[100, 200].forEach(km => {
|
||||
L.circle([site.lat, site.lon], {
|
||||
radius: km * 1000, color: '#fff', weight: 0.4,
|
||||
opacity: 0.10, fill: false, interactive: false,
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
L.circleMarker([site.lat, site.lon], {
|
||||
radius: 3, color: '#fff', fillColor: '#fff', fillOpacity: 0.9, weight: 1,
|
||||
})
|
||||
.bindTooltip(`<b>${site.id}</b><br>${site.name}`, {
|
||||
direction: 'top', offset: [0, -6], opacity: 0.92, className: 'radar-tip',
|
||||
})
|
||||
.addTo(map);
|
||||
|
||||
L.marker([site.lat, site.lon], {
|
||||
interactive: false,
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="color:rgba(255,255,255,0.6);font:9px/1 monospace;padding-left:6px;white-space:nowrap">${site.id}</span>`,
|
||||
iconAnchor: [-2, 4],
|
||||
}),
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
// ── Scale bar ─────────────────────────────────────────────────────────────
|
||||
L.control.scale({ imperial: true, metric: true, position: 'bottomright' }).addTo(map);
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||
fetchFrames().then(() => refreshLayer());
|
||||
fetchWarnings();
|
||||
fetchScanTime();
|
||||
|
||||
setInterval(fetchFrames, 120_000);
|
||||
setInterval(fetchWarnings, 120_000);
|
||||
setInterval(fetchScanTime, 60_000);
|
||||
// Auto-refresh live layer every 60 s when not looping
|
||||
setInterval(() => { if (!state.playing && state.currentFrame === -1) refreshLayer(); }, 60_000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user