Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>O.W.L. embed</title> | |
| <style> | |
| :root { | |
| --bg: #0b1020; | |
| --bg-elev: #121830; | |
| --fg: #e6e9f2; | |
| --fg-dim: #9aa3b8; | |
| --accent: #7dd3fc; | |
| --border: #1f2845; | |
| --flag: #facc15; | |
| } | |
| 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; | |
| font-size: 14px; line-height: 1.4; } | |
| .card { padding: 12px 14px; height: 100%; display: flex; flex-direction: column; gap: 10px; } | |
| .head { display: flex; justify-content: space-between; align-items: baseline; } | |
| .id { font-size: 22px; font-weight: 800; letter-spacing: .02em; | |
| color: var(--accent); } | |
| .name { font-size: 12px; color: var(--fg); opacity: .85; } | |
| .kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } | |
| .kpi { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px; | |
| padding: 6px 8px; } | |
| .kpi .label { font-size: 10px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: .06em; } | |
| .kpi .value { font-size: 16px; font-variant-numeric: tabular-nums; margin-top: 2px; } | |
| .metar { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px; | |
| padding: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| font-size: 12px; white-space: pre-wrap; word-break: break-word; } | |
| .foot { display: flex; justify-content: space-between; align-items: center; | |
| font-size: 10px; color: var(--fg-dim); margin-top: auto; } | |
| .foot a { color: var(--accent); text-decoration: none; } | |
| .badge { display: inline-block; padding: 1px 6px; border-radius: 999px; background: var(--flag); | |
| color: #1b1500; font-size: 10px; font-weight: 700; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="head"> | |
| <div> | |
| <div class="id" id="id">—</div> | |
| <div class="name" id="name">loading…</div> | |
| </div> | |
| <div id="flag"></div> | |
| </div> | |
| <div class="kpis"> | |
| <div class="kpi"><div class="label">temp °F</div><div class="value" id="tmpf">—</div></div> | |
| <div class="kpi"><div class="label">dew °F</div><div class="value" id="dwpf">—</div></div> | |
| <div class="kpi"><div class="label">wind kt</div><div class="value" id="wind">—</div></div> | |
| <div class="kpi"><div class="label">peak gust kt</div><div class="value" id="gust">—</div></div> | |
| <div class="kpi"><div class="label">pres inHg</div><div class="value" id="pres">—</div></div> | |
| <div class="kpi"><div class="label">precip in/h</div><div class="value" id="precip">—</div></div> | |
| </div> | |
| <div class="metar" id="metar">—</div> | |
| <div class="foot"> | |
| <span id="ts">—</span> | |
| <a id="link" href="#" target="_blank">powered by O.W.L. ↗</a> | |
| </div> | |
| </div> | |
| <script src="/sdk.js"></script> | |
| <script> | |
| (async function () { | |
| // /embed/{STATION}; fall back to ?station=XXXX | |
| const path = window.location.pathname.split('/').filter(Boolean); | |
| const fromPath = path[0] === 'embed' && path[1] ? path[1] : null; | |
| const fromQuery = new URLSearchParams(window.location.search).get('station'); | |
| const sid = (fromPath || fromQuery || 'KJFK').toUpperCase(); | |
| document.getElementById('id').textContent = sid; | |
| document.getElementById('link').href = '/explore?station=' + encodeURIComponent(sid); | |
| const owl = OWL.client(); | |
| let rec = null; | |
| try { | |
| rec = await owl.station(sid); | |
| document.getElementById('name').textContent = | |
| (rec.name || '') + (rec.state ? ', ' + rec.state : ''); | |
| } catch (e) { | |
| document.getElementById('name').textContent = 'station not found'; | |
| return; | |
| } | |
| async function refresh() { | |
| try { | |
| // IEM 1-min archive ingests with a ~2-4h lag; walk back through | |
| // expanding windows until we hit data (or give up after 7 days). | |
| const vars = ['tmpf','dwpf','sknt','gust_sknt','pres1','precip']; | |
| const windows = [[1,0],[6,2],[24,2],[168,24]]; | |
| let obs = []; | |
| for (const [spanH, lagH] of windows) { | |
| const end = new Date(Date.now() - lagH * 3600 * 1000); | |
| const start = new Date(end.getTime() - spanH * 3600 * 1000); | |
| obs = await owl.obs1min(sid, isoZ(start), isoZ(end), { vars }); | |
| if (obs && obs.length) break; | |
| } | |
| const metar = await owl.metarLatest(sid).catch(() => null); | |
| if (obs.length) { | |
| const latest = obs[obs.length - 1]; | |
| const peakGust = obs.reduce((a, r) => Math.max(a, r.gust_sknt || 0), 0); | |
| const hourPrecip = obs.reduce((a, r) => a + (r.precip || 0), 0); | |
| set('tmpf', num(latest.tmpf, 1)); | |
| set('dwpf', num(latest.dwpf, 1)); | |
| set('wind', num(latest.sknt, 0)); | |
| set('gust', num(peakGust, 0)); | |
| set('pres', num(latest.pres1, 2)); | |
| set('precip', num(hourPrecip, 2)); | |
| document.getElementById('ts').textContent = 'updated ' + escape(latest.valid); | |
| } | |
| if (metar && metar.metar) { | |
| const raw = metar.metar.rawOb || metar.metar.raw_text || ''; | |
| document.getElementById('metar').textContent = raw; | |
| const flagged = raw.trimEnd().replace(/=$/, '').endsWith('$'); | |
| document.getElementById('flag').innerHTML = flagged | |
| ? '<span class="badge">$ MAINT</span>' : ''; | |
| } | |
| } catch (e) { | |
| console.warn('embed refresh failed', e); | |
| } | |
| } | |
| refresh(); | |
| setInterval(refresh, 60000); | |
| function num(v, p) { return (v == null || isNaN(v)) ? '—' : Number(v).toFixed(p); } | |
| function set(id, v) { document.getElementById(id).textContent = v; } | |
| function escape(s) { return s == null ? '' : String(s); } | |
| function isoZ(d) { return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |