import { showStatusMessage } from "./ui.js"; import { getNewsDisplaySummary, getNewsDisplayTitle, getNewsEnrichmentStatusLabel, getNewsFeedLabel, getNewsRegionLabel, } from "./news-locale.js"; // Desktop news has two surfaces: // - a persistent top ticker // - a center HUD that expands from the ticker // Mobile keeps its existing drawer page. const EARTH_NEWS_API = "/api/v1/news/earth-feed"; const FOCUS_UPDATE_INTERVAL_MS = 4000; const DATA_REFRESH_INTERVAL_MS = 180000; const MIN_REGION_SWITCH_INTERVAL_MS = 2500; const REQUEST_TIMEOUT_MS = 15000; const NEWS_HUD_MORPH_MS = 300; const NEWS_HUD_MIN_WIDTH_PX = 420; const NEWS_HUD_MIN_HEIGHT_PX = 360; const NEWS_HUD_RESIZE_MARGIN_PX = 12; const NEWS_REALTIME_RECONNECT_MS = 5000; let initialized = false; let refreshPromise = null; let payload = null; let lastFocus = null; let lastFetchAt = 0; let lastRegionSwitchAt = 0; let selectedCruiseStoryId = null; let morphTimer = null; let newsRealtimeSocket = null; let newsRealtimeReconnectTimer = null; function getElements() { const isMobile = document.body.classList.contains("layout-mode-mobile"); return { refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"), openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"), status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"), focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"), focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"), sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"), regionChip: document.getElementById("news-region-chip"), board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"), empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"), feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"), ticker: document.getElementById("desktop-news-ticker"), tickerRegion: document.getElementById("news-ticker-region"), tickerTrack: document.getElementById("news-ticker-track"), hud: document.getElementById("news-hud-panel"), hudCloseBtn: document.getElementById("news-hud-close"), }; } function formatCoord(value, positiveLabel, negativeLabel) { const abs = Math.abs(value).toFixed(1); return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`; } function formatRelativeTime(raw) { if (!raw) return "刚刚同步"; const date = new Date(raw); if (Number.isNaN(date.getTime())) return "刚刚同步"; const diff = Date.now() - date.getTime(); const minutes = Math.max(1, Math.round(diff / 60000)); if (minutes < 60) return `${minutes} 分钟前`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours} 小时前`; const days = Math.round(hours / 24); return `${days} 天前`; } export function updateNewsToggleUI(visible) { void visible; } function getHudScale() { const scale = Number.parseFloat( getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), ); return Number.isFinite(scale) && scale > 0 ? scale : 1; } function clearMorphTimer() { if (!morphTimer) return; window.clearTimeout(morphTimer); morphTimer = null; } function setHudRect(hud, rect, opacity = 1) { hud.style.left = `${rect.left}px`; hud.style.top = `${rect.top}px`; hud.style.width = `${rect.width}px`; hud.style.height = `${rect.height}px`; hud.style.transform = "none"; hud.style.opacity = String(opacity); } function setHudRectWithoutTransition(hud, rect, opacity = 1) { const previousTransition = hud.style.transition; hud.style.transition = "none"; setHudRect(hud, rect, opacity); void hud.offsetHeight; hud.style.transition = previousTransition; } function getNewsHudTargetRect(hud) { const wasHidden = hud.classList.contains("hud-panel-hidden"); const previousVisibility = hud.style.visibility; if (wasHidden) hud.classList.remove("hud-panel-hidden"); hud.style.visibility = "hidden"; const rect = hud.getBoundingClientRect(); hud.style.visibility = previousVisibility; if (wasHidden) hud.classList.add("hud-panel-hidden"); return rect; } function finishHudOpen(hud) { hud.classList.remove("is-morphing"); hud.style.opacity = ""; revealSelectedCruiseStory(); } function finishHudClose(hud, ticker, restoreRect = null) { hud.classList.add("hud-panel-hidden"); hud.classList.remove("is-morphing"); hud.style.opacity = ""; if ((hud.dataset.dragged === "true" || hud.dataset.resized === "true") && restoreRect) { setHudRect(hud, restoreRect, 1); hud.style.opacity = ""; } else { hud.style.left = ""; hud.style.top = ""; hud.style.width = ""; hud.style.height = ""; hud.style.transform = ""; } ticker?.classList.remove("is-hidden"); } function setNewsHudOpen(open, { highlightId = null } = {}) { const { hud, ticker } = getElements(); if (!(hud instanceof HTMLElement)) return; clearMorphTimer(); if (highlightId) { selectedCruiseStoryId = highlightId; applyCruiseStorySelection(); } const tickerRect = ticker instanceof HTMLElement ? ticker.getBoundingClientRect() : { left: window.innerWidth / 2 - 240, top: 20, width: 480, height: 38 }; if (open) { const targetRect = getNewsHudTargetRect(hud); hud.classList.remove("hud-panel-hidden"); hud.classList.add("is-morphing"); setHudRectWithoutTransition(hud, tickerRect, 1); ticker?.classList.add("is-hidden"); requestAnimationFrame(() => { setHudRect(hud, targetRect, 1); }); morphTimer = window.setTimeout(() => { morphTimer = null; finishHudOpen(hud); }, NEWS_HUD_MORPH_MS); } else { const currentRect = hud.getBoundingClientRect(); hud.classList.add("is-morphing"); setHudRectWithoutTransition(hud, currentRect, 1); requestAnimationFrame(() => { setHudRect(hud, tickerRect, 1); }); morphTimer = window.setTimeout(() => { morphTimer = null; finishHudClose(hud, ticker, currentRect); }, NEWS_HUD_MORPH_MS); } ticker?.setAttribute("aria-expanded", open ? "true" : "false"); } function openNewsHud(options = {}) { setNewsHudOpen(true, options); } function closeNewsHud() { setNewsHudOpen(false); } function setupNewsHudResize() { const { hud } = getElements(); const container = document.getElementById("container"); if (!(hud instanceof HTMLElement) || !(container instanceof HTMLElement)) return; let resizing = false; let activeEdge = ""; const resizeStart = { pointerX: 0, pointerY: 0, width: 0, height: 0, left: 0, top: 0, }; const clearPositioningForResize = () => { const rect = hud.getBoundingClientRect(); hud.style.left = `${rect.left}px`; hud.style.top = `${rect.top}px`; hud.style.width = `${rect.width}px`; hud.style.height = `${rect.height}px`; hud.style.transform = "none"; hud.dataset.dragged = "true"; hud.dataset.resized = "true"; }; const stopResize = () => { resizing = false; activeEdge = ""; hud.classList.remove("is-resizing"); document.body.style.userSelect = ""; }; const onMove = (event) => { if (!resizing) return; const containerRect = container.getBoundingClientRect(); const hudScale = getHudScale(); const minWidth = Math.round(NEWS_HUD_MIN_WIDTH_PX * hudScale); const minHeight = Math.round(NEWS_HUD_MIN_HEIGHT_PX * hudScale); const dx = event.clientX - resizeStart.pointerX; const dy = event.clientY - resizeStart.pointerY; if (activeEdge.includes("r")) { const maxW = containerRect.right - resizeStart.left - NEWS_HUD_RESIZE_MARGIN_PX; hud.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`; } if (activeEdge.includes("l")) { const newW = Math.max(minWidth, resizeStart.width - dx); hud.style.width = `${newW}px`; hud.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`; } if (activeEdge.includes("b")) { const maxH = containerRect.bottom - resizeStart.top - NEWS_HUD_RESIZE_MARGIN_PX; hud.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`; } }; hud.querySelectorAll(".earth-news-hud-edge[data-edge]").forEach((edgeEl) => { edgeEl.addEventListener("pointerdown", (event) => { if (document.body.classList.contains("layout-mode-mobile")) return; if (hud.classList.contains("hud-panel-hidden")) return; event.preventDefault(); event.stopPropagation(); clearPositioningForResize(); activeEdge = edgeEl.dataset.edge ?? ""; resizing = true; resizeStart.pointerX = event.clientX; resizeStart.pointerY = event.clientY; const rect = hud.getBoundingClientRect(); resizeStart.width = rect.width; resizeStart.height = rect.height; resizeStart.left = rect.left; resizeStart.top = rect.top; hud.classList.add("is-resizing"); document.body.style.userSelect = "none"; edgeEl.setPointerCapture?.(event.pointerId); }); edgeEl.addEventListener("pointermove", onMove); edgeEl.addEventListener("pointerup", stopResize); edgeEl.addEventListener("pointercancel", stopResize); edgeEl.addEventListener("lostpointercapture", stopResize); }); } function escapeTickerText(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function escapeNewsHtml(value) { return escapeTickerText(value); } function hasLocalizedNewsContent(item) { return Boolean(String(item?.display_title || "").trim() && String(item?.display_summary || "").trim()); } function renderTicker(nextPayload) { const { ticker, tickerRegion, tickerTrack } = getElements(); if (!(ticker instanceof HTMLElement) || !(tickerTrack instanceof HTMLElement)) return; const items = Array.isArray(nextPayload?.items) ? nextPayload.items : []; const focus = nextPayload?.focus || {}; if (tickerRegion instanceof HTMLElement) { tickerRegion.textContent = focus.display_region || getNewsRegionLabel(focus.region); tickerRegion.style.color = focus.accent || ""; } if (items.length === 0) { tickerTrack.textContent = "正在准备全球态势新闻..."; tickerTrack.style.removeProperty("--news-ticker-duration"); return; } const visibleItems = items.filter(hasLocalizedNewsContent).slice(0, 6); if (visibleItems.length === 0) { tickerTrack.textContent = "正在等待中文新闻..."; tickerTrack.style.removeProperty("--news-ticker-duration"); return; } const tickerItems = [...visibleItems, ...visibleItems]; tickerTrack.innerHTML = tickerItems .map((item) => ` ${escapeTickerText(item.source || item.feed_name || "NEWS")} ${escapeTickerText(getNewsDisplayTitle(item))} `) .join(""); tickerTrack.style.setProperty("--news-ticker-duration", `${Math.max(22, visibleItems.length * 7)}s`); } function renderEmptyState(message) { const { board, empty, status, openBtn } = getElements(); if (board) board.innerHTML = ""; if (empty) { empty.hidden = false; empty.textContent = message; } if (status) { status.textContent = "等待聚合新闻源"; } if (openBtn) openBtn.disabled = true; renderTicker({ items: [], focus: payload?.focus || { region: "global" } }); } function renderPayload(nextPayload) { payload = nextPayload; const { board, empty, status, focusLabel, focusCoords, sourceCount, regionChip, openBtn, feedAnchor, } = getElements(); const items = Array.isArray(nextPayload?.items) ? nextPayload.items : []; const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : []; const focus = nextPayload?.focus || {}; renderTicker(nextPayload); if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) { if (document.body.classList.contains("layout-mode-mobile")) { // Mobile page omits the region chip shell, but the rest of the page is still renderable. if (!board || !status || !focusLabel || !focusCoords || !sourceCount) { return; } } else { return; } } if (regionChip) { regionChip.textContent = focus.display_region || getNewsRegionLabel(focus.region); regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff"); } if (document.body.classList.contains("layout-mode-mobile")) { // Mobile page does not show the compact chip row. } else if (!regionChip) { return; } focusLabel.textContent = focus.label || "全球焦点"; if (typeof focus.lat === "number" && typeof focus.lon === "number") { focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`; } else { focusCoords.textContent = "跟随当前视角自动聚焦"; } sourceCount.textContent = `${sources.length} 路聚合源`; if (nextPayload?.stale) { status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length} 条`; } else { status.textContent = nextPayload?.errors?.length ? `已聚合 ${items.length} 条,部分源不可用` : `已聚合 ${items.length} 条态势新闻`; } if (feedAnchor) { const matchedSource = sources.find((source) => source.region === focus.region) || sources[0]; feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/"; } if (openBtn) { openBtn.disabled = !feedAnchor?.href; } if (items.length === 0) { board.innerHTML = ""; if (empty) { empty.hidden = false; empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。"; } return; } if (empty) empty.hidden = true; board.innerHTML = items .map((item) => { const cardClass = item.is_focus_match ? "news-story-card news-story-card--focus" : "news-story-card"; const title = getNewsDisplayTitle(item); const summaryText = getNewsDisplaySummary(item); const regionLabel = item.display_region || getNewsRegionLabel(item.region); const feedLabel = getNewsFeedLabel(item.feed_name); const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status); const summary = summaryText ? `
${escapeNewsHtml(summaryText)}
` : ""; return `
${escapeNewsHtml(item.source || "NEWS")} ${formatRelativeTime(item.published_at)}
${escapeNewsHtml(title)}
${summary}
`; }) .join(""); applyCruiseStorySelection(); window.dispatchEvent(new CustomEvent("earth:news-payload-updated", { detail: { payload: nextPayload, itemIds: items.map((item) => item.id), }, })); } function getNewsRealtimeUrl() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${window.location.host}/ws`; } function clearNewsRealtimeReconnectTimer() { if (!newsRealtimeReconnectTimer) return; window.clearTimeout(newsRealtimeReconnectTimer); newsRealtimeReconnectTimer = null; } function scheduleNewsRealtimeReconnect() { if (newsRealtimeReconnectTimer) return; newsRealtimeReconnectTimer = window.setTimeout(() => { newsRealtimeReconnectTimer = null; connectNewsRealtime(); }, NEWS_REALTIME_RECONNECT_MS); } function applyNewsRealtimePatch(updatePayload) { const itemId = updatePayload?.item_id; const patch = updatePayload?.patch; if (!payload || !itemId || !patch || typeof patch !== "object") return; const items = Array.isArray(payload.items) ? payload.items : []; let changed = false; const nextItems = items.map((item) => { if (item?.id !== itemId) return item; changed = true; return { ...item, ...patch, }; }); if (!changed) return; renderPayload({ ...payload, items: nextItems, }); } function connectNewsRealtime() { if (newsRealtimeSocket || typeof WebSocket === "undefined") return; const socket = new WebSocket(getNewsRealtimeUrl()); newsRealtimeSocket = socket; socket.onopen = () => { clearNewsRealtimeReconnectTimer(); socket.send(JSON.stringify({ type: "subscribe", data: { channel: "earth_news", }, })); }; socket.onmessage = (event) => { let message; try { message = JSON.parse(event.data); } catch { return; } if (message.type === "heartbeat" && message.data?.action === "ping") { socket.send(JSON.stringify({ type: "heartbeat" })); return; } if (message.type !== "data_frame" || message.channel !== "earth_news") return; applyNewsRealtimePatch(message.payload); }; socket.onclose = () => { if (newsRealtimeSocket === socket) { newsRealtimeSocket = null; } scheduleNewsRealtimeReconnect(); }; socket.onerror = () => { socket.close(); }; } async function fetchNews(lat, lon) { const url = new URL(EARTH_NEWS_API, window.location.origin); if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4)); if (typeof lon === "number") url.searchParams.set("lon", lon.toFixed(4)); const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const response = await fetch(url.toString(), { cache: "no-store", signal: controller.signal, }).finally(() => { window.clearTimeout(timeoutId); }); if (!response.ok) { throw new Error(`新闻源请求失败: ${response.status}`); } return response.json(); } async function refreshNews(lat, lon, { silent = false } = {}) { if (refreshPromise) return refreshPromise; const { status } = getElements(); if (status) { status.textContent = "正在同步全球态势新闻..."; } refreshPromise = fetchNews(lat, lon) .then((nextPayload) => { renderPayload(nextPayload); lastFetchAt = Date.now(); if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) { const { status } = getElements(); if (status) { status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试"; } } return nextPayload; }) .catch((error) => { console.error("加载 Earth RSS 新闻失败:", error); const message = error?.name === "AbortError" ? "新闻聚合请求超时,请稍后重试" : `新闻聚合暂时不可用: ${error?.message || "未知错误"}`; if (!payload) { renderEmptyState(message); } else if (!silent) { showStatusMessage("态势新闻同步失败", "error"); } throw error; }) .finally(() => { refreshPromise = null; }); return refreshPromise; } function shouldRefreshForFocus(lat, lon, region) { const now = Date.now(); if (!lastFocus) return true; if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) { lastRegionSwitchAt = now; return true; } if (now - lastFetchAt > DATA_REFRESH_INTERVAL_MS) return true; if (now - (lastFocus.updatedAt || 0) < FOCUS_UPDATE_INTERVAL_MS) return false; const latDrift = Math.abs((lat || 0) - (lastFocus.lat || 0)); const lonDrift = Math.abs((lon || 0) - (lastFocus.lon || 0)); return latDrift >= 18 || lonDrift >= 25; } function inferRegion(lat, lon) { if (typeof lat !== "number" || typeof lon !== "number") return "global"; if (lon >= -170 && lon <= -30) return "americas"; if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa"; if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific"; return "asia-pacific"; } function openCurrentSourceHomepage() { const { feedAnchor } = getElements(); if (feedAnchor?.href) { window.open(feedAnchor.href, "_blank", "noopener,noreferrer"); } } export function updateNewsViewFocus(coords) { if (!initialized) return; if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return; const region = inferRegion(coords.lat, coords.lon); const nextFocus = { lat: coords.lat, lon: coords.lon, region, updatedAt: Date.now(), }; const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region); lastFocus = nextFocus; if (shouldRefresh) { refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {}); } } export async function ensureNewsPanelReady() { if (!initialized) { initNewsPanel(); } if (!payload) { await refreshNews(lastFocus?.lat, lastFocus?.lon); } return payload; } export function getNewsPayload() { return payload; } function updateBoardSelection(board, { scrollIntoView = false } = {}) { if (!(board instanceof HTMLElement)) return; const cards = board.querySelectorAll("[data-news-id]"); cards.forEach((card) => { const matches = card.getAttribute("data-news-id") === selectedCruiseStoryId; card.classList.toggle("news-story-card--cruise", matches); if (matches && scrollIntoView) { card.scrollIntoView({ block: "nearest", behavior: "smooth" }); } }); } function applyCruiseStorySelection(options = {}) { const desktopBoard = document.getElementById("news-board-list"); const mobileBoard = document.getElementById("mobile-news-board-list"); updateBoardSelection(desktopBoard, options); updateBoardSelection(mobileBoard, options); } function revealSelectedCruiseStory() { if (!selectedCruiseStoryId) return; applyCruiseStorySelection({ scrollIntoView: true }); } export function selectNewsItem(itemId, options = {}) { selectedCruiseStoryId = itemId || null; applyCruiseStorySelection(options); } export function clearSelectedNewsItem() { selectedCruiseStoryId = null; applyCruiseStorySelection(); } export function initNewsPanel() { if (initialized) return; initialized = true; updateNewsToggleUI(true); renderEmptyState("正在准备全球态势新闻聚合源..."); const { ticker, hudCloseBtn } = getElements(); ticker?.addEventListener("click", (event) => { const itemEl = event.target instanceof Element ? event.target.closest("[data-news-id]") : null; const highlightId = itemEl?.getAttribute("data-news-id") || null; openNewsHud({ highlightId }); }); ticker?.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); openNewsHud(); }); hudCloseBtn?.addEventListener("click", closeNewsHud); setupNewsHudResize(); connectNewsRealtime(); ["news-refresh", "mobile-news-refresh"].forEach((id) => { const refreshBtn = document.getElementById(id); refreshBtn?.addEventListener("click", async () => { try { await refreshNews(lastFocus?.lat, lastFocus?.lon); showStatusMessage("态势新闻已刷新", "info"); } catch { showStatusMessage("态势新闻刷新失败", "error"); } }); }); ["news-open-external", "mobile-news-open-external"].forEach((id) => { const openBtn = document.getElementById(id); openBtn?.addEventListener("click", openCurrentSourceHomepage); }); refreshNews(undefined, undefined, { silent: true }).catch(() => {}); }