Files
planet/frontend/public/earth/js/news.js
linkong f14ff6ec0f
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.56.0
2026-05-13 18:21:03 +08:00

637 lines
20 KiB
JavaScript

import { showStatusMessage } from "./ui.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;
let initialized = false;
let refreshPromise = null;
let payload = null;
let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
let selectedCruiseStoryId = null;
let morphTimer = null;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
regionChip: document.getElementById("news-region-chip"),
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
ticker: document.getElementById("desktop-news-ticker"),
tickerRegion: document.getElementById("news-ticker-region"),
tickerTrack: document.getElementById("news-ticker-track"),
hud: document.getElementById("news-hud-panel"),
hudCloseBtn: document.getElementById("news-hud-close"),
};
}
function formatCoord(value, positiveLabel, negativeLabel) {
const abs = Math.abs(value).toFixed(1);
return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`;
}
function formatRelativeTime(raw) {
if (!raw) return "刚刚同步";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return "刚刚同步";
const diff = Date.now() - date.getTime();
const minutes = Math.max(1, Math.round(diff / 60000));
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.round(hours / 24);
return `${days} 天前`;
}
export function updateNewsToggleUI(visible) {
void visible;
}
function getHudScale() {
const scale = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
);
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function clearMorphTimer() {
if (!morphTimer) return;
window.clearTimeout(morphTimer);
morphTimer = null;
}
function setHudRect(hud, rect, opacity = 1) {
hud.style.left = `${rect.left}px`;
hud.style.top = `${rect.top}px`;
hud.style.width = `${rect.width}px`;
hud.style.height = `${rect.height}px`;
hud.style.transform = "none";
hud.style.opacity = String(opacity);
}
function setHudRectWithoutTransition(hud, rect, opacity = 1) {
const previousTransition = hud.style.transition;
hud.style.transition = "none";
setHudRect(hud, rect, opacity);
void hud.offsetHeight;
hud.style.transition = previousTransition;
}
function getNewsHudTargetRect(hud) {
const wasHidden = hud.classList.contains("hud-panel-hidden");
const previousVisibility = hud.style.visibility;
if (wasHidden) hud.classList.remove("hud-panel-hidden");
hud.style.visibility = "hidden";
const rect = hud.getBoundingClientRect();
hud.style.visibility = previousVisibility;
if (wasHidden) hud.classList.add("hud-panel-hidden");
return rect;
}
function finishHudOpen(hud) {
hud.classList.remove("is-morphing");
hud.style.opacity = "";
revealSelectedCruiseStory();
}
function finishHudClose(hud, ticker, restoreRect = null) {
hud.classList.add("hud-panel-hidden");
hud.classList.remove("is-morphing");
hud.style.opacity = "";
if ((hud.dataset.dragged === "true" || hud.dataset.resized === "true") && restoreRect) {
setHudRect(hud, restoreRect, 1);
hud.style.opacity = "";
} else {
hud.style.left = "";
hud.style.top = "";
hud.style.width = "";
hud.style.height = "";
hud.style.transform = "";
}
ticker?.classList.remove("is-hidden");
}
function setNewsHudOpen(open, { highlightId = null } = {}) {
const { hud, ticker } = getElements();
if (!(hud instanceof HTMLElement)) return;
clearMorphTimer();
if (highlightId) {
selectedCruiseStoryId = highlightId;
applyCruiseStorySelection();
}
const tickerRect = ticker instanceof HTMLElement
? ticker.getBoundingClientRect()
: { left: window.innerWidth / 2 - 240, top: 20, width: 480, height: 38 };
if (open) {
const targetRect = getNewsHudTargetRect(hud);
hud.classList.remove("hud-panel-hidden");
hud.classList.add("is-morphing");
setHudRectWithoutTransition(hud, tickerRect, 1);
ticker?.classList.add("is-hidden");
requestAnimationFrame(() => {
setHudRect(hud, targetRect, 1);
});
morphTimer = window.setTimeout(() => {
morphTimer = null;
finishHudOpen(hud);
}, NEWS_HUD_MORPH_MS);
} else {
const currentRect = hud.getBoundingClientRect();
hud.classList.add("is-morphing");
setHudRectWithoutTransition(hud, currentRect, 1);
requestAnimationFrame(() => {
setHudRect(hud, tickerRect, 1);
});
morphTimer = window.setTimeout(() => {
morphTimer = null;
finishHudClose(hud, ticker, currentRect);
}, NEWS_HUD_MORPH_MS);
}
ticker?.setAttribute("aria-expanded", open ? "true" : "false");
}
function openNewsHud(options = {}) {
setNewsHudOpen(true, options);
}
function closeNewsHud() {
setNewsHudOpen(false);
}
function setupNewsHudResize() {
const { hud } = getElements();
const container = document.getElementById("container");
if (!(hud instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
let resizing = false;
let activeEdge = "";
const resizeStart = {
pointerX: 0,
pointerY: 0,
width: 0,
height: 0,
left: 0,
top: 0,
};
const clearPositioningForResize = () => {
const rect = hud.getBoundingClientRect();
hud.style.left = `${rect.left}px`;
hud.style.top = `${rect.top}px`;
hud.style.width = `${rect.width}px`;
hud.style.height = `${rect.height}px`;
hud.style.transform = "none";
hud.dataset.dragged = "true";
hud.dataset.resized = "true";
};
const stopResize = () => {
resizing = false;
activeEdge = "";
hud.classList.remove("is-resizing");
document.body.style.userSelect = "";
};
const onMove = (event) => {
if (!resizing) return;
const containerRect = container.getBoundingClientRect();
const hudScale = getHudScale();
const minWidth = Math.round(NEWS_HUD_MIN_WIDTH_PX * hudScale);
const minHeight = Math.round(NEWS_HUD_MIN_HEIGHT_PX * hudScale);
const dx = event.clientX - resizeStart.pointerX;
const dy = event.clientY - resizeStart.pointerY;
if (activeEdge.includes("r")) {
const maxW = containerRect.right - resizeStart.left - NEWS_HUD_RESIZE_MARGIN_PX;
hud.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`;
}
if (activeEdge.includes("l")) {
const newW = Math.max(minWidth, resizeStart.width - dx);
hud.style.width = `${newW}px`;
hud.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
}
if (activeEdge.includes("b")) {
const maxH = containerRect.bottom - resizeStart.top - NEWS_HUD_RESIZE_MARGIN_PX;
hud.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
}
};
hud.querySelectorAll(".earth-news-hud-edge[data-edge]").forEach((edgeEl) => {
edgeEl.addEventListener("pointerdown", (event) => {
if (document.body.classList.contains("layout-mode-mobile")) return;
if (hud.classList.contains("hud-panel-hidden")) return;
event.preventDefault();
event.stopPropagation();
clearPositioningForResize();
activeEdge = edgeEl.dataset.edge ?? "";
resizing = true;
resizeStart.pointerX = event.clientX;
resizeStart.pointerY = event.clientY;
const rect = hud.getBoundingClientRect();
resizeStart.width = rect.width;
resizeStart.height = rect.height;
resizeStart.left = rect.left;
resizeStart.top = rect.top;
hud.classList.add("is-resizing");
document.body.style.userSelect = "none";
edgeEl.setPointerCapture?.(event.pointerId);
});
edgeEl.addEventListener("pointermove", onMove);
edgeEl.addEventListener("pointerup", stopResize);
edgeEl.addEventListener("pointercancel", stopResize);
edgeEl.addEventListener("lostpointercapture", stopResize);
});
}
function escapeTickerText(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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.region || "global").toUpperCase();
tickerRegion.style.color = focus.accent || "";
}
if (items.length === 0) {
tickerTrack.textContent = "正在准备全球态势新闻...";
tickerTrack.style.removeProperty("--news-ticker-duration");
return;
}
const visibleItems = items.slice(0, 6);
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(item.title || "未命名新闻")}</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;
const {
board,
empty,
status,
focusLabel,
focusCoords,
sourceCount,
regionChip,
openBtn,
feedAnchor,
} = getElements();
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
renderTicker(nextPayload);
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
return;
}
} else {
return;
}
}
if (regionChip) {
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
}
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page does not show the compact chip row.
} else if (!regionChip) {
return;
}
focusLabel.textContent = focus.label || "全球焦点";
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
} else {
focusCoords.textContent = "跟随当前视角自动聚焦";
}
sourceCount.textContent = `${sources.length} 路聚合源`;
if (nextPayload?.stale) {
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length}`;
} else {
status.textContent = nextPayload?.errors?.length
? `已聚合 ${items.length} 条,部分源不可用`
: `已聚合 ${items.length} 条态势新闻`;
}
if (feedAnchor) {
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/";
}
if (openBtn) {
openBtn.disabled = !feedAnchor?.href;
}
if (items.length === 0) {
board.innerHTML = "";
if (empty) {
empty.hidden = false;
empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。";
}
return;
}
if (empty) empty.hidden = true;
board.innerHTML = items
.map((item) => {
const cardClass = item.is_focus_match
? "news-story-card news-story-card--focus"
: "news-story-card";
const summary = item.summary
? `<div class="news-story-summary">${item.summary}</div>`
: "";
return `
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<div class="news-story-meta">
<span class="news-story-source">${item.source}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
</div>
<div class="news-story-title">${item.title}</div>
${summary}
<div class="news-story-tags">
<span class="news-story-tag">${item.region}</span>
<span class="news-story-tag">${item.feed_name}</span>
</div>
</a>
`;
})
.join("");
applyCruiseStorySelection();
window.dispatchEvent(new CustomEvent("earth:news-payload-updated", {
detail: {
payload: nextPayload,
itemIds: items.map((item) => item.id),
},
}));
}
async function fetchNews(lat, lon) {
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));
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 } = {}) {
if (refreshPromise) return refreshPromise;
const { status } = getElements();
if (status) {
status.textContent = "正在同步全球态势新闻...";
}
refreshPromise = fetchNews(lat, lon)
.then((nextPayload) => {
renderPayload(nextPayload);
lastFetchAt = Date.now();
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
const { status } = getElements();
if (status) {
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
}
}
return nextPayload;
})
.catch((error) => {
console.error("加载 Earth RSS 新闻失败:", error);
const message = error?.name === "AbortError"
? "新闻聚合请求超时,请稍后重试"
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
if (!payload) {
renderEmptyState(message);
} else if (!silent) {
showStatusMessage("态势新闻同步失败", "error");
}
throw error;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
function shouldRefreshForFocus(lat, lon, region) {
const now = Date.now();
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 nextFocus = {
lat: coords.lat,
lon: coords.lon,
region,
updatedAt: Date.now(),
};
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
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;
}
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);
setupNewsHudResize();
["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(() => {});
}