{
  "sha": "484b8051170e1fc2f6c0b5b203619fde95ad13de",
  "redacted": false,
  "message": "feat(radar): redesign map interface and aircraft silhouettes (#378)",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "CHI HSUN YANG",
    "email": "125815713+nnneeeooo-nnneeeooo@users.noreply.github.com"
  },
  "committer": {
    "login": "web-flow",
    "name": "GitHub",
    "email": "noreply@github.com"
  },
  "created_at": "2026-10-23T19:48:30Z",
  "files": [
    {
      "filename": ".github/workflows/radar-snapshot.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 0,
      "changes": 6
    },
    {
      "filename": "docs/open-source-scouting.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 12,
      "deletions": 0,
      "changes": 12
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 0,
      "changes": 3
    },
    {
      "filename": "static/radar-aircraft.js",
      "previous_filename": "",
      "status": "added",
      "additions": 37,
      "deletions": 0,
      "changes": 37
    },
    {
      "filename": "static/radar.css",
      "previous_filename": "",
      "status": "added",
      "additions": 97,
      "deletions": 0,
      "changes": 97
    },
    {
      "filename": "static/radar.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 62,
      "deletions": 7,
      "changes": 69
    },
    {
      "filename": "templates/radar.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 13,
      "deletions": 2,
      "changes": 15
    },
    {
      "filename": "tests/test_radar.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 0,
      "changes": 4
    },
    {
      "filename": "tests/test_radar_aircraft.js",
      "previous_filename": "",
      "status": "added",
      "additions": 21,
      "deletions": 0,
      "changes": 21
    }
  ],
  "diff": "diff --git a/.github/workflows/radar-snapshot.yml b/.github/workflows/radar-snapshot.yml\\nindex d69680c36..de6d499e5 100644\\n--- a/.github/workflows/radar-snapshot.yml\\n+++ b/.github/workflows/radar-snapshot.yml\\n@@ -16,6 +16,9 @@ on:\\n       - \"pipeline/build.py\"\\n       - \"pipeline/article_schema.py\"\\n       - \"static/radar.js\"\\n+      - \"static/radar.css\"\\n+      - \"static/radar-aircraft.js\"\\n+      - \"tests/test_radar_aircraft.js\"\\n       - \"templates/radar.html\"\\n       - \"tests/test_radar.py\"\\n       - \"tests/test_radar_snapshot.py\"\\n@@ -29,6 +32,8 @@ on:\\n       - \"pipeline/build.py\"\\n       - \"pipeline/article_schema.py\"\\n       - \"static/radar.js\"\\n+      - \"static/radar.css\"\\n+      - \"static/radar-aircraft.js\"\\n       - \"templates/radar.html\"\\n \\n jobs:\\n@@ -49,6 +54,7 @@ jobs:\\n       - run: pip install -r requirements.txt\\n       - name: Run radar regression tests\\n         run: |\\n+          node tests/test_radar_aircraft.js\\n           python tests/test_radar_snapshot.py\\n           python tests/test_build_resilience.py\\n           python tests/test_radar.py\\ndiff --git a/docs/open-source-scouting.md b/docs/open-source-scouting.md\\nindex d8b6a26c6..86ca44767 100644\\n--- a/docs/open-source-scouting.md\\n+++ b/docs/open-source-scouting.md\\n@@ -2,6 +2,18 @@\\n \\n 更新日期：2026-10-23\\n \\n+## 民航雷達介面與機型剪影（2026-10-24）\\n+\\n+透過 GitHub 搜尋及官方 API 比較，僅借鑑地圖優先、機型剪影與選取資訊的互動概念，沒有複製第三方程式碼或圖示。\\n+\\n+| 專案 | 最近推送 | Stars | 語言／授權 | 比較與導入成本 |\\n+| --- | --- | --- | --- | --- |\\n+| [tar1090](https://github.com/wiedehopf/tar1090) | 2026-10-19 | 1,941 | JavaScript／GitHub 標示 NOASSERTION | 完整 ADS-B 地圖、依機型顯示標記；整套接入會引入額外地圖與資料流程。本次自行繪製 SVG，不重用其授權未辨識素材。 |\\n+| [dump1090](https://github.com/flightaware/dump1090) | 2026-10-19 | 1,149 | C／GitHub 標示 NOASSERTION | 接收解碼與地圖顯示；需要接收器與後端，超出靜態網站介面需求，沒有引用程式碼。 |\\n+| [Leaflet](https://github.com/Leaflet/Leaflet) | 2026-10-23 | 45,771 | JavaScript／BSD-2-Clause | 既有地圖工具；沿用 divIcon、popup 與縮放控制就能完成，無新增依賴。 |\\n+\\n+保留現有 OSM 圖磚、來源署名、民航安全過濾與快照時效限制。剪影由 SKYTICAL 原創，以明確 ICAO 機型代碼對應類別及相對尺寸；未知機型保留通用圖示，不由航空公司推測機型。符號尺寸以辨識為目的，並非地理比例。此階段不導入其他開源子系統。\\n+\\n ## 團隊頁與創辦人介紹（2026-10-23）\\n \\n 透過 GitHub repository search、官方 API 與 README 比較三個仍在維護的個人網站主題。僅參考內容層級與版面設計，未複製程式碼或新增依賴。\\ndiff --git a/pipeline/build.py b/pipeline/build.py\\nindex 554bca522..d51243670 100644\\n--- a/pipeline/build.py\\n+++ b/pipeline/build.py\\n@@ -104,6 +104,9 @@ def static_asset_version() -> str:\\n         \"briefing.js\",\\n         \"team.css\",\\n         \"team-neo.jpg\",\\n+        \"radar.css\",\\n+        \"radar.js\",\\n+        \"radar-aircraft.js\",\\n     ):\\n         digest.update((STATIC_DIR / name).read_bytes())\\n     return digest.hexdigest()[:10]\\ndiff --git a/static/radar-aircraft.js b/static/radar-aircraft.js\\nnew file mode 100644\\nindex 000000000..096a6fb87\\n--- /dev/null\\n+++ b/static/radar-aircraft.js\\n@@ -0,0 +1,37 @@\\n+/* Original SKYTICAL aircraft silhouettes. Sizes are readable relative symbols,\\n+ * not geographic footprints. Unknown ICAO types stay generic. */\\n+(function (root) {\\n+  \"use strict\";\\n+  const paths = {\\n+    jet: \"M32 2 Q35 3 35 12 L35 25 58 40 58 44 35 37 35 51 44 58 44 61 32 57 20 61 20 58 29 51 29 37 6 44 6 40 29 25 29 12 Q29 3 32 2Z M21 31h4v10h-4z M39 31h4v10h-4z\",\\n+    wide: \"M32 1 Q36 3 36 14 L36 26 61 41 61 45 36 38 36 51 46 59 46 62 32 58 18 62 18 59 28 51 28 38 3 45 3 41 28 26 28 14 Q28 3 32 1Z M19 31h5v12h-5z M40 31h5v12h-5z\",\\n+    quad: \"M32 1 Q36 3 36 13 L36 26 62 41 62 45 36 38 36 51 46 59 46 62 32 58 18 62 18 59 28 51 28 38 2 45 2 41 28 26 28 13 Q28 3 32 1Z M19 31h5v12h-5z M9 37h4v11H9z M40 31h5v12h-5z M51 37h4v11h-4z\",\\n+    prop: \"M32 5 Q35 6 35 15 L35 28 57 31 57 36 35 34 35 50 43 55 43 59 32 56 21 59 21 55 29 50 29 34 7 36 7 31 29 28 29 15 Q29 6 32 5Z M18 25h4v17h-4z M42 25h4v17h-4z M13 24h14v2H13z M37 24h14v2H37z\",\\n+    light: \"M32 6 Q34 8 34 18 L34 28 56 29 56 34 34 34 34 49 42 52 42 56 32 54 22 56 22 52 30 49 30 34 8 34 8 29 30 28 30 18 Q30 8 32 6Z M24 12h16v2H24z\",\\n+    helicopter: \"M29 17 Q32 11 35 17 L38 30 Q38 38 34 40 L34 53 43 54 43 57 32 56 21 57 21 54 30 53 30 40 Q26 38 26 30Z M5 26h54v3H5z M31 3h2v52h-2z M21 21h2v23h-2z M41 21h2v23h-2z\",\\n+    generic: \"M32 7 37 27 51 41 49 45 35 38 35 51 42 56 40 59 32 56 24 59 22 56 29 51 29 38 15 45 13 41 27 27Z\",\\n+  };\\n+  const groups = [\\n+    [\"super\", \"quad\", 46, \"A388\"],\\n+    [\"quad\", \"quad\", 42, \"B741 B742 B743 B744 B748 A342 A343 A345 A346\"],\\n+    [\"wide\", \"wide\", 38, \"A332 A333 A338 A339 A359 A35K B762 B763 B764 B772 B773 B77L B77W B778 B779 B788 B789 B78X MD11 DC10\"],\\n+    [\"narrow\", \"jet\", 30, \"A318 A319 A320 A321 A19N A20N A21N B732 B733 B734 B735 B736 B737 B738 B739 B37M B38M B39M B3XM B752 B753 MD80 MD81 MD82 MD83 MD87 MD88 MD90 B712\"],\\n+    [\"regional\", \"jet\", 25, \"E170 E175 E190 E195 E290 E295 CRJ1 CRJ2 CRJ7 CRJ9 CRJX\"],\\n+    [\"prop\", \"prop\", 26, \"AT43 AT45 AT46 AT72 AT73 AT75 AT76 DH8A DH8B DH8C DH8D DHC6 SF34 SB20 F50\"],\\n+    [\"light\", \"light\", 22, \"C152 C172 C182 C206 C208 C25A C25B C25C C510 C525 C550 C560 C680 C700 C750 PC12 PC24 BE20 BE30 PA28 DA40 DA42 DA62 GLF4 GLF5 GLF6 GLEX GL5T GL7T CL30 CL35 CL60 FA7X FA8X\"],\\n+    [\"helicopter\", \"helicopter\", 25, \"R22 R44 R66 B06 B407 B412 EC20 EC25 EC30 EC35 EC45 EC55 H60 S76 S92 A109 A139 A169 A189\"],\\n+  ];\\n+  function profile(typeCode) {\\n+    const code = String(typeCode || \"\").trim().toUpperCase();\\n+    const group = groups.find((entry) => entry[3].split(\" \").includes(code));\\n+    return group ? { kind: group[0], shape: group[1], size: group[2] } :\\n+      { kind: \"unknown\", shape: \"generic\", size: 25 };\\n+  }\\n+  function svg(typeCode) {\\n+    const p = profile(typeCode);\\n+    return `<svg viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\" data-aircraft-kind=\"${p.kind}\"><path d=\"${paths[p.shape]}\"/></svg>`;\\n+  }\\n+  const api = { profile, svg };\\n+  if (typeof module !== \"undefined\" && module.exports) module.exports = api;\\n+  else root.SkyticalAircraft = api;\\n+})(typeof window === \"undefined\" ? this : window);\\ndiff --git a/static/radar.css b/static/radar.css\\nnew file mode 100644\\nindex 000000000..083b0bf56\\n--- /dev/null\\n+++ b/static/radar.css\\n@@ -0,0 +1,97 @@\\n+/* Radar-specific visual system. Navy instruments, yellow aircraft, quiet map.\\n+   Loaded after site themes so map controls remain legible in every theme. */\\n+.radar-page {\\n+  --radar-ink: #152b40; --radar-panel: #1c354c; --radar-line: #355067;\\n+  --radar-yellow: #ffd34e; --radar-muted: #b6c8d5;\\n+  padding: 22px 20px 28px;\\n+  font-family: \"Archivo\", \"Noto Sans TC\", sans-serif;\\n+}\\n+.radar-header { align-items: center; gap: 16px; margin-bottom: 18px; }\\n+.radar-header h1 { font-size: clamp(26px, 3vw, 36px); letter-spacing: -.04em; }\\n+.radar-header p { max-width: 720px; font-size: 12px; line-height: 1.65; }\\n+.radar-header .card-kicker { font-size: 10px; letter-spacing: .08em; }\\n+.radar-live-badge { border: 0; border-radius: 20px; background: var(--radar-ink); color: #e9f2f8; letter-spacing: 0; padding: 9px 14px; }\\n+.radar-live-badge .pulse-dot { background: #64d7b0; border-radius: 50%; animation: none; }\\n+.radar-shell { border: 1px solid var(--radar-line); border-radius: 12px; overflow: hidden; background: var(--radar-ink); color: #f1f6fa; box-shadow: 0 12px 32px #102b4026; }\\n+.radar-toolbar { background: var(--radar-ink); border-bottom: 1px solid var(--radar-line); padding: 14px; gap: 12px; flex-wrap: wrap; }\\n+.radar-search { min-width: 210px; }\\n+.radar-search input { min-height: 44px; border: 1px solid #526a7e; border-radius: 6px; background: #102538; color: #f1f6fa; font-size: 13px; padding: 10px 13px; }\\n+.radar-search input::placeholder { color: var(--radar-muted); opacity: 1; }\\n+.radar-callsign-toggle { border: 1px solid #526a7e; border-radius: 6px; min-height: 44px; overflow: hidden; }\\n+.radar-callsign-label { color: var(--radar-muted); border-color: var(--radar-line); }\\n+.radar-callsign-toggle .seg-btn { color: #e9f2f8; background: transparent; border: 0; min-width: 44px; }\\n+.radar-callsign-toggle .seg-btn.active { background: var(--radar-yellow); color: #152b40; }\\n+.radar-check { color: #e9f2f8; font-size: 12px; }\\n+.radar-check input { accent-color: var(--radar-yellow); width: 17px; height: 17px; }\\n+.radar-toolbar .btn { border: 0; border-radius: 6px; background: var(--radar-yellow); color: #152b40; min-height: 44px; font-size: 12px; padding: 10px 16px; }\\n+.radar-toolbar .btn:disabled { opacity: .6; }\\n+.radar-status-row { background: #102538; border-color: var(--radar-line); padding: 10px 14px; gap: 8px 16px; font-size: 11px; }\\n+.radar-status-row .text-muted { color: var(--radar-muted); }\\n+.radar-state.live { color: #79dfba; }\\n+.radar-state.loading { color: #ffe49a; }\\n+.radar-state.error { color: #ffb3aa; }\\n+.radar-layout { grid-template-columns: minmax(0, 1fr) 300px; min-height: 660px; }\\n+.radar-map-stage { position: relative; min-width: 0; }\\n+.radar-map { height: 100%; min-height: 660px; background: #aad3df; }\\n+.radar-map .leaflet-tile-pane { filter: saturate(.42) contrast(.94) brightness(1.04); }\\n+.radar-map-tools { position: absolute; z-index: 500; left: 14px; top: 14px; display: flex; gap: 6px; }\\n+.radar-map-tools button { min-height: 42px; padding: 8px 12px; border: 1px solid #526a7e; border-radius: 6px; background: #152b40f5; color: #fff; box-shadow: 0 2px 8px #10253833; font: 600 12px \"Archivo\", \"Noto Sans TC\", sans-serif; cursor: pointer; }\\n+.radar-map-tools button:hover { background: #2a4a64; }\\n+.radar-map-key { position: absolute; z-index: 500; left: 12px; bottom: 28px; max-width: calc(100% - 75px); padding: 5px 9px; border-radius: 4px; background: #152b40ed; color: #e9f2f8; font-size: 10px; pointer-events: none; }\\n+.radar-key-plane { color: var(--radar-yellow); font-size: 17px; margin-right: 5px; }\\n+.radar-list-panel { background: var(--radar-ink); border-color: var(--radar-line); }\\n+.radar-panel-heading { display: flex; align-items: center; justify-content: space-between; padding: 18px 16px 4px; }\\n+.radar-panel-heading h2 { border: 0; padding: 0; color: #fff; letter-spacing: 0; font-size: 15px; }\\n+#radar-list-count { padding: 2px 9px; border-radius: 12px; background: #2b465e; color: var(--radar-yellow); font-size: 12px; font-variant-numeric: tabular-nums; }\\n+.radar-panel-hint { margin: 0; padding: 4px 16px 16px; color: var(--radar-muted); font-size: 11px; border-bottom: 1px solid var(--radar-line); }\\n+.radar-list { max-height: 576px; scrollbar-width: thin; scrollbar-color: #526a7e #152b40; }\\n+.radar-list-item { position: relative; padding: 14px 16px; gap: 5px; border-color: var(--radar-line); border-left: 3px solid transparent; color: #fff; }\\n+.radar-list-item:hover { background: var(--radar-panel); }\\n+.radar-list-item.selected { background: #29435a; border-left-color: var(--radar-yellow); }\\n+.radar-list-item strong { font-size: 18px; letter-spacing: .01em; }\\n+.radar-list-item .radar-callsign-carrier, .radar-list-item strong { color: var(--radar-yellow); }\\n+.radar-list-item .radar-callsign-flight { color: #fff; }\\n+.radar-list-item > .text-muted, .radar-empty { color: var(--radar-muted); font-size: 11px; }\\n+.radar-list-item .radar-list-telemetry { color: #e9f2f8; font-size: 11px; font-variant-numeric: tabular-nums; }\\n+.radar-plane-wrap .radar-plane { width: 100%; height: 100%; display: grid; place-items: center; color: var(--radar-yellow); transform: rotate(var(--heading)); filter: drop-shadow(0 1px 1px #102538); }\\n+.radar-plane svg { display: block; width: var(--aircraft-size); height: var(--aircraft-size); fill: currentColor; stroke: #5d491a; stroke-width: 1; stroke-linejoin: round; }\\n+.radar-plane-wrap .radar-plane.ground { color: #e5edf2; }\\n+.radar-plane-wrap .radar-plane.selected { color: #ff802e; filter: drop-shadow(0 0 3px #fff); }\\n+.radar-map .leaflet-popup-content-wrapper { border-radius: 10px; padding: 3px; }\\n+.radar-map .leaflet-popup-content { margin: 14px 18px; }\\n+.radar-detail-aircraft { background: #edf3f6; border-radius: 6px; display: grid; place-items: center; height: 82px; margin-bottom: 12px; color: #274760; }\\n+.radar-detail-aircraft svg { width: 68px; height: 68px; fill: currentColor; transform: rotate(38deg); }\\n+.radar-popup-title { font-size: 22px; color: #153c59; }\\n+.radar-popup-title .radar-callsign-carrier { color: #153c59; }\\n+.radar-popup-row { padding: 5px 0; border-bottom: 1px solid #edf0f3; }\\n+.radar-page button:focus-visible, .radar-page input:focus-visible { outline: 3px solid #ffd34e; outline-offset: -3px; }\\n+.radar-shell.radar-expanded { position: fixed; inset: 0; z-index: 10000; border-radius: 0; display: flex; flex-direction: column; }\\n+.radar-expanded .radar-layout { flex: 1; min-height: 0; overflow: auto; }\\n+.radar-expanded .radar-map-stage, .radar-expanded .radar-map { height: 100%; min-height: 340px; }\\n+.radar-expanded .radar-list { max-height: calc(100dvh - 230px); }\\n+@media (max-width: 980px) {\\n+  .radar-layout { grid-template-columns: minmax(0, 1fr) 260px; }\\n+  .radar-callsign-label { display: none; }\\n+}\\n+@media (max-width: 720px) {\\n+  .radar-page { padding: 16px 10px 22px; }\\n+  .radar-header { align-items: flex-start; flex-direction: column; gap: 10px; }\\n+  .radar-header p { font-size: 11px; }\\n+  .radar-live-badge { padding: 6px 10px; font-size: 10px; }\\n+  .radar-toolbar { padding: 10px; gap: 8px; }\\n+  .radar-search { flex-basis: 100%; min-width: 0; }\\n+  .radar-callsign-toggle { order: 0; }\\n+  .radar-check { flex: 1; font-size: 11px; }\\n+  .radar-toolbar .btn { padding: 8px 10px; }\\n+  .radar-layout { grid-template-columns: minmax(0, 1fr); min-height: 0; }\\n+  .radar-map { height: 62dvh; min-height: 440px; }\\n+  .radar-map-tools { left: 10px; top: 10px; }\\n+  .radar-map-tools button { font-size: 11px; }\\n+  .radar-list-panel { border-left: 0; border-top: 1px solid var(--radar-line); }\\n+  .radar-list { max-height: 310px; }\\n+  .radar-list-item { padding: 12px 16px; }\\n+  .radar-status-row { gap: 6px 12px; font-size: 10px; }\\n+  #radar-count { flex-basis: 100%; }\\n+  .radar-expanded .radar-map-stage, .radar-expanded .radar-map { height: 65dvh; min-height: 300px; }\\n+  .radar-expanded .radar-list { max-height: 240px; }\\n+}\\ndiff --git a/static/radar.js b/static/radar.js\\nindex 04a81a48b..b17ed34ac 100644\\n--- a/static/radar.js\\n+++ b/static/radar.js\\n@@ -96,6 +96,8 @@\\n   let callsignMode = [\"icao\", \"iata\"].includes(savedCallsignMode) ?\\n     savedCallsignMode : defaultCallsignMode;\\n   let routeCache = loadRouteCache();\\n+  let selectedKey = null;\\n+  const aircraftSymbols = window.SkyticalAircraft;\\n \\n   const numberOrNull = (value) => {\\n     const n = Number(value);\\n@@ -424,6 +426,10 @@\\n     box.className = \"radar-popup\";\\n     const title = document.createElement(\"strong\");\\n     title.className = \"radar-popup-title\";\\n+    const silhouette = document.createElement(\"div\");\\n+    silhouette.className = \"radar-detail-aircraft\";\\n+    silhouette.innerHTML = aircraftSymbols.svg(row.typeCode);\\n+    box.appendChild(silhouette);\\n     renderCallsign(title, displayCallsign(row) || copy.noCallsign);\\n     box.appendChild(title);\\n     const route = formatRoute(row);\\n@@ -445,11 +451,14 @@\\n     const heading = row.heading === null ? 0 :\\n       ((row.heading % 360) + 360) % 360;\\n     const ground = row.altitude === \"ground\" ? \" ground\" : \"\";\\n+    const selected = row.key === selectedKey ? \" selected\" : \"\";\\n+    const profile = aircraftSymbols.profile(row.typeCode);\\n+    const size = profile.size;\\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+      html: `<span class=\"radar-plane${ground}${selected}\" style=\"--heading:${heading}deg;--aircraft-size:${size}px\">${aircraftSymbols.svg(row.typeCode)}</span>`,\\n+      iconSize: [48, 48],\\n+      iconAnchor: [24, 24],\\n     });\\n   }\\n \\n@@ -468,6 +477,20 @@\\n       rowMatches(row, query, airborneEl.checked));\\n     aircraftLayer.clearLayers();\\n     listEl.replaceChildren();\\n+    document.getElementById(\"radar-list-count\").textContent = rows.length;\\n+    const selections = [];\\n+    if (!rows.some((row) => row.key === selectedKey)) selectedKey = null;\\n+\\n+    function selectFlight(key) {\\n+      selectedKey = key;\\n+      selections.forEach(({ row, marker, item }) => {\\n+        const active = row.key === key;\\n+        item.classList.toggle(\"selected\", active);\\n+        item.setAttribute(\"aria-pressed\", String(active));\\n+        marker.setIcon(planeIcon(row));\\n+        marker.setZIndexOffset(active ? 1000 : 0);\\n+      });\\n+    }\\n \\n     rows.sort((a, b) =>\\n       (displayCallsign(a) || \"ZZZ\").localeCompare(\\n@@ -487,20 +510,29 @@\\n \\n       const item = document.createElement(\"button\");\\n       item.type = \"button\";\\n-      item.className = \"radar-list-item\";\\n+      item.className = \"radar-list-item\" + (row.key === selectedKey ? \" selected\" : \"\");\\n+      item.setAttribute(\"aria-pressed\", String(row.key === selectedKey));\\n       const name = document.createElement(\"strong\");\\n       renderCallsign(name, visibleCallsign || copy.noCallsign);\\n       const meta = document.createElement(\"span\");\\n       meta.className = \"text-muted\";\\n       meta.textContent = [\\n-        formatRoute(row), displayAirline(row), row.typeCode, formatAltitude(row),\\n+        formatRoute(row), displayAirline(row), row.typeCode,\\n       ].filter(Boolean).join(\" · \");\\n       item.append(name, meta);\\n+      const telemetry = document.createElement(\"span\");\\n+      telemetry.className = \"radar-list-telemetry\";\\n+      telemetry.textContent = `${formatAltitude(row)}  /  ${formatNumber(row.speed, copy.knots)}`;\\n+      item.appendChild(telemetry);\\n+      selections.push({ row, marker, item });\\n+      marker.on(\"popupopen\", () => selectFlight(row.key));\\n       item.addEventListener(\"click\", () => {\\n+        selectFlight(row.key);\\n         map.setView([row.lat, row.lon], Math.max(map.getZoom(), 9));\\n         marker.openPopup();\\n       });\\n       listEl.appendChild(item);\\n+      if (row.key === selectedKey) marker.openPopup();\\n     });\\n \\n     if (!rows.length) {\\n@@ -591,16 +623,18 @@\\n       zoomSnap: 0.5,\\n       maxBounds: [[17.5, 114.5], [30, 128]],\\n       maxBoundsViscosity: 0.65,\\n+      zoomControl: false,\\n     });\\n+    L.control.zoom({ position: \"bottomright\" }).addTo(map);\\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+      color: \"#526d81\",\\n       weight: 1,\\n-      opacity: 0.45,\\n+      opacity: 0.25,\\n       fill: false,\\n       dashArray: \"5 7\",\\n       interactive: false,\\n@@ -627,6 +661,27 @@\\n   searchEl.addEventListener(\"input\", render);\\n   airborneEl.addEventListener(\"change\", render);\\n   refreshEl.addEventListener(\"click\", () => loadAircraft(true));\\n+  document.getElementById(\"radar-reset\").addEventListener(\"click\", () => {\\n+    map.setView([23.7, 120.9], 7);\\n+    map.closePopup();\\n+  });\\n+  const expandEl = document.getElementById(\"radar-expand\");\\n+  function setExpanded(expanded) {\\n+    root.classList.toggle(\"radar-expanded\", expanded);\\n+    expandEl.setAttribute(\"aria-pressed\", String(expanded));\\n+    expandEl.textContent = expanded ?\\n+      (lang === \"en\" ? \"⛶ Exit map\" : \"⛶ 收合地圖\") :\\n+      (lang === \"en\" ? \"⛶ Expand map\" : \"⛶ 展開地圖\");\\n+    map.invalidateSize();\\n+  }\\n+  expandEl.addEventListener(\"click\", () =>\\n+    setExpanded(!root.classList.contains(\"radar-expanded\")));\\n+  document.addEventListener(\"keydown\", (event) => {\\n+    if (event.key === \"Escape\" && root.classList.contains(\"radar-expanded\")) {\\n+      setExpanded(false);\\n+      expandEl.focus();\\n+    }\\n+  });\\n   callsignModeEls.forEach((button) => {\\n     button.addEventListener(\"click\", () =>\\n       setCallsignMode(button.dataset.callsignMode, true));\\ndiff --git a/templates/radar.html b/templates/radar.html\\nindex 41796f231..35f863daf 100644\\n--- a/templates/radar.html\\n+++ b/templates/radar.html\\n@@ -3,8 +3,10 @@\\n <link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"\\n       integrity=\"sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=\"\\n       crossorigin=\"\">\\n+<link rel=\"stylesheet\" href=\"{{ base }}/assets/radar.css?v={{ asset_version }}\">\\n {% endblock %}\\n {% block content %}\\n+{% set en = lang == \"en\" %}\\n <main class=\"radar-page\">\\n   <header class=\"radar-header\">\\n     <div>\\n@@ -54,10 +56,18 @@\\n     </div>\\n \\n     <div class=\"radar-layout\">\\n+      <div class=\"radar-map-stage\">\\n+      <div class=\"radar-map-tools\">\\n+        <button id=\"radar-reset\" type=\"button\">◎ {{ \"Taiwan\" if en else \"回到臺灣\" }}</button>\\n+        <button id=\"radar-expand\" type=\"button\" aria-pressed=\"false\">⛶ {{ \"Expand map\" if en else \"展開地圖\" }}</button>\\n+      </div>\\n+      <div class=\"radar-map-key\"><span class=\"radar-key-plane\">✈</span> {{ \"Aircraft silhouettes by type · relative sizes\" if en else \"依機型顯示剪影・大小為相對比例\" }}</div>\\n       <div id=\"radar-map\" class=\"radar-map\" role=\"application\"\\n            aria-label=\"{{ t.radarMapLabel }}\"></div>\\n+      </div>\\n       <aside class=\"radar-list-panel\">\\n-        <h2>{{ t.radarList }}</h2>\\n+        <div class=\"radar-panel-heading\"><h2>{{ t.radarList }}</h2><span id=\"radar-list-count\">0</span></div>\\n+        <p class=\"radar-panel-hint\">{{ \"Select an aircraft to explore its flight.\" if en else \"點選地圖上的飛機，查看航班資訊。\" }}</p>\\n         <div id=\"radar-list\" class=\"radar-list\"></div>\\n       </aside>\\n     </div>\\n@@ -85,5 +95,6 @@\\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+<script src=\"{{ base }}/assets/radar-aircraft.js?v={{ asset_version }}\" defer></script>\\n+<script src=\"{{ base }}/assets/radar.js?v={{ asset_version }}\" defer></script>\\n {% endblock %}\\ndiff --git a/tests/test_radar.py b/tests/test_radar.py\\nindex 109e97ebf..4add7cabb 100644\\n--- a/tests/test_radar.py\\n+++ b/tests/test_radar.py\\n@@ -50,6 +50,10 @@ def main() -> None:\\n \\n     for page in (zh, en):\\n         assert 'id=\"radar-map\"' in page\\n+        assert '/assets/radar.css?v=' in page\\n+        assert page.index('/assets/radar-aircraft.js?v=') < page.index('/assets/radar.js?v=')\\n+        assert 'id=\"radar-expand\"' in page\\n+        assert 'id=\"radar-reset\"' in page\\n         assert 'data-api-url=\"/assets/radar.json\"' in page\\n         assert 'data-max-snapshot-age-ms=\"1800000\"' in page\\n         assert \"https://api.adsbdb.com/v0/callsign\" in page\\ndiff --git a/tests/test_radar_aircraft.js b/tests/test_radar_aircraft.js\\nnew file mode 100644\\nindex 000000000..97289fdfb\\n--- /dev/null\\n+++ b/tests/test_radar_aircraft.js\\n@@ -0,0 +1,21 @@\\n+\"use strict\";\\n+const assert = require(\"node:assert/strict\");\\n+const aircraft = require(\"../static/radar-aircraft.js\");\\n+\\n+// A known heavy must not look like a commuter turboprop or a light aircraft.\\n+assert.ok(aircraft.profile(\"A388\").size > aircraft.profile(\"B77W\").size);\\n+assert.ok(aircraft.profile(\"B77W\").size > aircraft.profile(\"A20N\").size);\\n+assert.ok(aircraft.profile(\"A20N\").size > aircraft.profile(\"AT76\").size);\\n+assert.ok(aircraft.profile(\"AT76\").size > aircraft.profile(\"C172\").size);\\n+assert.equal(aircraft.profile(\" b748 \").shape, \"quad\");\\n+assert.equal(aircraft.profile(\"E190\").kind, \"regional\");\\n+assert.equal(aircraft.profile(\"DHC6\").shape, \"prop\");\\n+assert.equal(aircraft.profile(\"A139\").shape, \"helicopter\");\\n+assert.notEqual(aircraft.svg(\"AT76\"), aircraft.svg(\"B77W\"));\\n+assert.notEqual(aircraft.svg(\"B748\"), aircraft.svg(\"A320\"));\\n+for (const value of [\"\", null, undefined, \"UNKNOWN\", \"A999\", '<img src=x onerror=alert(1)>']) {\\n+  assert.equal(aircraft.profile(value).kind, \"unknown\");\\n+  assert.ok(!aircraft.svg(value).includes(\"<img\"));\\n+  assert.ok(aircraft.svg(value).includes('aria-hidden=\"true\"'));\\n+}\\n+console.log(\"radar aircraft: relative sizes, silhouettes and unknown-type safety passed\");"
}
