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:
commit
1658eda480
2 changed files with 927 additions and 0 deletions
118
PROJECT.md
Normal file
118
PROJECT.md
Normal 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 2–4 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
809
index.html
Normal 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 >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>
|
||||||
Loading…
Add table
Reference in a new issue