asos-tools / static /explore.html
consgicody's picture
Sync from GitHub a470e01
68d54e8 verified
Raw
History Blame Contribute Delete
23.7 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>O.W.L. Explore — click anywhere · 2,929 ASOS sites · live observations</title>
<meta name="description" content="Click anywhere in the US to see the nearest ASOS station and its latest 1-minute observations. Powered by the O.W.L. public API.">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
:root {
--bg: #0b1020;
--bg-elev: #121830;
--fg: #e6e9f2;
--fg-dim: #9aa3b8;
--accent: #7dd3fc;
--accent-hot: #fb7185;
--border: #1f2845;
--flag: #facc15;
--maint: #fb7185;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#app { display: grid; grid-template-columns: 1fr 380px; grid-template-rows: 56px 1fr;
height: 100vh; gap: 0; }
header { grid-column: 1 / -1; padding: 10px 18px; background: var(--bg-elev);
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; }
header h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: .02em; }
header .badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: var(--border);
color: var(--fg-dim); }
header nav { margin-left: auto; display: flex; gap: 12px; }
header nav a { color: var(--fg-dim); text-decoration: none; font-size: 13px; }
header nav a:hover { color: var(--accent); }
#map { width: 100%; height: 100%; background: var(--bg); }
aside { background: var(--bg-elev); border-left: 1px solid var(--border); overflow-y: auto;
padding: 14px 16px; display: flex; flex-direction: column; gap: 14px; }
.panel { background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 8px;
padding: 12px; }
.panel h2 { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: var(--accent);
text-transform: uppercase; letter-spacing: .08em; }
.row { display: flex; justify-content: space-between; align-items: baseline; padding: 4px 0;
border-bottom: 1px dotted rgba(255,255,255,0.06); font-size: 13px; }
.row:last-child { border-bottom: 0; }
.row .k { color: var(--fg-dim); }
.row .v { font-variant-numeric: tabular-nums; }
.hint { font-size: 12px; color: var(--fg-dim); line-height: 1.5; }
select, input { background: var(--bg); color: var(--fg); border: 1px solid var(--border);
border-radius: 6px; padding: 6px 8px; font: inherit; width: 100%; }
select option { background: var(--bg-elev); color: var(--fg); }
input::placeholder { color: var(--fg-dim); opacity: 0.8; }
input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--fg);
-webkit-box-shadow: 0 0 0 1000px var(--bg) inset;
transition: background-color 9999s ease-in-out 0s;
}
pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 8px; font-size: 12px; line-height: 1.45; overflow-x: auto; white-space: pre-wrap;
word-break: break-word; max-height: 220px; }
.kpi { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.kpi .cell { background: var(--bg); padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border); }
.kpi .cell .label { font-size: 11px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: .06em; }
.kpi .cell .value { font-size: 18px; font-variant-numeric: tabular-nums; margin-top: 2px; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px;
background: var(--border); color: var(--fg); }
.pill.maint { background: var(--maint); color: #1a0810; }
.pill.online { background: #16a34a; color: #03110a; }
button { background: var(--accent); color: #001620; border: 0; border-radius: 6px;
padding: 8px 12px; font-weight: 600; cursor: pointer; font: inherit; }
button:disabled { opacity: .5; cursor: not-allowed; }
.empty { color: var(--fg-dim); font-style: italic; font-size: 13px; padding: 6px 0; }
/* Leaflet dark tweaks */
.leaflet-control-attribution { background: rgba(0,0,0,0.5) !important; color: #aab; }
.leaflet-control-attribution a { color: var(--accent); }
@media (max-width: 900px) {
#app { grid-template-columns: 1fr; grid-template-rows: 56px 50vh 1fr; }
aside { border-left: 0; border-top: 1px solid var(--border); }
}
</style>
</head>
<body>
<div id="app">
<header>
<h1>O.W.L. Explore</h1>
<span class="badge">2,929 ASOS sites · click anywhere</span>
<nav>
<a href="/v1/docs" target="_blank">API docs</a>
<a href="/sdk.js" target="_blank">SDK</a>
<a href="/" target="_blank">Dashboard</a>
<a href="https://github.com/consigcody94/asos-tools-py" target="_blank">GitHub</a>
</nav>
</header>
<div id="map"></div>
<aside>
<div class="panel">
<h2>Pick a station</h2>
<div class="hint">Click anywhere on the map to snap to the nearest ASOS site, or pick a curated group below.</div>
<div style="margin-top:8px"><select id="groupSel"><option value="">— preset group —</option></select></div>
<div style="margin-top:8px"><input id="stationInput" placeholder="ICAO id (e.g. KJFK)" autocomplete="off"></div>
</div>
<div class="panel" id="stationPanel">
<h2>Selected station</h2>
<div id="stationBody" class="empty">No station selected.</div>
</div>
<div class="panel" id="metarPanel">
<h2>Latest METAR</h2>
<div id="metarBody" class="empty"></div>
</div>
<div class="panel" id="obsPanel">
<h2>Last hour · 1-minute</h2>
<div id="obsBody" class="empty"></div>
</div>
<div class="panel" id="forecastPanel">
<h2>3-day forecast (Open-Meteo)</h2>
<div id="forecastBody" class="empty"></div>
</div>
<div class="panel" id="marinePanel">
<h2>Marine · waves</h2>
<div id="marineBody" class="empty"></div>
</div>
<div class="panel" id="aqPanel">
<h2>Air quality + UV</h2>
<div id="aqBody" class="empty"></div>
</div>
<div class="panel" id="tidesPanel">
<h2>Tides (NOAA T&amp;C)</h2>
<div id="tidesBody" class="empty"></div>
</div>
<div class="panel" id="riversPanel">
<h2>Rivers (USGS)</h2>
<div id="riversBody" class="empty"></div>
</div>
<div class="panel" id="imageryPanel">
<h2>Radar · satellite</h2>
<div id="imageryBody" class="empty"></div>
</div>
<div class="panel" id="astroPanel">
<h2>Sun &amp; moon</h2>
<div id="astroBody" class="empty"></div>
</div>
<div class="panel">
<h2>Hazards</h2>
<div id="hazardsBody" class="empty"></div>
</div>
</aside>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/sdk.js"></script>
<script>
(async function () {
const owl = OWL.client();
const stationPanel = document.getElementById('stationBody');
const metarPanel = document.getElementById('metarBody');
const obsPanel = document.getElementById('obsBody');
const hazardsPanel = document.getElementById('hazardsBody');
const groupSel = document.getElementById('groupSel');
const stationInput = document.getElementById('stationInput');
// Populate group selector.
try {
const g = await owl.groups();
g.groups.forEach(grp => {
const o = document.createElement('option');
o.value = grp.name; o.textContent = grp.name + ' (' + grp.stations.length + ')';
groupSel.appendChild(o);
});
} catch (_) { /* non-fatal */ }
// Build map + load all stations + opt-in overlays.
const m = await OWL.map('#map', { basemap: 'dark' });
await m.addStationLayer();
await m.addSearchControl();
// Fire these in parallel; each silently no-ops if its data is empty.
m.addWindOverlay().catch(()=>{});
m.addNhcConesOverlay().catch(()=>{});
m.addDroughtOverlay().catch(()=>{});
m.addSpcOutlookOverlay().catch(()=>{});
m.addQuakesOverlay(39.5, -98.35, { radiusKm: 5000, minMag: 3.0 }).catch(()=>{});
// Click-or-search station → load detail.
m.on('stationClick', async (s) => {
selectStation(s.id);
if (s.lat != null && s.lon != null) m.map.setView([s.lat, s.lon], 8);
});
m.on('error', (e) => console.warn('explorer error', e));
stationInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter' && stationInput.value.trim()) {
selectStation(stationInput.value.trim().toUpperCase());
}
});
groupSel.addEventListener('change', async () => {
const name = groupSel.value;
if (!name) return;
const r = await owl.group(name);
if (r.stations.length) selectStation(r.stations[0]);
});
async function selectStation(id) {
stationPanel.innerHTML = '<div class="hint">Loading ' + id + '…</div>';
['metarBody','obsBody','forecastBody','marineBody','aqBody','tidesBody',
'riversBody','imageryBody','astroBody','hazardsBody']
.forEach(x => { document.getElementById(x).textContent = '…'; });
let rec;
try {
rec = await owl.station(id);
} catch (e) {
stationPanel.innerHTML = '<div class="empty">' + (e.body && e.body.error || e.message) + '</div>';
return;
}
renderStation(rec);
if (rec.lat != null && rec.lon != null) m.map.setView([rec.lat, rec.lon], 8);
// Fast: latest METAR + last-hour obs (station-scoped, cheap).
loadMetar(id);
loadObs(id);
// Mega: one composite request fans every data source for this point.
if (rec.lat != null && rec.lon != null) loadEverything(id, rec.lat, rec.lon);
}
async function loadEverything(id, lat, lon) {
let payload;
try {
payload = await owl.everything(lat, lon);
} catch (e) {
console.warn('everything failed', e);
return;
}
renderForecast(payload.forecast);
renderMarine(payload.lat, payload.lon); // separate small call — Open-Meteo marine
renderAQ(payload.air_quality);
renderTides(payload.nearest_tide_station);
renderRivers(payload.rivers);
renderImagery(payload.radar, payload.satellite, id);
renderAstro(payload.astro);
renderHazardsFull(payload);
}
function renderStation(s) {
const pills = [];
if (s.online) pills.push('<span class="pill online">online</span>');
if (s.country && s.country !== 'US') pills.push('<span class="pill">' + s.country + '</span>');
stationPanel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:baseline">
<div style="font-size:22px;font-weight:700;color:var(--accent);letter-spacing:.02em">${escape(s.id)}</div>
<div>${pills.join(' ')}</div>
</div>
<div style="margin-top:4px;color:var(--fg);font-size:13px;font-weight:500">${escape(s.name || '')}${s.state || s.country ? ', ' + escape(s.state || s.country) : ''}</div>
<div class="row"><span class="k">lat / lon</span><span class="v">${num(s.lat,4)}, ${num(s.lon,4)}</span></div>
<div class="row"><span class="k">elevation</span><span class="v">${num(s.elevation,1)} m</span></div>
<div class="row"><span class="k">IEM id</span><span class="v">${escape(s.iem_id || '—')}</span></div>
${s.nearest_buoy ? `<div class="row"><span class="k">nearest buoy</span><span class="v">${escape(s.nearest_buoy.id)} (${num(s.nearest_buoy.distance_km,1)} km)</span></div>` : ''}
<div style="margin-top:8px"><a href="/embed/${encodeURIComponent(s.id)}" target="_blank" style="color:var(--accent);font-size:12px">↗ open embed widget</a></div>
`;
}
async function loadMetar(id) {
try {
const r = await owl.metarLatest(id);
const m = r.metar || {};
metarPanel.innerHTML = `
<pre>${escape(m.rawOb || m.raw_text || JSON.stringify(m, null, 2))}</pre>
<div class="hint">issued ${escape(m.reportTime || '—')}</div>
`;
} catch (e) {
metarPanel.innerHTML = '<div class="empty">no recent METAR</div>';
}
}
async function loadObs(id) {
// IEM's 1-minute archive ingests from NCEI with a ~2-4h lag — the
// most recent hour often returns empty. Walk the window back in
// expanding steps until we get rows (or give up after 7 days).
const vars = ['tmpf','dwpf','sknt','gust_sknt','pres1','precip'];
const windows = [
[ 1, 0], // last hour
[ 6, 2], // last 6h ending 2h ago
[ 24, 2], // last 24h ending 2h ago
[ 168, 24], // last 7d ending 24h ago
];
try {
let rows = [];
let windowLabel = '';
for (const [spanH, lagH] of windows) {
const end = new Date(Date.now() - lagH * 3600 * 1000);
const start = new Date(end.getTime() - spanH * 3600 * 1000);
rows = await owl.obs1min(id, isoZ(start), isoZ(end), { vars });
if (rows && rows.length) {
windowLabel =
spanH === 1 ? 'last hour' :
spanH === 6 ? 'last 6h (lagged 2h)' :
spanH === 24 ? 'last 24h (lagged 2h)' :
'last 7 days (lagged 1d)';
break;
}
}
if (!rows || !rows.length) {
obsPanel.innerHTML = '<div class="empty">no 1-minute data in the last 7 days — IEM archive may be lagging</div>';
return;
}
const latest = rows[rows.length - 1];
const peakGust = rows.reduce((a, r) => Math.max(a, r.gust_sknt || 0), 0);
const totalPrecip = rows.reduce((a, r) => a + (r.precip || 0), 0);
obsPanel.innerHTML = `
<div class="kpi">
<div class="cell"><div class="label">temp °F</div><div class="value">${num(latest.tmpf,1)}</div></div>
<div class="cell"><div class="label">dew °F</div><div class="value">${num(latest.dwpf,1)}</div></div>
<div class="cell"><div class="label">wind kt</div><div class="value">${num(latest.sknt,0)}</div></div>
<div class="cell"><div class="label">peak gust kt</div><div class="value">${num(peakGust,0)}</div></div>
<div class="cell"><div class="label">pressure inHg</div><div class="value">${num(latest.pres1,2)}</div></div>
<div class="cell"><div class="label">precip in</div><div class="value">${num(totalPrecip,2)}</div></div>
</div>
<div class="hint" style="margin-top:6px">${rows.length} minute samples · ${windowLabel} · latest ${escape(latest.valid)}</div>
`;
} catch (e) {
obsPanel.innerHTML = '<div class="empty">' + (e.body && e.body.error || e.message) + '</div>';
}
}
function renderForecast(fc) {
const el = document.getElementById('forecastBody');
if (!fc || !fc.daily) { el.innerHTML = '<div class="empty">no forecast</div>'; return; }
const d = fc.daily;
const days = d.time || [];
const rows = days.slice(0, 3).map((day, i) => {
const hi = d.temperature_2m_max && d.temperature_2m_max[i];
const lo = d.temperature_2m_min && d.temperature_2m_min[i];
const pop = d.precipitation_probability_max && d.precipitation_probability_max[i];
const ppt = d.precipitation_sum && d.precipitation_sum[i];
const wmax = d.wind_speed_10m_max && d.wind_speed_10m_max[i];
const gmax = d.wind_gusts_10m_max && d.wind_gusts_10m_max[i];
const uv = d.uv_index_max && d.uv_index_max[i];
return `<div style="border-bottom:1px dotted rgba(255,255,255,0.06);padding:6px 0">
<div style="font-weight:600;font-size:13px">${escape(day)}</div>
<div class="row"><span class="k">hi / lo °F</span><span class="v">${num(hi,0)} / ${num(lo,0)}</span></div>
<div class="row"><span class="k">precip in (max%)</span><span class="v">${num(ppt,2)} (${num(pop,0)}%)</span></div>
<div class="row"><span class="k">wind / gust kt</span><span class="v">${num(wmax,0)} / ${num(gmax,0)}</span></div>
<div class="row"><span class="k">UV max</span><span class="v">${num(uv,1)}</span></div>
</div>`;
}).join('');
const cur = fc.current || {};
el.innerHTML = `<div class="hint" style="margin-bottom:6px">current: ${num(cur.temperature_2m,1)}°F, wind ${num(cur.wind_speed_10m,0)} kt @ ${num(cur.wind_direction_10m,0)}°, ${num(cur.cloud_cover,0)}% cloud</div>${rows}`;
}
async function renderMarine(lat, lon) {
const el = document.getElementById('marineBody');
try {
const mr = await owl.forecastMarine(lat, lon, 3);
const cur = mr.current || {};
if (!cur.wave_height && cur.wave_height !== 0) {
el.innerHTML = '<div class="empty">no marine data (inland)</div>';
return;
}
el.innerHTML = `
<div class="row"><span class="k">wave height ft</span><span class="v">${num(cur.wave_height,1)}</span></div>
<div class="row"><span class="k">wave period s</span><span class="v">${num(cur.wave_period,1)}</span></div>
<div class="row"><span class="k">wave dir °</span><span class="v">${num(cur.wave_direction,0)}</span></div>
<div class="row"><span class="k">swell height ft</span><span class="v">${num(cur.swell_wave_height,1)}</span></div>
<div class="row"><span class="k">wind wave ft</span><span class="v">${num(cur.wind_wave_height,1)}</span></div>
<div class="row"><span class="k">current kt</span><span class="v">${num(cur.ocean_current_velocity,1)}</span></div>
`;
} catch (e) {
el.innerHTML = '<div class="empty">marine unavailable</div>';
}
}
function renderAQ(aq) {
const el = document.getElementById('aqBody');
if (!aq || !aq.current) { el.innerHTML = '<div class="empty">no AQ data</div>'; return; }
const c = aq.current;
const aqi = c.us_aqi;
const aqiColor = aqi == null ? '#9aa3b8' :
aqi <= 50 ? '#10b981' : aqi <= 100 ? '#eab308' :
aqi <= 150 ? '#f97316' : aqi <= 200 ? '#ef4444' :
aqi <= 300 ? '#a855f7' : '#7f1d1d';
el.innerHTML = `
<div class="kpi">
<div class="cell"><div class="label">US AQI</div>
<div class="value" style="color:${aqiColor}">${num(aqi,0)}</div></div>
<div class="cell"><div class="label">UV index</div>
<div class="value">${num(c.uv_index,1)}</div></div>
<div class="cell"><div class="label">PM2.5 µg/m³</div>
<div class="value">${num(c.pm2_5,1)}</div></div>
<div class="cell"><div class="label">PM10 µg/m³</div>
<div class="value">${num(c.pm10,1)}</div></div>
<div class="cell"><div class="label">O₃ µg/m³</div>
<div class="value">${num(c.ozone,0)}</div></div>
<div class="cell"><div class="label">NO₂ µg/m³</div>
<div class="value">${num(c.nitrogen_dioxide,0)}</div></div>
</div>
`;
}
function renderTides(t) {
const el = document.getElementById('tidesBody');
if (!t) { el.innerHTML = '<div class="empty">no nearby tide station</div>'; return; }
el.innerHTML = `
<div class="row"><span class="k">station</span><span class="v">${escape(t.name || t.id)}</span></div>
<div class="row"><span class="k">distance km</span><span class="v">${num(t.distance_km,1)}</span></div>
<div class="hint" style="margin-top:6px"><a href="/v1/tides/${encodeURIComponent(t.id || '')}?days=3" target="_blank" style="color:var(--accent)">predictions ↗</a></div>
`;
}
function renderRivers(rs) {
const el = document.getElementById('riversBody');
if (!rs || !rs.length) { el.innerHTML = '<div class="empty">no USGS gauges nearby</div>'; return; }
const top = rs.slice(0, 4);
el.innerHTML = top.map(g => {
const flow = (g.values || []).find(v => v.variable_code === '00060');
const gh = (g.values || []).find(v => v.variable_code === '00065');
return `<div style="border-bottom:1px dotted rgba(255,255,255,0.06);padding:5px 0">
<div style="font-size:12px">${escape(g.name || g.site_code)}</div>
<div class="row"><span class="k">flow cfs / height ft</span>
<span class="v">${flow ? num(flow.value,0) : '—'} / ${gh ? num(gh.value,1) : '—'}</span></div>
<div class="hint">${num(g.distance_km,1)} km away</div>
</div>`;
}).join('');
}
function renderImagery(rd, sat, stationId) {
const el = document.getElementById('imageryBody');
const parts = [];
if (rd && rd.loop_url) {
parts.push(`<div style="margin-bottom:8px">
<div class="hint">NEXRAD ${escape(rd.site_id || '')}</div>
<img src="${escape(rd.loop_url)}" alt="radar" style="width:100%;border-radius:6px;background:#0a0e1f">
</div>`);
}
if (sat && sat.loop_url) {
parts.push(`<div>
<div class="hint">GOES satellite</div>
<img src="${escape(sat.loop_url)}" alt="satellite" style="width:100%;border-radius:6px;background:#0a0e1f">
</div>`);
}
el.innerHTML = parts.length ? parts.join('') : '<div class="empty">no imagery</div>';
}
function renderAstro(a) {
const el = document.getElementById('astroBody');
if (!a) { el.innerHTML = '<div class="empty"></div>'; return; }
const sr = a.sun && a.sun.sunrise_utc ? new Date(a.sun.sunrise_utc).toUTCString().split(' ')[4] : '—';
const ss = a.sun && a.sun.sunset_utc ? new Date(a.sun.sunset_utc).toUTCString().split(' ')[4] : '—';
const dl = a.sun && a.sun.daylight_seconds ? (a.sun.daylight_seconds / 3600).toFixed(1) + ' h' : '—';
const moon = a.moon || {};
el.innerHTML = `
<div class="row"><span class="k">sunrise (UTC)</span><span class="v">${sr}</span></div>
<div class="row"><span class="k">sunset (UTC)</span><span class="v">${ss}</span></div>
<div class="row"><span class="k">daylight</span><span class="v">${dl}</span></div>
<div class="row"><span class="k">moon</span><span class="v">${escape(moon.name || '—')} (${num((moon.illumination_fraction || 0) * 100, 0)}%)</span></div>
`;
}
function renderHazardsFull(p) {
const el = document.getElementById('hazardsBody');
const quakes = p.quakes || [];
const sw = p.space_weather || {};
const alerts = p.alerts || [];
const tropical = p.tropical_storms || [];
const qPart = quakes.length
? `<div class="row"><span class="k">quakes ≤300km / 24h</span><span class="v">${quakes.length} (max M${num(Math.max(...quakes.map(x=>x.mag||0)),1)})</span></div>`
: `<div class="row"><span class="k">quakes ≤300km / 24h</span><span class="v">none</span></div>`;
const kp = sw.kp && sw.kp.value != null ? num(sw.kp.value,1) : '—';
const aPart = alerts.length
? `<div class="row"><span class="k">active NWS alerts</span><span class="v">${alerts.length}</span></div>`
: `<div class="row"><span class="k">active NWS alerts</span><span class="v">none</span></div>`;
const tPart = tropical.length
? `<div class="row"><span class="k">active tropical</span><span class="v">${tropical.length}</span></div>`
: '';
el.innerHTML = qPart + aPart + tPart +
`<div class="row"><span class="k">Kp index</span><span class="v">${kp}</span></div>`;
}
// --- utils -------------------------------------------------------------
function num(v, p) {
if (v == null || isNaN(v)) return '—';
return Number(v).toFixed(p);
}
function escape(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function isoZ(d) {
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
})();
</script>
</body>
</html>