airbrief/index.html
Dave Campbell 1658eda480 Initial commit: AirBrief MVP
Airport lookup and route weather strip. Pulls from aviationweather.gov
with no API key. Decodes METAR/TAF client-side. Hosted at
rockcampbell.com/airbrief via nginx proxy on rocklab.
2026-06-16 14:31:40 +00:00

809 lines
35 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AirBrief</title>
<style>
:root {
--bg: #0c0e16;
--surface: #161926;
--surface2: #1f2336;
--text: #e4e7f0;
--dim: #7c82a0;
--vfr: #22c55e;
--mvfr: #3b82f6;
--ifr: #ef4444;
--lifr: #a855f7;
--accent: #60a5fa;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 20px;
}
.container { max-width: 720px; margin: 0 auto; }
/* ── Header ── */
header { text-align: center; padding: 32px 0 36px; }
.logo { font-size: 2rem; font-weight: 800; letter-spacing: 0.06em; color: var(--accent); }
.logo span { color: #93c5fd; font-weight: 400; }
.tagline { color: var(--dim); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.14em; margin-top: 6px; }
/* ── Search ── */
.search-row { display: flex; gap: 10px; margin-bottom: 32px; }
#id-input {
flex: 1;
padding: 13px 18px;
font-size: 1.2rem;
font-family: 'Courier New', monospace;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
background: var(--surface);
border: 2px solid var(--surface2);
border-radius: 8px;
color: var(--text);
outline: none;
transition: border-color 0.15s;
}
#id-input:focus { border-color: var(--accent); }
#id-input::placeholder { color: var(--dim); font-weight: 400; font-size: 0.9rem; letter-spacing: 0; }
#go-btn {
padding: 13px 26px;
font-size: 1rem;
font-weight: 700;
background: var(--accent);
color: #06090f;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
#go-btn:hover { background: #93c5fd; }
/* ── Status ── */
.loading, .error-msg {
text-align: center;
padding: 48px 20px;
color: var(--dim);
font-size: 0.95rem;
}
.error-msg { color: var(--ifr); }
/* ── Flight category badge (airport view) ── */
.flt-badge {
border-radius: 10px;
padding: 18px 24px;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.flt-badge.VFR { background: rgba(34,197,94,0.1); border: 2px solid var(--vfr); }
.flt-badge.MVFR { background: rgba(59,130,246,0.1); border: 2px solid var(--mvfr); }
.flt-badge.IFR { background: rgba(239,68,68,0.1); border: 2px solid var(--ifr); }
.flt-badge.LIFR { background: rgba(168,85,247,0.1); border: 2px solid var(--lifr); }
.flt-badge.UNKN { background: rgba(124,130,160,0.1); border: 2px solid var(--dim); }
.flt-cat { font-size: 2.4rem; font-weight: 900; letter-spacing: 0.06em; }
.flt-badge.VFR .flt-cat { color: var(--vfr); }
.flt-badge.MVFR .flt-cat { color: var(--mvfr); }
.flt-badge.IFR .flt-cat { color: var(--ifr); }
.flt-badge.LIFR .flt-cat { color: var(--lifr); }
.flt-badge.UNKN .flt-cat { color: var(--dim); }
.station-info { text-align: right; }
.station-id { font-size: 1.5rem; font-weight: 700; font-family: 'Courier New', monospace; letter-spacing: 0.1em; }
.station-name-sub { font-size: 0.8rem; color: var(--dim); margin-top: 2px; }
.obs-time { font-size: 0.75rem; color: var(--dim); text-align: right; margin-bottom: 20px; }
/* ── Weather data grid (airport view) ── */
.wx-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 10px; }
.wx-card { background: var(--surface); border-radius: 8px; padding: 14px 16px; }
.wx-card.full { grid-column: 1 / -1; }
.wx-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--dim); margin-bottom: 5px; }
.wx-val { font-size: 1.15rem; font-weight: 600; line-height: 1.2; }
.wx-unit { font-size: 0.78rem; color: var(--dim); font-weight: 400; }
.sky-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.sky-tag { background: var(--surface2); border-radius: 4px; padding: 4px 10px; font-family: 'Courier New', monospace; font-size: 0.88rem; }
/* ── Section blocks ── */
.section { background: var(--surface); border-radius: 8px; padding: 14px 16px; margin-top: 10px; }
.section-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--dim); margin-bottom: 8px; }
.mono-block { font-family: 'Courier New', monospace; font-size: 0.82rem; color: #b0b6cc; white-space: pre-wrap; line-height: 1.65; word-break: break-all; }
.plain-english { font-size: 0.9rem; line-height: 1.7; color: var(--text); margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--surface2); }
/* ── TAF periods ── */
.taf-period { margin-bottom: 12px; }
.taf-period-header { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); font-weight: 700; margin-bottom: 3px; }
.taf-period-header.tempo { color: #f59e0b; }
.taf-period-header.becmg { color: #34d399; }
.taf-period-header.prob { color: #fb923c; }
.taf-period-body { font-size: 0.88rem; color: var(--text); line-height: 1.5; }
/* ── Route strip ── */
.route-header { font-size: 0.75rem; color: var(--dim); text-align: center; margin-bottom: 16px; }
.route-strip {
display: flex;
align-items: stretch;
gap: 0;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 20px;
}
.route-airport {
flex: 1;
min-width: 110px;
border-radius: 10px;
padding: 14px 10px;
text-align: center;
position: relative;
}
.route-airport.VFR { background: rgba(34,197,94,0.12); border: 2px solid var(--vfr); }
.route-airport.MVFR { background: rgba(59,130,246,0.12); border: 2px solid var(--mvfr); }
.route-airport.IFR { background: rgba(239,68,68,0.12); border: 2px solid var(--ifr); }
.route-airport.LIFR { background: rgba(168,85,247,0.12); border: 2px solid var(--lifr); }
.route-airport.UNKN { background: rgba(124,130,160,0.1); border: 2px solid var(--dim); }
.route-airport.NOTFOUND { background: var(--surface); border: 2px dashed var(--surface2); opacity: 0.6; }
.route-icao { font-size: 1rem; font-weight: 700; font-family: 'Courier New', monospace; letter-spacing: 0.08em; }
.route-cat { font-size: 0.85rem; font-weight: 800; margin-top: 4px; letter-spacing: 0.06em; }
.route-airport.VFR .route-cat { color: var(--vfr); }
.route-airport.MVFR .route-cat { color: var(--mvfr); }
.route-airport.IFR .route-cat { color: var(--ifr); }
.route-airport.LIFR .route-cat { color: var(--lifr); }
.route-airport.UNKN .route-cat { color: var(--dim); }
.route-detail { font-size: 0.72rem; color: var(--dim); margin-top: 5px; line-height: 1.5; }
.route-arrow { color: var(--dim); font-size: 1.1rem; padding: 0 8px; display: flex; align-items: center; flex-shrink: 0; }
/* ── Per-airport detail cards (route view) ── */
.airport-cards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px; }
.airport-card { background: var(--surface); border-radius: 8px; padding: 14px 16px; }
.airport-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.airport-card-id { font-family: 'Courier New', monospace; font-size: 1rem; font-weight: 700; letter-spacing: 0.1em; }
.airport-card-name { font-size: 0.78rem; color: var(--dim); }
.airport-card-cat {
font-size: 0.8rem;
font-weight: 800;
padding: 3px 10px;
border-radius: 4px;
letter-spacing: 0.06em;
}
.airport-card-cat.VFR { background: rgba(34,197,94,0.15); color: var(--vfr); }
.airport-card-cat.MVFR { background: rgba(59,130,246,0.15); color: var(--mvfr); }
.airport-card-cat.IFR { background: rgba(239,68,68,0.15); color: var(--ifr); }
.airport-card-cat.LIFR { background: rgba(168,85,247,0.15); color: var(--lifr); }
.airport-card-cat.UNKN { background: rgba(124,130,160,0.1); color: var(--dim); }
.airport-card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.airport-card-item .label { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); }
.airport-card-item .val { font-size: 0.92rem; font-weight: 600; margin-top: 2px; }
.airport-card-time { font-size: 0.72rem; color: var(--dim); margin-top: 8px; }
.airport-card-plain { font-size: 0.82rem; color: var(--dim); margin-top: 8px; line-height: 1.5; border-top: 1px solid var(--surface2); padding-top: 8px; }
/* ── Hazard summary ── */
.hazard-section { background: var(--surface); border-radius: 8px; padding: 14px 16px; margin-top: 10px; }
.no-hazards { font-size: 0.88rem; color: var(--vfr); }
.hazard-item { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--surface2); }
.hazard-item:last-child { border-bottom: none; padding-bottom: 0; }
.hazard-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
.hazard-dot.red { background: #ef4444; }
.hazard-dot.orange { background: #f97316; }
.hazard-dot.yellow { background: #eab308; }
.hazard-dot.blue { background: #3b82f6; }
.hazard-content { flex: 1; }
.hazard-type { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 700; color: var(--dim); margin-bottom: 2px; }
.hazard-body { font-size: 0.86rem; line-height: 1.5; }
.hazard-valid { font-size: 0.72rem; color: var(--dim); margin-top: 3px; }
/* ── Legend & Disclaimer ── */
.legend { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; margin: 28px 0 10px; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.78rem; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.disclaimer { text-align: center; font-size: 0.72rem; color: var(--dim); margin-top: 36px; padding: 16px; border-top: 1px solid var(--surface2); line-height: 1.6; }
@media (max-width: 540px) {
.logo { font-size: 1.6rem; }
.wx-grid { grid-template-columns: repeat(2, 1fr); }
.flt-cat { font-size: 1.8rem; }
.airport-card-grid { grid-template-columns: repeat(2, 1fr); }
.route-airport { min-width: 90px; }
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">Air<span>Brief</span></div>
<div class="tagline">Aviation Weather · Situational Awareness</div>
</header>
<div class="search-row">
<input type="text" id="id-input" placeholder="KMKC or KMKC KSTL KDEN for a route" maxlength="60"
autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false">
<button id="go-btn">Go</button>
</div>
<div id="results"></div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div>VFR &gt;3SM &amp; &gt;3000'</div>
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6"></div>MVFR 13SM or 10003000'</div>
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div>IFR &lt;1SM or &lt;1000'</div>
<div class="legend-item"><div class="legend-dot" style="background:#a855f7"></div>LIFR &lt;½SM or &lt;500'</div>
</div>
<div class="disclaimer">
This tool is for situational awareness only and does not constitute an official preflight weather briefing.
Pilots must obtain an official briefing before flight per FAA regulations.
</div>
</div>
<script>
// ── AirBrief ───────────────────────────────────────────────────────────────
// Single airport: type one ICAO/IATA → full detail view.
// Route: type multiple space-separated IDs → strip + per-airport cards + hazards.
// All data from aviationweather.gov (no API key). Proxy at /airbrief-api/.
const API = '/airbrief-api';
// ── Utilities ──────────────────────────────────────────────────────────────
function degToCompass(deg) {
if (deg === null || deg === undefined) return 'VRB';
const dirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'];
return dirs[Math.round(deg / 22.5) % 16];
}
function cToF(c) { return Math.round(c * 9 / 5 + 32); }
// API returns altimeter in hPa; pilots need inHg for the Kollsman window.
function hpaToInHg(hpa) { return hpa != null ? (hpa / 33.8639).toFixed(2) : null; }
function formatTime(t) {
if (!t) return '';
try {
const d = new Date(t.includes('Z') ? t : t + ' UTC');
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });
} catch { return t; }
}
function formatUnixTime(ts) {
return new Date(ts * 1000).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });
}
// Parse input string to array of airport IDs (handles spaces, commas, slashes)
function parseIds(input) {
return input.trim().toUpperCase().split(/[\s,/]+/).filter(id => id.length >= 3 && id.length <= 4);
}
// ── Display formatters ─────────────────────────────────────────────────────
function formatWind(wdir, wspd, wgst) {
if (wspd === null || wspd === undefined) return '—';
if (wspd === 0) return 'Calm';
const dir = (wdir && wdir > 0) ? `${degToCompass(wdir)} (${wdir}°)` : 'VRB';
return wgst ? `${dir} @ ${wspd} G${wgst} kt` : `${dir} @ ${wspd} kt`;
}
function formatVis(v) {
if (v === null || v === undefined) return '—';
if (String(v).startsWith('6+') || String(v).startsWith('10+') || Number(v) >= 6) return '10+ SM';
return `${v} SM`;
}
function skyLayerTags(clouds) {
if (!clouds || clouds.length === 0) return ['Clear'];
return clouds.map(cl => {
if (!cl.cover) return '';
if (cl.cover === 'CLR' || cl.cover === 'SKC') return cl.cover;
const base = cl.base != null ? ` ${cl.base.toLocaleString()}'` : '';
const type = cl.type ? `/${cl.type}` : '';
return `${cl.cover}${base}${type}`;
}).filter(Boolean);
}
function ceilingStr(clouds) {
if (!clouds) return 'None';
for (const cl of clouds) {
if ((cl.cover === 'BKN' || cl.cover === 'OVC') && cl.base != null) {
return `${cl.base.toLocaleString()}' ${cl.cover}`;
}
}
return 'None';
}
// ── Flight category ────────────────────────────────────────────────────────
// API field is fltCat (capital C). Compute from ceiling + vis as fallback.
function computeFltCat(clouds, visib) {
let ceil = Infinity;
if (Array.isArray(clouds)) {
for (const cl of clouds) {
if ((cl.cover === 'BKN' || cl.cover === 'OVC') && cl.base != null) { ceil = cl.base; break; }
}
}
const vis = parseFloat(visib) || 10;
if (ceil < 500 || vis < 1) return 'LIFR';
if (ceil < 1000 || vis < 3) return 'IFR';
if (ceil <= 3000 || vis <= 5) return 'MVFR';
return 'VFR';
}
function getFltCat(metar) {
return metar.fltCat || metar.fltcat || computeFltCat(metar.clouds, metar.visib);
}
// ── Plain-English decoders ─────────────────────────────────────────────────
const WX_PHENOMENA = {
'RA':'rain','DZ':'drizzle','SN':'snow','SG':'snow grains',
'IC':'ice crystals','PL':'ice pellets','GR':'hail','GS':'graupel',
'BR':'mist','FG':'fog','FU':'smoke','VA':'volcanic ash',
'DU':'dust','SA':'sand','HZ':'haze','FC':'funnel cloud',
'SQ':'squalls','DS':'dust storm','SS':'sand storm'
};
const WX_DESCRIPTORS = {
'MI':'shallow','PR':'partial','BC':'patchy','DR':'low drifting',
'BL':'blowing','SH':'shower','TS':'thunderstorm with','FZ':'freezing'
};
const SKY_NAMES = {
'FEW':'Few clouds','SCT':'Scattered clouds',
'BKN':'Broken ceiling','OVC':'Overcast',
'SKC':'Sky clear','CLR':'Sky clear'
};
function decodeWx(wx) {
if (!wx) return '';
let s = wx, prefix = '';
if (s[0] === '+') { prefix = 'Heavy '; s = s.slice(1); }
else if (s[0] === '-') { prefix = 'Light '; s = s.slice(1); }
else if (s.startsWith('VC')) { prefix = 'In vicinity — '; s = s.slice(2); }
for (const [code, name] of Object.entries(WX_DESCRIPTORS)) {
if (s.startsWith(code)) {
return prefix + name + ' ' + (WX_PHENOMENA[s.slice(code.length)] || s.slice(code.length));
}
}
return prefix + (WX_PHENOMENA[s] || s);
}
function decodeSky(clouds) {
if (!clouds || clouds.length === 0) return 'Sky clear';
const parts = clouds.map(cl => {
if (!cl.cover) return '';
if (cl.cover === 'SKC' || cl.cover === 'CLR') return 'sky clear';
const name = (SKY_NAMES[cl.cover] || cl.cover).toLowerCase();
const base = cl.base ? ` at ${cl.base.toLocaleString()} feet` : '';
const type = cl.type === 'CB' ? ' (cumulonimbus)' : cl.type === 'TCU' ? ' (towering cumulus)' : '';
return name + base + type;
}).filter(Boolean);
const j = parts.join('; ');
return j.charAt(0).toUpperCase() + j.slice(1);
}
function decodeWindSentence(wdir, wspd, wgst) {
if (wspd === null || wspd === undefined) return '';
if (wspd === 0) return 'Winds calm';
const dir = (wdir && wdir > 0) ? `from the ${degToCompass(wdir)} (${wdir}°)` : 'variable direction';
const gust = wgst ? `, gusting to ${wgst} knots` : '';
return `Winds ${dir} at ${wspd} knots${gust}`;
}
function metarToEnglish(m) {
const parts = [];
const wind = decodeWindSentence(m.wdir, m.wspd, m.wgst);
if (wind) parts.push(wind);
const v = String(m.visib || '');
if (v) {
parts.push((parseFloat(v) >= 6 || v.startsWith('6+') || v.startsWith('10+'))
? 'Visibility greater than 6 statute miles'
: `Visibility ${v} statute miles`);
}
if (m.wxString) parts.push(decodeWx(m.wxString));
if (Array.isArray(m.clouds) && m.clouds.length > 0) parts.push(decodeSky(m.clouds));
const inHg = hpaToInHg(m.altim);
if (inHg) parts.push(`Set altimeter to ${inHg} inHg`);
if (m.temp != null) {
parts.push(`Temperature ${m.temp}°C (${cToF(m.temp)}°F), dewpoint ${m.dewp ?? '—'}°C (${m.dewp != null ? cToF(m.dewp) + '°F' : '—'})`);
}
return parts.join('. ') + '.';
}
function tafFcstToEnglish(fcst) {
const parts = [];
const wind = decodeWindSentence(fcst.wdir, fcst.wspd, fcst.wgst);
if (wind) parts.push(wind);
if (fcst.visib) {
const v = String(fcst.visib);
parts.push((parseFloat(v) >= 6 || v.startsWith('6+'))
? 'Visibility greater than 6 SM'
: `Visibility ${v} SM`);
}
if (fcst.wxString) parts.push(decodeWx(fcst.wxString));
if (Array.isArray(fcst.clouds) && fcst.clouds.length > 0) parts.push(decodeSky(fcst.clouds));
return parts.length ? parts.join('. ') + '.' : 'No significant change.';
}
function tafToEnglish(taf) {
if (!taf || !Array.isArray(taf.fcsts) || taf.fcsts.length === 0) return '';
return taf.fcsts.map(fcst => {
const change = fcst.fcstChange;
const prob = fcst.probability;
let headerClass = '', headerText = '';
if (!change) {
headerText = `Initial: ${formatUnixTime(fcst.timeFrom)} ${formatUnixTime(fcst.timeTo)}`;
} else if (change === 'TEMPO') {
headerClass = 'tempo';
headerText = `Temporary (${formatUnixTime(fcst.timeFrom)} ${formatUnixTime(fcst.timeTo)})`;
} else if (change === 'BECMG') {
headerClass = 'becmg';
headerText = `Becoming (by ${formatUnixTime(fcst.timeBec || fcst.timeTo)})`;
} else if (change === 'PROB') {
headerClass = 'prob';
headerText = `${prob || ''}% probability (${formatUnixTime(fcst.timeFrom)} ${formatUnixTime(fcst.timeTo)})`;
} else {
headerText = `From ${formatUnixTime(fcst.timeFrom)}`;
}
return `<div class="taf-period">
<div class="taf-period-header ${headerClass}">${headerText}</div>
<div class="taf-period-body">${tafFcstToEnglish(fcst)}</div>
</div>`;
}).join('');
}
function formatRawTAF(raw) {
if (!raw) return '';
return raw.replace(/\s+(TEMPO|BECMG|FM\d{6}|PROB\d{2})/g, '\n $1').trim();
}
// ── Hazard decoding ────────────────────────────────────────────────────────
const HAZARD_NAMES = {
'CONVECTIVE':'Convective SIGMET',
'ICE':'Icing','TURB':'Turbulence',
'IFR':'IFR Conditions','MTN OBSCN':'Mountain Obscuration',
'WIND':'Non-Convective SIGMET','LLWS':'Low-Level Wind Shear',
'SFC WIND':'Surface Wind'
};
const SEV_NAMES = { 'SEV':'Severe','MOD':'Moderate','LGT':'Light','EXTRM':'Extreme' };
function hazardDot(item) {
if (item.airSigmetType === 'SIGMET' && item.hazard === 'CONVECTIVE') return 'red';
if (item.airSigmetType === 'SIGMET') return 'orange';
if (item.hazard === 'TURB' || item.hazard === 'ICE') return 'orange';
if (item.airSigmetType === 'AIRMET') return 'yellow';
return 'blue';
}
function decodeHazard(item) {
const type = item.airSigmetType || '';
const hazard = item.hazard || '';
const sev = item.severity ? (SEV_NAMES[item.severity] + ' ') : '';
const char = item.alphaChar ? ` ${item.alphaChar}` : '';
let label = '';
if (type === 'SIGMET' && hazard === 'CONVECTIVE') {
label = `Convective SIGMET${char}`;
} else if (type === 'SIGMET') {
label = `${HAZARD_NAMES[hazard] || hazard} SIGMET${char}`;
} else {
label = `AIRMET — ${sev}${HAZARD_NAMES[hazard] || hazard}`;
}
// Altitude range
const lo = item.altitudeLow1, hi = item.altitudeHi1;
let alt = '';
if (lo != null && hi != null && hi < 99999) {
alt = lo === 0 ? `Surface to ${hi.toLocaleString()}'` : `${lo.toLocaleString()}' ${hi.toLocaleString()}'`;
} else if (hi != null && hi < 99999) {
alt = `Below ${hi.toLocaleString()}'`;
} else if (lo != null && lo > 0) {
alt = `Above ${lo.toLocaleString()}'`;
}
const validTo = item.validTimeTo ? `Valid until ${formatUnixTime(item.validTimeTo)}` : '';
return { label, alt, validTo, dot: hazardDot(item) };
}
// Filter hazards to those whose coords overlap the route bounding box (+ padding)
function filterHazardsToRoute(hazards, metars) {
const lats = metars.map(m => m.lat).filter(Boolean);
const lons = metars.map(m => m.lon).filter(Boolean);
if (!lats.length) return hazards; // no coords available, show all
const pad = 3;
const minLat = Math.min(...lats) - pad, maxLat = Math.max(...lats) + pad;
const minLon = Math.min(...lons) - pad, maxLon = Math.max(...lons) + pad;
return hazards.filter(h => {
if (!h.coords || !h.coords.length) return true; // include if no geometry
return h.coords.some(c => c.lat >= minLat && c.lat <= maxLat && c.lon >= minLon && c.lon <= maxLon);
});
}
// ── Airport detail view ────────────────────────────────────────────────────
function renderAirport(metar, taf) {
const flt = getFltCat(metar);
const layers = skyLayerTags(metar.clouds);
const ceil = ceilingStr(metar.clouds);
const inHg = hpaToInHg(metar.altim);
const wxCard = metar.wxString
? `<div class="wx-card"><div class="wx-label">Present Wx</div><div class="wx-val">${decodeWx(metar.wxString)}</div></div>`
: '';
const tafSection = taf
? `<div class="section">
<div class="section-label">TAF — Plain English</div>
${tafToEnglish(taf)}
<div class="section-label" style="margin-top:12px">TAF — Raw</div>
<pre class="mono-block">${formatRawTAF(taf.rawTAF)}</pre>
</div>`
: '';
document.getElementById('results').innerHTML = `
<div class="flt-badge ${flt}">
<div class="flt-cat">${flt}</div>
<div class="station-info">
<div class="station-id">${metar.icaoId || ''}</div>
<div class="station-name-sub">${metar.name || ''}</div>
</div>
</div>
<div class="obs-time">Observed ${formatTime(metar.reportTime)}</div>
<div class="wx-grid">
<div class="wx-card full">
<div class="wx-label">Wind</div>
<div class="wx-val">${formatWind(metar.wdir, metar.wspd, metar.wgst)}</div>
</div>
<div class="wx-card">
<div class="wx-label">Visibility</div>
<div class="wx-val">${formatVis(metar.visib)}</div>
</div>
<div class="wx-card">
<div class="wx-label">Ceiling</div>
<div class="wx-val">${ceil}</div>
</div>
<div class="wx-card">
<div class="wx-label">Altimeter</div>
<div class="wx-val">${inHg ?? '—'}<span class="wx-unit"> inHg</span></div>
</div>
<div class="wx-card">
<div class="wx-label">Temperature</div>
<div class="wx-val">${metar.temp ?? '—'}°C <span class="wx-unit">${metar.temp != null ? cToF(metar.temp) + '°F' : ''}</span></div>
</div>
<div class="wx-card">
<div class="wx-label">Dewpoint</div>
<div class="wx-val">${metar.dewp ?? '—'}°C <span class="wx-unit">${metar.dewp != null ? cToF(metar.dewp) + '°F' : ''}</span></div>
</div>
<div class="wx-card">
<div class="wx-label">Sky</div>
<div class="sky-tags">${layers.map(l => `<span class="sky-tag">${l}</span>`).join('')}</div>
</div>
${wxCard}
</div>
<div class="section">
<div class="section-label">METAR — Plain English</div>
<div class="plain-english">${metarToEnglish(metar)}</div>
<div class="section-label" style="margin-top:12px">METAR — Raw</div>
<div class="mono-block">${metar.rawOb || '—'}</div>
</div>
${tafSection}
`;
}
// ── Route view ─────────────────────────────────────────────────────────────
function renderRoute(inputIds, metars, hazards) {
// Build a lookup map from ICAO to metar so we can preserve input order
const metarMap = {};
metars.forEach(m => { metarMap[m.icaoId] = m; });
// Flight category strip
const stripItems = inputIds.map((id, i) => {
const m = metarMap[id];
if (!m) {
return `${i > 0 ? '<div class="route-arrow">→</div>' : ''}
<div class="route-airport NOTFOUND">
<div class="route-icao">${id}</div>
<div class="route-cat" style="color:var(--dim)">NOT FOUND</div>
</div>`;
}
const flt = getFltCat(m);
const ceil = ceilingStr(m.clouds);
const vis = formatVis(m.visib);
return `${i > 0 ? '<div class="route-arrow">→</div>' : ''}
<div class="route-airport ${flt}">
<div class="route-icao">${id}</div>
<div class="route-cat">${flt}</div>
<div class="route-detail">${vis}<br>${ceil}</div>
</div>`;
}).join('');
// Per-airport detail cards
const cards = inputIds.map(id => {
const m = metarMap[id];
if (!m) return '';
const flt = getFltCat(m);
const inHg = hpaToInHg(m.altim);
return `<div class="airport-card">
<div class="airport-card-header">
<div>
<div class="airport-card-id">${m.icaoId}</div>
<div class="airport-card-name">${m.name || ''}</div>
</div>
<div class="airport-card-cat ${flt}">${flt}</div>
</div>
<div class="airport-card-grid">
<div class="airport-card-item">
<div class="label">Wind</div>
<div class="val">${formatWind(m.wdir, m.wspd, m.wgst)}</div>
</div>
<div class="airport-card-item">
<div class="label">Visibility</div>
<div class="val">${formatVis(m.visib)}</div>
</div>
<div class="airport-card-item">
<div class="label">Ceiling</div>
<div class="val">${ceilingStr(m.clouds)}</div>
</div>
<div class="airport-card-item">
<div class="label">Altimeter</div>
<div class="val">${inHg ?? '—'} inHg</div>
</div>
<div class="airport-card-item">
<div class="label">Temp / Dewp</div>
<div class="val">${m.temp ?? '—'}° / ${m.dewp ?? '—'}°C</div>
</div>
<div class="airport-card-item">
<div class="label">Wx</div>
<div class="val">${m.wxString ? decodeWx(m.wxString) : 'None'}</div>
</div>
</div>
<div class="airport-card-time">Observed ${formatTime(m.reportTime)}</div>
<div class="airport-card-plain">${metarToEnglish(m)}</div>
</div>`;
}).join('');
// Hazard list
const filtered = filterHazardsToRoute(hazards, metars);
let hazardHtml = '';
if (!filtered.length) {
hazardHtml = '<div class="no-hazards">No active SIGMETs or AIRMETs found near this route.</div>';
} else {
// Sort: SIGMETs first, then AIRMETs; convective first within SIGMETs
const sorted = [...filtered].sort((a, b) => {
const aScore = (a.airSigmetType === 'SIGMET' ? 0 : 1) + (a.hazard === 'CONVECTIVE' ? 0 : 0.5);
const bScore = (b.airSigmetType === 'SIGMET' ? 0 : 1) + (b.hazard === 'CONVECTIVE' ? 0 : 0.5);
return aScore - bScore;
});
hazardHtml = sorted.map(item => {
const h = decodeHazard(item);
const alt = h.alt ? `<span style="color:var(--dim)"> · ${h.alt}</span>` : '';
return `<div class="hazard-item">
<div class="hazard-dot ${h.dot}"></div>
<div class="hazard-content">
<div class="hazard-type">${h.label}${alt}</div>
<div class="hazard-body">${item.rawAirSigmet || ''}</div>
<div class="hazard-valid">${h.validTo}</div>
</div>
</div>`;
}).join('');
}
document.getElementById('results').innerHTML = `
<div class="route-header">Route Weather Strip · ${inputIds.join(' → ')}</div>
<div class="route-strip">${stripItems}</div>
<div class="section-label">Airport Details</div>
<div class="airport-cards">${cards}</div>
<div class="hazard-section">
<div class="section-label">Active Hazards Near Route</div>
${hazardHtml}
</div>
`;
}
// ── Data fetching ──────────────────────────────────────────────────────────
async function lookupAirport(id) {
const el = document.getElementById('results');
el.innerHTML = `<div class="loading">Fetching ${id}…</div>`;
try {
const [mRes, tRes] = await Promise.all([
fetch(`${API}/metar?ids=${id}&format=json&hours=2`),
fetch(`${API}/taf?ids=${id}&format=json`)
]);
const metars = await mRes.json();
const tafs = await tRes.json();
if (!Array.isArray(metars) || metars.length === 0) {
el.innerHTML = `<div class="error-msg">No METAR found for ${id}. Check the identifier.</div>`;
return;
}
renderAirport(metars[0], Array.isArray(tafs) && tafs.length ? tafs[0] : null);
} catch (err) {
el.innerHTML = `<div class="error-msg">Error: ${err.message}</div>`;
}
}
async function lookupRoute(ids) {
const el = document.getElementById('results');
el.innerHTML = `<div class="loading">Fetching route weather for ${ids.join(' → ')}…</div>`;
try {
const [mRes, hRes] = await Promise.all([
fetch(`${API}/metar?ids=${ids.join(',')}&format=json&hours=2`),
fetch(`${API}/airsigmet?format=json`)
]);
const metars = await mRes.json();
const hazards = await hRes.json().catch(() => []);
if (!Array.isArray(metars) || metars.length === 0) {
el.innerHTML = `<div class="error-msg">No weather data found for any of those airports. Check the identifiers.</div>`;
return;
}
renderRoute(ids, metars, Array.isArray(hazards) ? hazards : []);
} catch (err) {
el.innerHTML = `<div class="error-msg">Error: ${err.message}</div>`;
}
}
async function lookup(input) {
const ids = parseIds(input);
if (ids.length === 0) return;
if (ids.length === 1) {
await lookupAirport(ids[0]);
} else {
await lookupRoute(ids);
}
}
// ── Event wiring ───────────────────────────────────────────────────────────
document.getElementById('go-btn').addEventListener('click', () => {
const val = document.getElementById('id-input').value;
if (val.trim().length >= 3) lookup(val);
});
document.getElementById('id-input').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('go-btn').click();
});
// Default: KMKC airport view on load
lookup('KMKC');
</script>
</body>
</html>