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 ? `