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)); }, }; }