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.
809 lines
35 KiB
HTML
809 lines
35 KiB
HTML
<!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 >3SM & >3000'</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6"></div>MVFR 1–3SM or 1000–3000'</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div>IFR <1SM or <1000'</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#a855f7"></div>LIFR <½SM or <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>
|