import * as THREE from "three"; import { CONNECTOR_CONFIG, CRUISE_CONFIG, PATHS } from "./constants.js"; import { computeNearestPerimeterAnchor, createConnectorPath, resolveConnectorAnchor, } from "./callout-connector.js"; const scratchBGPWorldPosition = new THREE.Vector3(); const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420; const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300; const CRUISE_CARD_VIEWPORT_PADDING_PX = 32; const CRUISE_CARD_SCREEN_MARGIN_PX = 12; const CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220; const CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68; const CRUISE_MOBILE_POPUP_TOP_RATIO = 0.17; const CRUISE_MOBILE_POPUP_MARGIN_PX = 14; const CRUISE_MOBILE_DRAWER_CLEARANCE_PX = 52; const CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT = 3; const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200; const CRUISE_CONNECTOR_DRAW_MS = 420; const CRUISE_PRESENTATION_HIDE_MS = 220; const MOBILE_POPUP_OBSTACLE_PADDING_PX = 16; const DESKTOP_PANEL_OBSTACLE_PADDING_PX = 12; const CRUISE_MARKER_SCREEN_PADDING_PX = 4; function getDockAxisOffsets(dockSide, gapPx) { return { offsetX: dockSide === "right" ? gapPx : dockSide === "left" ? -gapPx : 0, offsetY: dockSide === "bottom" ? gapPx : dockSide === "top" ? -gapPx : 0, }; } function getObstaclePaddingBySide(side, paddingPx) { if (side === "left") { return { left: 0, top: paddingPx, right: paddingPx, bottom: paddingPx }; } if (side === "right") { return { left: paddingPx, top: paddingPx, right: 0, bottom: paddingPx }; } if (side === "top") { return { left: paddingPx, top: 0, right: paddingPx, bottom: paddingPx }; } return { left: paddingPx, top: paddingPx, right: paddingPx, bottom: 0 }; } const scratchMarkerWorldScale = new THREE.Vector3(); const scratchCameraQuaternion = new THREE.Quaternion(); const scratchCameraRight = new THREE.Vector3(); const scratchCameraUp = new THREE.Vector3(); const scratchMarkerRightPoint = new THREE.Vector3(); const scratchMarkerLeftPoint = new THREE.Vector3(); const scratchMarkerTopPoint = new THREE.Vector3(); const scratchMarkerBottomPoint = new THREE.Vector3(); function projectWorldToScreen(point, camera) { if (!point || !camera) return null; const projected = point.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 getMarkerTimestamp(marker) { const rawValue = marker?.userData?.created_at_raw; const parsedValue = rawValue ? new Date(rawValue).getTime() : 0; return Number.isFinite(parsedValue) ? parsedValue : 0; } export function createBGPCruiseAdapter({ camera, getMarkers, connector, focusView, setMarkerLocked, clearMarkerState, showMarkerOverlay, applySatelliteHighlights, showMarkerInfo, hideInfo, isInfoVisible, getLockedObject, refreshMarkers, }) { let currentMarkerId = null; let cardPlacement = null; let knownEventIds = new Set(); function getCurrentMarker() { if (!currentMarkerId) return null; return getMarkers().find((marker) => marker?.userData?.id === currentMarkerId) || null; } function getSortedMarkers() { return getMarkers() .slice() .sort((a, b) => getMarkerTimestamp(b) - getMarkerTimestamp(a)); } function getMarkerScreenCoords(marker) { if (!marker || !camera) return null; scratchBGPWorldPosition.copy(marker.position); marker.parent?.localToWorld(scratchBGPWorldPosition); return projectWorldToScreen(scratchBGPWorldPosition, camera); } 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 getMarkerScreenRect(marker) { const center = getMarkerScreenCoords(marker); if (!center || !camera || !marker) return null; marker.getWorldScale(scratchMarkerWorldScale); const worldWidth = Math.max( 0.0001, Number(marker.userData?.baseScale ?? scratchMarkerWorldScale.x ?? 0) || scratchMarkerWorldScale.x, ); const worldHeight = Math.max( 0.0001, Number(scratchMarkerWorldScale.y || worldWidth), ); camera.getWorldQuaternion(scratchCameraQuaternion); scratchCameraRight.set(1, 0, 0).applyQuaternion(scratchCameraQuaternion).normalize(); scratchCameraUp.set(0, 1, 0).applyQuaternion(scratchCameraQuaternion).normalize(); scratchMarkerRightPoint .copy(scratchBGPWorldPosition) .addScaledVector(scratchCameraRight, worldWidth * 0.5); scratchMarkerLeftPoint .copy(scratchBGPWorldPosition) .addScaledVector(scratchCameraRight, -worldWidth * 0.5); scratchMarkerTopPoint .copy(scratchBGPWorldPosition) .addScaledVector(scratchCameraUp, worldHeight * 0.5); scratchMarkerBottomPoint .copy(scratchBGPWorldPosition) .addScaledVector(scratchCameraUp, -worldHeight * 0.5); const rightPoint = projectWorldToScreen(scratchMarkerRightPoint, camera); const leftPoint = projectWorldToScreen(scratchMarkerLeftPoint, camera); const topPoint = projectWorldToScreen(scratchMarkerTopPoint, camera); const bottomPoint = projectWorldToScreen(scratchMarkerBottomPoint, camera); if (!rightPoint || !leftPoint || !topPoint || !bottomPoint) { return null; } const halfWidth = Math.max( Math.abs(rightPoint.x - center.x), Math.abs(leftPoint.x - center.x), 1, ); const halfHeight = Math.max( Math.abs(topPoint.y - center.y), Math.abs(bottomPoint.y - center.y), 1, ); return { x: center.x - halfWidth - CRUISE_MARKER_SCREEN_PADDING_PX, y: center.y - halfHeight - CRUISE_MARKER_SCREEN_PADDING_PX, width: halfWidth * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2, height: halfHeight * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2, }; } function getCardScreenCoords(marker) { const markerCoords = getMarkerScreenCoords(marker); if (!markerCoords) return null; if (document.body.classList.contains("layout-mode-mobile")) { const safeBottom = parseFloat( getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"), ) || 0; const estimatedCardWidth = Math.min( CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX, window.innerWidth - CRUISE_MOBILE_POPUP_MARGIN_PX * 2, ); const estimatedCardHeight = CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX; const topBound = Math.max( CRUISE_MOBILE_POPUP_MARGIN_PX, Math.min( window.innerHeight * CRUISE_MOBILE_POPUP_TOP_RATIO, window.innerHeight - CRUISE_MOBILE_DRAWER_CLEARANCE_PX - safeBottom - estimatedCardHeight - CRUISE_MOBILE_POPUP_MARGIN_PX, ), ); const rightSlotLeft = Math.max( CRUISE_MOBILE_POPUP_MARGIN_PX, window.innerWidth - estimatedCardWidth - CRUISE_MOBILE_POPUP_MARGIN_PX, ); const leftSlotLeft = CRUISE_MOBILE_POPUP_MARGIN_PX; const rightSlotCenterX = rightSlotLeft + estimatedCardWidth * 0.5; const leftSlotCenterX = leftSlotLeft + estimatedCardWidth * 0.5; const rightClearance = rightSlotLeft - markerCoords.x; const leftClearance = markerCoords.x - (leftSlotLeft + estimatedCardWidth); const rightCost = Math.max(0, -rightClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT + Math.abs(rightSlotCenterX - markerCoords.x); const leftCost = Math.max(0, -leftClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT + Math.abs(markerCoords.x - leftSlotCenterX); const placeOnRight = rightCost <= leftCost; const left = placeOnRight ? rightSlotLeft : leftSlotLeft; return { x: left, y: topBound, width: estimatedCardWidth, height: estimatedCardHeight, dockSide: placeOnRight ? "left" : "right", }; } const hudScale = Number.parseFloat( getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), ) || 1; const estimatedCardHeight = Math.min( CRUISE_CARD_ESTIMATED_HEIGHT_PX * hudScale, window.innerHeight * 0.7, ); const estimatedCardWidth = Math.min( CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale, window.innerWidth - CRUISE_CARD_VIEWPORT_PADDING_PX, ); const x = window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5; const y = window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5; const margin = CRUISE_CARD_SCREEN_MARGIN_PX; const clampedX = Math.min( Math.max(margin, x), Math.max(margin, window.innerWidth - estimatedCardWidth - margin), ); const clampedY = Math.min( Math.max(margin, y), Math.max(margin, window.innerHeight - estimatedCardHeight - margin), ); return { x: clampedX, y: clampedY, width: estimatedCardWidth, height: estimatedCardHeight, }; } function getCardAnchorTarget() { const mobilePopup = getVisibleMobilePopup(); if ( document.body.classList.contains("layout-mode-mobile") && mobilePopup ) { const dockSide = mobilePopup.dataset.dockSide || "left"; const { offsetX, offsetY } = getDockAxisOffsets( dockSide, CONNECTOR_CONFIG.panelGapPx, ); return { element: mobilePopup, side: dockSide, alignRatio: 0.5, offsetX, offsetY, }; } return null; } function getCardObstacleTarget(fallbackPlacement = null) { const mobilePopup = getVisibleMobilePopup(); if ( document.body.classList.contains("layout-mode-mobile") && mobilePopup ) { const side = mobilePopup.dataset.dockSide || "left"; return { element: mobilePopup, padding: getObstaclePaddingBySide(side, MOBILE_POPUP_OBSTACLE_PADDING_PX), }; } const infoPanel = getVisibleInfoPanel(); if (infoPanel) { return { element: infoPanel, padding: getObstaclePaddingBySide("left", DESKTOP_PANEL_OBSTACLE_PADDING_PX), }; } if (fallbackPlacement) { return { x: fallbackPlacement.x, y: fallbackPlacement.y, width: fallbackPlacement.width ?? 0, height: fallbackPlacement.height ?? 0, padding: DESKTOP_PANEL_OBSTACLE_PADDING_PX, }; } return null; } function resolveAdaptiveDockSide(markerCoords, fallbackPlacement = null) { const mobilePopup = getVisibleMobilePopup(); const popupRect = mobilePopup ? mobilePopup.getBoundingClientRect() : fallbackPlacement ? { left: fallbackPlacement.x, top: fallbackPlacement.y, right: fallbackPlacement.x + (fallbackPlacement.width ?? 0), bottom: fallbackPlacement.y + (fallbackPlacement.height ?? 0), } : null; if (!markerCoords || !popupRect) return fallbackPlacement?.dockSide === "right" ? "right" : "left"; return computeNearestPerimeterAnchor(markerCoords, popupRect, 0)?.side || "left"; } function syncMobileDockSide(markerCoords, fallbackPlacement = null) { const mobilePopup = getVisibleMobilePopup(); const dockSide = resolveAdaptiveDockSide(markerCoords, fallbackPlacement); if (mobilePopup) { mobilePopup.dataset.dockSide = dockSide; } } function getConnectorPath(marker) { const markerCoords = getMarkerScreenCoords(marker); const markerRect = getMarkerScreenRect(marker); if (!markerCoords) return null; if (document.body.classList.contains("layout-mode-mobile")) { const targetCardCoords = cardPlacement || getCardScreenCoords(marker); syncMobileDockSide(markerCoords, targetCardCoords); const cardAnchorTarget = getCardAnchorTarget(); const cardAnchorCoords = resolveConnectorAnchor(cardAnchorTarget); const cardObstacleTarget = getCardObstacleTarget(targetCardCoords); if (!cardAnchorCoords) return null; return createConnectorPath(markerCoords, cardAnchorTarget ?? cardAnchorCoords, { routingMode: "adaptive", sourceRect: markerRect, targetAnchor: cardAnchorTarget ?? cardAnchorCoords, obstacles: cardObstacleTarget ? [cardObstacleTarget] : [], startFrom: "source", sourceGapPx: CONNECTOR_CONFIG.markerGapPx, targetGapPx: CONNECTOR_CONFIG.panelGapPx, obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx, }); } const targetCardCoords = cardPlacement || getCardScreenCoords(marker); const cardObstacleTarget = getCardObstacleTarget(targetCardCoords); if (!targetCardCoords) return null; const infoPanel = getVisibleInfoPanel(); const desktopTarget = infoPanel ? infoPanel : { x: targetCardCoords.x, y: targetCardCoords.y, width: targetCardCoords.width, height: targetCardCoords.height, }; return createConnectorPath( markerCoords, desktopTarget, { routingMode: "adaptive", sourceRect: markerRect, obstacles: cardObstacleTarget ? [cardObstacleTarget] : [], obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx, startFrom: "source", sourceGapPx: CONNECTOR_CONFIG.markerGapPx, targetGapPx: CONNECTOR_CONFIG.panelGapPx, }, ); } function renderConnector(marker, { animate = false } = {}) { const path = getConnectorPath(marker); if (!path) return false; return connector.render(path, { animate }); } function extractFeatureIds(features = []) { return features .map((feature) => { const properties = feature?.properties || {}; const coords = feature?.geometry?.coordinates || []; return ( properties.id || properties.incident_key || `${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}` ); }) .filter(Boolean); } return { getSortedMarkers, getCurrentMarker, isPresentationVisible() { return cardPlacement != null; }, clearCurrentHighlight() { const marker = getCurrentMarker(); if (marker && getLockedObject() !== marker) { clearMarkerState(marker); } currentMarkerId = null; }, async focusMarker(marker, { interrupt = false } = {}) { if (!marker) return; currentMarkerId = marker.userData?.id || null; setMarkerLocked(marker); showMarkerOverlay(marker); await focusView({ lat: marker.userData?.latitude ?? 0, lon: marker.userData?.longitude ?? 0, rotLon: (marker.userData?.longitude ?? 0) - 270, duration: interrupt ? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78) : CRUISE_CONFIG.focusDurationMs, suppressStatus: true, }); cardPlacement = getCardScreenCoords(marker); }, async presentMarker(marker, { context }) { if (!marker) return false; const showCruiseMarkerInfo = ({ reveal = true } = {}) => showMarkerInfo(marker, { x: cardPlacement?.x, y: cardPlacement?.y, absolute: true, reveal, anchorStable: true, dockSide: cardPlacement?.dockSide, }); showCruiseMarkerInfo({ reveal: false }); await context.nextFrame(); if (!context.isCurrent()) { cardPlacement = null; connector.hide(); hideInfo(); return false; } const startedAt = performance.now(); let connectorReady = false; while (context.isCurrent()) { connectorReady = renderConnector(marker, { 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(); hideInfo(); return false; } applySatelliteHighlights(marker); const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS); if (!connectorDelayCompleted || !context.isCurrent()) { cardPlacement = null; connector.hide(); hideInfo(); return false; } showCruiseMarkerInfo(); await context.nextFrame(); if (!isInfoVisible()) { showCruiseMarkerInfo(); await context.nextFrame(); } if (!isInfoVisible() || !context.isCurrent()) { cardPlacement = null; connector.hide(); hideInfo(); return false; } return true; }, async hidePresentation({ context }) { if (!getLockedObject()) { hideInfo(); } connector.hide(); const hideDelayCompleted = await context.wait(CRUISE_PRESENTATION_HIDE_MS, { secondary: true, }); if (!hideDelayCompleted) return; cardPlacement = null; }, repositionConnector(marker) { if (!cardPlacement || !marker || !connector.isVisible() || connector.isAnimating()) { return; } renderConnector(marker, { animate: false }); }, resetPresentation() { cardPlacement = null; connector.hide(); }, syncKnownEventIds() { knownEventIds = new Set( getMarkers() .map((marker) => marker?.userData?.id) .filter(Boolean), ); return knownEventIds; }, async pollForNewMarkerIds() { const [incidentResponse, anomalyResponse] = await Promise.all([ fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`), fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`), ]); if (!incidentResponse.ok || !anomalyResponse.ok) { return []; } const [incidentPayload, anomalyPayload] = await Promise.all([ incidentResponse.json(), anomalyResponse.json(), ]); const incidentFeatures = Array.isArray(incidentPayload?.features) ? incidentPayload.features : []; const anomalyFeatures = Array.isArray(anomalyPayload?.features) ? anomalyPayload.features : []; const selectedFeatures = incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures; const nextIds = extractFeatureIds(selectedFeatures); const newIds = nextIds.filter((id) => !knownEventIds.has(id)); if (newIds.length === 0) return []; await refreshMarkers(); this.syncKnownEventIds(); return newIds; }, }; }