nakas's picture
Prefetch ECMWF GRIBs on startup; serve forecasts from cache only; add /api/status and UI polling
bd2cd87
Raw
History Blame Contribute Delete
7.69 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ECMWF Open Data – 4‑Day Point Forecast</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<style>
html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#app { display: grid; grid-template-columns: 1fr 420px; grid-template-rows: 100%; height: 100%; }
#map { height: 100%; }
#side { border-left: 1px solid #e2e8f0; padding: 12px; overflow: auto; }
h1 { font-size: 18px; margin: 0 0 8px; }
.muted { color: #64748b; font-size: 12px; }
.row { margin: 8px 0; }
button { background: #2563eb; color: white; border: 0; padding: 8px 10px; border-radius: 6px; cursor: pointer; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
table { border-collapse: collapse; width: 100%; font-size: 12px; }
th, td { border-bottom: 1px solid #e5e7eb; padding: 4px 6px; text-align: right; }
th:first-child, td:first-child { text-align: left; position: sticky; left: 0; background: white; }
.small { font-size: 11px; }
.chip { display: inline-block; background: #eef2ff; color: #3730a3; padding: 2px 6px; border-radius: 999px; font-size: 11px; }
</style>
</head>
<body>
<div id="app">
<div id="map"></div>
<div id="side">
<h1>ECMWF 4‑Day Forecast</h1>
<div class="muted">Click anywhere on the map to get a point forecast (hourly up to 96h).</div>
<div class="row small">
<div>Location: <span id="loc"></span></div>
<div>Source: <span id="src" class="chip"></span></div>
</div>
<div class="row">
<button id="dl" disabled>Download JSON</button>
</div>
<div id="status" class="row muted"></div>
<div id="table-wrap" class="row"></div>
</div>
</div>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<script>
const map = L.map('map').setView([20, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 6,
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
let marker = null;
const locEl = document.getElementById('loc');
const srcEl = document.getElementById('src');
const statusEl = document.getElementById('status');
const tableWrap = document.getElementById('table-wrap');
const dlBtn = document.getElementById('dl');
let lastPayload = null;
let statusTimer = null;
function fmt(n, d=2) { return (n === null || n === undefined) ? '—' : Number(n).toFixed(d); }
function fmtTime(t) { return t.replace('T', ' '); }
function buildTable(payload) {
const hours = payload.hours || [];
const data = payload.data || {};
const columns = ['time', '2t', '2d', '10u', '10v', 'msl', 'sp', 'tp', 'cp', 'sf', 'tcc', 'lcc', 'mcc', 'hcc', 'cape', 'cin', 'vis'];
const labels = {
'time': 'Time (UTC)', '2t':'2m T (°C)', '2d':'2m Td (°C)', '10u':'U10 (m/s)', '10v':'V10 (m/s)',
'msl':'MSLP (hPa)', 'sp':'Surf P (hPa)', 'tp':'Prec (mm)', 'cp':'Conv Prec (mm)', 'sf':'Snow (cm)',
'tcc':'Cloud %', 'lcc':'Low %', 'mcc':'Mid %', 'hcc':'High %', 'cape':'CAPE (J/kg)', 'cin':'CIN (J/kg)', 'vis':'Vis (m)'
};
// Convert units where sensible
const rows = hours.map((t, i) => {
const row = { time: fmtTime(t) };
row['2t'] = data['2t'] ? fmt(Number(data['2t'][i]), 1) : '—'; // °C
row['2d'] = data['2d'] ? fmt(Number(data['2d'][i]), 1) : '—'; // °C
row['10u'] = data['10u'] ? fmt(data['10u'][i], 1) : '—';
row['10v'] = data['10v'] ? fmt(data['10v'][i], 1) : '—';
const isOpenMeteo = (payload.source||'').includes('Open-Meteo');
row['msl'] = data['msl'] ? fmt(Number(data['msl'][i]) / (isOpenMeteo ? 1 : 100.0), 1) : '—';
row['sp'] = data['sp'] ? fmt(Number(data['sp'][i]) / (isOpenMeteo ? 1 : 100.0), 1) : '—';
row['tp'] = data['tp'] ? fmt(data['tp'][i], 2) : '—';
row['cp'] = data['cp'] ? fmt(data['cp'][i], 2) : '—';
row['sf'] = data['sf'] ? fmt(data['sf'][i], 2) : '—';
row['tcc'] = data['tcc'] ? fmt(data['tcc'][i], 0) : '—';
row['lcc'] = data['lcc'] ? fmt(data['lcc'][i], 0) : '—';
row['mcc'] = data['mcc'] ? fmt(data['mcc'][i], 0) : '—';
row['hcc'] = data['hcc'] ? fmt(data['hcc'][i], 0) : '—';
row['cape']= data['cape']? fmt(data['cape'][i], 0) : '—';
row['cin'] = data['cin'] ? fmt(data['cin'][i], 0) : '—';
row['vis'] = data['vis'] ? fmt(data['vis'][i], 0) : '—';
return row;
});
const available = columns.filter(c => c === 'time' || (data[c] && data[c].length));
const header = '<tr>' + available.map(c => `<th>${labels[c]||c}</th>`).join('') + '</tr>';
const body = rows.map(r => '<tr>' + available.map(c => `<td>${r[c]}</td>`).join('') + '</tr>').join('');
tableWrap.innerHTML = `<table>${header}${body}</table>`;
}
async function fetchForecast(lat, lon) {
statusEl.textContent = 'Fetching 4‑day forecast…';
dlBtn.disabled = true;
tableWrap.innerHTML = '';
try {
const r = await fetch(`/api/forecast?lat=${lat}&lon=${lon}`);
if (r.status === 202) {
statusEl.textContent = 'Cache warming… downloading ECMWF GRIB files. Please wait.';
return;
}
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const payload = await r.json();
lastPayload = payload;
statusEl.textContent = `Received ${payload.hours?.length||0} hourly steps.`;
srcEl.textContent = payload.source || 'Unknown';
buildTable(payload);
dlBtn.disabled = false;
} catch (e) {
console.error(e);
statusEl.textContent = 'Failed to fetch forecast.';
}
}
async function pollStatus() {
try {
const r = await fetch('/api/status');
if (!r.ok) return;
const s = await r.json();
const ready = s.ready||0; const total = s.total||0; const mb = ((s.bytes||0)/1e6).toFixed(1);
if (total > 0) {
statusEl.textContent = `Cache: ${ready}/${total} steps ready (${mb} MB).`;
}
} catch {}
}
statusTimer = setInterval(pollStatus, 5000);
pollStatus();
map.on('click', (e) => {
const { lat, lng } = e.latlng;
if (marker) marker.remove();
marker = L.marker([lat, lng]).addTo(map);
locEl.textContent = `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
fetchForecast(lat, lng);
});
dlBtn.addEventListener('click', () => {
if (!lastPayload) return;
const blob = new Blob([JSON.stringify(lastPayload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `forecast_${(lastPayload.lat||0).toFixed(4)}_${(lastPayload.lon||0).toFixed(4)}.json`;
a.click();
URL.revokeObjectURL(url);
});
</script>
</body>
</html>