Spaces:
Sleeping
Sleeping
| <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: '© 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> | |