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