Files
heloha/web/templates/index.html
2026-04-25 20:31:55 -05:00

415 lines
19 KiB
HTML
Raw 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.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">&gt; 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: '&copy; OpenStreetMap contributors &copy; 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>