2026-10-27 TUE UTC 5:36 AM TPE 1:36 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

feat(radar): show matching aircraft photos and yellow markers (#384)

SHA
151cff5874870c847d9741255ba422ab2b3e1ae7
Commit date
Author
CHI HSUN YANG · 125815713+nnneeeooo-nnneeeooo@users.noreply.github.com
Committer
GitHub · noreply@github.com

Changed files (8)

  • .github/workflows/radar-snapshot.yml modified · +4 / −0
  • docs/open-source-scouting.md modified · +12 / −0
  • pipeline/build.py modified · +1 / −0
  • static/radar-photos.js added · +89 / −0
  • static/radar.css modified · +9 / −6
  • static/radar.js modified · +39 / −7
  • templates/radar.html modified · +1 / −0
  • tests/test_radar_photos.js added · +69 / −0

Full diff

diff --git a/.github/workflows/radar-snapshot.yml b/.github/workflows/radar-snapshot.yml\nindex de6d499e5..1e1807cb6 100644\n--- a/.github/workflows/radar-snapshot.yml\n+++ b/.github/workflows/radar-snapshot.yml\n@@ -18,7 +18,9 @@ on:\n       - "static/radar.js"\n       - "static/radar.css"\n       - "static/radar-aircraft.js"\n+      - "static/radar-photos.js"\n       - "tests/test_radar_aircraft.js"\n+      - "tests/test_radar_photos.js"\n       - "templates/radar.html"\n       - "tests/test_radar.py"\n       - "tests/test_radar_snapshot.py"\n@@ -34,6 +36,7 @@ on:\n       - "static/radar.js"\n       - "static/radar.css"\n       - "static/radar-aircraft.js"\n+      - "static/radar-photos.js"\n       - "templates/radar.html"\n \n jobs:\n@@ -55,6 +58,7 @@ jobs:\n       - name: Run radar regression tests\n         run: |\n           node tests/test_radar_aircraft.js\n+          node tests/test_radar_photos.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 86ca44767..623609cd1 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+查證官方 Photo API 文件與三個開源專案;不複製第三方實作。\n+\n+| 專案 | 最近推送 | Stars | 語言/授權 | 比較與導入成本 |\n+| --- | --- | --- | --- | --- |\n+| [tar1090](https://github.com/wiedehopf/tar1090) | 2026-10-19 | 1,941 | JavaScript/NOASSERTION | 成熟 ADS-B 介面,參考選取航機再顯示資訊的流程;整套導入過重。 |\n+| [SkyWatcher](https://github.com/Sperryfreak01/SkyWatcher) | 2026-04-24 | 0 | JavaScript/未宣告授權 | 近期功能包含按 hex 查詢照片與攝影者署名;僅比較 API 整合方式,不使用其程式碼。 |\n+| [contrails](https://github.com/gauravdraj/contrails) | 2026-10-21 | 0 | JavaScript/MIT | 輕量 plane spotting、照片 metadata 快取;不導入其 Worker 與代理層。 |\n+\n+採用原生瀏覽器 fetch 與現有 Leaflet popup,僅點選航機時依完整六位 ICAO hex 查詢照片。依 [Planespotters 官方規範](https://www.planespotters.net/photo/api) 保留 API 回傳的原始縮圖/原圖連結 URL、顯示攝影者文字署名;縮圖直接由顯示它的瀏覽器載入,不下載、重存或代理圖片。metadata 僅記憶體快取一小時、無照片十五分鐘;查無照片與失敗均不改用同機型其他實機。官方 hex 端點依已知現役實機查詢,未知 hex 由其資料庫對應註冊號;未加入機型 fallback 參數。\n+\n ## 民航雷達介面與機型剪影(2026-10-24)\n \n 透過 GitHub 搜尋及官方 API 比較,僅借鑑地圖優先、機型剪影與選取資訊的互動概念,沒有複製第三方程式碼或圖示。\ndiff --git a/pipeline/build.py b/pipeline/build.py\nindex 37b89f575..1b317fedf 100644\n--- a/pipeline/build.py\n+++ b/pipeline/build.py\n@@ -107,6 +107,7 @@ def static_asset_version() -> str:\n         "radar.css",\n         "radar.js",\n         "radar-aircraft.js",\n+        "radar-photos.js",\n     ):\n         digest.update((STATIC_DIR / name).read_bytes())\n     return digest.hexdigest()[:10]\ndiff --git a/static/radar-photos.js b/static/radar-photos.js\nnew file mode 100644\nindex 000000000..4dba7a692\n--- /dev/null\n+++ b/static/radar-photos.js\n@@ -0,0 +1,89 @@\n+/* Planespotters metadata is cached in memory; images load directly from the\n+ * unchanged provider URL. Never substitute a photo from a different airframe. */\n+(function (root) {\n+  "use strict";\n+  const cache = new Map();\n+  const pending = new Map();\n+  function safeUrl(value, hosts, prefix) {\n+    if (typeof value !== "string") return false;\n+    try {\n+      const url = new URL(value);\n+      return url.protocol === "https:" && !url.username && !url.password &&\n+        !url.port && hosts.includes(url.hostname) && url.pathname.startsWith(prefix);\n+    } catch (_) { return false; }\n+  }\n+  function normalize(payload) {\n+    const photo = payload && Array.isArray(payload.photos) && payload.photos[0];\n+    if (!photo) return null;\n+    const src = (photo.thumbnail_large || photo.thumbnail || {}).src;\n+    if (!safeUrl(src, ["t.plnspttrs.net", "cdn.planespotters.net"], "/") ||\n+        !safeUrl(photo.link, ["www.planespotters.net"], "/photo/") ||\n+        typeof photo.photographer !== "string" || !photo.photographer.trim()) return null;\n+    return { src, link: photo.link, photographer: photo.photographer };\n+  }\n+  async function fetchPhoto(hex) {\n+    const key = String(hex || "").toLowerCase();\n+    if (!/^[a-f0-9]{6}$/.test(key)) return null;\n+    const cached = cache.get(key);\n+    if (cached && cached.expires > Date.now()) return cached.photo;\n+    if (pending.has(key)) return pending.get(key);\n+    const request = (async () => {\n+      const controller = new AbortController();\n+      const timeout = setTimeout(() => controller.abort(), 8000);\n+      try {\n+        const response = await fetch(`https://api.planespotters.net/pub/photos/hex/${key}`, {\n+          mode: "cors", credentials: "omit", signal: controller.signal,\n+        });\n+        if (!response.ok) throw new Error(`Photo HTTP ${response.status}`);\n+        const payload = await response.json();\n+        if (!payload || !Array.isArray(payload.photos)) throw new Error("Invalid photo response");\n+        const photo = normalize(payload);\n+        if (cache.size >= 100) cache.delete(cache.keys().next().value);\n+        cache.set(key, { photo, expires: Date.now() + (photo ? 3600000 : 900000) });\n+        return photo;\n+      } finally { clearTimeout(timeout); }\n+    })();\n+    pending.set(key, request);\n+    try { return await request; } finally { pending.delete(key); }\n+  }\n+  async function render(container, hex, label, copy, onLayout = () => {}) {\n+    if (container.dataset.requested) return;\n+    container.dataset.requested = "true";\n+    container.setAttribute("aria-busy", "true");\n+    container.textContent = copy.photoLoading;\n+    try {\n+      const photo = await fetchPhoto(hex);\n+      if (!photo) { container.textContent = copy.photoMissing; return; }\n+      const link = document.createElement("a");\n+      link.href = photo.link;\n+      link.target = "_blank";\n+      link.rel = "noopener";\n+      link.setAttribute("aria-label", `${copy.photoOriginal} · ${photo.photographer}`);\n+      const img = document.createElement("img");\n+      img.alt = `${label} · ${copy.photoAircraft}`;\n+      img.decoding = "async";\n+      img.addEventListener("error", () => {\n+        cache.delete(String(hex).toLowerCase());\n+        container.textContent = copy.photoError;\n+        onLayout();\n+      }, { once: true });\n+      img.addEventListener("load", () => {\n+        container.setAttribute("aria-busy", "false");\n+        onLayout();\n+      }, { once: true });\n+      img.src = photo.src;\n+      link.appendChild(img);\n+      const credit = document.createElement("span");\n+      credit.className = "radar-photo-credit";\n+      credit.textContent = `© ${photo.photographer} / Planespotters.net`;\n+      container.replaceChildren(link, credit);\n+    } catch (_) { container.textContent = copy.photoError; }\n+    finally {\n+      container.setAttribute("aria-busy", "false");\n+      onLayout();\n+    }\n+  }\n+  const api = { normalize, fetchPhoto, render };\n+  if (typeof module !== "undefined" && module.exports) module.exports = api;\n+  else root.SkyticalPhotos = api;\n+})(typeof window === "undefined" ? this : window);\ndiff --git a/static/radar.css b/static/radar.css\nindex 8b425eca2..40e0763d2 100644\n--- a/static/radar.css\n+++ b/static/radar.css\n@@ -52,14 +52,12 @@\n .radar-list-item .radar-callsign-flight { color: var(--color-text); }\n .radar-list-item > .text-muted, .radar-empty { color: var(--radar-muted); font-size: 11px; }\n .radar-list-item .radar-list-telemetry { color: var(--color-text); 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-accent); transform: rotate(var(--heading)); filter: drop-shadow(0 1px 1px #201e1d80); }\n-.radar-plane svg { display: block; width: var(--aircraft-size); height: var(--aircraft-size); fill: currentColor; stroke: #fff; stroke-width: 1; stroke-linejoin: round; }\n-.radar-plane-wrap .radar-plane.ground { color: #73706d; }\n-.radar-plane-wrap .radar-plane.selected { color: var(--color-text); filter: drop-shadow(0 0 3px #fff); }\n+.radar-plane-wrap .radar-plane { width: 100%; height: 100%; display: grid; place-items: center; color: #ffd34e; transform: rotate(var(--heading)); filter: drop-shadow(0 1px 1px #201e1d80); }\n+.radar-plane svg { display: block; width: var(--aircraft-size); height: var(--aircraft-size); fill: currentColor; stroke: #68501a; stroke-width: 1; stroke-linejoin: round; }\n+.radar-plane-wrap .radar-plane.ground { color: #ffd34e; }\n+.radar-plane-wrap .radar-plane.selected { color: #ffe99c; 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: var(--color-surface); border-radius: 6px; display: grid; place-items: center; height: 82px; margin-bottom: 12px; color: var(--color-text); }\n-.radar-detail-aircraft svg { width: 68px; height: 68px; fill: currentColor; transform: rotate(38deg); }\n .radar-popup-title { font-size: 22px; color: var(--color-accent); }\n .radar-popup-title .radar-callsign-carrier { color: var(--color-accent); }\n .radar-popup-row { padding: 5px 0; border-bottom: 1px solid var(--color-divider); }\n@@ -101,3 +99,8 @@\n .radar-popup-title .radar-callsign-flight { color: var(--color-text); }\n [data-theme="dark"] .radar-state.live, [data-theme="8bit"] .radar-state.live { color: #79dfba; }\n [data-theme="8bit"] .radar-page { --radar-on-accent: var(--color-bg); }\n+\n+.radar-aircraft-photo { width: 260px; min-height: 150px; margin-bottom: 12px; background: var(--color-surface); color: var(--radar-muted); border-radius: 6px; overflow: hidden; font-size: 12px; display: flex; flex-direction: column; justify-content: center; text-align: center; }\n+.radar-aircraft-photo a { display: block; }\n+.radar-aircraft-photo img { display: block; width: 100%; height: 165px; object-fit: contain; }\n+.radar-photo-credit { display: block; padding: 6px 8px; font-size: 10px; line-height: 1.4; }\ndiff --git a/static/radar.js b/static/radar.js\nindex b17ed34ac..b33fb5cdf 100644\n--- a/static/radar.js\n+++ b/static/radar.js\n@@ -22,6 +22,9 @@\n       cooldown: (seconds) => `請於 ${seconds} 秒後再手動更新`,\n       callsign: "航班代碼", airline: "航空公司", type: "機型",\n       route: "起迄點",\n+      photoLoading: "正在載入實機照片…", photoMissing: "暫無這架飛機的照片",\n+      photoError: "實機照片暫時無法載入", photoOriginal: "查看原始照片",\n+      photoAircraft: "對應實機照片",\n       altitude: "高度", speed: "地速", heading: "航向",\n       vertical: "垂直速率", freshness: "位置新鮮度",\n       ground: "地面", unknown: "未提供", feet: "呎", knots: "節",\n@@ -38,6 +41,9 @@\n       cooldown: (seconds) => `Manual refresh available in ${seconds}s`,\n       callsign: "Flight code", airline: "Airline", type: "Aircraft",\n       route: "Route",\n+      photoLoading: "Loading aircraft photo…", photoMissing: "No photo available for this aircraft",\n+      photoError: "Aircraft photo is temporarily unavailable", photoOriginal: "View original photo",\n+      photoAircraft: "Photo of this 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@@ -426,10 +432,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+    const photo = document.createElement("div");\n+    photo.className = "radar-aircraft-photo";\n+    photo.textContent = copy.photoLoading;\n+    box.appendChild(photo);\n     renderCallsign(title, displayCallsign(row) || copy.noCallsign);\n     box.appendChild(title);\n     const route = formatRoute(row);\n@@ -462,6 +468,21 @@\n     });\n   }\n \n+  function fitFlightPopup(marker) {\n+    if (!marker.isPopupOpen()) return;\n+    const popup = marker.getPopup();\n+    popup.update();\n+    const card = popup.getElement().getBoundingClientRect();\n+    const bounds = map.getContainer().getBoundingClientRect();\n+    const top = Math.max(bounds.top, 0) + 16;\n+    const bottom = Math.min(bounds.bottom, window.innerHeight) - 16;\n+    const left = Math.max(bounds.left, 0) + 8;\n+    const right = Math.min(bounds.right, window.innerWidth) - 8;\n+    const dx = card.left < left ? card.left - left : Math.max(0, card.right - right);\n+    const dy = card.top < top ? card.top - top : Math.max(0, card.bottom - bottom);\n+    if (dx || dy) map.panBy([dx, dy], { animate: false });\n+  }\n+\n   function rowMatches(row, query, airborneOnly) {\n     if (airborneOnly && row.altitude === "ground") return false;\n     if (!query) return true;\n@@ -506,7 +527,11 @@\n       const tooltip = document.createElement("span");\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+      marker.bindPopup(popupFor(row), {\n+        minWidth: 260,\n+        maxHeight: Math.max(200, Math.min(440, window.innerHeight - 240)),\n+        autoPan: false,\n+      });\n \n       const item = document.createElement("button");\n       item.type = "button";\n@@ -525,10 +550,17 @@\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+      marker.on("popupopen", () => {\n+        selectFlight(row.key);\n+        const photo = marker.getPopup().getContent().querySelector(".radar-aircraft-photo");\n+        void window.SkyticalPhotos.render(photo, row.key,\n+          [visibleCallsign, row.typeCode].filter(Boolean).join(" · "), copy,\n+          () => fitFlightPopup(marker));\n+        fitFlightPopup(marker);\n+      });\n       item.addEventListener("click", () => {\n         selectFlight(row.key);\n-        map.setView([row.lat, row.lon], Math.max(map.getZoom(), 9));\n+        map.setView([row.lat, row.lon], Math.max(map.getZoom(), 9), { animate: false });\n         marker.openPopup();\n       });\n       listEl.appendChild(item);\ndiff --git a/templates/radar.html b/templates/radar.html\nindex c4bbbcc8b..8b5e5bfb2 100644\n--- a/templates/radar.html\n+++ b/templates/radar.html\n@@ -95,5 +95,6 @@\n         integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="\n         crossorigin="" defer></script>\n <script src="{{ base }}/assets/radar-aircraft.js?v={{ asset_version }}" defer></script>\n+<script src="{{ base }}/assets/radar-photos.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_photos.js b/tests/test_radar_photos.js\nnew file mode 100644\nindex 000000000..9704a8da1\n--- /dev/null\n+++ b/tests/test_radar_photos.js\n@@ -0,0 +1,69 @@\n+"use strict";\n+const assert = require("node:assert/strict");\n+const photos = require("../static/radar-photos.js");\n+const payload = { photos: [{\n+  thumbnail_large: { src: "https://t.plnspttrs.net/123/photo_280.jpg?original=1" },\n+  link: "https://www.planespotters.net/photo/123/airframe?utm_source=api",\n+  photographer: "Test photographer",\n+}] };\n+\n+(async () => {\n+  const normalized = photos.normalize(payload);\n+  assert.equal(normalized.src, payload.photos[0].thumbnail_large.src);\n+  assert.equal(normalized.link, payload.photos[0].link);\n+  assert.equal(photos.normalize({ photos: [] }), null);\n+  for (const src of ["javascript:alert(1)", "http://t.plnspttrs.net/a.jpg",\n+    "https://t.plnspttrs.net.evil.test/a.jpg", "https://user@t.plnspttrs.net/a.jpg"]) {\n+    assert.equal(photos.normalize({ photos: [{ ...payload.photos[0], thumbnail_large: { src } }] }), null);\n+  }\n+  assert.equal(photos.normalize({ photos: [{ ...payload.photos[0], photographer: "" }] }), null);\n+  assert.equal(photos.normalize({ photos: [{ ...payload.photos[0], link: "https://evil.test/photo/123" }] }), null);\n+\n+  let calls = [];\n+  global.fetch = async (url, options) => {\n+    calls.push(url);\n+    assert.equal(options.credentials, "omit");\n+    return { ok: true, json: async () => payload };\n+  };\n+  const [a, b] = await Promise.all([photos.fetchPhoto("789275"), photos.fetchPhoto("789275")]);\n+  assert.equal(calls.length, 1, "Concurrent opens must share one request");\n+  assert.equal(calls[0], "https://api.planespotters.net/pub/photos/hex/789275");\n+  assert.deepEqual(a, b);\n+  await photos.fetchPhoto("789275");\n+  assert.equal(calls.length, 1, "Cached metadata must avoid another request");\n+  await photos.fetchPhoto("ABC123");\n+  assert.ok(calls[1].endsWith("/abc123"));\n+  await photos.fetchPhoto("~abc123");\n+  assert.equal(calls.length, 2, "Invalid identities must never reach the provider");\n+\n+  global.fetch = async () => ({ ok: true, json: async () => ({ photos: [] }) });\n+  assert.equal(await photos.fetchPhoto("123456"), null, "Missing photo must not use a different airframe");\n+  global.fetch = async () => ({ ok: false, status: 429 });\n+  await assert.rejects(photos.fetchPhoto("111111"), /429/);\n+  global.fetch = async () => ({ ok: true, json: async () => payload });\n+  assert.ok(await photos.fetchPhoto("111111"), "Temporary failures must remain retryable");\n+\n+  class Element {\n+    constructor() { this.dataset = {}; this.events = {}; }\n+    setAttribute() {}\n+    appendChild(child) { this.child = child; }\n+    replaceChildren(...children) { this.children = children; }\n+    addEventListener(event, handler) { this.events[event] = handler; }\n+  }\n+  global.document = { createElement: () => new Element() };\n+  const copy = { photoLoading: "loading", photoMissing: "missing", photoError: "error",\n+    photoOriginal: "original", photoAircraft: "aircraft" };\n+  const container = new Element();\n+  await photos.render(container, "789275", "CX844", copy);\n+  const [link, credit] = container.children;\n+  assert.equal(link.href, normalized.link);\n+  assert.equal(link.child.src, normalized.src);\n+  assert.equal(link.rel, "noopener");\n+  assert.ok(credit.textContent.includes("Test photographer"));\n+  link.child.events.error();\n+  assert.equal(container.textContent, "error", "Broken image must become a clear status");\n+  const missing = new Element();\n+  await photos.render(missing, "123456", "missing", copy);\n+  assert.equal(missing.textContent, "missing");\n+  console.log("radar photos: exact identity, cache, URL safety, credit and failure states passed");\n+})().catch((error) => { console.error(error); process.exitCode = 1; });

You found a secret theme!

8-bit mode is unlocked. Refresh to apply it now. You can switch themes anytime using the theme selector.