| """Builds the neon 'hacker' sidebar: MOST TRACKED + AIRPORT DISRUPTIONS panels. |
| |
| Mirrors the FlightRadar24 sidebar layout. Since the FR24 API exposes neither |
| viewer/tracker counts nor an airport-disruption feed, both panels are derived |
| from the live in-view flights (and real weather from Open-Meteo). Labels make |
| the derivation explicit so nothing is presented as official FR24 data. |
| """ |
| from __future__ import annotations |
|
|
| from collections import Counter |
|
|
| import fr24 |
| import weather |
|
|
| _AIRPORT_NAMES = { |
| "EGLL": "London", "KJFK": "New York", "OMDB": "Dubai", "EGBB": "Birmingham", |
| "ZGSZ": "Shenzhen", "KDFW": "Dallas", "LTBJ": "Izmir", "EHAM": "Amsterdam", |
| "LFPG": "Paris", "EDDF": "Frankfurt", "RJTT": "Tokyo", "VHHH": "Hong Kong", |
| } |
|
|
|
|
| def _fmt_int(n) -> str: |
| try: |
| return f"{int(n):,}" |
| except Exception: |
| return "—" |
|
|
|
|
| def _esc(s) -> str: |
| return (str(s).replace("&", "&").replace("<", "<").replace(">", ">") |
| if s is not None else "") |
|
|
|
|
| def _most_tracked(flights, limit=8): |
| ranked = sorted( |
| [f for f in flights if f.get("gspeed")], |
| key=lambda f: f.get("gspeed") or 0, reverse=True, |
| )[:limit] |
| rows = [] |
| for i, f in enumerate(ranked, 1): |
| cs = _esc(f.get("callsign") or f.get("flight") or "N/A") |
| typ = _esc(f.get("type") or "—") |
| orig = _esc(f.get("orig") or "N/A") |
| dest = _esc(f.get("dest") or "N/A") |
| rows.append(f""" |
| <div class="fd-item"> |
| <div class="fd-row"> |
| <span class="fd-rank">{i}.</span> |
| <span class="fd-cs">{cs}</span> |
| <span class="fd-badge">{typ}</span> |
| <span class="fd-metric">{_fmt_int(f.get('gspeed'))} <small>kt</small></span> |
| </div> |
| <div class="fd-route">{orig} <span class="fd-arrow">–</span> {dest}</div> |
| </div>""") |
| if not rows: |
| rows.append('<div class="fd-empty">no contacts</div>') |
| return "".join(rows) |
|
|
|
|
| def _disruptions(flights, limit=6): |
| |
| counts = Counter( |
| f.get("dest") for f in flights if f.get("dest")) |
| top = counts.most_common(limit) |
| if not top: |
| return '<div class="fd-empty">no inbound traffic</div>' |
|
|
| max_n = max(n for _, n in top) or 1 |
| rows = [] |
| for code, n in top: |
| ll = fr24.airport_coords(code) |
| wx = weather.current(*ll) if ll else None |
| name = _AIRPORT_NAMES.get(code, (code or "—")) |
|
|
| if wx: |
| temp = (f"{round(wx['temp_c'])}°C" if wx.get("temp_c") is not None else "—°C") |
| wind = (f"{weather.wind_arrow(wx.get('wind_dir'))} " |
| f"{round(wx.get('wind_dir') or 0)}° " |
| f"{round(wx.get('wind_kts') or 0)} kts") |
| sky = weather.code_glyph(wx.get("code")) |
| else: |
| temp, wind, sky = "—", "wx n/a", "·" |
|
|
| |
| idx = round(min(9.9, 9.9 * (n / max_n)), 1) |
| dot = "#FF2BD6" if idx >= 6 else ("#FFD23F" if idx >= 3 else "#39FF14") |
| rows.append(f""" |
| <div class="fd-item"> |
| <div class="fd-row"> |
| <span class="fd-cs">{_esc(name)}</span> |
| <span class="fd-badge">{_esc(code)}</span> |
| </div> |
| <div class="fd-wx"> |
| <span>{sky} {temp}</span> |
| <span class="fd-wind">{wind}</span> |
| </div> |
| <div class="fd-scores"> |
| <span title="inbound contacts in view">🛬 {n}</span> |
| <span class="fd-dot" style="color:{dot}">● {idx}</span> |
| </div> |
| </div>""") |
| return "".join(rows) |
|
|
|
|
| def build_sidebar_html(flights) -> str: |
| tracked = _most_tracked(flights) |
| disrupt = _disruptions(flights) |
| return f""" |
| <div id="fd-sidebar"> |
| <details open class="fd-panel"> |
| <summary>MOST TRACKED <span class="fd-live">LIVE</span><span class="fd-chev">▾</span></summary> |
| <div class="fd-note">ranked by ground speed · API has no viewer counts</div> |
| <div class="fd-list">{tracked}</div> |
| </details> |
| |
| <details open class="fd-panel"> |
| <summary>AIRPORT DISRUPTIONS <span class="fd-live">LIVE</span><span class="fd-chev">▾</span></summary> |
| <div class="fd-note">busiest inbound · weather: open-meteo · index derived</div> |
| <div class="fd-list">{disrupt}</div> |
| </details> |
| </div>""" |
|
|