Files
planet/frontend/public/earth/js/news-cruise-adapter.js
2026-04-23 17:57:35 +08:00

358 lines
10 KiB
JavaScript

import * as THREE from "three";
import { CONFIG, CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
import { showInfoCard, hideInfoCard } from "./info-card.js";
import { latLonToVector3 } from "./utils.js";
import {
createConnectorPath,
resolveConnectorAnchor,
} from "./callout-connector.js";
import {
ensureNewsPanelReady,
getNewsPayload,
selectNewsItem,
clearSelectedNewsItem,
} from "./news.js";
const CRUISE_PRESENTATION_HIDE_MS = 220;
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
const CRUISE_CONNECTOR_DRAW_MS = 420;
const MOBILE_CARD_MARGIN_PX = 14;
const MOBILE_CARD_TOP_RATIO = 0.16;
const MOBILE_CARD_WIDTH_PX = 220;
const scratchNewsWorldPosition = new THREE.Vector3();
const REGION_LABELS = {
americas: "美洲",
europe: "欧洲",
"middle-east-africa": "中东与非洲",
"asia-pacific": "亚太",
global: "全球",
};
function getItemTimestamp(item) {
const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0;
return Number.isFinite(parsed) ? parsed : 0;
}
function formatPublishedAt(rawValue) {
if (!rawValue) return "刚刚同步";
const parsed = new Date(rawValue);
if (Number.isNaN(parsed.getTime())) return "刚刚同步";
return parsed.toLocaleString("zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function getCardPlacement() {
if (document.body.classList.contains("layout-mode-mobile")) {
const safeBottom =
parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
) || 0;
const width = Math.min(MOBILE_CARD_WIDTH_PX, window.innerWidth - MOBILE_CARD_MARGIN_PX * 2);
return {
x: Math.max(MOBILE_CARD_MARGIN_PX, window.innerWidth - width - MOBILE_CARD_MARGIN_PX),
y: Math.max(
MOBILE_CARD_MARGIN_PX,
Math.min(
window.innerHeight * MOBILE_CARD_TOP_RATIO,
window.innerHeight - safeBottom - 120,
),
),
width,
};
}
const hudScale =
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
) || 1;
const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7);
const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32);
return {
x: window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5,
y: window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5,
width: estimatedCardWidth,
height: estimatedCardHeight,
};
}
function mapNewsItemToCruiseEvent(item) {
const latitude = Number(item?.latitude);
const longitude = Number(item?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return {
id: `news:${item.id}`,
sourceId: item.id,
type: "news",
title: item.title || "新闻事件",
summary: item.summary || "",
source: item.source || "",
feedName: item.feed_name || "",
region: item.region || "global",
regionLabel: REGION_LABELS[item.region] || item.region || "全球",
url: item.url || "",
publishedAt: item.published_at || null,
publishedAtDisplay: formatPublishedAt(item.published_at),
latitude,
longitude,
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
sortTimestamp: getItemTimestamp(item),
};
}
export function createNewsCruiseAdapter({ camera, earth, connector, focusView }) {
let currentItemId = null;
let knownEventIds = new Set();
let cardPlacement = null;
function getVisibleMobilePopup() {
const mobilePopup = document.getElementById("earth-mobile-popup");
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
? mobilePopup
: null;
}
function getVisibleInfoPanel() {
const infoPanel = document.getElementById("info-panel");
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
? infoPanel
: null;
}
function getItemWorldPosition(item) {
const earthObj = earth?.();
if (!earthObj || !item) return null;
scratchNewsWorldPosition.copy(
latLonToVector3(item.latitude, item.longitude, CONFIG.earthRadius + 0.3),
);
earthObj.localToWorld(scratchNewsWorldPosition);
return scratchNewsWorldPosition;
}
function getItemScreenCoords(item) {
if (!camera || !item) return null;
const worldPosition = getItemWorldPosition(item);
if (!worldPosition) return null;
const projected = worldPosition.clone().project(camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return null;
}
return {
x: ((projected.x + 1) * 0.5) * window.innerWidth,
y: ((1 - projected.y) * 0.5) * window.innerHeight,
};
}
function getCardAnchorTarget() {
const mobilePopup = getVisibleMobilePopup();
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
return {
element: mobilePopup,
side: mobilePopup.dataset.dockSide || "left",
alignRatio: 0.5,
};
}
return null;
}
function getCardObstacleTarget(fallbackPlacement = null) {
const mobilePopup = getVisibleMobilePopup();
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
return { element: mobilePopup };
}
const infoPanel = getVisibleInfoPanel();
if (infoPanel) {
return { element: infoPanel };
}
if (fallbackPlacement) {
return {
x: fallbackPlacement.x,
y: fallbackPlacement.y,
width: fallbackPlacement.width ?? 0,
height: fallbackPlacement.height ?? 0,
};
}
return null;
}
function getConnectorPath(item) {
const itemCoords = getItemScreenCoords(item);
if (!itemCoords) return null;
const targetPlacement = cardPlacement || getCardPlacement();
if (document.body.classList.contains("layout-mode-mobile")) {
const anchorTarget = getCardAnchorTarget();
const anchorCoords = resolveConnectorAnchor(anchorTarget);
if (!anchorCoords) return null;
return createConnectorPath(itemCoords, anchorTarget ?? anchorCoords, {
routingMode: "adaptive",
obstacles: getCardObstacleTarget(targetPlacement),
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
});
}
const infoPanel = getVisibleInfoPanel();
const target =
infoPanel ||
{
x: targetPlacement.x,
y: targetPlacement.y,
width: targetPlacement.width,
height: targetPlacement.height,
};
return createConnectorPath(itemCoords, target, {
routingMode: "adaptive",
obstacles: getCardObstacleTarget(targetPlacement),
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
});
}
function renderConnector(item, { animate = false } = {}) {
const path = getConnectorPath(item);
if (!path) return false;
return connector?.render(path, { animate }) === true;
}
function getSortedItems() {
const items = Array.isArray(getNewsPayload()?.items) ? getNewsPayload().items : [];
return items
.map(mapNewsItemToCruiseEvent)
.filter(Boolean)
.sort((a, b) => b.sortTimestamp - a.sortTimestamp);
}
return {
async ensureItemsLoaded() {
await ensureNewsPanelReady();
},
getSortedItems,
clearCurrentHighlight() {
currentItemId = null;
clearSelectedNewsItem();
},
async focusItem(item, { interrupt = false } = {}) {
if (!item) return;
currentItemId = item.id;
await ensureNewsPanelReady();
await focusView({
lat: item.latitude,
lon: item.longitude,
rotLon: item.longitude - 270,
duration: interrupt
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
: CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
cardPlacement = getCardPlacement();
},
async presentItem(item, { context }) {
if (!item) return false;
selectNewsItem(item.sourceId);
const placement = cardPlacement || getCardPlacement();
cardPlacement = placement;
showInfoCard("news", {
title: item.title,
summary: item.summary || item.title || "",
}, {
x: placement.x,
y: placement.y,
absolute: true,
anchorStable: true,
reveal: false,
});
await context.nextFrame();
if (!context.isCurrent()) {
cardPlacement = null;
connector?.hide();
hideInfoCard();
return false;
}
const startedAt = performance.now();
let connectorReady = false;
while (context.isCurrent()) {
connectorReady = renderConnector(item, { animate: !connectorReady });
if (connectorReady) break;
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
break;
}
await context.nextFrame();
}
if (!connectorReady || !context.isCurrent()) {
cardPlacement = null;
connector?.hide();
hideInfoCard();
return false;
}
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
if (!connectorDelayCompleted || !context.isCurrent()) {
cardPlacement = null;
connector?.hide();
hideInfoCard();
return false;
}
showInfoCard("news", {
title: item.title,
summary: item.summary || item.title || "",
}, {
x: placement.x,
y: placement.y,
absolute: true,
anchorStable: true,
});
await context.nextFrame();
return context.isCurrent();
},
async hidePresentation({ context }) {
clearSelectedNewsItem();
connector?.hide();
hideInfoCard();
await context.wait(CRUISE_PRESENTATION_HIDE_MS, { secondary: true });
cardPlacement = null;
},
repositionConnector(item) {
if (!cardPlacement || !item || !connector?.isVisible?.() || connector.isAnimating?.()) {
return;
}
renderConnector(item, { animate: false });
},
resetPresentation() {
clearSelectedNewsItem();
cardPlacement = null;
connector?.hide();
},
syncKnownEventIds() {
knownEventIds = new Set(getSortedItems().map((item) => item.id));
return knownEventIds;
},
diffNewEventIds(itemIds = []) {
return itemIds
.map((itemId) => `news:${itemId}`)
.filter((itemId) => !knownEventIds.has(itemId));
},
};
}