| <!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-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&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 & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); |
| } |
| function isoZ(d) { |
| return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); |
| } |
| })(); |
| </script> |
| </body> |
| </html> |
|
|