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.
完整差異
@@ -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")