{
  "sha": "1089c36017707b5e8458f8063ce70b30b4b91af1",
  "message": "add Taiwan civil flight radar (#7)\n\nCo-authored-by: Codex <noreply@openai.com>",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-07-27T09:51:01Z",
  "files": [
    {
      "filename": "README.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 0,
      "changes": 6
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 71,
      "deletions": 2,
      "changes": 73
    },
    {
      "filename": "static/radar.js",
      "previous_filename": "",
      "status": "added",
      "additions": 352,
      "deletions": 0,
      "changes": 352
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 103,
      "deletions": 0,
      "changes": 103
    },
    {
      "filename": "templates/base.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 2,
      "changes": 6
    },
    {
      "filename": "templates/home.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 2,
      "changes": 4
    },
    {
      "filename": "templates/radar.html",
      "previous_filename": "",
      "status": "added",
      "additions": 74,
      "deletions": 0,
      "changes": 74
    },
    {
      "filename": "tests/test_radar.py",
      "previous_filename": "",
      "status": "added",
      "additions": 43,
      "deletions": 0,
      "changes": 43
    }
  ],
  "diff": "@@ -103,6 +103,12 @@ python -m http.server -d site 8000\\n prompt 撰寫並**一律進入 `data/review.json` 人工覆核**，不自動發布。\\n \\n 啟用方式：Repo Variables 設 `FLIGHT_TRACKING_ENABLED=true`。\\n+\\n+公開網站另提供 `/radar/`（英文版 `/en/radar/`）台灣民航雷達頁。頁面由\\n+訪客瀏覽器每 5 分鐘直接查詢一次 Airplanes.live 公開 API，以 Leaflet 與\\n+OpenStreetMap 顯示航班呼號、航空公司、機型、高度、地速及航向；不需 API\\n+key、不儲存即時座標，且會在建立任何標記前排除軍事、PIA、LADD、緊急狀態、\\n+非 ICAO 位址與位置過期資料。此頁僅供航空資訊參考，不是航管雷達。\\n 資料來源授權：Airplanes.live 社群資料（非商業用途；本站無廣告無營利）、\\n ADSB.lol 開放資料（ODbL，本站僅擷取事件摘要並保留出處標註，不鏡像資料庫）。\\n \\n@@ -85,8 +85,20 @@\\n         \"aboutKicker\": \"Methodology\", \"aboutTitle\": \"方法論\",\\n         \"footerAbout\": \"全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源，自動撰寫並發布，每篇文末標註原始出處。本頁為設計原型，內容為示意樣本。\",\\n         \"footerSources\": \"資料來源 Data Sources\",\\n-        \"nav\": [\"最新\", \"快報\", \"事故資料庫\", \"來源\", \"方法論\"],\\n+        \"nav\": [\"最新\", \"快報\", \"航班雷達\", \"事故資料庫\", \"來源\", \"方法論\"],\\n         \"cats\": [\"全部\", \"事故\", \"法規\", \"商業\", \"營運\", \"軍事\"],\\n+        \"radarKicker\": \"Taiwan Flight Radar\",\\n+        \"radarTitle\": \"台灣民航雷達\",\\n+        \"radarSub\": \"公開 ADS-B 即時觀測圖，顯示台灣周邊民航機的航班呼號、機型、高度、速度與航向。\",\\n+        \"radarSearch\": \"搜尋航班呼號、航空公司或機型\",\\n+        \"radarAirborne\": \"僅顯示飛行中\",\\n+        \"radarRefresh\": \"立即更新\",\\n+        \"radarLoading\": \"正在取得最新 ADS-B 觀測資料…\",\\n+        \"radarMapLabel\": \"台灣周邊民航即時觀測地圖\",\\n+        \"radarList\": \"目前航機\",\\n+        \"radarNotice\": \"本頁不是航管雷達，資料可能延遲、缺漏或錯誤，不得用於導航、飛航操作或緊急判斷。航班呼號不一定等同旅客票面班號。\",\\n+        \"radarPrivacy\": \"為保障安全與隱私，來源標記為軍事、PIA、LADD、緊急狀態及位置過期的資料不會顯示。\",\\n+        \"radarSource\": \"航機資料：Airplanes.live 公開社群 ADS-B；地圖：OpenStreetMap。\",\\n         # daily briefings\\n         \"brKicker\": \"Daily Briefing\", \"brIndexTitle\": \"快報\",\\n         \"brIndexSub\": \"每日三次（臺北時間 07:15／15:15／23:15）的條列式全球航空與交通要聞快報，由具搜尋能力的模型彙整（Beta）；本站正式新聞請見「最新」。\",\\n@@ -146,9 +158,22 @@\\n         \"aboutKicker\": \"Methodology\", \"aboutTitle\": \"Methodology\",\\n         \"footerAbout\": \"A fully automated aviation news aggregator. GitHub Actions fetches trusted sources hourly, writes and publishes automatically, and credits originals at the end of every article. Design prototype; sample content.\",\\n         \"footerSources\": \"Data Sources\",\\n-        \"nav\": [\"Latest\", \"Briefings\", \"Incident DB\", \"Sources\", \"Methodology\"],\\n+        \"nav\": [\"Latest\", \"Briefings\", \"Flight Radar\", \"Incident DB\", \"Sources\",\\n+                \"Methodology\"],\\n         \"cats\": [\"All\", \"Safety\", \"Regulation\", \"Business\", \"Operations\",\\n                  \"Military\"],\\n+        \"radarKicker\": \"Taiwan Flight Radar\",\\n+        \"radarTitle\": \"Taiwan civil flight radar\",\\n+        \"radarSub\": \"A live public ADS-B view of civil aircraft around Taiwan, with callsign, type, altitude, speed and heading.\",\\n+        \"radarSearch\": \"Search callsign, airline or aircraft type\",\\n+        \"radarAirborne\": \"Airborne only\",\\n+        \"radarRefresh\": \"Refresh now\",\\n+        \"radarLoading\": \"Loading the latest ADS-B observations…\",\\n+        \"radarMapLabel\": \"Live civil-aircraft observations around Taiwan\",\\n+        \"radarList\": \"Aircraft now\",\\n+        \"radarNotice\": \"This is not air traffic control radar. Data may be delayed, incomplete or wrong and must not be used for navigation, flight operations or emergencies. A callsign may differ from a passenger flight number.\",\\n+        \"radarPrivacy\": \"For safety and privacy, source-tagged military, PIA, LADD, emergency-state and stale-position records are not shown.\",\\n+        \"radarSource\": \"Aircraft data: Airplanes.live public community ADS-B; map: OpenStreetMap.\",\\n         # daily briefings\\n         \"brKicker\": \"Daily Briefing\", \"brIndexTitle\": \"Briefings\",\\n         \"brIndexSub\": \"A bulleted global air & transport bulletin three times daily (07:15 / 15:15 / 23:15 Taipei time), compiled by a search-capable model (Beta); the site's formal articles live under Latest.\",\\n@@ -1364,6 +1389,7 @@ def render_usage_dashboard(env, build) -> int:\\n def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=True):\\n     t = L[lang]\\n     nav_defs = [(\"home\", \"\"), (\"briefings\", \"briefings/\"),\\n+                (\"radar\", \"radar/\"),\\n                 (\"incidents\", \"incidents/\"),\\n                 (\"sources\", \"sources/\"), (\"about\", \"about/\")]\\n     return {\\n@@ -1376,6 +1402,7 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=Tru\\n         \"self_abs_url\": SITE_ORIGIN + page_url(lang, sub),\\n         \"hreflang\": hreflang,\\n         \"home_url\": page_url(lang, \"\"),\\n+        \"radar_url\": page_url(lang, \"radar/\"),\\n         \"nav\": [{\"label\": t[\"nav\"][i], \"url\": page_url(lang, s), \"active\": page == p}\\n                 for i, (p, s) in enumerate(nav_defs)],\\n         \"ticker_items\": ticker or [], \"build\": build,\\n@@ -1511,6 +1538,48 @@ def main() -> int:\\n                rel_path(lang, \"briefings/index.html\"), ctx)\\n         pages += 1\\n \\n+        airline_cfg = load_json(\\n+            Path(__file__).resolve().parent.parent\\n+            / \"config\" / \"airline_icao_codes.json\", {})\\n+        airline_names = {\\n+            str(row.get(\"icao_code\") or \"\").upper():\\n+                (row.get(\"airline_name_zh_tw\") if lang == \"zh\"\\n+                 else row.get(\"airline_name_en\"))\\n+            for row in (airline_cfg.get(\"airlines\") or [])\\n+            if isinstance(row, dict) and row.get(\"active\", True)\\n+               and row.get(\"icao_code\")\\n+        }\\n+        type_cfg = load_json(\\n+            Path(__file__).resolve().parent.parent\\n+            / \"config\" / \"aircraft_types.json\", {})\\n+        aircraft_types = type_cfg.get(\"types\") or {}\\n+        airport_cfg = load_json(\\n+            Path(__file__).resolve().parent.parent\\n+            / \"config\" / \"tw_civil_airports.json\", {})\\n+        radar_airports = [\\n+            {\\n+                \"icao\": row.get(\"icao\"), \"iata\": row.get(\"iata\"),\\n+                \"name\": (row.get(\"name_zh\") if lang == \"zh\"\\n+                         else row.get(\"name_en\")),\\n+                \"lat\": row.get(\"lat\"), \"lon\": row.get(\"lon\"),\\n+            }\\n+            for row in (airport_cfg.get(\"airports\") or [])\\n+            if isinstance(row, dict) and row.get(\"enabled\", True)\\n+               and isinstance(row.get(\"lat\"), (int, float))\\n+               and isinstance(row.get(\"lon\"), (int, float))\\n+        ]\\n+        ctx = base_ctx(\\n+            lang, \"radar\", \"radar/\",\\n+            title=f\"{t['siteName']} — {t['radarTitle']}\",\\n+            description=t[\"radarSub\"], ticker=ticker, build=build)\\n+        ctx.update(\\n+            airline_names=airline_names,\\n+            aircraft_types=aircraft_types,\\n+            radar_airports=radar_airports,\\n+        )\\n+        render(env, \"radar.html\", rel_path(lang, \"radar/index.html\"), ctx)\\n+        pages += 1\\n+\\n         for a, v in zip(articles, views):\\n             try:\\n                 ctx = base_ctx(lang, \"home\", f\"news/{a['id']}/\",\\n@@ -0,0 +1,352 @@\\n+/* AVWIRE Taiwan civil-flight radar.\\n+ *\\n+ * One browser-side Airplanes.live point query every five minutes. The page\\n+ * stores no aircraft positions and filters source-tagged sensitive records\\n+ * before creating any marker or list row.\\n+ */\\n+(function () {\\n+  \"use strict\";\\n+\\n+  const root = document.getElementById(\"flight-radar\");\\n+  if (!root) return;\\n+\\n+  const lang = root.dataset.lang === \"en\" ? \"en\" : \"zh\";\\n+  const copy = {\\n+    zh: {\\n+      live: \"資料正常\", loading: \"正在取得最新 ADS-B 觀測資料…\",\\n+      error: \"目前無法取得航機資料，將保留上次成功畫面並稍後再試。\",\\n+      noData: \"目前篩選條件下沒有可顯示的民航機。\",\\n+      shown: (visible, total, hidden) =>\\n+        `顯示 ${visible} 架｜有效民航 ${total} 架｜安全與品質過濾 ${hidden} 筆`,\\n+      updated: (time) => `資料時間 ${time}`,\\n+      next: (seconds) => `下次更新 ${seconds} 秒`,\\n+      cooldown: (seconds) => `請於 ${seconds} 秒後再手動更新`,\\n+      callsign: \"航班呼號\", airline: \"航空公司\", type: \"機型\",\\n+      altitude: \"高度\", speed: \"地速\", heading: \"航向\",\\n+      vertical: \"垂直速率\", freshness: \"位置新鮮度\",\\n+      ground: \"地面\", unknown: \"未提供\", feet: \"呎\", knots: \"節\",\\n+      fpm: \"呎／分\", seconds: \"秒前\", noCallsign: \"無呼號\",\\n+    },\\n+    en: {\\n+      live: \"Data live\", loading: \"Loading the latest ADS-B observations…\",\\n+      error: \"Aircraft data is unavailable. The last successful view is retained and the page will retry.\",\\n+      noData: \"No civil aircraft match the current filters.\",\\n+      shown: (visible, total, hidden) =>\\n+        `Showing ${visible} | valid civil ${total} | safety/quality filtered ${hidden}`,\\n+      updated: (time) => `Data time ${time}`,\\n+      next: (seconds) => `Next refresh ${seconds}s`,\\n+      cooldown: (seconds) => `Manual refresh available in ${seconds}s`,\\n+      callsign: \"Callsign\", airline: \"Airline\", type: \"Aircraft\",\\n+      altitude: \"Altitude\", speed: \"Ground speed\", heading: \"Heading\",\\n+      vertical: \"Vertical rate\", freshness: \"Position age\",\\n+      ground: \"Ground\", unknown: \"Not reported\", feet: \"ft\", knots: \"kt\",\\n+      fpm: \"ft/min\", seconds: \"s ago\", noCallsign: \"No callsign\",\\n+    },\\n+  }[lang];\\n+\\n+  const parseJson = (id, fallback) => {\\n+    try {\\n+      return JSON.parse(document.getElementById(id).textContent);\\n+    } catch (_) {\\n+      return fallback;\\n+    }\\n+  };\\n+  const airlines = parseJson(\"radar-airlines\", {});\\n+  const aircraftTypes = parseJson(\"radar-types\", {});\\n+  const airports = parseJson(\"radar-airports\", []);\\n+  const refreshMs = Math.max(Number(root.dataset.refreshMs) || 300000, 300000);\\n+  const manualCooldownMs = 30000;\\n+\\n+  const stateEl = document.getElementById(\"radar-state\");\\n+  const countEl = document.getElementById(\"radar-count\");\\n+  const updatedEl = document.getElementById(\"radar-updated\");\\n+  const countdownEl = document.getElementById(\"radar-countdown\");\\n+  const listEl = document.getElementById(\"radar-list\");\\n+  const searchEl = document.getElementById(\"radar-search\");\\n+  const airborneEl = document.getElementById(\"radar-airborne\");\\n+  const refreshEl = document.getElementById(\"radar-refresh\");\\n+\\n+  let map;\\n+  let aircraftLayer;\\n+  let currentRows = [];\\n+  let sourceRows = 0;\\n+  let hiddenRows = 0;\\n+  let nextRefreshAt = Date.now() + refreshMs;\\n+  let lastRequestAt = 0;\\n+  let inFlight = false;\\n+\\n+  const numberOrNull = (value) => {\\n+    const n = Number(value);\\n+    return Number.isFinite(n) ? n : null;\\n+  };\\n+\\n+  const clean = (value, maxLength) =>\\n+    String(value == null ? \"\" : value).trim().slice(0, maxLength);\\n+\\n+  function isSensitiveOrStale(ac) {\\n+    if (!ac || typeof ac !== \"object\") return true;\\n+    const hex = clean(ac.hex, 16);\\n+    if (!hex || hex.startsWith(\"~\")) return true;\\n+    const flags = Number(ac.dbFlags) || 0;\\n+    if (flags & (1 | 4 | 8)) return true; // military, PIA, LADD\\n+    if ([\"7500\", \"7600\", \"7700\"].includes(clean(ac.squawk, 8))) return true;\\n+    const emergency = clean(ac.emergency, 24).toLowerCase();\\n+    if (emergency && emergency !== \"none\") return true;\\n+    if (clean(ac.type, 24).toLowerCase() === \"adsb_icao_nt\") return true;\\n+    const lat = numberOrNull(ac.lat);\\n+    const lon = numberOrNull(ac.lon);\\n+    const seenPos = numberOrNull(ac.seen_pos);\\n+    const seen = numberOrNull(ac.seen);\\n+    if (lat === null || lon === null || seenPos === null || seenPos > 30) return true;\\n+    if (seen !== null && seen > 60) return true;\\n+    return false;\\n+  }\\n+\\n+  function normalize(ac) {\\n+    const callsign = clean(ac.flight, 16).toUpperCase();\\n+    const operator = /^[A-Z]{3}/.test(callsign) ? callsign.slice(0, 3) : \"\";\\n+    const typeCode = clean(ac.t, 12).toUpperCase();\\n+    const altitude = ac.alt_baro === \"ground\" ? \"ground\" : numberOrNull(ac.alt_baro);\\n+    return {\\n+      key: clean(ac.hex, 16),\\n+      callsign,\\n+      operator,\\n+      airline: airlines[operator] || \"\",\\n+      typeCode,\\n+      typeName: aircraftTypes[typeCode] || \"\",\\n+      lat: Number(ac.lat),\\n+      lon: Number(ac.lon),\\n+      altitude,\\n+      speed: numberOrNull(ac.gs),\\n+      heading: numberOrNull(ac.track),\\n+      verticalRate: numberOrNull(ac.baro_rate != null ? ac.baro_rate : ac.geom_rate),\\n+      seenPos: numberOrNull(ac.seen_pos),\\n+    };\\n+  }\\n+\\n+  function formatAltitude(row) {\\n+    if (row.altitude === \"ground\") return copy.ground;\\n+    return row.altitude === null ? copy.unknown :\\n+      `${Math.round(row.altitude).toLocaleString()} ${copy.feet}`;\\n+  }\\n+\\n+  function formatNumber(value, unit) {\\n+    return value === null ? copy.unknown :\\n+      `${Math.round(value).toLocaleString()} ${unit}`;\\n+  }\\n+\\n+  function addDetail(container, label, value) {\\n+    const row = document.createElement(\"div\");\\n+    row.className = \"radar-popup-row\";\\n+    const key = document.createElement(\"span\");\\n+    key.className = \"text-muted\";\\n+    key.textContent = label;\\n+    const val = document.createElement(\"b\");\\n+    val.textContent = value;\\n+    row.append(key, val);\\n+    container.appendChild(row);\\n+  }\\n+\\n+  function popupFor(row) {\\n+    const box = document.createElement(\"div\");\\n+    box.className = \"radar-popup\";\\n+    const title = document.createElement(\"strong\");\\n+    title.className = \"radar-popup-title\";\\n+    title.textContent = row.callsign || copy.noCallsign;\\n+    box.appendChild(title);\\n+    addDetail(box, copy.airline, row.airline || copy.unknown);\\n+    addDetail(box, copy.type,\\n+      [row.typeCode, row.typeName].filter(Boolean).join(\" · \") || copy.unknown);\\n+    addDetail(box, copy.altitude, formatAltitude(row));\\n+    addDetail(box, copy.speed, formatNumber(row.speed, copy.knots));\\n+    addDetail(box, copy.heading,\\n+      row.heading === null ? copy.unknown : `${Math.round(row.heading)}°`);\\n+    addDetail(box, copy.vertical, formatNumber(row.verticalRate, copy.fpm));\\n+    addDetail(box, copy.freshness,\\n+      row.seenPos === null ? copy.unknown : `${Math.round(row.seenPos)} ${copy.seconds}`);\\n+    return box;\\n+  }\\n+\\n+  function planeIcon(row) {\\n+    const heading = row.heading === null ? 0 :\\n+      ((row.heading % 360) + 360) % 360;\\n+    const ground = row.altitude === \"ground\" ? \" ground\" : \"\";\\n+    return L.divIcon({\\n+      className: \"radar-plane-wrap\",\\n+      html: `<span class=\"radar-plane${ground}\" style=\"--heading:${heading}deg\">▲</span>`,\\n+      iconSize: [24, 24],\\n+      iconAnchor: [12, 12],\\n+    });\\n+  }\\n+\\n+  function rowMatches(row, query, airborneOnly) {\\n+    if (airborneOnly && row.altitude === \"ground\") return false;\\n+    if (!query) return true;\\n+    return [\\n+      row.callsign, row.operator, row.airline, row.typeCode, row.typeName,\\n+    ].some((value) => value.toUpperCase().includes(query));\\n+  }\\n+\\n+  function render() {\\n+    const query = searchEl.value.trim().toUpperCase();\\n+    const rows = currentRows.filter((row) =>\\n+      rowMatches(row, query, airborneEl.checked));\\n+    aircraftLayer.clearLayers();\\n+    listEl.replaceChildren();\\n+\\n+    rows.sort((a, b) =>\\n+      (a.callsign || \"ZZZ\").localeCompare(b.callsign || \"ZZZ\"));\\n+    rows.forEach((row) => {\\n+      const marker = L.marker([row.lat, row.lon], {\\n+        icon: planeIcon(row),\\n+        keyboard: true,\\n+        title: row.callsign || copy.noCallsign,\\n+        alt: row.callsign || row.typeCode || copy.noCallsign,\\n+      }).addTo(aircraftLayer);\\n+      const tooltip = document.createElement(\"span\");\\n+      tooltip.textContent = row.callsign || row.typeCode || copy.noCallsign;\\n+      marker.bindTooltip(tooltip, { direction: \"top\", offset: [0, -10] });\\n+      marker.bindPopup(popupFor(row), { minWidth: 230 });\\n+\\n+      const item = document.createElement(\"button\");\\n+      item.type = \"button\";\\n+      item.className = \"radar-list-item\";\\n+      const name = document.createElement(\"strong\");\\n+      name.textContent = row.callsign || copy.noCallsign;\\n+      const meta = document.createElement(\"span\");\\n+      meta.className = \"text-muted\";\\n+      meta.textContent = [\\n+        row.airline, row.typeCode, formatAltitude(row),\\n+      ].filter(Boolean).join(\" · \");\\n+      item.append(name, meta);\\n+      item.addEventListener(\"click\", () => {\\n+        map.setView([row.lat, row.lon], Math.max(map.getZoom(), 9));\\n+        marker.openPopup();\\n+      });\\n+      listEl.appendChild(item);\\n+    });\\n+\\n+    if (!rows.length) {\\n+      const empty = document.createElement(\"p\");\\n+      empty.className = \"text-muted radar-empty\";\\n+      empty.textContent = copy.noData;\\n+      listEl.appendChild(empty);\\n+    }\\n+    countEl.textContent = copy.shown(rows.length, currentRows.length, hiddenRows);\\n+  }\\n+\\n+  function setStatus(kind, text) {\\n+    stateEl.className = `radar-state ${kind}`;\\n+    stateEl.textContent = text;\\n+  }\\n+\\n+  async function loadAircraft(manual) {\\n+    const now = Date.now();\\n+    if (inFlight) return;\\n+    if (manual && now - lastRequestAt < manualCooldownMs) {\\n+      setStatus(\"loading\",\\n+        copy.cooldown(Math.ceil((manualCooldownMs - (now - lastRequestAt)) / 1000)));\\n+      return;\\n+    }\\n+\\n+    inFlight = true;\\n+    lastRequestAt = now;\\n+    refreshEl.disabled = true;\\n+    setStatus(\"loading\", copy.loading);\\n+    const controller = new AbortController();\\n+    const timeout = window.setTimeout(() => controller.abort(), 15000);\\n+    try {\\n+      const response = await fetch(root.dataset.apiUrl, {\\n+        method: \"GET\",\\n+        mode: \"cors\",\\n+        credentials: \"omit\",\\n+        cache: \"no-store\",\\n+        signal: controller.signal,\\n+      });\\n+      if (!response.ok) throw new Error(`HTTP ${response.status}`);\\n+      const payload = await response.json();\\n+      const raw = Array.isArray(payload.ac) ? payload.ac : [];\\n+      sourceRows = raw.length;\\n+      currentRows = raw.filter((ac) => !isSensitiveOrStale(ac)).map(normalize);\\n+      hiddenRows = sourceRows - currentRows.length;\\n+      render();\\n+\\n+      const sourceTime = numberOrNull(payload.now);\\n+      const dataTime = sourceTime === null ? new Date() :\\n+        new Date(sourceTime > 1e12 ? sourceTime : sourceTime * 1000);\\n+      updatedEl.textContent = copy.updated(new Intl.DateTimeFormat(\\n+        lang === \"zh\" ? \"zh-TW\" : \"en-GB\",\\n+        { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }\\n+      ).format(dataTime));\\n+      setStatus(\"live\", copy.live);\\n+      nextRefreshAt = Date.now() + refreshMs;\\n+    } catch (_) {\\n+      setStatus(\"error\", copy.error);\\n+      nextRefreshAt = Date.now() + refreshMs;\\n+    } finally {\\n+      window.clearTimeout(timeout);\\n+      inFlight = false;\\n+      refreshEl.disabled = false;\\n+    }\\n+  }\\n+\\n+  function initMap() {\\n+    if (!window.L) {\\n+      setStatus(\"error\", copy.error);\\n+      return false;\\n+    }\\n+    map = L.map(\"radar-map\", {\\n+      center: [23.7, 120.9],\\n+      zoom: 7,\\n+      minZoom: 5,\\n+      maxZoom: 13,\\n+      zoomSnap: 0.5,\\n+      maxBounds: [[17.5, 114.5], [30, 128]],\\n+      maxBoundsViscosity: 0.65,\\n+    });\\n+    L.tileLayer(\"https://tile.openstreetmap.org/{z}/{x}/{y}.png\", {\\n+      maxZoom: 19,\\n+      attribution: '&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors',\\n+    }).addTo(map);\\n+    L.circle([23.7, 120.9], {\\n+      radius: 250 * 1852,\\n+      color: \"#ec3013\",\\n+      weight: 1,\\n+      opacity: 0.45,\\n+      fill: false,\\n+      dashArray: \"5 7\",\\n+      interactive: false,\\n+    }).addTo(map);\\n+    airports.forEach((airport) => {\\n+      if (!Number.isFinite(Number(airport.lat)) ||\\n+          !Number.isFinite(Number(airport.lon))) return;\\n+      const marker = L.circleMarker([Number(airport.lat), Number(airport.lon)], {\\n+        radius: 3.5,\\n+        color: \"#111\",\\n+        weight: 1,\\n+        fillColor: \"#fff\",\\n+        fillOpacity: 1,\\n+      }).addTo(map);\\n+      const label = document.createElement(\"span\");\\n+      label.textContent = `${clean(airport.iata, 4)} · ${clean(airport.name, 80)}`;\\n+      marker.bindTooltip(label);\\n+    });\\n+    aircraftLayer = L.layerGroup().addTo(map);\\n+    L.control.scale({ imperial: false }).addTo(map);\\n+    return true;\\n+  }\\n+\\n+  searchEl.addEventListener(\"input\", render);\\n+  airborneEl.addEventListener(\"change\", render);\\n+  refreshEl.addEventListener(\"click\", () => loadAircraft(true));\\n+  document.addEventListener(\"visibilitychange\", () => {\\n+    if (!document.hidden && Date.now() >= nextRefreshAt) loadAircraft(false);\\n+  });\\n+  window.setInterval(() => {\\n+    const seconds = Math.max(0, Math.ceil((nextRefreshAt - Date.now()) / 1000));\\n+    countdownEl.textContent = copy.next(seconds);\\n+    if (!document.hidden && seconds === 0) loadAircraft(false);\\n+  }, 1000);\\n+\\n+  if (initMap()) loadAircraft(false);\\n+})();\\n@@ -64,6 +64,7 @@ a.ticker-item:hover .ticker-time { color: var(--color-accent); }\\n }\\n .nav-link:hover { color: var(--color-accent); }\\n .nav-link.active { color: var(--color-accent); border-bottom-color: var(--color-accent); }\\n+.site-nav { display: flex; gap: 2px; margin-left: auto; white-space: nowrap; }\\n \\n /* — segmented controls (language links / theme + filter buttons) — */\\n .seg-btn {\\n@@ -131,6 +132,92 @@ a.flash-row:hover { color: inherit; }\\n .feed-row:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); color: inherit; }\\n \\n .is-hidden { display: none !important; }\\n+.sr-only {\\n+  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;\\n+  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;\\n+}\\n+.radar-stat-link { color: inherit; text-decoration: none; }\\n+.radar-stat-link:hover {\\n+  color: inherit;\\n+  background: color-mix(in srgb, var(--color-accent) 7%, transparent);\\n+}\\n+\\n+/* — Taiwan civil-flight radar — */\\n+.radar-page { padding: 24px 28px 32px; }\\n+.radar-header {\\n+  display: flex; align-items: flex-end; justify-content: space-between;\\n+  gap: 24px; margin-bottom: 18px;\\n+}\\n+.radar-header h1 { margin: 5px 0 6px; font-size: clamp(28px, 4vw, 46px); }\\n+.radar-header p { max-width: 760px; margin: 0; line-height: 1.7; }\\n+.radar-live-badge {\\n+  display: flex; align-items: center; gap: 8px; flex-shrink: 0;\\n+  border: 1px solid var(--color-divider); padding: 7px 10px;\\n+  font-size: 11px; font-weight: 800; letter-spacing: .12em;\\n+}\\n+.radar-shell { border: 2px solid var(--color-divider); }\\n+.radar-toolbar {\\n+  display: flex; gap: 10px; align-items: center; padding: 12px;\\n+  border-bottom: 1px solid var(--color-divider);\\n+}\\n+.radar-search { flex: 1; min-width: 180px; }\\n+.radar-search input {\\n+  width: 100%; min-height: 38px; border: 1px solid var(--color-divider);\\n+  background: var(--color-bg); color: var(--color-text); padding: 8px 11px;\\n+  font: inherit;\\n+}\\n+.radar-search input:focus { outline: 2px solid var(--color-accent); outline-offset: -2px; }\\n+.radar-check { display: flex; align-items: center; gap: 7px; font-size: 13px; white-space: nowrap; }\\n+.radar-check input { accent-color: var(--color-accent); }\\n+.radar-status-row {\\n+  display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center;\\n+  min-height: 39px; padding: 8px 12px; border-bottom: 1px solid var(--color-divider);\\n+  font-size: 12px;\\n+}\\n+.radar-state { font-weight: 800; }\\n+.radar-state::before { content: \"●\"; margin-right: 7px; }\\n+.radar-state.live { color: #167348; }\\n+.radar-state.loading { color: var(--color-accent); }\\n+.radar-state.error { color: #a82516; }\\n+.radar-layout { display: grid; grid-template-columns: minmax(0, 1fr) 330px; min-height: 650px; }\\n+.radar-map { min-height: 650px; background: #d8e3e9; z-index: 0; }\\n+.radar-list-panel {\\n+  min-width: 0; border-left: 1px solid var(--color-divider);\\n+  background: var(--color-bg); overflow: hidden;\\n+}\\n+.radar-list-panel h2 {\\n+  margin: 0; padding: 12px 14px; border-bottom: 1px solid var(--color-divider);\\n+  font-size: 13px; letter-spacing: .08em; text-transform: uppercase;\\n+}\\n+.radar-list { max-height: 606px; overflow-y: auto; }\\n+.radar-list-item {\\n+  width: 100%; display: grid; gap: 3px; padding: 10px 14px;\\n+  text-align: left; border: 0; border-bottom: 1px solid var(--color-divider);\\n+  background: transparent; color: var(--color-text); font: inherit; cursor: pointer;\\n+}\\n+.radar-list-item:hover, .radar-list-item:focus-visible {\\n+  background: color-mix(in srgb, var(--color-accent) 8%, transparent);\\n+}\\n+.radar-list-item strong { color: var(--color-accent); letter-spacing: .04em; }\\n+.radar-list-item span { font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\\n+.radar-empty { padding: 16px; font-size: 12px; }\\n+.radar-notes {\\n+  display: grid; grid-template-columns: 1.5fr 1fr 1fr; gap: 20px;\\n+  margin-top: 16px; font-size: 11.5px; line-height: 1.65;\\n+}\\n+.radar-notes p { margin: 0; }\\n+.radar-plane-wrap { background: transparent; border: 0; }\\n+.radar-plane {\\n+  display: block; width: 24px; height: 24px; color: #ec3013;\\n+  font-size: 20px; line-height: 24px; text-align: center;\\n+  transform: rotate(var(--heading)); transform-origin: 50% 50%;\\n+  filter: drop-shadow(0 1px 1px rgba(255, 255, 255, .9));\\n+}\\n+.radar-plane.ground { color: #555; font-size: 15px; }\\n+.radar-popup { min-width: 220px; color: #171514; font-family: \"Archivo\", \"Noto Sans TC\", sans-serif; }\\n+.radar-popup-title { display: block; color: #ec3013; font-size: 16px; margin-bottom: 7px; }\\n+.radar-popup-row { display: flex; justify-content: space-between; gap: 16px; padding: 3px 0; font-size: 12px; }\\n+.radar-popup-row b { text-align: right; }\\n \\n /* — article footer: AI model credit (vendor-colored square, ◈ mark) — */\\n .model-mark {\\n@@ -154,9 +241,25 @@ a.flash-row:hover { color: inherit; }\\n   .rail-left { border-right: 0; border-bottom: 2px solid var(--color-divider); }\\n   .rail-right { border-left: 0; border-top: 2px solid var(--color-divider); }\\n   .footer-grid { grid-template-columns: 1fr; }\\n+  .brand-row { flex-wrap: wrap; }\\n+  .site-nav {\\n+    order: 3; width: 100%; margin-left: 0; overflow-x: auto;\\n+    padding-bottom: 2px;\\n+  }\\n+  .radar-layout { grid-template-columns: minmax(0, 1fr) 280px; }\\n+  .radar-notes { grid-template-columns: 1fr; gap: 8px; }\\n }\\n \\n @media (max-width: 640px) {\\n+  .radar-page { padding: 18px 12px 24px; }\\n+  .radar-header { align-items: flex-start; flex-direction: column; gap: 12px; }\\n+  .radar-toolbar { align-items: stretch; flex-wrap: wrap; }\\n+  .radar-search { flex-basis: 100%; }\\n+  .radar-check { flex: 1; }\\n+  .radar-layout { grid-template-columns: minmax(0, 1fr); min-height: 0; }\\n+  .radar-map { min-height: 520px; }\\n+  .radar-list-panel { border-left: 0; border-top: 1px solid var(--color-divider); }\\n+  .radar-list { max-height: 360px; }\\n   .seg-cat {\\n     display: grid;\\n     grid-template-columns: repeat(3, minmax(0, 1fr));\\n@@ -18,6 +18,7 @@\\n <link href=\"https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&family=Noto+Sans+TC:wght@400;500;700;900&display=swap\" rel=\"stylesheet\">\\n <link rel=\"stylesheet\" href=\"{{ base }}/assets/modernist.css\">\\n <link rel=\"stylesheet\" href=\"{{ base }}/assets/site.css\">\\n+{% block head_extra %}{% endblock %}\\n <script src=\"{{ base }}/assets/app.js\" defer></script>\\n </head>\\n <body>\\n@@ -42,15 +43,15 @@\\n     </div>\\n   </div>\\n   <div style=\"border-bottom:2px solid var(--color-divider);\">\\n-    <div style=\"padding:14px 20px;display:flex;align-items:center;gap:28px;\">\\n+    <div class=\"brand-row\" style=\"padding:14px 20px;display:flex;align-items:center;gap:28px;\">\\n       <a href=\"{{ home_url }}\" style=\"display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;\">\\n         <div style=\"width:34px;height:34px;background:var(--color-accent);color:var(--color-bg);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:15px;\">AW</div>\\n         <div>\\n           <div style=\"font-weight:800;font-size:20px;line-height:1.05;letter-spacing:-.015em;\">AVWIRE <span style=\"color:var(--color-accent);\">{{ t.brandZh }}</span></div>\\n           <div class=\"text-muted\" style=\"font-size:10px;letter-spacing:.18em;text-transform:uppercase;\">Aviation News · Automated Hourly</div>\\n         </div>\\n       </a>\\n-      <nav style=\"display:flex;gap:2px;margin-left:auto;\">\\n+      <nav class=\"site-nav\">\\n         {% for n in nav %}\\n         <a class=\"nav-link{% if n.active %} active{% endif %}\" href=\"{{ n.url }}\">{{ n.label }}</a>\\n         {% endfor %}\\n@@ -97,5 +98,6 @@ <h6 style=\"margin:0 0 10px;\">{{ t.footerSources }}</h6>\\n     </div>\\n   </div>\\n </div>\\n+{% block body_scripts %}{% endblock %}\\n </body>\\n </html>\\n@@ -91,10 +91,10 @@ <h6 style=\"margin:0;\">{{ t.latest }}</h6>\\n     <div class=\"rail-right\">\\n       <h6 style=\"margin:0 0 12px;\">{{ t.today }}</h6>\\n       <div style=\"display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--color-divider);\">\\n-        <div style=\"padding:12px;border-right:1px solid var(--color-divider);border-bottom:1px solid var(--color-divider);\">\\n+        <a class=\"radar-stat-link\" href=\"{{ radar_url }}\" style=\"padding:12px;border-right:1px solid var(--color-divider);border-bottom:1px solid var(--color-divider);\">\\n           <div style=\"font-size:24px;font-weight:800;color:var(--color-accent);letter-spacing:-.02em;\">{{ stats.flights }}</div>\\n           <div class=\"text-muted\" style=\"font-size:11px;\">{{ t.statFlights }}</div>\\n-        </div>\\n+        </a>\\n         <div style=\"padding:12px;border-bottom:1px solid var(--color-divider);\">\\n           <div style=\"font-size:24px;font-weight:800;letter-spacing:-.02em;\">{{ stats.delay }}</div>\\n           <div class=\"text-muted\" style=\"font-size:11px;\">{{ t.statDelay }}</div>\\n@@ -0,0 +1,74 @@\\n+{% extends \"base.html\" %}\\n+{% block head_extra %}\\n+<link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"\\n+      integrity=\"sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=\"\\n+      crossorigin=\"\">\\n+{% endblock %}\\n+{% block content %}\\n+<main class=\"radar-page\">\\n+  <header class=\"radar-header\">\\n+    <div>\\n+      <div class=\"card-kicker\">{{ t.radarKicker }}</div>\\n+      <h1>{{ t.radarTitle }}</h1>\\n+      <p class=\"text-muted\">{{ t.radarSub }}</p>\\n+    </div>\\n+    <div class=\"radar-live-badge\"><span class=\"pulse-dot\"></span> LIVE ADS-B</div>\\n+  </header>\\n+\\n+  <section id=\"flight-radar\"\\n+           class=\"radar-shell\"\\n+           data-lang=\"{{ lang }}\"\\n+           data-api-url=\"https://api.airplanes.live/v2/point/23.7000/120.9000/250\"\\n+           data-refresh-ms=\"300000\">\\n+    <div class=\"radar-toolbar\">\\n+      <label class=\"radar-search\">\\n+        <span class=\"sr-only\">{{ t.radarSearch }}</span>\\n+        <input id=\"radar-search\" type=\"search\" autocomplete=\"off\"\\n+               placeholder=\"{{ t.radarSearch }}\">\\n+      </label>\\n+      <label class=\"radar-check\">\\n+        <input id=\"radar-airborne\" type=\"checkbox\" checked>\\n+        <span>{{ t.radarAirborne }}</span>\\n+      </label>\\n+      <button id=\"radar-refresh\" class=\"btn btn-primary\" type=\"button\">\\n+        {{ t.radarRefresh }}\\n+      </button>\\n+    </div>\\n+\\n+    <div class=\"radar-status-row\" aria-live=\"polite\">\\n+      <span id=\"radar-state\" class=\"radar-state loading\">{{ t.radarLoading }}</span>\\n+      <span id=\"radar-count\" class=\"text-muted\"></span>\\n+      <span id=\"radar-updated\" class=\"text-muted\"></span>\\n+      <span id=\"radar-countdown\" class=\"text-muted\"></span>\\n+    </div>\\n+\\n+    <div class=\"radar-layout\">\\n+      <div id=\"radar-map\" class=\"radar-map\" role=\"application\"\\n+           aria-label=\"{{ t.radarMapLabel }}\"></div>\\n+      <aside class=\"radar-list-panel\">\\n+        <h2>{{ t.radarList }}</h2>\\n+        <div id=\"radar-list\" class=\"radar-list\"></div>\\n+      </aside>\\n+    </div>\\n+  </section>\\n+\\n+  <div class=\"radar-notes\">\\n+    <p><b>{{ t.radarNotice }}</b></p>\\n+    <p>{{ t.radarPrivacy }}</p>\\n+    <p>{{ t.radarSource }}\\n+      <a href=\"https://airplanes.live/api-guide/\" target=\"_blank\" rel=\"noopener\">API</a>\\n+      · <a href=\"https://www.openstreetmap.org/copyright\" target=\"_blank\" rel=\"noopener\">ODbL</a>\\n+    </p>\\n+  </div>\\n+\\n+  <script id=\"radar-airlines\" type=\"application/json\">{{ airline_names|tojson }}</script>\\n+  <script id=\"radar-types\" type=\"application/json\">{{ aircraft_types|tojson }}</script>\\n+  <script id=\"radar-airports\" type=\"application/json\">{{ radar_airports|tojson }}</script>\\n+</main>\\n+{% endblock %}\\n+{% block body_scripts %}\\n+<script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"\\n+        integrity=\"sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=\"\\n+        crossorigin=\"\" defer></script>\\n+<script src=\"{{ base }}/assets/radar.js\" defer></script>\\n+{% endblock %}\\n@@ -0,0 +1,43 @@\\n+\"\"\"Offline build regression checks for the public Taiwan flight-radar page.\"\"\"\\n+from __future__ import annotations\\n+\\n+import sys\\n+from pathlib import Path\\n+\\n+ROOT = Path(__file__).resolve().parent.parent\\n+sys.path.insert(0, str(ROOT / \"pipeline\"))\\n+\\n+import build  # noqa: E402\\n+\\n+\\n+def main() -> None:\\n+    assert build.main() == 0\\n+\\n+    zh = (ROOT / \"site\" / \"radar\" / \"index.html\").read_text(encoding=\"utf-8\")\\n+    en = (ROOT / \"site\" / \"en\" / \"radar\" / \"index.html\").read_text(\\n+        encoding=\"utf-8\")\\n+    home = (ROOT / \"site\" / \"index.html\").read_text(encoding=\"utf-8\")\\n+    js = (ROOT / \"static\" / \"radar.js\").read_text(encoding=\"utf-8\")\\n+\\n+    for page in (zh, en):\\n+        assert 'id=\"radar-map\"' in page\\n+        assert \"https://api.airplanes.live/v2/point/23.7000/120.9000/250\" in page\\n+        assert \"https://tile.openstreetmap.org\" not in page  # lives in JS\\n+        assert \"leaflet@1.9.4\" in page\\n+        assert \"sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=\" in page\\n+        assert \"API_KEY\" not in page\\n+    assert \"台灣民航雷達\" in zh\\n+    assert \"Taiwan civil flight radar\" in en\\n+    assert 'href=\"/avwire/radar/\"' in home\\n+\\n+    assert \"flags & (1 | 4 | 8)\" in js\\n+    assert '[\"7500\", \"7600\", \"7700\"]' in js\\n+    assert \"seenPos > 30\" in js\\n+    assert \"refreshMs\" in js and \"300000\" in js\\n+    assert \"https://tile.openstreetmap.org/{z}/{x}/{y}.png\" in js\\n+    assert \"textContent\" in js\\n+    print(\"test_radar: OK\")\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    main()"
}
