{
  "sha": "155cb67854fd3ae8aaab70ee98ed24f64f73d45e",
  "message": "Add IATA callsign toggle and flight routes to radar (#8)\n\nAdd 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.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-07-27T21:25:53Z",
  "files": [
    {
      "filename": "README.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 3,
      "changes": 8
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 8,
      "deletions": 6,
      "changes": 14
    },
    {
      "filename": "static/radar.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 215,
      "deletions": 8,
      "changes": 223
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 14,
      "deletions": 0,
      "changes": 14
    },
    {
      "filename": "templates/radar.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 10,
      "deletions": 0,
      "changes": 10
    },
    {
      "filename": "tests/test_radar.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 9,
      "deletions": 0,
      "changes": 9
    }
  ],
  "diff": "@@ -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\")"
}
