import * as THREE from "three"; import { CRUISE_CONFIG, PATHS } from "./constants.js"; import { createElbowConnectorPoints } 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_CARD_ANCHOR_OFFSET_PX = 18; const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200; const CRUISE_CONNECTOR_DRAW_MS = 420; const CRUISE_PRESENTATION_HIDE_MS = 220; 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); const projected = scratchBGPWorldPosition.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 getCardScreenCoords(marker) { const markerCoords = getMarkerScreenCoords(marker); if (!markerCoords) return null; 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), ); const anchorY = clampedY + Math.max( CRUISE_CARD_ANCHOR_OFFSET_PX * hudScale, estimatedCardHeight * 0.18, ); return { x: clampedX, y: clampedY, width: estimatedCardWidth, height: estimatedCardHeight, anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx, anchorY, }; } function getConnectorPath(marker) { const markerCoords = getMarkerScreenCoords(marker); const targetCardCoords = cardPlacement || getCardScreenCoords(marker); if (!markerCoords || !targetCardCoords) return null; return createElbowConnectorPoints( markerCoords, { x: targetCardCoords.anchorX, y: targetCardCoords.anchorY, }, { startFrom: "source", sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx, targetGapPx: CRUISE_CONFIG.linkPanelGapPx, elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx, elbowDropPx: CRUISE_CONFIG.linkElbowDropPx, }, ); } 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; cardPlacement = getCardScreenCoords(marker); 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, }); }, async presentMarker(marker, { context }) { if (!marker) 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; } showMarkerInfo(marker, { x: cardPlacement?.x, y: cardPlacement?.y, absolute: true, }); await context.nextFrame(); if (!isInfoVisible()) { showMarkerInfo(marker, { x: cardPlacement?.x, y: cardPlacement?.y, absolute: true, }); 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; }, }; }