完整差異
@@ -103,6 +103,12 @@ python -m http.server -d site 8000\n prompt 撰寫並**一律進入 `data/review.json` 人工覆核**,不自動發布。\n \n 啟用方式:Repo Variables 設 `FLIGHT_TRACKING_ENABLED=true`。\n+\n+公開網站另提供 `/radar/`(英文版 `/en/radar/`)台灣民航雷達頁。頁面由\n+訪客瀏覽器每 5 分鐘直接查詢一次 Airplanes.live 公開 API,以 Leaflet 與\n+OpenStreetMap 顯示航班呼號、航空公司、機型、高度、地速及航向;不需 API\n+key、不儲存即時座標,且會在建立任何標記前排除軍事、PIA、LADD、緊急狀態、\n+非 ICAO 位址與位置過期資料。此頁僅供航空資訊參考,不是航管雷達。\n 資料來源授權:Airplanes.live 社群資料(非商業用途;本站無廣告無營利)、\n ADSB.lol 開放資料(ODbL,本站僅擷取事件摘要並保留出處標註,不鏡像資料庫)。\n \n@@ -85,8 +85,20 @@\n "aboutKicker": "Methodology", "aboutTitle": "方法論",\n "footerAbout": "全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源,自動撰寫並發布,每篇文末標註原始出處。本頁為設計原型,內容為示意樣本。",\n "footerSources": "資料來源 Data Sources",\n- "nav": ["最新", "快報", "事故資料庫", "來源", "方法論"],\n+ "nav": ["最新", "快報", "航班雷達", "事故資料庫", "來源", "方法論"],\n "cats": ["全部", "事故", "法規", "商業", "營運", "軍事"],\n+ "radarKicker": "Taiwan Flight Radar",\n+ "radarTitle": "台灣民航雷達",\n+ "radarSub": "公開 ADS-B 即時觀測圖,顯示台灣周邊民航機的航班呼號、機型、高度、速度與航向。",\n+ "radarSearch": "搜尋航班呼號、航空公司或機型",\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 # daily briefings\n "brKicker": "Daily Briefing", "brIndexTitle": "快報",\n "brIndexSub": "每日三次(臺北時間 07:15/15:15/23:15)的條列式全球航空與交通要聞快報,由具搜尋能力的模型彙整(Beta);本站正式新聞請見「最新」。",\n@@ -146,9 +158,22 @@\n "aboutKicker": "Methodology", "aboutTitle": "Methodology",\n "footerAbout": "A fully automated aviation news aggregator. GitHub Actions fetches trusted sources hourly, writes and publishes automatically, and credits originals at the end of every article. Design prototype; sample content.",\n "footerSources": "Data Sources",\n- "nav": ["Latest", "Briefings", "Incident DB", "Sources", "Methodology"],\n+ "nav": ["Latest", "Briefings", "Flight Radar", "Incident DB", "Sources",\n+ "Methodology"],\n "cats": ["All", "Safety", "Regulation", "Business", "Operations",\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+ "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 # 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@@ -1364,6 +1389,7 @@ def render_usage_dashboard(env, build) -> int:\n def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=True):\n t = L[lang]\n nav_defs = [("home", ""), ("briefings", "briefings/"),\n+ ("radar", "radar/"),\n ("incidents", "incidents/"),\n ("sources", "sources/"), ("about", "about/")]\n return {\n@@ -1376,6 +1402,7 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=Tru\n "self_abs_url": SITE_ORIGIN + page_url(lang, sub),\n "hreflang": hreflang,\n "home_url": page_url(lang, ""),\n+ "radar_url": page_url(lang, "radar/"),\n "nav": [{"label": t["nav"][i], "url": page_url(lang, s), "active": page == p}\n for i, (p, s) in enumerate(nav_defs)],\n "ticker_items": ticker or [], "build": build,\n@@ -1511,6 +1538,48 @@ def main() -> int:\n rel_path(lang, "briefings/index.html"), ctx)\n pages += 1\n \n+ airline_cfg = load_json(\n+ Path(__file__).resolve().parent.parent\n+ / "config" / "airline_icao_codes.json", {})\n+ airline_names = {\n+ str(row.get("icao_code") or "").upper():\n+ (row.get("airline_name_zh_tw") if lang == "zh"\n+ else row.get("airline_name_en"))\n+ for row in (airline_cfg.get("airlines") or [])\n+ if isinstance(row, dict) and row.get("active", True)\n+ and row.get("icao_code")\n+ }\n+ type_cfg = load_json(\n+ Path(__file__).resolve().parent.parent\n+ / "config" / "aircraft_types.json", {})\n+ aircraft_types = type_cfg.get("types") or {}\n+ airport_cfg = load_json(\n+ Path(__file__).resolve().parent.parent\n+ / "config" / "tw_civil_airports.json", {})\n+ radar_airports = [\n+ {\n+ "icao": row.get("icao"), "iata": row.get("iata"),\n+ "name": (row.get("name_zh") if lang == "zh"\n+ else row.get("name_en")),\n+ "lat": row.get("lat"), "lon": row.get("lon"),\n+ }\n+ for row in (airport_cfg.get("airports") or [])\n+ if isinstance(row, dict) and row.get("enabled", True)\n+ and isinstance(row.get("lat"), (int, float))\n+ and isinstance(row.get("lon"), (int, float))\n+ ]\n+ ctx = base_ctx(\n+ lang, "radar", "radar/",\n+ title=f"{t['siteName']} — {t['radarTitle']}",\n+ description=t["radarSub"], ticker=ticker, build=build)\n+ ctx.update(\n+ airline_names=airline_names,\n+ aircraft_types=aircraft_types,\n+ radar_airports=radar_airports,\n+ )\n+ render(env, "radar.html", rel_path(lang, "radar/index.html"), ctx)\n+ pages += 1\n+\n for a, v in zip(articles, views):\n try:\n ctx = base_ctx(lang, "home", f"news/{a['id']}/",\n@@ -0,0 +1,352 @@\n+/* AVWIRE Taiwan civil-flight radar.\n+ *\n+ * One browser-side Airplanes.live point query every five minutes. The page\n+ * stores no aircraft positions and filters source-tagged sensitive records\n+ * before creating any marker or list row.\n+ */\n+(function () {\n+ "use strict";\n+\n+ const root = document.getElementById("flight-radar");\n+ if (!root) return;\n+\n+ const lang = root.dataset.lang === "en" ? "en" : "zh";\n+ const copy = {\n+ zh: {\n+ live: "資料正常", loading: "正在取得最新 ADS-B 觀測資料…",\n+ error: "目前無法取得航機資料,將保留上次成功畫面並稍後再試。",\n+ noData: "目前篩選條件下沒有可顯示的民航機。",\n+ shown: (visible, total, hidden) =>\n+ `顯示 ${visible} 架|有效民航 ${total} 架|安全與品質過濾 ${hidden} 筆`,\n+ updated: (time) => `資料時間 ${time}`,\n+ next: (seconds) => `下次更新 ${seconds} 秒`,\n+ cooldown: (seconds) => `請於 ${seconds} 秒後再手動更新`,\n+ callsign: "航班呼號", airline: "航空公司", type: "機型",\n+ altitude: "高度", speed: "地速", heading: "航向",\n+ vertical: "垂直速率", freshness: "位置新鮮度",\n+ ground: "地面", unknown: "未提供", feet: "呎", knots: "節",\n+ fpm: "呎/分", seconds: "秒前", noCallsign: "無呼號",\n+ },\n+ en: {\n+ live: "Data live", loading: "Loading the latest ADS-B observations…",\n+ error: "Aircraft data is unavailable. The last successful view is retained and the page will retry.",\n+ noData: "No civil aircraft match the current filters.",\n+ shown: (visible, total, hidden) =>\n+ `Showing ${visible} | valid civil ${total} | safety/quality filtered ${hidden}`,\n+ updated: (time) => `Data time ${time}`,\n+ next: (seconds) => `Next refresh ${seconds}s`,\n+ cooldown: (seconds) => `Manual refresh available in ${seconds}s`,\n+ callsign: "Callsign", airline: "Airline", type: "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+ fpm: "ft/min", seconds: "s ago", noCallsign: "No callsign",\n+ },\n+ }[lang];\n+\n+ const parseJson = (id, fallback) => {\n+ try {\n+ return JSON.parse(document.getElementById(id).textContent);\n+ } catch (_) {\n+ return fallback;\n+ }\n+ };\n+ const airlines = parseJson("radar-airlines", {});\n+ const aircraftTypes = parseJson("radar-types", {});\n+ const airports = parseJson("radar-airports", []);\n+ const refreshMs = Math.max(Number(root.dataset.refreshMs) || 300000, 300000);\n+ const manualCooldownMs = 30000;\n+\n+ const stateEl = document.getElementById("radar-state");\n+ const countEl = document.getElementById("radar-count");\n+ const updatedEl = document.getElementById("radar-updated");\n+ const countdownEl = document.getElementById("radar-countdown");\n+ const listEl = document.getElementById("radar-list");\n+ const searchEl = document.getElementById("radar-search");\n+ const airborneEl = document.getElementById("radar-airborne");\n+ const refreshEl = document.getElementById("radar-refresh");\n+\n+ let map;\n+ let aircraftLayer;\n+ let currentRows = [];\n+ let sourceRows = 0;\n+ let hiddenRows = 0;\n+ let nextRefreshAt = Date.now() + refreshMs;\n+ let lastRequestAt = 0;\n+ let inFlight = false;\n+\n+ const numberOrNull = (value) => {\n+ const n = Number(value);\n+ return Number.isFinite(n) ? n : null;\n+ };\n+\n+ const clean = (value, maxLength) =>\n+ String(value == null ? "" : value).trim().slice(0, maxLength);\n+\n+ function isSensitiveOrStale(ac) {\n+ if (!ac || typeof ac !== "object") return true;\n+ const hex = clean(ac.hex, 16);\n+ if (!hex || hex.startsWith("~")) return true;\n+ const flags = Number(ac.dbFlags) || 0;\n+ if (flags & (1 | 4 | 8)) return true; // military, PIA, LADD\n+ if (["7500", "7600", "7700"].includes(clean(ac.squawk, 8))) return true;\n+ const emergency = clean(ac.emergency, 24).toLowerCase();\n+ if (emergency && emergency !== "none") return true;\n+ if (clean(ac.type, 24).toLowerCase() === "adsb_icao_nt") return true;\n+ const lat = numberOrNull(ac.lat);\n+ const lon = numberOrNull(ac.lon);\n+ const seenPos = numberOrNull(ac.seen_pos);\n+ const seen = numberOrNull(ac.seen);\n+ if (lat === null || lon === null || seenPos === null || seenPos > 30) return true;\n+ if (seen !== null && seen > 60) return true;\n+ return false;\n+ }\n+\n+ function normalize(ac) {\n+ const callsign = clean(ac.flight, 16).toUpperCase();\n+ const operator = /^[A-Z]{3}/.test(callsign) ? callsign.slice(0, 3) : "";\n+ const typeCode = clean(ac.t, 12).toUpperCase();\n+ const altitude = ac.alt_baro === "ground" ? "ground" : numberOrNull(ac.alt_baro);\n+ return {\n+ key: clean(ac.hex, 16),\n+ callsign,\n+ operator,\n+ airline: airlines[operator] || "",\n+ typeCode,\n+ typeName: aircraftTypes[typeCode] || "",\n+ lat: Number(ac.lat),\n+ lon: Number(ac.lon),\n+ altitude,\n+ speed: numberOrNull(ac.gs),\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+ };\n+ }\n+\n+ function formatAltitude(row) {\n+ if (row.altitude === "ground") return copy.ground;\n+ return row.altitude === null ? copy.unknown :\n+ `${Math.round(row.altitude).toLocaleString()} ${copy.feet}`;\n+ }\n+\n+ function formatNumber(value, unit) {\n+ return value === null ? copy.unknown :\n+ `${Math.round(value).toLocaleString()} ${unit}`;\n+ }\n+\n+ function addDetail(container, label, value) {\n+ const row = document.createElement("div");\n+ row.className = "radar-popup-row";\n+ const key = document.createElement("span");\n+ key.className = "text-muted";\n+ key.textContent = label;\n+ const val = document.createElement("b");\n+ val.textContent = value;\n+ row.append(key, val);\n+ container.appendChild(row);\n+ }\n+\n+ function popupFor(row) {\n+ const box = document.createElement("div");\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+ box.appendChild(title);\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+ addDetail(box, copy.altitude, formatAltitude(row));\n+ addDetail(box, copy.speed, formatNumber(row.speed, copy.knots));\n+ addDetail(box, copy.heading,\n+ row.heading === null ? copy.unknown : `${Math.round(row.heading)}°`);\n+ addDetail(box, copy.vertical, formatNumber(row.verticalRate, copy.fpm));\n+ addDetail(box, copy.freshness,\n+ row.seenPos === null ? copy.unknown : `${Math.round(row.seenPos)} ${copy.seconds}`);\n+ return box;\n+ }\n+\n+ function planeIcon(row) {\n+ const heading = row.heading === null ? 0 :\n+ ((row.heading % 360) + 360) % 360;\n+ const ground = row.altitude === "ground" ? " ground" : "";\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+ });\n+ }\n+\n+ function rowMatches(row, query, airborneOnly) {\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+ ].some((value) => value.toUpperCase().includes(query));\n+ }\n+\n+ function render() {\n+ const query = searchEl.value.trim().toUpperCase();\n+ const rows = currentRows.filter((row) =>\n+ rowMatches(row, query, airborneEl.checked));\n+ aircraftLayer.clearLayers();\n+ listEl.replaceChildren();\n+\n+ rows.sort((a, b) =>\n+ (a.callsign || "ZZZ").localeCompare(b.callsign || "ZZZ"));\n+ rows.forEach((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+ }).addTo(aircraftLayer);\n+ const tooltip = document.createElement("span");\n+ tooltip.textContent = row.callsign || 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+ const meta = document.createElement("span");\n+ meta.className = "text-muted";\n+ meta.textContent = [\n+ row.airline, row.typeCode, formatAltitude(row),\n+ ].filter(Boolean).join(" · ");\n+ item.append(name, meta);\n+ item.addEventListener("click", () => {\n+ map.setView([row.lat, row.lon], Math.max(map.getZoom(), 9));\n+ marker.openPopup();\n+ });\n+ listEl.appendChild(item);\n+ });\n+\n+ if (!rows.length) {\n+ const empty = document.createElement("p");\n+ empty.className = "text-muted radar-empty";\n+ empty.textContent = copy.noData;\n+ listEl.appendChild(empty);\n+ }\n+ countEl.textContent = copy.shown(rows.length, currentRows.length, hiddenRows);\n+ }\n+\n+ function setStatus(kind, text) {\n+ stateEl.className = `radar-state ${kind}`;\n+ stateEl.textContent = text;\n+ }\n+\n+ async function loadAircraft(manual) {\n+ const now = Date.now();\n+ if (inFlight) return;\n+ if (manual && now - lastRequestAt < manualCooldownMs) {\n+ setStatus("loading",\n+ copy.cooldown(Math.ceil((manualCooldownMs - (now - lastRequestAt)) / 1000)));\n+ return;\n+ }\n+\n+ inFlight = true;\n+ lastRequestAt = now;\n+ refreshEl.disabled = true;\n+ setStatus("loading", copy.loading);\n+ const controller = new AbortController();\n+ const timeout = window.setTimeout(() => controller.abort(), 15000);\n+ try {\n+ const response = await fetch(root.dataset.apiUrl, {\n+ method: "GET",\n+ mode: "cors",\n+ credentials: "omit",\n+ cache: "no-store",\n+ signal: controller.signal,\n+ });\n+ if (!response.ok) throw new Error(`HTTP ${response.status}`);\n+ const payload = await response.json();\n+ const raw = Array.isArray(payload.ac) ? payload.ac : [];\n+ sourceRows = raw.length;\n+ currentRows = raw.filter((ac) => !isSensitiveOrStale(ac)).map(normalize);\n+ hiddenRows = sourceRows - currentRows.length;\n+ render();\n+\n+ const sourceTime = numberOrNull(payload.now);\n+ const dataTime = sourceTime === null ? new Date() :\n+ new Date(sourceTime > 1e12 ? sourceTime : sourceTime * 1000);\n+ updatedEl.textContent = copy.updated(new Intl.DateTimeFormat(\n+ lang === "zh" ? "zh-TW" : "en-GB",\n+ { hour: "2-digit", minute: "2-digit", second: "2-digit" }\n+ ).format(dataTime));\n+ setStatus("live", copy.live);\n+ nextRefreshAt = Date.now() + refreshMs;\n+ } catch (_) {\n+ setStatus("error", copy.error);\n+ nextRefreshAt = Date.now() + refreshMs;\n+ } finally {\n+ window.clearTimeout(timeout);\n+ inFlight = false;\n+ refreshEl.disabled = false;\n+ }\n+ }\n+\n+ function initMap() {\n+ if (!window.L) {\n+ setStatus("error", copy.error);\n+ return false;\n+ }\n+ map = L.map("radar-map", {\n+ center: [23.7, 120.9],\n+ zoom: 7,\n+ minZoom: 5,\n+ maxZoom: 13,\n+ zoomSnap: 0.5,\n+ maxBounds: [[17.5, 114.5], [30, 128]],\n+ maxBoundsViscosity: 0.65,\n+ });\n+ L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {\n+ maxZoom: 19,\n+ attribution: '© <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+ weight: 1,\n+ opacity: 0.45,\n+ fill: false,\n+ dashArray: "5 7",\n+ interactive: false,\n+ }).addTo(map);\n+ airports.forEach((airport) => {\n+ if (!Number.isFinite(Number(airport.lat)) ||\n+ !Number.isFinite(Number(airport.lon))) return;\n+ const marker = L.circleMarker([Number(airport.lat), Number(airport.lon)], {\n+ radius: 3.5,\n+ color: "#111",\n+ weight: 1,\n+ fillColor: "#fff",\n+ fillOpacity: 1,\n+ }).addTo(map);\n+ const label = document.createElement("span");\n+ label.textContent = `${clean(airport.iata, 4)} · ${clean(airport.name, 80)}`;\n+ marker.bindTooltip(label);\n+ });\n+ aircraftLayer = L.layerGroup().addTo(map);\n+ L.control.scale({ imperial: false }).addTo(map);\n+ return true;\n+ }\n+\n+ searchEl.addEventListener("input", render);\n+ airborneEl.addEventListener("change", render);\n+ refreshEl.addEventListener("click", () => loadAircraft(true));\n+ document.addEventListener("visibilitychange", () => {\n+ if (!document.hidden && Date.now() >= nextRefreshAt) loadAircraft(false);\n+ });\n+ window.setInterval(() => {\n+ const seconds = Math.max(0, Math.ceil((nextRefreshAt - Date.now()) / 1000));\n+ countdownEl.textContent = copy.next(seconds);\n+ if (!document.hidden && seconds === 0) loadAircraft(false);\n+ }, 1000);\n+\n+ if (initMap()) loadAircraft(false);\n+})();\n@@ -64,6 +64,7 @@ a.ticker-item:hover .ticker-time { color: var(--color-accent); }\n }\n .nav-link:hover { color: var(--color-accent); }\n .nav-link.active { color: var(--color-accent); border-bottom-color: var(--color-accent); }\n+.site-nav { display: flex; gap: 2px; margin-left: auto; white-space: nowrap; }\n \n /* — segmented controls (language links / theme + filter buttons) — */\n .seg-btn {\n@@ -131,6 +132,92 @@ a.flash-row:hover { color: inherit; }\n .feed-row:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); color: inherit; }\n \n .is-hidden { display: none !important; }\n+.sr-only {\n+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;\n+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;\n+}\n+.radar-stat-link { color: inherit; text-decoration: none; }\n+.radar-stat-link:hover {\n+ color: inherit;\n+ background: color-mix(in srgb, var(--color-accent) 7%, transparent);\n+}\n+\n+/* — Taiwan civil-flight radar — */\n+.radar-page { padding: 24px 28px 32px; }\n+.radar-header {\n+ display: flex; align-items: flex-end; justify-content: space-between;\n+ gap: 24px; margin-bottom: 18px;\n+}\n+.radar-header h1 { margin: 5px 0 6px; font-size: clamp(28px, 4vw, 46px); }\n+.radar-header p { max-width: 760px; margin: 0; line-height: 1.7; }\n+.radar-live-badge {\n+ display: flex; align-items: center; gap: 8px; flex-shrink: 0;\n+ border: 1px solid var(--color-divider); padding: 7px 10px;\n+ font-size: 11px; font-weight: 800; letter-spacing: .12em;\n+}\n+.radar-shell { border: 2px solid var(--color-divider); }\n+.radar-toolbar {\n+ display: flex; gap: 10px; align-items: center; padding: 12px;\n+ border-bottom: 1px solid var(--color-divider);\n+}\n+.radar-search { flex: 1; min-width: 180px; }\n+.radar-search input {\n+ width: 100%; min-height: 38px; border: 1px solid var(--color-divider);\n+ background: var(--color-bg); color: var(--color-text); padding: 8px 11px;\n+ font: inherit;\n+}\n+.radar-search input:focus { outline: 2px solid var(--color-accent); outline-offset: -2px; }\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+ display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center;\n+ min-height: 39px; padding: 8px 12px; border-bottom: 1px solid var(--color-divider);\n+ font-size: 12px;\n+}\n+.radar-state { font-weight: 800; }\n+.radar-state::before { content: "●"; margin-right: 7px; }\n+.radar-state.live { color: #167348; }\n+.radar-state.loading { color: var(--color-accent); }\n+.radar-state.error { color: #a82516; }\n+.radar-layout { display: grid; grid-template-columns: minmax(0, 1fr) 330px; min-height: 650px; }\n+.radar-map { min-height: 650px; background: #d8e3e9; z-index: 0; }\n+.radar-list-panel {\n+ min-width: 0; border-left: 1px solid var(--color-divider);\n+ background: var(--color-bg); overflow: hidden;\n+}\n+.radar-list-panel h2 {\n+ margin: 0; padding: 12px 14px; border-bottom: 1px solid var(--color-divider);\n+ font-size: 13px; letter-spacing: .08em; text-transform: uppercase;\n+}\n+.radar-list { max-height: 606px; overflow-y: auto; }\n+.radar-list-item {\n+ width: 100%; display: grid; gap: 3px; padding: 10px 14px;\n+ text-align: left; border: 0; border-bottom: 1px solid var(--color-divider);\n+ background: transparent; color: var(--color-text); font: inherit; cursor: pointer;\n+}\n+.radar-list-item:hover, .radar-list-item:focus-visible {\n+ background: color-mix(in srgb, var(--color-accent) 8%, transparent);\n+}\n+.radar-list-item strong { color: var(--color-accent); letter-spacing: .04em; }\n+.radar-list-item span { font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n+.radar-empty { padding: 16px; font-size: 12px; }\n+.radar-notes {\n+ display: grid; grid-template-columns: 1.5fr 1fr 1fr; gap: 20px;\n+ margin-top: 16px; font-size: 11.5px; line-height: 1.65;\n+}\n+.radar-notes p { margin: 0; }\n+.radar-plane-wrap { background: transparent; border: 0; }\n+.radar-plane {\n+ display: block; width: 24px; height: 24px; color: #ec3013;\n+ font-size: 20px; line-height: 24px; text-align: center;\n+ transform: rotate(var(--heading)); transform-origin: 50% 50%;\n+ filter: drop-shadow(0 1px 1px rgba(255, 255, 255, .9));\n+}\n+.radar-plane.ground { color: #555; font-size: 15px; }\n+.radar-popup { min-width: 220px; color: #171514; font-family: "Archivo", "Noto Sans TC", sans-serif; }\n+.radar-popup-title { display: block; color: #ec3013; font-size: 16px; margin-bottom: 7px; }\n+.radar-popup-row { display: flex; justify-content: space-between; gap: 16px; padding: 3px 0; font-size: 12px; }\n+.radar-popup-row b { text-align: right; }\n \n /* — article footer: AI model credit (vendor-colored square, ◈ mark) — */\n .model-mark {\n@@ -154,9 +241,25 @@ a.flash-row:hover { color: inherit; }\n .rail-left { border-right: 0; border-bottom: 2px solid var(--color-divider); }\n .rail-right { border-left: 0; border-top: 2px solid var(--color-divider); }\n .footer-grid { grid-template-columns: 1fr; }\n+ .brand-row { flex-wrap: wrap; }\n+ .site-nav {\n+ order: 3; width: 100%; margin-left: 0; overflow-x: auto;\n+ padding-bottom: 2px;\n+ }\n+ .radar-layout { grid-template-columns: minmax(0, 1fr) 280px; }\n+ .radar-notes { grid-template-columns: 1fr; gap: 8px; }\n }\n \n @media (max-width: 640px) {\n+ .radar-page { padding: 18px 12px 24px; }\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-check { flex: 1; }\n+ .radar-layout { grid-template-columns: minmax(0, 1fr); min-height: 0; }\n+ .radar-map { min-height: 520px; }\n+ .radar-list-panel { border-left: 0; border-top: 1px solid var(--color-divider); }\n+ .radar-list { max-height: 360px; }\n .seg-cat {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n@@ -18,6 +18,7 @@\n <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&family=Noto+Sans+TC:wght@400;500;700;900&display=swap" rel="stylesheet">\n <link rel="stylesheet" href="{{ base }}/assets/modernist.css">\n <link rel="stylesheet" href="{{ base }}/assets/site.css">\n+{% block head_extra %}{% endblock %}\n <script src="{{ base }}/assets/app.js" defer></script>\n </head>\n <body>\n@@ -42,15 +43,15 @@\n </div>\n </div>\n <div style="border-bottom:2px solid var(--color-divider);">\n- <div style="padding:14px 20px;display:flex;align-items:center;gap:28px;">\n+ <div class="brand-row" style="padding:14px 20px;display:flex;align-items:center;gap:28px;">\n <a href="{{ home_url }}" style="display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;">\n <div style="width:34px;height:34px;background:var(--color-accent);color:var(--color-bg);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:15px;">AW</div>\n <div>\n <div style="font-weight:800;font-size:20px;line-height:1.05;letter-spacing:-.015em;">AVWIRE <span style="color:var(--color-accent);">{{ t.brandZh }}</span></div>\n <div class="text-muted" style="font-size:10px;letter-spacing:.18em;text-transform:uppercase;">Aviation News · Automated Hourly</div>\n </div>\n </a>\n- <nav style="display:flex;gap:2px;margin-left:auto;">\n+ <nav class="site-nav">\n {% for n in nav %}\n <a class="nav-link{% if n.active %} active{% endif %}" href="{{ n.url }}">{{ n.label }}</a>\n {% endfor %}\n@@ -97,5 +98,6 @@ <h6 style="margin:0 0 10px;">{{ t.footerSources }}</h6>\n </div>\n </div>\n </div>\n+{% block body_scripts %}{% endblock %}\n </body>\n </html>\n@@ -91,10 +91,10 @@ <h6 style="margin:0;">{{ t.latest }}</h6>\n <div class="rail-right">\n <h6 style="margin:0 0 12px;">{{ t.today }}</h6>\n <div style="display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--color-divider);">\n- <div style="padding:12px;border-right:1px solid var(--color-divider);border-bottom:1px solid var(--color-divider);">\n+ <a class="radar-stat-link" href="{{ radar_url }}" style="padding:12px;border-right:1px solid var(--color-divider);border-bottom:1px solid var(--color-divider);">\n <div style="font-size:24px;font-weight:800;color:var(--color-accent);letter-spacing:-.02em;">{{ stats.flights }}</div>\n <div class="text-muted" style="font-size:11px;">{{ t.statFlights }}</div>\n- </div>\n+ </a>\n <div style="padding:12px;border-bottom:1px solid var(--color-divider);">\n <div style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.delay }}</div>\n <div class="text-muted" style="font-size:11px;">{{ t.statDelay }}</div>\n@@ -0,0 +1,74 @@\n+{% extends "base.html" %}\n+{% block head_extra %}\n+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"\n+ integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="\n+ crossorigin="">\n+{% endblock %}\n+{% block content %}\n+<main class="radar-page">\n+ <header class="radar-header">\n+ <div>\n+ <div class="card-kicker">{{ t.radarKicker }}</div>\n+ <h1>{{ t.radarTitle }}</h1>\n+ <p class="text-muted">{{ t.radarSub }}</p>\n+ </div>\n+ <div class="radar-live-badge"><span class="pulse-dot"></span> LIVE ADS-B</div>\n+ </header>\n+\n+ <section id="flight-radar"\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-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+ <label class="radar-check">\n+ <input id="radar-airborne" type="checkbox" checked>\n+ <span>{{ t.radarAirborne }}</span>\n+ </label>\n+ <button id="radar-refresh" class="btn btn-primary" type="button">\n+ {{ t.radarRefresh }}\n+ </button>\n+ </div>\n+\n+ <div class="radar-status-row" aria-live="polite">\n+ <span id="radar-state" class="radar-state loading">{{ t.radarLoading }}</span>\n+ <span id="radar-count" class="text-muted"></span>\n+ <span id="radar-updated" class="text-muted"></span>\n+ <span id="radar-countdown" class="text-muted"></span>\n+ </div>\n+\n+ <div class="radar-layout">\n+ <div id="radar-map" class="radar-map" role="application"\n+ aria-label="{{ t.radarMapLabel }}"></div>\n+ <aside class="radar-list-panel">\n+ <h2>{{ t.radarList }}</h2>\n+ <div id="radar-list" class="radar-list"></div>\n+ </aside>\n+ </div>\n+ </section>\n+\n+ <div class="radar-notes">\n+ <p><b>{{ t.radarNotice }}</b></p>\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.openstreetmap.org/copyright" target="_blank" rel="noopener">ODbL</a>\n+ </p>\n+ </div>\n+\n+ <script id="radar-airlines" type="application/json">{{ airline_names|tojson }}</script>\n+ <script id="radar-types" type="application/json">{{ aircraft_types|tojson }}</script>\n+ <script id="radar-airports" type="application/json">{{ radar_airports|tojson }}</script>\n+</main>\n+{% endblock %}\n+{% block body_scripts %}\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+{% endblock %}\n@@ -0,0 +1,43 @@\n+"""Offline build regression checks for the public Taiwan flight-radar page."""\n+from __future__ import annotations\n+\n+import sys\n+from pathlib import Path\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+\n+import build # noqa: E402\n+\n+\n+def main() -> None:\n+ assert build.main() == 0\n+\n+ zh = (ROOT / "site" / "radar" / "index.html").read_text(encoding="utf-8")\n+ en = (ROOT / "site" / "en" / "radar" / "index.html").read_text(\n+ encoding="utf-8")\n+ home = (ROOT / "site" / "index.html").read_text(encoding="utf-8")\n+ js = (ROOT / "static" / "radar.js").read_text(encoding="utf-8")\n+\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://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+ assert "API_KEY" not in page\n+ assert "台灣民航雷達" in zh\n+ assert "Taiwan civil flight radar" in en\n+ assert 'href="/avwire/radar/"' in home\n+\n+ assert "flags & (1 | 4 | 8)" in js\n+ assert '["7500", "7600", "7700"]' in js\n+ assert "seenPos > 30" in js\n+ assert "refreshMs" in js and "300000" in js\n+ assert "https://tile.openstreetmap.org/{z}/{x}/{y}.png" in js\n+ assert "textContent" in js\n+ print("test_radar: OK")\n+\n+\n+if __name__ == "__main__":\n+ main()