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

提交原始紀錄

Add IATA callsign toggle and flight routes to radar (#8) Add an ICAO/IATA callsign display toggle and enrich tracked flights with origin-destination airport codes through ADSBdb. Keep radar operation resilient with bounded client-side caching, concurrency limits, attribution, and ICAO fallback.

SHA
155cb67854fd3ae8aaab70ee98ed24f64f73d45e
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (6)

  • README.md modified · +5 / −3
  • pipeline/build.py modified · +8 / −6
  • static/radar.js modified · +215 / −8
  • static/site.css modified · +14 / −0
  • templates/radar.html modified · +10 / −0
  • tests/test_radar.py modified · +9 / −0

完整差異

@@ -106,9 +106,11 @@ prompt 撰寫並**一律進入 `data/review.json` 人工覆核**,不自動發\n \n 公開網站另提供 `/radar/`(英文版 `/en/radar/`)台灣民航雷達頁。頁面由\n 訪客瀏覽器每 5 分鐘直接查詢一次 Airplanes.live 公開 API,以 Leaflet 與\n-OpenStreetMap 顯示航班呼號、航空公司、機型、高度、地速及航向;不需 API\n-key、不儲存即時座標,且會在建立任何標記前排除軍事、PIA、LADD、緊急狀態、\n-非 ICAO 位址與位置過期資料。此頁僅供航空資訊參考,不是航管雷達。\n+OpenStreetMap 顯示航班呼號、航空公司、機型、高度、地速及航向,並以\n+ADSBdb 公開呼號端點補充 ICAO/IATA 班號切換與起迄機場;兩者皆不需 API\n+key。航線查詢會在訪客瀏覽器內去重、限制併發並快取,不儲存即時座標;\n+建立任何標記前會排除軍事、PIA、LADD、緊急狀態、非 ICAO 位址與位置過期\n+資料。此頁僅供航空資訊參考,不是航管雷達,航線資料查不到時不會猜測。\n 資料來源授權:Airplanes.live 社群資料(非商業用途;本站無廣告無營利)、\n ADSB.lol 開放資料(ODbL,本站僅擷取事件摘要並保留出處標註,不鏡像資料庫)。\n \n@@ -89,16 +89,17 @@\n         "cats": ["全部", "事故", "法規", "商業", "營運", "軍事"],\n         "radarKicker": "Taiwan Flight Radar",\n         "radarTitle": "台灣民航雷達",\n-        "radarSub": "公開 ADS-B 即時觀測圖,顯示台灣周邊民航機的航班呼號、機型、高度、速度與航向。",\n-        "radarSearch": "搜尋航班呼號、航空公司或機型",\n+        "radarSub": "公開 ADS-B 即時觀測圖,顯示台灣周邊民航機的 ICAO/IATA 航班呼號、航線、機型、高度、速度與航向。",\n+        "radarSearch": "搜尋 ICAO/IATA 呼號、航線、航空公司或機型",\n+        "radarCallsignFormat": "呼號顯示",\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+        "radarSource": "航機位置:Airplanes.live 公開社群 ADS-B;航班代碼與航線:ADSBdb;地圖:OpenStreetMap。",\n         # daily briefings\n         "brKicker": "Daily Briefing", "brIndexTitle": "快報",\n         "brIndexSub": "每日三次(臺北時間 07:15/15:15/23:15)的條列式全球航空與交通要聞快報,由具搜尋能力的模型彙整(Beta);本站正式新聞請見「最新」。",\n@@ -164,16 +165,17 @@\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+        "radarSub": "A live public ADS-B view of civil aircraft around Taiwan, with ICAO/IATA callsigns, routes, type, altitude, speed and heading.",\n+        "radarSearch": "Search ICAO/IATA callsign, route, airline or aircraft type",\n+        "radarCallsignFormat": "Callsign format",\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+        "radarSource": "Aircraft positions: Airplanes.live public community ADS-B; flight codes and routes: ADSBdb; 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@@ -22,6 +22,7 @@\n       next: (seconds) => `下次更新 ${seconds} 秒`,\n       cooldown: (seconds) => `請於 ${seconds} 秒後再手動更新`,\n       callsign: "航班呼號", airline: "航空公司", type: "機型",\n+      route: "起迄點",\n       altitude: "高度", speed: "地速", heading: "航向",\n       vertical: "垂直速率", freshness: "位置新鮮度",\n       ground: "地面", unknown: "未提供", feet: "呎", knots: "節",\n@@ -37,6 +38,7 @@\n       next: (seconds) => `Next refresh ${seconds}s`,\n       cooldown: (seconds) => `Manual refresh available in ${seconds}s`,\n       callsign: "Callsign", airline: "Airline", type: "Aircraft",\n+      route: "Route",\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@@ -56,6 +58,13 @@\n   const airports = parseJson("radar-airports", []);\n   const refreshMs = Math.max(Number(root.dataset.refreshMs) || 300000, 300000);\n   const manualCooldownMs = 30000;\n+  const routeApiBase = String(root.dataset.routeApiUrl || "").replace(/\/+$/, "");\n+  const routeCacheKey = "avwire-radar-routes-v1";\n+  const callsignModeKey = "avwire-radar-callsign-mode";\n+  const routePositiveTtlMs = 24 * 60 * 60 * 1000;\n+  const routeNegativeTtlMs = 6 * 60 * 60 * 1000;\n+  const routeConcurrency = 4;\n+  const routeCacheLimit = 400;\n \n   const stateEl = document.getElementById("radar-state");\n   const countEl = document.getElementById("radar-count");\n@@ -65,6 +74,8 @@\n   const searchEl = document.getElementById("radar-search");\n   const airborneEl = document.getElementById("radar-airborne");\n   const refreshEl = document.getElementById("radar-refresh");\n+  const callsignModeEls = Array.from(\n+    document.querySelectorAll("[data-callsign-mode]"));\n \n   let map;\n   let aircraftLayer;\n@@ -74,6 +85,9 @@\n   let nextRefreshAt = Date.now() + refreshMs;\n   let lastRequestAt = 0;\n   let inFlight = false;\n+  let loadGeneration = 0;\n+  let callsignMode = readStorage(callsignModeKey) === "iata" ? "iata" : "icao";\n+  let routeCache = loadRouteCache();\n \n   const numberOrNull = (value) => {\n     const n = Number(value);\n@@ -83,6 +97,42 @@\n   const clean = (value, maxLength) =>\n     String(value == null ? "" : value).trim().slice(0, maxLength);\n \n+  function readStorage(key) {\n+    try {\n+      return window.localStorage.getItem(key);\n+    } catch (_) {\n+      return null;\n+    }\n+  }\n+\n+  function writeStorage(key, value) {\n+    try {\n+      window.localStorage.setItem(key, value);\n+    } catch (_) {\n+      // Storage can be disabled; the live view must continue without caching.\n+    }\n+  }\n+\n+  function loadRouteCache() {\n+    try {\n+      const parsed = JSON.parse(readStorage(routeCacheKey) || "{}");\n+      return parsed && typeof parsed === "object" && !Array.isArray(parsed) ?\n+        parsed : {};\n+    } catch (_) {\n+      return {};\n+    }\n+  }\n+\n+  function saveRouteCache() {\n+    const now = Date.now();\n+    const entries = Object.entries(routeCache)\n+      .filter(([, value]) => value && Number(value.expiresAt) > now)\n+      .sort((a, b) => Number(b[1].savedAt) - Number(a[1].savedAt))\n+      .slice(0, routeCacheLimit);\n+    routeCache = Object.fromEntries(entries);\n+    writeStorage(routeCacheKey, JSON.stringify(routeCache));\n+  }\n+\n   function isSensitiveOrStale(ac) {\n     if (!ac || typeof ac !== "object") return true;\n     const hex = clean(ac.hex, 16);\n@@ -121,9 +171,154 @@\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+      route: null,\n+    };\n+  }\n+\n+  function displayCallsign(row) {\n+    if (callsignMode === "iata" && row.route && row.route.callsignIata) {\n+      return row.route.callsignIata;\n+    }\n+    return row.callsign;\n+  }\n+\n+  function airportCode(airport) {\n+    if (!airport) return "";\n+    return airport.iata || airport.icao || "";\n+  }\n+\n+  function formatRoute(row) {\n+    if (!row.route) return "";\n+    const origin = airportCode(row.route.origin);\n+    const destination = airportCode(row.route.destination);\n+    return origin && destination ? `${origin}–${destination}` : "";\n+  }\n+\n+  function normalizeAirport(value) {\n+    if (!value || typeof value !== "object") return null;\n+    const iata = clean(value.iata_code, 4).toUpperCase();\n+    const icao = clean(value.icao_code, 5).toUpperCase();\n+    if (!iata && !icao) return null;\n+    return {\n+      iata,\n+      icao,\n+      name: clean(value.name, 100),\n+    };\n+  }\n+\n+  function normalizeRoutePayload(payload) {\n+    const flightroute = payload && payload.response &&\n+      payload.response.flightroute;\n+    if (!flightroute || typeof flightroute !== "object") return null;\n+    const origin = normalizeAirport(flightroute.origin);\n+    const destination = normalizeAirport(flightroute.destination);\n+    if (!origin || !destination) return null;\n+    return {\n+      callsignIcao: clean(flightroute.callsign_icao, 16).toUpperCase(),\n+      callsignIata: clean(flightroute.callsign_iata, 16).toUpperCase(),\n+      origin,\n+      destination,\n+    };\n+  }\n+\n+  function cachedRoute(callsign) {\n+    const entry = routeCache[callsign];\n+    if (!entry || Number(entry.expiresAt) <= Date.now()) {\n+      delete routeCache[callsign];\n+      return { hit: false, route: null };\n+    }\n+    return { hit: true, route: entry.route || null };\n+  }\n+\n+  function rememberRoute(callsign, route) {\n+    const now = Date.now();\n+    routeCache[callsign] = {\n+      route,\n+      savedAt: now,\n+      expiresAt: now + (route ? routePositiveTtlMs : routeNegativeTtlMs),\n     };\n   }\n \n+  async function fetchRoute(callsign) {\n+    if (!routeApiBase || !/^[A-Z0-9]{3,12}$/.test(callsign)) {\n+      return { cacheable: true, route: null };\n+    }\n+    const controller = new AbortController();\n+    const timeout = window.setTimeout(() => controller.abort(), 8000);\n+    try {\n+      const response = await fetch(\n+        `${routeApiBase}/${encodeURIComponent(callsign)}`, {\n+          method: "GET",\n+          mode: "cors",\n+          credentials: "omit",\n+          cache: "default",\n+          signal: controller.signal,\n+        });\n+      if (response.status === 404) return { cacheable: true, route: null };\n+      if (!response.ok) throw new Error(`HTTP ${response.status}`);\n+      return {\n+        cacheable: true,\n+        route: normalizeRoutePayload(await response.json()),\n+      };\n+    } catch (_) {\n+      return { cacheable: false, route: null };\n+    } finally {\n+      window.clearTimeout(timeout);\n+    }\n+  }\n+\n+  async function hydrateRoutes(rows, generation) {\n+    const byCallsign = new Map();\n+    rows.forEach((row) => {\n+      if (/^[A-Z]{3}[A-Z0-9]{1,9}$/.test(row.callsign)) {\n+        if (!byCallsign.has(row.callsign)) byCallsign.set(row.callsign, []);\n+        byCallsign.get(row.callsign).push(row);\n+      }\n+    });\n+\n+    const pending = [];\n+    let changed = false;\n+    byCallsign.forEach((matchingRows, callsign) => {\n+      const cached = cachedRoute(callsign);\n+      if (!cached.hit) {\n+        pending.push(callsign);\n+        return;\n+      }\n+      matchingRows.forEach((row) => { row.route = cached.route; });\n+      changed = changed || Boolean(cached.route);\n+    });\n+    if (changed && generation === loadGeneration) render();\n+\n+    let cursor = 0;\n+    async function worker() {\n+      while (cursor < pending.length) {\n+        const callsign = pending[cursor++];\n+        const result = await fetchRoute(callsign);\n+        if (!result.cacheable) continue;\n+        rememberRoute(callsign, result.route);\n+        (byCallsign.get(callsign) || []).forEach((row) => {\n+          row.route = result.route;\n+        });\n+        changed = changed || Boolean(result.route);\n+      }\n+    }\n+    const workerCount = Math.min(routeConcurrency, pending.length);\n+    await Promise.all(Array.from({ length: workerCount }, () => worker()));\n+    if (pending.length) saveRouteCache();\n+    if (changed && generation === loadGeneration) render();\n+  }\n+\n+  function setCallsignMode(mode, persist) {\n+    callsignMode = mode === "iata" ? "iata" : "icao";\n+    callsignModeEls.forEach((button) => {\n+      const active = button.dataset.callsignMode === callsignMode;\n+      button.classList.toggle("active", active);\n+      button.setAttribute("aria-pressed", String(active));\n+    });\n+    if (persist) writeStorage(callsignModeKey, callsignMode);\n+    if (aircraftLayer) render();\n+  }\n+\n   function formatAltitude(row) {\n     if (row.altitude === "ground") return copy.ground;\n     return row.altitude === null ? copy.unknown :\n@@ -152,8 +347,10 @@\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+    title.textContent = displayCallsign(row) || copy.noCallsign;\n     box.appendChild(title);\n+    const route = formatRoute(row);\n+    addDetail(box, copy.route, route || copy.unknown);\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@@ -183,7 +380,8 @@\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+      row.callsign, displayCallsign(row), formatRoute(row), row.operator,\n+      row.airline, row.typeCode, row.typeName,\n     ].some((value) => value.toUpperCase().includes(query));\n   }\n \n@@ -195,28 +393,30 @@\n     listEl.replaceChildren();\n \n     rows.sort((a, b) =>\n-      (a.callsign || "ZZZ").localeCompare(b.callsign || "ZZZ"));\n+      (displayCallsign(a) || "ZZZ").localeCompare(\n+        displayCallsign(b) || "ZZZ"));\n     rows.forEach((row) => {\n+      const visibleCallsign = displayCallsign(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+        title: visibleCallsign || copy.noCallsign,\n+        alt: visibleCallsign || row.typeCode || copy.noCallsign,\n       }).addTo(aircraftLayer);\n       const tooltip = document.createElement("span");\n-      tooltip.textContent = row.callsign || row.typeCode || copy.noCallsign;\n+      tooltip.textContent = visibleCallsign || 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+      name.textContent = visibleCallsign || copy.noCallsign;\n       const meta = document.createElement("span");\n       meta.className = "text-muted";\n       meta.textContent = [\n-        row.airline, row.typeCode, formatAltitude(row),\n+        formatRoute(row), row.airline, row.typeCode, formatAltitude(row),\n       ].filter(Boolean).join(" · ");\n       item.append(name, meta);\n       item.addEventListener("click", () => {\n@@ -269,7 +469,9 @@\n       sourceRows = raw.length;\n       currentRows = raw.filter((ac) => !isSensitiveOrStale(ac)).map(normalize);\n       hiddenRows = sourceRows - currentRows.length;\n+      const generation = ++loadGeneration;\n       render();\n+      void hydrateRoutes(currentRows, generation);\n \n       const sourceTime = numberOrNull(payload.now);\n       const dataTime = sourceTime === null ? new Date() :\n@@ -339,6 +541,10 @@\n   searchEl.addEventListener("input", render);\n   airborneEl.addEventListener("change", render);\n   refreshEl.addEventListener("click", () => loadAircraft(true));\n+  callsignModeEls.forEach((button) => {\n+    button.addEventListener("click", () =>\n+      setCallsignMode(button.dataset.callsignMode, true));\n+  });\n   document.addEventListener("visibilitychange", () => {\n     if (!document.hidden && Date.now() >= nextRefreshAt) loadAircraft(false);\n   });\n@@ -348,5 +554,6 @@\n     if (!document.hidden && seconds === 0) loadAircraft(false);\n   }, 1000);\n \n+  setCallsignMode(callsignMode, false);\n   if (initMap()) loadAircraft(false);\n })();\n@@ -167,6 +167,19 @@ a.flash-row:hover { color: inherit; }\n   font: inherit;\n }\n .radar-search input:focus { outline: 2px solid var(--color-accent); outline-offset: -2px; }\n+.radar-callsign-toggle {\n+  display: inline-flex; align-items: stretch; flex-shrink: 0;\n+  min-height: 38px; border: 1px solid var(--color-divider);\n+}\n+.radar-callsign-label {\n+  display: inline-flex; align-items: center; padding: 0 9px;\n+  border-right: 1px solid var(--color-divider);\n+  color: color-mix(in srgb, var(--color-text) 65%, transparent);\n+  font-size: 11px; font-weight: 700; white-space: nowrap;\n+}\n+.radar-callsign-toggle .seg-btn {\n+  min-width: 48px; justify-content: center; padding: 6px 9px; font-size: 11px;\n+}\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@@ -255,6 +268,7 @@ a.flash-row:hover { color: inherit; }\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-callsign-toggle { order: 2; }\n   .radar-check { flex: 1; }\n   .radar-layout { grid-template-columns: minmax(0, 1fr); min-height: 0; }\n   .radar-map { min-height: 520px; }\n@@ -19,13 +19,22 @@ <h1>{{ t.radarTitle }}</h1>\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-route-api-url="https://api.adsbdb.com/v0/callsign"\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+      <div class="radar-callsign-toggle" role="group"\n+           aria-label="{{ t.radarCallsignFormat }}">\n+        <span class="radar-callsign-label">{{ t.radarCallsignFormat }}</span>\n+        <button id="radar-callsign-icao" class="seg-btn active" type="button"\n+                data-callsign-mode="icao" aria-pressed="true">ICAO</button>\n+        <button id="radar-callsign-iata" class="seg-btn" type="button"\n+                data-callsign-mode="iata" aria-pressed="false">IATA</button>\n+      </div>\n       <label class="radar-check">\n         <input id="radar-airborne" type="checkbox" checked>\n         <span>{{ t.radarAirborne }}</span>\n@@ -57,6 +66,7 @@ <h2>{{ t.radarList }}</h2>\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.adsbdb.com/" target="_blank" rel="noopener">ADSBdb</a>\n       · <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">ODbL</a>\n     </p>\n   </div>\n@@ -22,6 +22,10 @@ def main() -> None:\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://api.adsbdb.com/v0/callsign" in page\n+        assert 'id="radar-callsign-icao"' in page\n+        assert 'id="radar-callsign-iata"' in page\n+        assert "ADSBdb" 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@@ -34,6 +38,11 @@ def main() -> None:\n     assert '["7500", "7600", "7700"]' in js\n     assert "seenPos > 30" in js\n     assert "refreshMs" in js and "300000" in js\n+    assert "callsign_iata" in js\n+    assert "flightroute.origin" in js\n+    assert "flightroute.destination" in js\n+    assert "routeConcurrency = 4" in js\n+    assert "routeCacheKey" in js\n     assert "https://tile.openstreetmap.org/{z}/{x}/{y}.png" in js\n     assert "textContent" in js\n     print("test_radar: OK")

你發現了隱藏主題!

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