1277 lines
45 KiB
JavaScript
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("&", "&")
|
|
.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) => `
|
|
<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(() => {});
|
|
}
|