301 lines
8.8 KiB
JavaScript
301 lines
8.8 KiB
JavaScript
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);
|
|
applySatelliteHighlights(marker);
|
|
|
|
await focusView({
|
|
lat: marker.userData?.latitude ?? 0,
|
|
lon: marker.userData?.longitude ?? 0,
|
|
rotLon: (marker.userData?.longitude ?? 0) - 270,
|
|
zoom: 1.0,
|
|
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;
|
|
}
|
|
|
|
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;
|
|
},
|
|
};
|
|
}
|