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.
This commit is contained in:
Dave Campbell 2026-06-16 14:31:40 +00:00
commit 1658eda480
2 changed files with 927 additions and 0 deletions

118
PROJECT.md Normal file
View file

@ -0,0 +1,118 @@
# AirBrief — Project Status
*Last updated: June 16, 2026*
---
## What It Is
A static web app for business aviation situational awareness — specifically built for Airshare line pilots and flight control. Not a certified briefing replacement. Gives a fast, clean weather picture before the official brief happens.
**Live at:** rockcampbell.com/airbrief
**Target users:**
- Line pilot at KMKC with 24 legs today, needs a 90-second weather picture
- Flight control building the day's operation, wants to flag potential weather disruptions
---
## Current State (MVP Complete)
### Airport Lookup (single ICAO/IATA)
Type one airport ID → full detail view:
- Flight category badge (VFR / MVFR / IFR / LIFR), color coded
- Wind, visibility, ceiling, altimeter (inHg — Kollsman window value), temp/dewpoint
- Sky conditions (tagged)
- Present weather decoded to plain English
- METAR: plain-English paragraph + raw text
- TAF: plain-English per change group (Initial / From / Temporary / Becoming / Probability) + raw text
### Route Weather (multiple space-separated IDs)
Type `KMKC KSTL KDEN` → route view:
- Flight category strip: colored badges per airport with visibility and ceiling
- Per-airport detail cards: wind, vis, ceiling, altimeter, temp/dewpoint, plain-English summary, obs time
- Active hazard summary: SIGMETs and AIRMETs filtered to the route bounding box, sorted by severity (convective SIGMETs first)
---
## Tech Stack
| Layer | Choice | Notes |
|---|---|---|
| Frontend | Vanilla HTML/CSS/JS | Single file, no framework, no build step |
| Weather data | aviationweather.gov public API | No key, no cost, no rate limit |
| Hazard data | aviationweather.gov `/airsigmet` | Same API, returns SIGMETs + AIRMETs |
| METAR/TAF decode | Client-side JS | No third-party decoder — all done in-browser |
| Hosting | hugo-site nginx container on rocklab | Static file served directly from disk |
| CORS proxy | Nginx location `/airbrief-api/` | aviationweather.gov doesn't send CORS headers; nginx proxies server-side |
---
## File Locations
```
/home/rock/airbrief/
├── index.html ← entire app (single file)
└── PROJECT.md ← this file
```
**Nginx proxy config:** `/home/rock/docker/rockcampbell/nginx.conf`
- `/airbrief` → serves `/srv/airbrief/` (the airbrief directory)
- `/airbrief-api/` → proxies to `https://aviationweather.gov/api/data/`
**Docker volume mount:** `/home/rock/docker/rockcampbell/docker-compose.yml`
- `/home/rock/airbrief:/srv/airbrief:ro`
Changes to `index.html` go live immediately — no container restart needed.
---
## API Endpoints in Use
All routed through the local proxy at `/airbrief-api/`:
| Endpoint | Used for |
|---|---|
| `/metar?ids=KMKC&format=json&hours=2` | METAR for one or multiple airports (comma-separated) |
| `/taf?ids=KMKC&format=json` | TAF including structured `fcsts` array |
| `/airsigmet?format=json` | All active SIGMETs and AIRMETs |
**Key API field notes:**
- `fltCat` (capital C) — flight category; computed from ceiling/vis as fallback
- `altim` — in **hPa**, not inHg. Divide by 33.8639 to get the Kollsman window value
- `visib` — string, may return `"10+"` rather than a number
- `clouds[]` — array of `{cover, base, type}` objects; base in feet
---
## Hazard Filtering
When displaying route hazards, the app builds a bounding box from the lat/lon of all found airports (±3 degrees padding) and filters the SIGMET/AIRMET list to hazards whose coordinates overlap that box. Hazards with no coordinate data are included.
Sort order: convective SIGMETs → other SIGMETs → AIRMETs.
Color: red = convective SIGMET, orange = other SIGMET or severe turb/ice, yellow = AIRMET.
---
## v2 Backlog (do not build until David returns to this)
From the original project brief — in priority order:
1. **Winds Aloft by waypoint** — enter cruise altitude (FL350, FL410), get forecast winds and temps at each route waypoint. API: `/windtemp?format=json`
2. **Flight Category Map** — Leaflet.js map centered on the route, stations color-coded by flight category, route corridor overlay. Deferred because Turf.js route geometry math was saved for post-MVP.
3. **PIREPs near route** — pilot reports filtered to the route corridor. API: `/pirep?format=json`
4. **TFRs** — active temporary flight restrictions. API: `/tfr?format=json` (different response format from airsigmet)
5. **Multi-leg trip view** — full day's operation across multiple aircraft
6. **Saved routes / push alerts**
7. **Radar layer** on the map
8. **Mobile PWA** (offline-capable)
9. **Integration with Airshare/Horizon trip data**
---
## Known Notes / Gotchas
- The `airsigmet` endpoint returns ~2,000 records nationally. Bounding box filtering cuts this down significantly for regional routes. For very short routes the box is small; for cross-country the box is large and more hazards show.
- TAF not always available for smaller airports (returns empty array — handled gracefully).
- IATA identifiers (e.g., `MKC`) work in addition to ICAO (`KMKC`).
- Route input accepts space, comma, or slash as separators.
- The disclaimer is required on every page per the original brief.

809
index.html Normal file
View file

@ -0,0 +1,809 @@
<!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>