Files
planet/frontend/public/earth/js/news.js
linkong 3265d22af5
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.71.1
2026-06-26 17:34:19 +08:00

1277 lines
45 KiB
JavaScript

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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) => `
<button
class="news-filter-chip${enabled.has(key) ? " is-active" : ""}"
type="button"
data-news-category-toggle="${escapeNewsHtml(key)}"
aria-pressed="${enabled.has(key) ? "true" : "false"}"
>${escapeNewsHtml(getNewsCategoryLabel(key))}</button>
`)
.join("");
}
function renderSourceFilterChips() {
const sources = Array.isArray(payload?.sources) ? payload.sources : [];
const enabled = new Set(getEnabledNewsSourceIds());
if (!sources.length) return `<span class="news-filter-popover__hint">暂无可筛选来源。</span>`;
return sources
.map((source) => {
const id = String(source?.id || "").trim();
if (!id) return "";
const active = enabled.has(id);
return `
<button
class="news-filter-chip${active ? " is-active" : ""}"
type="button"
data-news-source-toggle="${escapeNewsHtml(id)}"
aria-pressed="${active ? "true" : "false"}"
>${escapeNewsHtml(source?.name || id)}</button>
`;
})
.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 = `
<div class="news-filter-popover__header">
<div class="news-filter-popover__title">${escapeNewsHtml(title)}</div>
<div class="news-filter-popover__hint">${escapeNewsHtml(hint)}</div>
</div>
<div class="news-filter-chip-group">${content}</div>
`;
});
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) => `
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
<span>${escapeTickerText(getNewsDisplaySummary(item))}</span>
</span>
`)
.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
? `<div class="news-story-summary">${escapeNewsHtml(title)}</div>`
: "";
const tagHtml = breakingLevel !== "none"
? `
<span class="news-story-tag news-story-tag--breaking">${escapeNewsHtml(breakingLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
`
: `
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(statusLabel)}</span>
`;
return `
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener" title="${escapeNewsHtml(sourceDescriptor.tooltip)}">
<div class="news-story-meta">
<span class="news-story-source">${escapeNewsHtml(sourceDescriptor.sourceName)}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
</div>
<div class="news-story-origin">${escapeNewsHtml(sourceDescriptor.originLabel)}</div>
<div class="news-story-title">${escapeNewsHtml(leadText)}</div>
${summary}
<div class="news-story-tags">
${tagHtml}
</div>
</a>
`;
})
.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(() => {});
}