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; });