2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

add Taiwan civil flight radar (#7) Co-authored-by: Codex <noreply@openai.com>

SHA
1089c36017707b5e8458f8063ce70b30b4b91af1
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (8)

  • README.md modified · +6 / −0
  • pipeline/build.py modified · +71 / −2
  • static/radar.js added · +352 / −0
  • static/site.css modified · +103 / −0
  • templates/base.html modified · +4 / −2
  • templates/home.html modified · +2 / −2
  • templates/radar.html added · +74 / −0
  • tests/test_radar.py added · +43 / −0

完整差異

@@ -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()

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。