import { showStatusMessage } from "./ui.js"; import { getNewsDisplaySummary, getNewsDisplayTitle, getNewsCategoryLabel, getNewsBreakingLabel, getNewsEnrichmentStatusLabel, getNewsFetchChannelLabel, getNewsRegionLabel, getNewsSourceTypeLabel, isNewsContentReady, } from "./news-locale.js"; import { canAttemptEarthRealtime, getEarthRealtimeCooldownMs, getEarthRealtimeUrl, recordEarthRealtimeFailure, recordEarthRealtimeOpen, } from "./realtime.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; const NEWS_SUMMARY_LIMIT = 12; const NEWS_FULL_LIMIT = 50; const NEWS_SOURCE_FILTER_STORAGE_KEY = "planet.earth.newsSourceFilters.v1"; let initialized = false; let refreshPromise = null; let refreshRequestKey = ""; let activeRefreshToken = 0; let payload = null; let lastFocus = null; let lastFetchAt = 0; let lastRegionSwitchAt = 0; let selectedCruiseStoryId = null; let morphTimer = null; let newsRealtimeSocket = null; let newsRealtimeReconnectTimer = null; let activeNewsCategoryFilters = null; let lastCategorySignature = ""; let activeNewsSourceFilters = loadNewsSourceFilters(); let lastSourceSignature = ""; let newsFullListMode = false; let activeFilterPopover = null; const NEWS_CATEGORY_ALIASES = { politics: ["politics", "political", "policy", "政府", "政治", "政策", "政务"], business: ["business", "economy", "economic", "commerce", "商业", "经济", "产业", "企业"], ecommerce: ["ecommerce", "e-commerce", "online_retail", "retail_online", "电商", "电子商务", "网上零售", "跨境电商"], finance: ["finance", "financial", "market", "stock", "金融", "财经", "市场", "证券"], sports: ["sports", "sport", "体育"], technology: ["technology", "tech", "science", "科技", "科学", "技术", "ai", "人工智能"], military: ["military", "defense", "war", "军事", "防务", "战争"], disaster: ["disaster", "emergency", "earthquake", "flood", "storm", "灾害", "灾难", "应急", "地震", "洪水"], energy: ["energy", "oil", "gas", "power", "能源", "石油", "天然气", "电力"], society: ["society", "social", "社会", "民生"], culture: ["culture", "arts", "entertainment", "文化", "艺术", "娱乐"], other: ["other", "general", "misc", "其他", "综合"], }; const NEWS_CATEGORY_KEYS = Object.keys(NEWS_CATEGORY_ALIASES); function loadNewsSourceFilters() { try { const raw = window.localStorage?.getItem(NEWS_SOURCE_FILTER_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (!Array.isArray(parsed?.enabled)) return null; return parsed.enabled.map((id) => String(id || "").trim()).filter(Boolean); } catch { return null; } } function persistNewsSourceFilters(enabledIds) { try { if (!Array.isArray(enabledIds)) { window.localStorage?.removeItem(NEWS_SOURCE_FILTER_STORAGE_KEY); return; } window.localStorage?.setItem( NEWS_SOURCE_FILTER_STORAGE_KEY, JSON.stringify({ enabled: enabledIds }), ); } catch { // Ignore localStorage failures; source filters are display-only preferences. } } 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"), filterPopovers: document.querySelectorAll("[data-news-filter-popover]"), filterToggles: document.querySelectorAll("[data-news-filter-toggle]"), viewAllToggles: document.querySelectorAll("#news-view-all-toggle, #mobile-news-view-all-toggle"), }; } 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 isNewsHudOpen() { const { hud } = getElements(); return hud instanceof HTMLElement && !hud.classList.contains("hud-panel-hidden"); } 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 getDisplayableNewsItems(items) { return Array.isArray(items) ? items.filter(isNewsContentReady) : []; } function normalizeBreakingLevel(level) { const normalized = String(level ?? "").trim().toLowerCase(); return ["watch", "breaking", "critical"].includes(normalized) ? normalized : "none"; } function normalizeBreakingScope(scope) { const normalized = String(scope ?? "").trim().toLowerCase(); return normalized === "global" ? "global" : "regional"; } function isBreakingActive(item) { const level = normalizeBreakingLevel(item?.breaking_level); if (level === "none") return false; const expiresAt = item?.breaking_expires_at ? new Date(item.breaking_expires_at) : null; return !expiresAt || Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() > Date.now(); } function getHighestBreakingLevel(items) { const ranks = { none: 0, watch: 1, breaking: 2, critical: 3 }; return (Array.isArray(items) ? items : []).reduce((highest, item) => { if (!isBreakingActive(item)) return highest; const level = normalizeBreakingLevel(item?.breaking_level); return ranks[level] > ranks[highest] ? level : highest; }, "none"); } function applyNewsBreakingShellState(nextPayload) { const filtersLevel = normalizeBreakingLevel(nextPayload?.filters?.highest_breaking_level); const computedLevel = getHighestBreakingLevel([ ...(Array.isArray(nextPayload?.items) ? nextPayload.items : []), ...(Array.isArray(nextPayload?.cruise_items) ? nextPayload.cruise_items : []), ]); const level = filtersLevel !== "none" ? filtersLevel : computedLevel; document .querySelectorAll(".earth-news-hud, .earth-news-ticker, .earth-mobile-news-board") .forEach((element) => { if (!(element instanceof HTMLElement)) return; element.classList.remove( "has-breaking-watch", "has-breaking-breaking", "has-breaking-critical", ); if (level !== "none") { element.classList.add(`has-breaking-${level}`); } }); } function normalizeNewsSourceType(value) { const normalized = String(value ?? "").trim().toLowerCase(); return normalized || ""; } function getNewsSourceDescriptor(item, sourcesByName, sourcesById) { const feedName = String(item?.feed_name || "").trim(); const sourceName = String(item?.source || feedName || "NEWS").trim(); const sourceId = String(item?.source_id || "").trim(); const sourceConfig = sourcesById.get(sourceId) || sourcesByName.get(feedName) || null; const sourceType = normalizeNewsSourceType(item?.source_type || sourceConfig?.source_type) || (feedName.startsWith("Global Monitor /") ? "aggregated" : "rss"); const sourceTypeLabel = getNewsSourceTypeLabel(sourceType); const channelLabel = getNewsFetchChannelLabel(feedName, sourceType); const sourceGroupName = String(sourceConfig?.name || "").trim(); const originLabel = sourceGroupName ? `${sourceGroupName} · ${channelLabel}` : feedName && feedName !== sourceName ? `${feedName} · ${sourceTypeLabel}` : `${channelLabel} · ${sourceTypeLabel}`; const tooltip = [ `媒体来源:${sourceName}`, sourceGroupName ? `来源组:${sourceGroupName}` : "", feedName ? `RSS 来源:${feedName}` : "", `源类型:${sourceTypeLabel}`, `抓取通道:${channelLabel}`, ].filter(Boolean).join("\n"); return { sourceName, feedName, sourceType, sourceTypeLabel, channelLabel, originLabel, tooltip, }; } function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) { if (!filters || typeof filters !== "object") return [...NEWS_CATEGORY_KEYS].sort(); return Object.entries(filters) .filter(([, enabled]) => enabled !== false) .map(([key]) => key) .filter((key) => Object.prototype.hasOwnProperty.call(NEWS_CATEGORY_ALIASES, key)) .sort(); } function getNewsCategorySignature(filters = activeNewsCategoryFilters) { const enabled = getEnabledNewsCategoryKeys(filters); const total = NEWS_CATEGORY_KEYS.length; if (enabled.length === 0) return "__none__"; if (enabled.length === total) return ""; return enabled.join(","); } function getAvailableSourceIds(nextPayload = payload) { return (Array.isArray(nextPayload?.sources) ? nextPayload.sources : []) .map((source) => String(source?.id || "").trim()) .filter(Boolean) .sort(); } function getEnabledNewsSourceIds(nextPayload = payload) { const available = getAvailableSourceIds(nextPayload); if (!available.length) return []; if (!Array.isArray(activeNewsSourceFilters)) return available; const allowed = new Set(activeNewsSourceFilters); const enabled = available.filter((id) => allowed.has(id)); return enabled.length > 0 ? enabled : available; } function reconcileNewsSourceFilters(nextPayload = payload) { const available = getAvailableSourceIds(nextPayload); if (!available.length || !Array.isArray(activeNewsSourceFilters)) return; const valid = activeNewsSourceFilters.filter((id) => available.includes(id)); const changed = valid.length !== activeNewsSourceFilters.length; if (activeNewsSourceFilters.length > 0 && valid.length === 0) { activeNewsSourceFilters = null; persistNewsSourceFilters(null); return; } if (valid.length === available.length) { activeNewsSourceFilters = null; persistNewsSourceFilters(null); return; } if (changed) { activeNewsSourceFilters = valid; persistNewsSourceFilters(valid); } } function getNewsSourceSignature(nextPayload = payload) { reconcileNewsSourceFilters(nextPayload); const available = getAvailableSourceIds(nextPayload); const enabled = getEnabledNewsSourceIds(nextPayload); if (available.length > 0 && enabled.length === 0) return "__none__"; if (enabled.length === available.length) return ""; return enabled.join(","); } function getNewsSourceSignatureForFetch(lat, lon) { const currentRegion = payload?.focus?.region || null; const nextRegion = inferRegion(lat, lon); if (currentRegion && nextRegion !== currentRegion) { return ""; } return getNewsSourceSignature(); } function getNewsLimit() { return newsFullListMode ? NEWS_FULL_LIMIT : NEWS_SUMMARY_LIMIT; } function setNewsSourceFilters(enabledIds, { persist = true } = {}) { const available = getAvailableSourceIds(); const next = Array.isArray(enabledIds) ? enabledIds.map((id) => String(id || "").trim()).filter((id) => available.includes(id)) : null; activeNewsSourceFilters = next && next.length === available.length ? null : next; if (persist) persistNewsSourceFilters(activeNewsSourceFilters); } function summarizeSelection(enabledCount, totalCount) { if (totalCount <= 0) return "暂无"; if (enabledCount <= 0) return "未选"; if (enabledCount === totalCount) return "全部"; return `${enabledCount} 项`; } function syncFilterSummaries(nextPayload = payload) { const categories = getEnabledNewsCategoryKeys(); const totalCategories = NEWS_CATEGORY_KEYS.length; const sources = getAvailableSourceIds(nextPayload); const enabledSources = getEnabledNewsSourceIds(nextPayload); document.querySelectorAll('[data-news-filter-summary="category"]').forEach((el) => { el.textContent = summarizeSelection(categories.length, totalCategories); }); document.querySelectorAll('[data-news-filter-summary="source"]').forEach((el) => { el.textContent = summarizeSelection(enabledSources.length, sources.length); }); document.querySelectorAll('[data-news-filter-summary="limit"]').forEach((el) => { const total = Array.isArray(nextPayload?.items) ? nextPayload.items.length : 0; el.textContent = newsFullListMode ? "全部" : `${Math.min(NEWS_SUMMARY_LIMIT, total || NEWS_SUMMARY_LIMIT)} 条`; }); document.querySelectorAll("[data-news-view-mode-label]").forEach((el) => { el.textContent = newsFullListMode ? "返回摘要" : "查看全部"; }); } function closeNewsFilterPopover() { activeFilterPopover = null; document.querySelectorAll("[data-news-filter-popover]").forEach((popover) => { if (popover instanceof HTMLElement) popover.hidden = true; }); document.querySelectorAll("[data-news-filter-toggle]").forEach((toggle) => { if (toggle instanceof HTMLElement) toggle.setAttribute("aria-expanded", "false"); }); } function renderCategoryFilterChips() { const enabled = new Set(getEnabledNewsCategoryKeys()); return NEWS_CATEGORY_KEYS .map((key) => ` `) .join(""); } function renderSourceFilterChips() { const sources = Array.isArray(payload?.sources) ? payload.sources : []; const enabled = new Set(getEnabledNewsSourceIds()); if (!sources.length) return `暂无可筛选来源。`; return sources .map((source) => { const id = String(source?.id || "").trim(); if (!id) return ""; const active = enabled.has(id); return ` `; }) .join(""); } function renderFilterPopover(kind) { const title = kind === "source" ? "新闻来源" : "新闻类型"; const hint = kind === "source" ? "按大来源筛选,不影响后台抓取。" : "按新闻内容分类筛选。"; const content = kind === "source" ? renderSourceFilterChips() : renderCategoryFilterChips(); activeFilterPopover = kind; document.querySelectorAll("[data-news-filter-popover]").forEach((popover) => { if (!(popover instanceof HTMLElement)) return; popover.hidden = false; popover.innerHTML = `
${escapeNewsHtml(title)}
${escapeNewsHtml(hint)}
${content}
`; }); document.querySelectorAll("[data-news-filter-toggle]").forEach((toggle) => { if (!(toggle instanceof HTMLElement)) return; toggle.setAttribute("aria-expanded", toggle.dataset.newsFilterToggle === kind ? "true" : "false"); }); } function toggleNewsSource(sourceId) { const available = getAvailableSourceIds(); if (!available.includes(sourceId)) return; const current = new Set(getEnabledNewsSourceIds()); if (current.has(sourceId)) current.delete(sourceId); else current.add(sourceId); setNewsSourceFilters([...current]); syncFilterSummaries(); if (activeFilterPopover === "source") renderFilterPopover("source"); lastFetchAt = 0; refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {}); } function toggleNewsCategory(category, enabled) { window.dispatchEvent(new CustomEvent("earth:set-news-category-enabled", { detail: { category, enabled }, })); } function toggleNewsListMode() { newsFullListMode = !newsFullListMode; syncFilterSummaries(); lastFetchAt = 0; refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {}); } 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 = getDisplayableNewsItems(items).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(getNewsDisplaySummary(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; reconcileNewsSourceFilters(nextPayload); const { board, empty, status, focusLabel, focusCoords, sourceCount, regionChip, openBtn, feedAnchor, } = getElements(); const items = Array.isArray(nextPayload?.items) ? nextPayload.items : []; const displayItems = getDisplayableNewsItems(items); const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : []; const sourcesByName = new Map( sources .filter((source) => source && typeof source === "object" && source.name) .map((source) => [String(source.name), source]), ); const sourcesById = new Map( sources .filter((source) => source && typeof source === "object" && source.id) .map((source) => [String(source.id), source]), ); const focus = nextPayload?.focus || {}; renderTicker(nextPayload); syncFilterSummaries(nextPayload); applyNewsBreakingShellState(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 = "跟随当前视角自动聚焦"; } const enabledSourceCount = getEnabledNewsSourceIds(nextPayload).length; sourceCount.textContent = `${enabledSourceCount || sources.length} / ${sources.length} 路来源`; if (displayItems.length !== items.length) { status.textContent = `展示 ${displayItems.length} / ${items.length} 条态势新闻`; } else 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 (displayItems.length === 0) { board.innerHTML = ""; if (empty) { empty.hidden = false; empty.textContent = items.length === 0 ? "当前未拉到可用新闻,请稍后刷新或切换视角区域。" : "当前新闻类型没有可显示新闻。"; } return; } if (empty) empty.hidden = true; board.innerHTML = displayItems .map((item) => { const breakingLevel = isBreakingActive(item) ? normalizeBreakingLevel(item.breaking_level) : "none"; const breakingScope = normalizeBreakingScope(item.breaking_scope); const cardClass = [ "news-story-card", item.is_focus_match ? "news-story-card--focus" : "", breakingLevel !== "none" ? `news-story-card--breaking-${breakingLevel}` : "", breakingLevel !== "none" && breakingScope === "global" ? "news-story-card--breaking-global" : "", ].filter(Boolean).join(" "); const title = getNewsDisplayTitle(item); const summaryText = getNewsDisplaySummary(item); const leadText = summaryText || title; const regionLabel = item.display_region || getNewsRegionLabel(item.region); const categoryLabel = getNewsCategoryLabel(item.category); const statusLabel = getNewsEnrichmentStatusLabel(item); const breakingLabel = getNewsBreakingLabel(breakingLevel, breakingScope); const sourceDescriptor = getNewsSourceDescriptor(item, sourcesByName, sourcesById); const summary = title && title !== leadText ? `
${escapeNewsHtml(title)}
` : ""; const tagHtml = breakingLevel !== "none" ? ` ${escapeNewsHtml(breakingLabel)} ${escapeNewsHtml(categoryLabel)} ${escapeNewsHtml(regionLabel)} ` : ` ${escapeNewsHtml(categoryLabel)} ${escapeNewsHtml(regionLabel)} ${escapeNewsHtml(statusLabel)} `; return `
${escapeNewsHtml(sourceDescriptor.sourceName)} ${formatRelativeTime(item.published_at)}
${escapeNewsHtml(sourceDescriptor.originLabel)}
${escapeNewsHtml(leadText)}
${summary}
${tagHtml}
`; }) .join(""); applyCruiseStorySelection(); window.dispatchEvent(new CustomEvent("earth:news-payload-updated", { detail: { payload: nextPayload, itemIds: displayItems.map((item) => item.id), }, })); } function clearNewsRealtimeReconnectTimer() { if (!newsRealtimeReconnectTimer) return; window.clearTimeout(newsRealtimeReconnectTimer); newsRealtimeReconnectTimer = null; } function scheduleNewsRealtimeReconnect() { if (newsRealtimeReconnectTimer) return; const delay = Math.max(NEWS_REALTIME_RECONNECT_MS, getEarthRealtimeCooldownMs()); newsRealtimeReconnectTimer = window.setTimeout(() => { newsRealtimeReconnectTimer = null; connectNewsRealtime(); }, delay); } 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 : []; const cruiseItems = Array.isArray(payload.cruise_items) ? payload.cruise_items : []; let changed = false; const patchItem = (item) => { if (item?.id !== itemId) return item; changed = true; return { ...item, ...patch, }; }; const nextItems = items.map(patchItem); const nextCruiseItems = cruiseItems.map(patchItem); if (!changed) return; renderPayload({ ...payload, items: nextItems, cruise_items: Array.isArray(payload.cruise_items) ? nextCruiseItems : payload.cruise_items, }); } function connectNewsRealtime() { if (newsRealtimeSocket || !canAttemptEarthRealtime()) { scheduleNewsRealtimeReconnect(); return; } const socket = new WebSocket(getEarthRealtimeUrl()); newsRealtimeSocket = socket; socket.onopen = () => { socket.__planetOpened = true; recordEarthRealtimeOpen(); 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; } if (!socket.__planetOpened) { recordEarthRealtimeFailure(); } scheduleNewsRealtimeReconnect(); }; socket.onerror = () => { socket.close(); }; } async function fetchNews(lat, lon, context = {}) { const categorySignature = context.categorySignature ?? getNewsCategorySignature(); const sourceSignature = context.sourceSignature ?? getNewsSourceSignatureForFetch(lat, lon); if (categorySignature === "__none__" || sourceSignature === "__none__") { return { ...(payload || {}), generated_at: new Date().toISOString(), focus: payload?.focus || { lat, lon, region: "global", label: "全球焦点", display_region: "全球" }, sources: payload?.sources || [], filters: { region: payload?.focus?.region || "global", categories: categorySignature === "__none__" ? [] : getEnabledNewsCategoryKeys(), sources: sourceSignature === "__none__" ? [] : getEnabledNewsSourceIds(), limit: getNewsLimit(), locale: "zh-CN", }, items: [], cruise_items: [], errors: [], stale: false, }; } 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)); if (categorySignature) url.searchParams.set("categories", categorySignature); if (sourceSignature) url.searchParams.set("sources", sourceSignature); url.searchParams.set("limit", String(getNewsLimit())); url.searchParams.set("locale", "zh-CN"); 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 } = {}) { const targetRegion = inferRegion(lat, lon); const categorySignature = getNewsCategorySignature(); const sourceSignature = getNewsSourceSignatureForFetch(lat, lon); const requestKey = [ targetRegion, categorySignature, sourceSignature, getNewsLimit(), ].join("|"); if (refreshPromise && refreshRequestKey === requestKey) return refreshPromise; const requestToken = ++activeRefreshToken; refreshRequestKey = requestKey; const { status } = getElements(); if (status) { status.textContent = "正在同步全球态势新闻..."; } refreshPromise = fetchNews(lat, lon, { categorySignature, sourceSignature }) .then((nextPayload) => { if (requestToken !== activeRefreshToken) { return nextPayload; } renderPayload(nextPayload); lastFetchAt = Date.now(); lastCategorySignature = getNewsCategorySignature(); lastSourceSignature = getNewsSourceSignature(nextPayload); if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) { const { status } = getElements(); if (status) { status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试"; } } return nextPayload; }) .catch((error) => { if (requestToken !== activeRefreshToken) { return null; } console.error("加载 Earth RSS 新闻失败:", error); const message = error?.name === "AbortError" ? "新闻聚合请求超时,请稍后重试" : `新闻聚合暂时不可用: ${error?.message || "未知错误"}`; if (!payload) { renderEmptyState(message); } else if (!silent) { showStatusMessage("态势新闻同步失败", "error"); } throw error; }) .finally(() => { if (requestToken === activeRefreshToken) { refreshPromise = null; refreshRequestKey = ""; } }); return refreshPromise; } export async function refreshEarthNews({ silent = true } = {}) { return refreshNews(lastFocus?.lat, lastFocus?.lon, { silent }); } function shouldRefreshForFocus(lat, lon, region) { const now = Date.now(); if (getNewsCategorySignature() !== lastCategorySignature) return true; if (getNewsSourceSignature() !== lastSourceSignature) return true; 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 previousRegion = lastFocus?.region || null; const nextFocus = { lat: coords.lat, lon: coords.lon, region, updatedAt: Date.now(), }; const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region); if (!shouldRefresh && previousRegion && region !== previousRegion) { return; } 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; } export function getVisibleNewsItems() { return getDisplayableNewsItems(payload?.items); } export function getCruiseNewsItems() { return getDisplayableNewsItems( Array.isArray(payload?.cruise_items) ? payload.cruise_items : payload?.items, ); } 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); document.addEventListener("keydown", (event) => { if (event.key !== "Escape" || !isNewsHudOpen()) return; event.preventDefault(); if (activeFilterPopover) { closeNewsFilterPopover(); return; } closeNewsHud(); }); document.addEventListener("click", (event) => { const target = event.target instanceof Element ? event.target : null; if (!target) return; const filterToggle = target.closest("[data-news-filter-toggle]"); if (filterToggle instanceof HTMLElement) { const kind = filterToggle.dataset.newsFilterToggle || ""; if (activeFilterPopover === kind) closeNewsFilterPopover(); else renderFilterPopover(kind); return; } const categoryToggle = target.closest("[data-news-category-toggle]"); if (categoryToggle instanceof HTMLElement && categoryToggle.closest("[data-news-filter-popover]")) { const category = categoryToggle.dataset.newsCategoryToggle || ""; const active = categoryToggle.classList.contains("is-active"); toggleNewsCategory(category, !active); return; } const sourceToggle = target.closest("[data-news-source-toggle]"); if (sourceToggle instanceof HTMLElement) { toggleNewsSource(sourceToggle.dataset.newsSourceToggle || ""); return; } const viewAllToggle = target.closest("#news-view-all-toggle, #mobile-news-view-all-toggle"); if (viewAllToggle instanceof HTMLElement) { toggleNewsListMode(); return; } if (activeFilterPopover && !target.closest("[data-news-filter-popover]")) { closeNewsFilterPopover(); } }); window.addEventListener("earth:news-category-filters-change", (event) => { activeNewsCategoryFilters = event.detail?.categories || null; syncFilterSummaries(); if (activeFilterPopover === "category") renderFilterPopover("category"); lastFetchAt = 0; refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {}); }); 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(() => {}); }