358 lines
10 KiB
JavaScript
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));
|
|
},
|
|
};
|
|
}
|