视图
diff --git a/frontend/public/earth/js/bgp.js b/frontend/public/earth/js/bgp.js
index f29af12a..bf956a3f 100644
--- a/frontend/public/earth/js/bgp.js
+++ b/frontend/public/earth/js/bgp.js
@@ -156,13 +156,14 @@ function drawExclamationSymbol(context) {
}
function drawWaveSymbol(context) {
- context.lineWidth = 12;
- context.lineCap = "round";
context.beginPath();
- context.moveTo(18, 76);
- context.bezierCurveTo(34, 46, 46, 46, 64, 76);
- context.bezierCurveTo(80, 106, 94, 106, 110, 76);
- context.stroke();
+ context.moveTo(14, 100);
+ context.lineTo(38, 26);
+ context.lineTo(64, 100);
+ context.lineTo(90, 26);
+ context.lineTo(114, 100);
+ context.closePath();
+ context.fill();
}
function drawBurstSymbol(context) {
@@ -1286,8 +1287,6 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
}
export async function loadBGPAnomalies(scene, earth) {
- clearBGPData(earth);
-
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
@@ -1312,6 +1311,9 @@ export async function loadBGPAnomalies(scene, earth) {
? collectorsPayload.features
: [];
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
+
+ clearBGPData(earth);
+
totalAnomalyCount = selectedEventData.totalAnomalyCount;
totalIncidentCount = selectedEventData.totalIncidentCount;
activeEventCountByCollector.clear();
@@ -1351,7 +1353,7 @@ export async function loadBGPAnomalies(scene, earth) {
};
}
-export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
+export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
const now = performance.now();
updateCollectorOverlayScan(lockedObjectType, lockedObject);
const hasLockedLayer = Boolean(
@@ -1459,7 +1461,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
const isLinkedCollectorLocked =
lockedObjectType === "bgp_collector" &&
lockedObject?.userData?.collector === marker.userData.collector;
- const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
+ const isCruise = !isLocked && !isLinkedCollectorLocked && cruiseMarker != null && marker === cruiseMarker;
+ const hasFocusedMarker = hasLockedLayer || cruiseMarker != null;
+ const isOtherLocked = hasFocusedMarker && !isLocked && !isLinkedCollectorLocked && !isCruise;
+ const isActive = isLocked || isLinkedCollectorLocked || isCruise;
const isHovered = marker.userData.state === "hover";
const pulse =
0.5 +
@@ -1477,18 +1482,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
- opacity =
- 0.9 +
- 0.1 * pulse;
+ opacity = 0.9 + 0.1 * pulse;
markerColor = 0xfff1a8;
ringBaseOpacity *= 1.2;
+ } else if (isCruise) {
+ scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
+ opacity = 0.9 + 0.1 * pulse;
+ ringBaseOpacity *= 1.2;
} else if (isHovered) {
scale *= BGP_CONFIG.marker.hoverScale;
opacity = 0.9;
ringBaseOpacity *= 1.05;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.marker.dimmedScale;
- opacity = 0.1;
+ opacity = 0.22;
markerColor = 0x7d8ca3;
ringBaseOpacity = 0.02;
} else {
@@ -1500,6 +1507,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
+ marker.renderOrder = isActive ? 7 : 3;
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
const applyRingState = (ring, phase, maxScale) => {
diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js
index a0bc7d39..d0456819 100644
--- a/frontend/public/earth/js/cables.js
+++ b/frontend/public/earth/js/cables.js
@@ -20,6 +20,7 @@ export let lockedCable = null;
let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
+let landingPointGeometry = null;
const landingPointWorldPosition = new THREE.Vector3();
function clamp(value, min, max) {
@@ -72,7 +73,7 @@ function disposeObject(object, parent) {
if (owner) {
owner.remove(object);
}
- if (object.geometry) {
+ if (object.geometry && !object.userData?.sharedGeometry) {
object.geometry.dispose();
}
if (object.material) {
@@ -369,70 +370,69 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
clearLandingPoints(earthObj);
- const sphereGeometry = new THREE.SphereGeometry(
- CABLE_CONFIG.landingPoint.radius,
- CABLE_CONFIG.landingPoint.widthSegments,
- CABLE_CONFIG.landingPoint.heightSegments,
- );
+ if (!landingPointGeometry) {
+ landingPointGeometry = new THREE.SphereGeometry(
+ CABLE_CONFIG.landingPoint.radius,
+ CABLE_CONFIG.landingPoint.widthSegments,
+ CABLE_CONFIG.landingPoint.heightSegments,
+ );
+ }
let validCount = 0;
- try {
- for (const feature of data.features) {
- if (!feature.geometry || !feature.geometry.coordinates) continue;
+ for (const feature of data.features) {
+ if (!feature.geometry || !feature.geometry.coordinates) continue;
- const [lon, lat] = feature.geometry.coordinates;
- const properties = feature.properties || {};
+ const [lon, lat] = feature.geometry.coordinates;
+ const properties = feature.properties || {};
- if (
- typeof lon !== "number" ||
- typeof lat !== "number" ||
- Number.isNaN(lon) ||
- Number.isNaN(lat) ||
- Math.abs(lat) > 90 ||
- Math.abs(lon) > 180
- ) {
- continue;
- }
-
- const position = latLonToVector3(
- lat,
- lon,
- CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
- );
- if (
- Number.isNaN(position.x) ||
- Number.isNaN(position.y) ||
- Number.isNaN(position.z)
- ) {
- continue;
- }
-
- const sphere = new THREE.Mesh(
- sphereGeometry.clone(),
- new THREE.MeshStandardMaterial({
- color: CABLE_CONFIG.landingPoint.color,
- emissive: CABLE_CONFIG.landingPoint.emissive,
- emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
- transparent: true,
- opacity: CABLE_CONFIG.landingPoint.opacity,
- }),
- );
- sphere.position.copy(position);
- sphere.userData = {
- type: "landingPoint",
- name: properties.name || "未知登陆站",
- cableNames: properties.cable_names || [],
- country: properties.country || "未知国家",
- status: properties.status || "Unknown",
- baseScale: CABLE_CONFIG.landingPoint.baseScale,
- };
-
- earthObj.add(sphere);
- landingPoints.push(sphere);
- validCount++;
+ if (
+ typeof lon !== "number" ||
+ typeof lat !== "number" ||
+ Number.isNaN(lon) ||
+ Number.isNaN(lat) ||
+ Math.abs(lat) > 90 ||
+ Math.abs(lon) > 180
+ ) {
+ continue;
}
- } finally {
- sphereGeometry.dispose();
+
+ const position = latLonToVector3(
+ lat,
+ lon,
+ CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
+ );
+ if (
+ Number.isNaN(position.x) ||
+ Number.isNaN(position.y) ||
+ Number.isNaN(position.z)
+ ) {
+ continue;
+ }
+
+ const sphere = new THREE.Mesh(
+ landingPointGeometry,
+ new THREE.MeshStandardMaterial({
+ color: CABLE_CONFIG.landingPoint.color,
+ emissive: CABLE_CONFIG.landingPoint.emissive,
+ emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
+ transparent: true,
+ opacity: CABLE_CONFIG.landingPoint.opacity,
+ }),
+ );
+ sphere.position.copy(position);
+ sphere.userData = {
+ type: "landingPoint",
+ name: properties.name || "未知登陆站",
+ cableNames: properties.cable_names || [],
+ country: properties.country || "未知国家",
+ status: properties.status || "Unknown",
+ baseScale: CABLE_CONFIG.landingPoint.baseScale,
+ sharedGeometry: true,
+ };
+
+ earthObj.add(sphere);
+ landingPoints.push(sphere);
+ validCount++;
}
const landingPointCountEl = document.getElementById("landing-point-count");
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js
index a5525c48..e5a04c23 100644
--- a/frontend/public/earth/js/constants.js
+++ b/frontend/public/earth/js/constants.js
@@ -12,6 +12,26 @@ export const CONFIG = {
dragRotationScaleMax: 2.0,
};
+export const ROTATION_MODE = {
+ ROTATE: "rotate",
+ CRUISE: "cruise",
+};
+
+export const CRUISE_CONFIG = {
+ dwellMs: 7_000,
+ focusDurationMs: 1_400,
+ pollIntervalMs: 15_000,
+ maxPolledEvents: 200,
+ cardAnchorXRatio: 0.68,
+ cardAnchorYRatio: 0.24,
+ linkMarkerGapPx: 18,
+ linkPanelGapPx: 12,
+ linkElbowOffsetPx: 72,
+ linkAnchorHeightRatio: 0.26,
+ linkForcedBendPx: 34,
+ linkElbowDropPx: 24,
+};
+
export const HUD_CONFIG = {
scaleReferenceWidth: 1920,
scaleReferenceHeight: 1080,
@@ -169,6 +189,8 @@ export const CABLE_STATE = {
export const SATELLITE_CONFIG = {
maxCount: -1,
+ initialLoadCount: 2400,
+ hydrateFullAfterInitialLoad: true,
trailLength: 10,
dotSize: 4,
ringSize: 0.07,
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index 4e70d061..fe9dbf46 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -1,7 +1,7 @@
// controls.js - Zoom, rotate and toggle controls
import * as THREE from "three";
-import { CONFIG, EARTH_CONFIG } from "./constants.js";
+import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js";
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain } from "./earth.js";
import {
@@ -35,6 +35,7 @@ export let autoRotate = true;
export let zoomLevel = 1.0;
export let showTerrain = false;
export let layoutExpanded = false;
+export let rotationMode = ROTATION_MODE.ROTATE;
let earthObj = null;
let listeners = [];
@@ -57,6 +58,7 @@ const TOOLBAR_ARCH_RISE_PX = 40;
const TOOLBAR_SIDE_PADDING_PX = 12;
const TOOLBAR_BOTTOM_CLEARANCE_PX = 34;
const TOOLBAR_EXTRA_HEIGHT_PX = 34;
+const HUD_EDGE_GAP_PX = 20;
const SETTINGS_MODAL_OPEN_ANIMATION_MS = 420;
const SETTINGS_MODAL_CLOSE_ANIMATION_MS = 320;
const SETTINGS_SHEET_MIN_SCALE = 0.06;
@@ -65,6 +67,7 @@ const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
let settingsModalTimer = null;
let settingsSheetAnimation = null;
let terrainToggleToken = 0;
+let focusViewAnimationToken = 0;
function getViewRotation(targetLat, targetRotLon) {
const latRot = (targetLat * Math.PI) / 180;
@@ -74,6 +77,17 @@ function getViewRotation(targetLat, targetRotLon) {
};
}
+function dispatchRotationModeChange() {
+ window.dispatchEvent(
+ new CustomEvent("earth:rotation-mode-change", {
+ detail: {
+ mode: rotationMode,
+ active: autoRotate,
+ },
+ }),
+ );
+}
+
function applyTerrainUiState(button, enabled) {
showTerrain = enabled;
toggleTerrain(enabled);
@@ -362,6 +376,7 @@ function setupSettingsControls() {
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
+ const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
if (terrainOpacitySlider instanceof HTMLInputElement) {
@@ -386,7 +401,18 @@ function setupSettingsControls() {
});
}
+ rotationModeButtons.forEach((button) => {
+ bindListener(button, "click", (event) => {
+ const target = event.currentTarget;
+ if (!(target instanceof HTMLButtonElement)) return;
+ const nextMode = target.dataset.rotationMode;
+ if (!nextMode) return;
+ setRotationMode(nextMode);
+ });
+ });
+
syncAllHudPanelToggles();
+ syncRotationModeButtons();
}
function setupHudPanelControls() {
@@ -403,6 +429,116 @@ function setupHudPanelControls() {
});
}
+function capturePanelAnchor(app, panel, desiredLeft, desiredTop) {
+ // 拖拽期间:按用户给出的绝对位置重置 anchor,轴模式回到 left/top。
+ // clamp 真的触发时由 syncPanelAnchorFromClamp 改写成 right/bottom 模式。
+ panel.dataset.anchorXSide = "left";
+ panel.dataset.anchorX = String(desiredLeft);
+ panel.dataset.anchorYSide = "top";
+ panel.dataset.anchorY = String(desiredTop);
+}
+
+function getHudScaleValue() {
+ const rawScale = getComputedStyle(document.documentElement)
+ .getPropertyValue("--hud-scale")
+ .trim();
+ const parsedScale = Number.parseFloat(rawScale);
+ return Number.isFinite(parsedScale) && parsedScale > 0 ? parsedScale : 1;
+}
+
+function getPreferredHudEdgeGap() {
+ return HUD_EDGE_GAP_PX * getHudScaleValue();
+}
+
+function syncDraggedPanelSize(panel) {
+ const dragWidthBase = Number.parseFloat(panel.dataset.dragWidthBase ?? "");
+ if (!Number.isFinite(dragWidthBase)) return;
+ const nextWidth = dragWidthBase * getHudScaleValue();
+ panel.style.width = `${nextWidth}px`;
+}
+
+function syncPanelAnchorFromClamp(
+ app,
+ panel,
+ desiredLeft,
+ desiredTop,
+ clampedLeft,
+ clampedTop,
+) {
+ const appRect = app.getBoundingClientRect();
+ const panelRect = panel.getBoundingClientRect();
+ // 只在 clamp 真的改了坐标时切换贴边方向:
+ // clamp 把 left 往小推 → 右边/下方的边碰到 panel 了 → 切到 right/bottom 模式。
+ // 这里保存的是“恢复时应回到的默认边距”,不是 shrink 期间瞬时的 0 间距。
+ // clamp 把 left 往大推 → 左边/上方的边(含 brand L 区)碰到 panel 了 → 记到 left/top 模式。
+ const preferredGap = getPreferredHudEdgeGap();
+ if (clampedLeft < desiredLeft) {
+ panel.dataset.anchorXSide = "right";
+ panel.dataset.anchorX = String(preferredGap);
+ } else if (clampedLeft > desiredLeft) {
+ panel.dataset.anchorXSide = "left";
+ panel.dataset.anchorX = String(clampedLeft <= 0 ? preferredGap : clampedLeft);
+ }
+ if (clampedTop < desiredTop) {
+ panel.dataset.anchorYSide = "bottom";
+ panel.dataset.anchorY = String(preferredGap);
+ } else if (clampedTop > desiredTop) {
+ panel.dataset.anchorYSide = "top";
+ panel.dataset.anchorY = String(clampedTop <= 0 ? preferredGap : clampedTop);
+ }
+}
+
+function resolveAnchorDesiredPosition(app, panel) {
+ syncDraggedPanelSize(panel);
+ const appRect = app.getBoundingClientRect();
+ const panelRect = panel.getBoundingClientRect();
+ const anchorX = parseFloat(panel.dataset.anchorX ?? "");
+ const anchorY = parseFloat(panel.dataset.anchorY ?? "");
+ const fallbackLeft = parseFloat(panel.style.left) || 0;
+ const fallbackTop = parseFloat(panel.style.top) || 0;
+
+ const desiredLeft = Number.isFinite(anchorX)
+ ? panel.dataset.anchorXSide === "right"
+ ? appRect.width - panelRect.width - anchorX
+ : anchorX
+ : fallbackLeft;
+ const desiredTop = Number.isFinite(anchorY)
+ ? panel.dataset.anchorYSide === "bottom"
+ ? appRect.height - panelRect.height - anchorY
+ : anchorY
+ : fallbackTop;
+
+ return { desiredLeft, desiredTop };
+}
+
+function clampDraggedPanelPosition(app, panel, desiredLeft, desiredTop) {
+ const appRect = app.getBoundingClientRect();
+ const panelRect = panel.getBoundingClientRect();
+ const brandPanel = document.getElementById("brand-panel");
+ const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null;
+ const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0;
+ const brandRight = brandRect ? brandRect.right - appRect.left : 0;
+
+ const maxLeft = Math.max(0, appRect.width - panelRect.width);
+ const maxTop = Math.max(0, appRect.height - panelRect.height);
+ let nextLeft = Math.min(Math.max(desiredLeft, 0), maxLeft);
+ let nextTop = Math.min(Math.max(desiredTop, 0), maxTop);
+
+ // Brand 面板形成 L 形禁区:panel 不能进入 brand 左上角矩形区域。
+ // 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。
+ // 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。
+ if (brandRect && nextLeft < brandRight && nextTop < brandBottom) {
+ const leftAdjust = brandRight - nextLeft;
+ const topAdjust = brandBottom - nextTop;
+ if (leftAdjust <= topAdjust) {
+ nextLeft = brandRight;
+ } else {
+ nextTop = brandBottom;
+ }
+ }
+ return { left: nextLeft, top: nextTop };
+}
+
function setupDraggableHudPanels() {
const app = document.getElementById("container");
const draggablePanels = document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR);
@@ -428,40 +564,22 @@ function setupDraggableHudPanels() {
const onMove = (event) => {
if (!isDragging) return;
- const appRect = app.getBoundingClientRect();
- const panelRect = panel.getBoundingClientRect();
- const brandPanel = document.getElementById("brand-panel");
- const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null;
- const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0;
- const brandRight = brandRect ? brandRect.right - appRect.left : 0;
-
- let nextLeft = Math.min(
- Math.max(startLeft + (event.clientX - startPointerX), 0),
- appRect.width - panelRect.width,
+ const desiredLeft = startLeft + (event.clientX - startPointerX);
+ const desiredTop = startTop + (event.clientY - startPointerY);
+ capturePanelAnchor(app, panel, desiredLeft, desiredTop);
+ const { left, top } = clampDraggedPanelPosition(
+ app,
+ panel,
+ desiredLeft,
+ desiredTop,
);
- let nextTop = Math.min(
- Math.max(startTop + (event.clientY - startPointerY), 0),
- appRect.height - panelRect.height,
- );
- // Brand 面板形成 L 形禁区:panel 不能进入 brand 左上角矩形区域。
- // 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。
- // 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。
- if (brandRect && nextLeft < brandRight && nextTop < brandBottom) {
- const leftAdjust = brandRight - nextLeft;
- const topAdjust = brandBottom - nextTop;
- if (leftAdjust <= topAdjust) {
- nextLeft = brandRight;
- } else {
- nextTop = brandBottom;
- }
- }
-
- panel.style.left = `${nextLeft}px`;
- panel.style.top = `${nextTop}px`;
+ panel.style.left = `${left}px`;
+ panel.style.top = `${top}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
+ syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top);
};
bindListener(handle, "pointerdown", (event) => {
@@ -478,6 +596,9 @@ function setupDraggableHudPanels() {
panel.dataset.originalParentId = panel.parentElement?.id || "";
panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || "";
const capturedWidth = panelRect.width;
+ panel.dataset.dragWidthBase = String(
+ capturedWidth / Math.max(getHudScaleValue(), 0.001),
+ );
panel.style.position = "absolute";
panel.style.width = `${capturedWidth}px`;
panel.style.margin = "0";
@@ -492,6 +613,7 @@ function setupDraggableHudPanels() {
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
+ capturePanelAnchor(app, panel, startLeft, startTop);
panel.classList.add("is-dragging");
document.body.style.userSelect = "none";
handle.setPointerCapture?.(event.pointerId);
@@ -502,6 +624,30 @@ function setupDraggableHudPanels() {
bindListener(handle, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
});
+
+ const reclampDraggedPanels = () => {
+ draggablePanels.forEach((panel) => {
+ if (panel.dataset.dragged !== "true") return;
+ syncDraggedPanelSize(panel);
+ const { desiredLeft, desiredTop } = resolveAnchorDesiredPosition(
+ app,
+ panel,
+ );
+ const { left, top } = clampDraggedPanelPosition(
+ app,
+ panel,
+ desiredLeft,
+ desiredTop,
+ );
+ panel.style.left = `${left}px`;
+ panel.style.top = `${top}px`;
+ syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top);
+ });
+ };
+
+ bindListener(window, "resize", () => {
+ window.requestAnimationFrame(reclampDraggedPanels);
+ });
}
function clearForcedFloatingClose() {
@@ -736,9 +882,14 @@ function applyZoom(camera) {
}
function animateValue(start, end, duration, onUpdate, onComplete) {
+ const animationToken = ++focusViewAnimationToken;
const startTime = performance.now();
function update(currentTime) {
+ if (animationToken !== focusViewAnimationToken) {
+ return;
+ }
+
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
@@ -748,7 +899,7 @@ function animateValue(start, end, duration, onUpdate, onComplete) {
if (progress < 1) {
requestAnimationFrame(update);
- } else if (onComplete) {
+ } else if (onComplete && animationToken === focusViewAnimationToken) {
onComplete();
}
}
@@ -759,58 +910,37 @@ function animateValue(start, end, duration, onUpdate, onComplete) {
export function resetView(camera) {
if (!earthObj) return;
- function animateToView(targetLat, targetLon, targetRotLon) {
- const targetRotation = getViewRotation(targetLat, targetRotLon);
-
- const startRotX = earthObj.rotation.x;
- const startRotY = earthObj.rotation.y;
- const startZoom = zoomLevel;
- const targetZoom = 1.0;
-
- animateValue(
- 0,
- 1,
- 800,
- (progress) => {
- const ease = 1 - Math.pow(1 - progress, 3);
- earthObj.rotation.x =
- startRotX + (targetRotation.x - startRotX) * ease;
- earthObj.rotation.y =
- startRotY + (targetRotation.y - startRotY) * ease;
-
- zoomLevel = startZoom + (targetZoom - startZoom) * ease;
- camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
- updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
- },
- () => {
- zoomLevel = 1.0;
- showStatusMessage("视角已重置", "info");
- },
- );
- }
-
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) =>
- animateToView(
- pos.coords.latitude,
- pos.coords.longitude,
- -pos.coords.longitude,
- ),
+ focusEarthView(camera, {
+ lat: pos.coords.latitude,
+ lon: pos.coords.longitude,
+ rotLon: pos.coords.longitude - 270,
+ zoom: 1.0,
+ duration: 800,
+ suppressStatus: false,
+ }),
() =>
- animateToView(
- EARTH_CONFIG.chinaLat,
- EARTH_CONFIG.chinaLon,
- EARTH_CONFIG.chinaRotLon,
- ),
+ focusEarthView(camera, {
+ lat: EARTH_CONFIG.chinaLat,
+ lon: EARTH_CONFIG.chinaLon,
+ rotLon: EARTH_CONFIG.chinaRotLon,
+ zoom: 1.0,
+ duration: 800,
+ suppressStatus: false,
+ }),
{ timeout: 5000, enableHighAccuracy: false },
);
} else {
- animateToView(
- EARTH_CONFIG.chinaLat,
- EARTH_CONFIG.chinaLon,
- EARTH_CONFIG.chinaRotLon,
- );
+ focusEarthView(camera, {
+ lat: EARTH_CONFIG.chinaLat,
+ lon: EARTH_CONFIG.chinaLon,
+ rotLon: EARTH_CONFIG.chinaRotLon,
+ zoom: 1.0,
+ duration: 800,
+ suppressStatus: false,
+ });
}
clearLockedObject();
@@ -822,7 +952,8 @@ function setupRotateControls(camera) {
bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate();
- showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
+ const label = rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
+ showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info");
});
updateRotateUI();
@@ -995,6 +1126,9 @@ function setupTerrainControls() {
const showNextBGP = !getShowBGP();
clearSelectionIfHiding(!showNextBGP);
toggleBGP(showNextBGP);
+ if (!showNextBGP && rotationMode === ROTATION_MODE.CRUISE && autoRotate) {
+ setAutoRotate(false);
+ }
updateLayerButtonState(this, showNextBGP);
setButtonTooltip(this, showNextBGP ? "隐藏BGP观测" : "显示BGP观测");
const bgpCountEl = document.getElementById("bgp-anomaly-count");
@@ -1292,28 +1426,111 @@ export function getAutoRotate() {
return autoRotate;
}
+function getRotationModeLabel(mode = rotationMode) {
+ return mode === ROTATION_MODE.CRUISE ? "巡航模式" : "旋转模式";
+}
+
+function syncRotationModeButtons() {
+ const buttons = document.querySelectorAll("[data-rotation-mode]");
+ buttons.forEach((button) => {
+ if (!(button instanceof HTMLButtonElement)) return;
+ const isActive = button.dataset.rotationMode === rotationMode;
+ button.classList.toggle("is-active", isActive);
+ button.setAttribute("aria-pressed", isActive ? "true" : "false");
+ });
+}
+
function updateRotateUI() {
const btn = document.getElementById("rotate-toggle");
if (btn) {
btn.classList.toggle("active", autoRotate);
btn.classList.toggle("is-stopped", !autoRotate);
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
- if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
+ const activeLabel =
+ rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
+ if (tooltip) {
+ tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`;
+ }
+ btn.title = `${getRotationModeLabel()} · ${activeLabel}`;
}
+
+ syncRotationModeButtons();
}
export function setAutoRotate(value) {
autoRotate = value;
updateRotateUI();
+ dispatchRotationModeChange();
}
export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
clearLockedObject();
+ dispatchRotationModeChange();
return autoRotate;
}
+export function getRotationMode() {
+ return rotationMode;
+}
+
+export function setRotationMode(nextMode) {
+ const normalizedMode =
+ nextMode === ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE : ROTATION_MODE.ROTATE;
+ const changed = normalizedMode !== rotationMode;
+ rotationMode = normalizedMode;
+ updateRotateUI();
+ dispatchRotationModeChange();
+ if (changed) {
+ showStatusMessage(
+ normalizedMode === ROTATION_MODE.CRUISE ? "已切换到巡航模式" : "已切换到旋转模式",
+ "info",
+ );
+ }
+}
+
+export function focusEarthView(camera, options = {}) {
+ if (!earthObj || !camera) return Promise.resolve();
+
+ const {
+ lat = EARTH_CONFIG.chinaLat,
+ lon = EARTH_CONFIG.chinaLon,
+ rotLon = lon - 270,
+ zoom = 1.0,
+ duration = 800,
+ suppressStatus = true,
+ } = options;
+
+ return new Promise((resolve) => {
+ const nextRotation = getViewRotation(lat, rotLon);
+ const startRotX = earthObj.rotation.x;
+ const startRotY = earthObj.rotation.y;
+ const startZoom = zoomLevel;
+
+ animateValue(
+ 0,
+ 1,
+ duration,
+ (progress) => {
+ const ease = 1 - Math.pow(1 - progress, 3);
+ earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
+ earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
+ zoomLevel = startZoom + (zoom - startZoom) * ease;
+ camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
+ updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
+ },
+ () => {
+ zoomLevel = zoom;
+ if (!suppressStatus) {
+ showStatusMessage("视角已重置", "info");
+ }
+ resolve();
+ },
+ );
+ });
+}
+
export function getZoomLevel() {
return zoomLevel;
}
@@ -1362,6 +1579,11 @@ function resetPanelInlineLayout(panel) {
panel.style.width = "";
panel.style.margin = "";
delete panel.dataset.dragged;
+ delete panel.dataset.anchorX;
+ delete panel.dataset.anchorY;
+ delete panel.dataset.anchorXSide;
+ delete panel.dataset.anchorYSide;
+ delete panel.dataset.dragWidthBase;
}
function isPanelVisible(panel) {
diff --git a/frontend/public/earth/js/earth.js b/frontend/public/earth/js/earth.js
index d9a313e3..dcf2b98b 100644
--- a/frontend/public/earth/js/earth.js
+++ b/frontend/public/earth/js/earth.js
@@ -224,6 +224,7 @@ export function createTerrain(earthObj) {
specular: TERRAIN_CONFIG.specular,
shininess: TERRAIN_CONFIG.shininess,
vertexColors: true,
+ vertexAlphas: true,
transparent: true,
opacity: TERRAIN_CONFIG.opacity,
flatShading: false,
diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js
index 585b2b3e..87914699 100644
--- a/frontend/public/earth/js/info-card.js
+++ b/frontend/public/earth/js/info-card.js
@@ -238,7 +238,7 @@ function mountCard() {
cardMounted = true;
}
-function positionPanel(panel, x, y) {
+function positionPanel(panel, x, y, options = {}) {
if (!panel) return;
const margin = 12;
const offset = 14;
@@ -251,6 +251,22 @@ function positionPanel(panel, x, y) {
const estW = Math.min(300 * scale, vpW - 32);
const estH = Math.min(420 * scale, vpH * 0.7);
+ if (options.absolute === true) {
+ const clampedLeft = Math.min(
+ Math.max(margin, x),
+ Math.max(margin, vpW - estW - margin),
+ );
+ const clampedTop = Math.min(
+ Math.max(margin, y),
+ Math.max(margin, vpH - estH - margin),
+ );
+ panel.style.left = `${clampedLeft}px`;
+ panel.style.top = `${clampedTop}px`;
+ panel.style.right = 'auto';
+ panel.style.bottom = 'auto';
+ return;
+ }
+
let left = x + offset;
let top = y + offset;
@@ -263,10 +279,10 @@ function positionPanel(panel, x, y) {
panel.style.bottom = 'auto';
}
-function showPanel(x, y) {
+function showPanel(x, y, options = {}) {
const panel = getPanel();
if (!panel) return;
- if (x != null && y != null) positionPanel(panel, x, y);
+ if (x != null && y != null) positionPanel(panel, x, y, options);
panel.classList.add('is-visible');
}
@@ -327,7 +343,7 @@ export function showInfoCard(type, data, options = {}) {
}
content.innerHTML = html;
- showPanel(options.x, options.y);
+ showPanel(options.x, options.y, options);
}
export function hideInfoCard() {
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index a06875a2..5effba34 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -1,6 +1,16 @@
import * as THREE from "three";
-import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
+import {
+ CONFIG,
+ HUD_CONFIG,
+ CABLE_CONFIG,
+ CABLE_STATE,
+ SATELLITE_CONFIG,
+ PATHS,
+ BGP_CONFIG,
+ CRUISE_CONFIG,
+ ROTATION_MODE,
+} from "./constants.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
showStatusMessage,
@@ -117,9 +127,11 @@ import {
import {
setupControls,
getAutoRotate,
+ getRotationMode,
getShowTerrain,
setAutoRotate,
applyImmediateView,
+ focusEarthView,
getZoomLevel,
teardownControls,
updateLayerButtonState,
@@ -174,7 +186,45 @@ let cablesEnabled = true;
let satellitesEnabled = true;
let cableToggleToken = 0;
let satelliteToggleToken = 0;
+let satelliteHydrationToken = 0;
let sceneLights = null;
+let cruiseTimerId = null;
+let cruiseHideCardTimerId = null;
+let cruisePollTimerId = null;
+let cruiseCurrentMarkerId = null;
+let cruiseCurrentIndex = -1;
+let cruiseQueuedMarkerIds = [];
+let cruiseKnownEventIds = new Set();
+let cruiseCardPinned = false;
+let cruiseConnectorEl = null;
+let cruiseConnectorPolyline = null;
+let cruiseConnectorStartpoint = null;
+let cruiseConnectorEndpoint = null;
+let cruiseConnectorNeedsAnimation = false;
+let cruiseCardPlacement = null;
+let cruiseSequenceToken = 0;
+let cruisePresentationPhase = "hidden";
+let cruiseAdvanceInFlight = false;
+let cruiseAdvanceQueued = false;
+let cruiseAdvanceInterrupt = false;
+let cruiseCancelNotifier = null;
+
+function createCruisePresentationTimer() {
+ let endsAt = 0;
+ return {
+ start(durationMs) {
+ endsAt = Date.now() + Math.max(0, durationMs);
+ },
+ stop() {
+ endsAt = 0;
+ },
+ isActive() {
+ return endsAt > 0 && Date.now() < endsAt;
+ },
+ };
+}
+
+const cruisePresentationTimer = createCruisePresentationTimer();
const clock = new THREE.Clock();
const interactionRaycaster = new THREE.Raycaster();
@@ -190,9 +240,14 @@ const cleanupFns = [];
const DRAG_SMOOTHING_FACTOR = 0.18;
const INERTIA_DAMPING = 0.92;
const INERTIA_MIN_VELOCITY = 0.00008;
+const CRUISE_CONNECTOR_DRAW_MS = 420;
+const CRUISE_TRANSITION_GAP_MS = 24;
+const CRUISE_PRESENTATION_HIDE_MS = 220;
+const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
+const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column",
".earth-left-column *",
@@ -216,10 +271,69 @@ function bindListener(target, eventName, handler, options) {
);
}
+function waitForCruiseDelay(durationMs, sequenceToken, { useHideTimer = false } = {}) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const settle = (ok) => {
+ if (settled) return;
+ settled = true;
+ if (cruiseCancelNotifier === onCancel) cruiseCancelNotifier = null;
+ resolve(ok);
+ };
+ const onCancel = () => settle(false);
+
+ const timerId = window.setTimeout(() => {
+ if (useHideTimer) {
+ if (cruiseHideCardTimerId === timerId) cruiseHideCardTimerId = null;
+ } else if (cruiseTimerId === timerId) {
+ cruiseTimerId = null;
+ }
+ settle(sequenceToken === cruiseSequenceToken);
+ }, durationMs);
+
+ if (useHideTimer) {
+ cruiseHideCardTimerId = timerId;
+ } else {
+ cruiseTimerId = timerId;
+ }
+
+ cruiseCancelNotifier = onCancel;
+ });
+}
+
+async function waitForCruiseConnectorReady(sequenceToken) {
+ const startedAt = performance.now();
+
+ while (sequenceToken === cruiseSequenceToken) {
+ if (!isCruiseModeActive() || !getAutoRotate()) {
+ return false;
+ }
+
+ const ready = updateCruiseConnector();
+ if (ready) {
+ return true;
+ }
+
+ if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
+ return false;
+ }
+
+ await nextAnimationFrame();
+ }
+
+ return false;
+}
+
function getViewportAspect() {
return window.innerWidth / window.innerHeight;
}
+function nextAnimationFrame() {
+ return new Promise((resolve) => {
+ window.requestAnimationFrame(() => resolve());
+ });
+}
+
function syncRendererViewport() {
if (!camera || !renderer) return;
camera.aspect = getViewportAspect();
@@ -614,8 +728,621 @@ function updateBGPHud(bgpResult) {
}
}
+function clearCruiseTimers() {
+ if (cruiseTimerId) {
+ clearTimeout(cruiseTimerId);
+ cruiseTimerId = null;
+ }
+ if (cruiseHideCardTimerId) {
+ clearTimeout(cruiseHideCardTimerId);
+ cruiseHideCardTimerId = null;
+ }
+}
+
+function ensureCruiseConnector() {
+ if (cruiseConnectorEl instanceof SVGSVGElement) {
+ return cruiseConnectorEl;
+ }
+
+ const container = document.getElementById("container");
+ if (!(container instanceof HTMLElement)) return null;
+
+ const connector = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ connector.id = "info-card-cruise-link";
+ connector.setAttribute("class", "info-card-cruise-link");
+ connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
+ connector.setAttribute("preserveAspectRatio", "none");
+
+ const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
+ const startpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ const endpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ startpoint.setAttribute("r", "4");
+ endpoint.setAttribute("r", "4");
+ connector.appendChild(startpoint);
+ connector.appendChild(polyline);
+ connector.appendChild(endpoint);
+
+ container.appendChild(connector);
+ cruiseConnectorEl = connector;
+ cruiseConnectorPolyline = polyline;
+ cruiseConnectorStartpoint = startpoint;
+ cruiseConnectorEndpoint = endpoint;
+ connector.addEventListener("animationend", (event) => {
+ if (
+ event.animationName === "cruiseConnectorDraw" &&
+ cruiseConnectorEl?.classList.contains("is-visible")
+ ) {
+ if (cruiseConnectorPolyline) {
+ cruiseConnectorPolyline.style.strokeDashoffset = "0";
+ }
+ cruiseConnectorEl.classList.remove("is-animating");
+ }
+ });
+ return connector;
+}
+
+function hideCruiseConnector() {
+ const connector = ensureCruiseConnector();
+ if (!connector) return;
+ connector.classList.remove("is-visible");
+ connector.classList.remove("is-animating");
+ cruiseCardPlacement = null;
+ cruisePresentationPhase = "hidden";
+}
+
+function hideCruiseConnectorVisual() {
+ const connector = ensureCruiseConnector();
+ if (!connector) return;
+ connector.classList.remove("is-visible");
+ connector.classList.remove("is-animating");
+}
+
+function showCruiseConnectorVisual() {
+ const connector = ensureCruiseConnector();
+ if (!connector) return;
+ connector.classList.add("is-visible");
+}
+
+function setCruiseCardPinned(pinned) {
+ cruiseCardPinned = pinned;
+ if (!pinned) {
+ cruisePresentationTimer.stop();
+ hideCruiseConnector();
+ }
+}
+
+function interruptCruisePresentation() {
+ ++cruiseSequenceToken;
+ clearCruiseTimers();
+ const notifier = cruiseCancelNotifier;
+ cruiseCancelNotifier = null;
+ notifier?.();
+ setCruiseCardPinned(false);
+}
+
+function beginCruisePresentationHide() {
+ if (!lockedObject) {
+ hideInfoCard();
+ }
+ cruisePresentationPhase = "hidden";
+ const connector = ensureCruiseConnector();
+ connector?.classList.remove("is-visible");
+ connector?.classList.remove("is-animating");
+}
+
+function scheduleCruiseCardHide() {
+ if (cruiseHideCardTimerId) {
+ clearTimeout(cruiseHideCardTimerId);
+ cruiseHideCardTimerId = null;
+ }
+}
+
+function clearCruiseMarkerHighlight() {
+ if (!cruiseCurrentMarkerId) return;
+ const marker = getBGPAnomalyMarkers().find(
+ (item) => item.userData?.id === cruiseCurrentMarkerId,
+ );
+ if (marker && lockedObject !== marker) {
+ setBGPMarkerState(marker, "normal");
+ }
+ cruiseCurrentMarkerId = null;
+}
+
+function getBGPMarkerTimestamp(marker) {
+ const rawValue = marker?.userData?.created_at_raw;
+ const parsedValue = rawValue ? new Date(rawValue).getTime() : 0;
+ return Number.isFinite(parsedValue) ? parsedValue : 0;
+}
+
+function getCruiseMarkersSorted() {
+ return getBGPAnomalyMarkers()
+ .slice()
+ .sort((a, b) => getBGPMarkerTimestamp(b) - getBGPMarkerTimestamp(a));
+}
+
+function getCruiseMarkerScreenCoords(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 getCruiseCardScreenCoords(marker) {
+ const markerCoords = getCruiseMarkerScreenCoords(marker);
+ if (!markerCoords) return null;
+
+ 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);
+
+ const x =
+ window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5;
+ const y =
+ window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5;
+ const margin = 12;
+ 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(18 * hudScale, estimatedCardHeight * 0.18);
+
+ return {
+ x: clampedX,
+ y: clampedY,
+ width: estimatedCardWidth,
+ height: estimatedCardHeight,
+ anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
+ anchorY,
+ };
+}
+
+function showCruiseEventCard(marker) {
+ const coords = cruiseCardPlacement || getCruiseCardScreenCoords(marker);
+ if (!coords) return;
+ showBGPInfo(marker, {
+ x: coords.x,
+ y: coords.y,
+ absolute: true,
+ });
+}
+
+function isCruiseInfoCardVisible() {
+ return document.getElementById("info-panel")?.classList.contains("is-visible") === true;
+}
+
+function computeCruiseConnectorPoints(marker) {
+ const markerCoords = getCruiseMarkerScreenCoords(marker);
+ if (!markerCoords) return null;
+
+ const targetCardCoords = cruiseCardPlacement || getCruiseCardScreenCoords(marker);
+ if (!targetCardCoords) return null;
+
+ const panelAnchorX = targetCardCoords.anchorX;
+ const panelAnchorY = targetCardCoords.anchorY;
+ const horizontalDirection = markerCoords.x <= panelAnchorX ? 1 : -1;
+ const startX = markerCoords.x + horizontalDirection * CRUISE_CONFIG.linkMarkerGapPx;
+ const startY = markerCoords.y;
+ const elbowX =
+ panelAnchorX -
+ horizontalDirection * (CRUISE_CONFIG.linkElbowOffsetPx + CRUISE_CONFIG.linkPanelGapPx);
+ const elbowY = Math.min(startY, panelAnchorY) + CRUISE_CONFIG.linkElbowDropPx;
+
+ if (Math.abs(panelAnchorX - startX) < 8 && Math.abs(panelAnchorY - startY) < 8) {
+ return null;
+ }
+
+ return { startX, startY, elbowX, elbowY, panelAnchorX, panelAnchorY };
+}
+
+function applyCruiseConnectorPoints(pts) {
+ const polyline = cruiseConnectorPolyline;
+ const startpoint = cruiseConnectorStartpoint;
+ const endpoint = cruiseConnectorEndpoint;
+ if (!polyline || !startpoint || !endpoint) return 0;
+
+ polyline.setAttribute(
+ "points",
+ `${pts.startX.toFixed(2)},${pts.startY.toFixed(2)} ` +
+ `${pts.elbowX.toFixed(2)},${pts.elbowY.toFixed(2)} ` +
+ `${pts.panelAnchorX.toFixed(2)},${pts.panelAnchorY.toFixed(2)}`,
+ );
+ startpoint.setAttribute("cx", pts.startX.toFixed(2));
+ startpoint.setAttribute("cy", pts.startY.toFixed(2));
+ endpoint.setAttribute("cx", pts.panelAnchorX.toFixed(2));
+ endpoint.setAttribute("cy", pts.panelAnchorY.toFixed(2));
+
+ const totalLength =
+ typeof polyline.getTotalLength === "function" ? polyline.getTotalLength() : 0;
+ return totalLength;
+}
+
+function updateCruiseConnector() {
+ const connector = ensureCruiseConnector();
+ if (
+ !connector ||
+ !cruiseConnectorPolyline ||
+ !cruiseConnectorStartpoint ||
+ !cruiseConnectorEndpoint ||
+ !cruiseCardPinned ||
+ cruisePresentationPhase === "hidden" ||
+ !cruiseCurrentMarkerId
+ ) {
+ return false;
+ }
+
+ const marker = getBGPAnomalyMarkers().find(
+ (item) => item.userData?.id === cruiseCurrentMarkerId,
+ );
+ if (!marker) return false;
+
+ const pts = computeCruiseConnectorPoints(marker);
+ if (!pts) return false;
+
+ connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
+ const totalLength = applyCruiseConnectorPoints(pts);
+ const polyline = cruiseConnectorPolyline;
+
+ polyline.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
+ polyline.style.strokeDashoffset =
+ totalLength > 0 ? `${cruiseConnectorNeedsAnimation ? totalLength : 0}` : "";
+ connector.style.setProperty(
+ "--connector-length",
+ totalLength > 0 ? `${totalLength}` : "0px",
+ );
+ connector.classList.add("is-visible");
+
+ if (cruiseConnectorNeedsAnimation && totalLength > 0) {
+ connector.classList.remove("is-animating");
+ void connector.getBoundingClientRect();
+ polyline.style.strokeDashoffset = `${totalLength}`;
+ connector.classList.add("is-animating");
+ cruiseConnectorNeedsAnimation = false;
+ }
+
+ return totalLength > 0;
+}
+
+function repositionCruiseConnector() {
+ if (!cruiseCardPinned || cruisePresentationPhase === "hidden" || !cruiseCurrentMarkerId) return;
+ const connector = cruiseConnectorEl;
+ if (!connector || !connector.classList.contains("is-visible")) return;
+
+ const marker = getBGPAnomalyMarkers().find(
+ (item) => item.userData?.id === cruiseCurrentMarkerId,
+ );
+ if (!marker) return;
+
+ const pts = computeCruiseConnectorPoints(marker);
+ if (!pts) return;
+
+ connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
+ applyCruiseConnectorPoints(pts);
+}
+
+function syncCruiseKnownEventIds() {
+ cruiseKnownEventIds = new Set(
+ getBGPAnomalyMarkers()
+ .map((marker) => marker.userData?.id)
+ .filter(Boolean),
+ );
+}
+
+function isCruiseModeActive() {
+ return getRotationMode() === ROTATION_MODE.CRUISE;
+}
+
+function stopCruiseMode({ preserveCard = false } = {}) {
+ clearCruiseTimers();
+ clearCruiseMarkerHighlight();
+ clearBGPSelection();
+ cruiseCurrentIndex = -1;
+ cruiseQueuedMarkerIds = [];
+ cruiseAdvanceQueued = false;
+ cruiseAdvanceInterrupt = false;
+ if (!preserveCard) {
+ setCruiseCardPinned(false);
+ }
+ if (!preserveCard && !lockedObject) {
+ hideInfoCard();
+ }
+}
+
+async function focusCruiseMarker(marker, { interrupt = false } = {}) {
+ const earth = getEarth();
+ if (!marker || !earth || !isCruiseModeActive() || !getAutoRotate()) return;
+ const sequenceToken = ++cruiseSequenceToken;
+ const abortPresentation = () => {
+ hideInfoCard();
+ setCruiseCardPinned(false);
+ };
+ const shouldAbortSequence = () =>
+ sequenceToken !== cruiseSequenceToken ||
+ !isCruiseModeActive() ||
+ !getAutoRotate();
+
+ clearCruiseTimers();
+ clearCruiseMarkerHighlight();
+ clearLockedObject();
+ hideInfoCard();
+ setCruiseCardPinned(false);
+
+ cruiseCurrentMarkerId = marker.userData?.id || null;
+ cruiseConnectorNeedsAnimation = true;
+ cruiseCardPlacement = getCruiseCardScreenCoords(marker);
+ cruiseCurrentIndex = getCruiseMarkersSorted().findIndex(
+ (item) => item.userData?.id === cruiseCurrentMarkerId,
+ );
+ setLegendMode("bgp");
+ setBGPMarkerState(marker, "locked");
+ showBGPEventOverlay(marker, earth);
+ applyBGPEventSatelliteHighlights(marker);
+
+ await focusEarthView(camera, {
+ 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,
+ });
+
+ if (
+ shouldAbortSequence()
+ ) {
+ abortPresentation();
+ return;
+ }
+
+ setCruiseCardPinned(true);
+ cruisePresentationPhase = "connector";
+ showCruiseConnectorVisual();
+
+ const connectorReady = await waitForCruiseConnectorReady(sequenceToken);
+
+ if (
+ !connectorReady ||
+ shouldAbortSequence()
+ ) {
+ abortPresentation();
+ return;
+ }
+
+ const connectorDelayCompleted = await waitForCruiseDelay(
+ CRUISE_CONNECTOR_DRAW_MS,
+ sequenceToken,
+ );
+
+ if (
+ !connectorDelayCompleted ||
+ shouldAbortSequence()
+ ) {
+ abortPresentation();
+ return;
+ }
+
+ cruisePresentationPhase = "card";
+ showCruiseEventCard(marker);
+ await nextAnimationFrame();
+ if (!isCruiseInfoCardVisible()) {
+ showCruiseEventCard(marker);
+ await nextAnimationFrame();
+ }
+ if (!isCruiseInfoCardVisible() || shouldAbortSequence()) {
+ abortPresentation();
+ return;
+ }
+ cruisePresentationTimer.start(CRUISE_CONFIG.dwellMs);
+
+ const dwellDelayCompleted = await waitForCruiseDelay(
+ CRUISE_CONFIG.dwellMs,
+ sequenceToken,
+ );
+ if (
+ !dwellDelayCompleted ||
+ shouldAbortSequence()
+ ) {
+ abortPresentation();
+ return;
+ }
+
+ beginCruisePresentationHide();
+ const hideDelayCompleted = await waitForCruiseDelay(
+ CRUISE_PRESENTATION_HIDE_MS,
+ sequenceToken,
+ { useHideTimer: true },
+ );
+ if (
+ !hideDelayCompleted ||
+ shouldAbortSequence()
+ ) {
+ abortPresentation();
+ return;
+ }
+
+ setCruiseCardPinned(false);
+ const transitionGapCompleted = await waitForCruiseDelay(
+ CRUISE_TRANSITION_GAP_MS,
+ sequenceToken,
+ );
+ if (
+ !transitionGapCompleted ||
+ shouldAbortSequence()
+ ) {
+ abortPresentation();
+ return;
+ }
+
+ void advanceCruiseEvent();
+}
+
+async function performCruiseAdvance({ interrupt = false } = {}) {
+ if (!isCruiseModeActive() || !getAutoRotate()) return;
+
+ const markers = getCruiseMarkersSorted();
+ if (markers.length === 0) return;
+
+ let targetMarker = null;
+
+ while (cruiseQueuedMarkerIds.length > 0 && !targetMarker) {
+ const queuedId = cruiseQueuedMarkerIds.shift();
+ targetMarker = markers.find((marker) => marker.userData?.id === queuedId) || null;
+ }
+
+ if (!targetMarker) {
+ const nextIndex =
+ cruiseCurrentIndex >= 0
+ ? (cruiseCurrentIndex + 1) % markers.length
+ : 0;
+ targetMarker = markers[nextIndex] || markers[0];
+ }
+
+ await focusCruiseMarker(targetMarker, { interrupt });
+}
+
+async function advanceCruiseEvent({ interrupt = false } = {}) {
+ if (!isCruiseModeActive() || !getAutoRotate()) return;
+
+ cruiseAdvanceQueued = true;
+ cruiseAdvanceInterrupt = cruiseAdvanceInterrupt || interrupt;
+
+ if (cruiseAdvanceInFlight) {
+ return;
+ }
+
+ cruiseAdvanceInFlight = true;
+ try {
+ while (cruiseAdvanceQueued && isCruiseModeActive() && getAutoRotate()) {
+ const nextInterrupt = cruiseAdvanceInterrupt;
+ cruiseAdvanceQueued = false;
+ cruiseAdvanceInterrupt = false;
+ await performCruiseAdvance({ interrupt: nextInterrupt });
+ }
+ } finally {
+ cruiseAdvanceInFlight = false;
+ }
+}
+
+async function pollCruiseEventsIfNeeded() {
+ if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return;
+
+ try {
+ 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 = selectedFeatures
+ .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);
+
+ const newIds = nextIds.filter((id) => !cruiseKnownEventIds.has(id));
+ if (newIds.length === 0) return;
+
+ const bgpResult = await loadBGPAnomalies(scene, getEarth());
+ updateBGPHud(bgpResult);
+ setLegendItems("bgp", getBGPLegendItems());
+ refreshLegend();
+ syncCruiseKnownEventIds();
+ cruiseQueuedMarkerIds = Array.from(
+ new Set([...newIds, ...cruiseQueuedMarkerIds]),
+ );
+ if (
+ cruisePresentationPhase === "hidden" &&
+ !cruiseCardPinned &&
+ !cruisePresentationTimer.isActive()
+ ) {
+ await advanceCruiseEvent({ interrupt: true });
+ }
+ } catch (error) {
+ console.warn("巡航模式轮询 BGP 事件失败:", error);
+ }
+}
+
+function ensureCruisePolling() {
+ if (cruisePollTimerId) return;
+ cruisePollTimerId = window.setInterval(
+ () => {
+ pollCruiseEventsIfNeeded().catch((error) => {
+ console.warn("巡航轮询失败:", error);
+ });
+ },
+ CRUISE_CONFIG.pollIntervalMs,
+ );
+ cleanupFns.push(() => {
+ if (cruisePollTimerId) {
+ clearInterval(cruisePollTimerId);
+ cruisePollTimerId = null;
+ }
+ });
+}
+
+function handleRotationModeChange(event) {
+ const detailMode = event?.detail?.mode || getRotationMode();
+ const detailActive =
+ typeof event?.detail?.active === "boolean"
+ ? event.detail.active
+ : getAutoRotate();
+
+ if (detailMode !== ROTATION_MODE.CRUISE) {
+ stopCruiseMode();
+ return;
+ }
+
+ ensureCruisePolling();
+ syncCruiseKnownEventIds();
+
+ if (!detailActive) {
+ stopCruiseMode({ preserveCard: true });
+ return;
+ }
+
+ advanceCruiseEvent({ interrupt: true }).catch((error) => {
+ console.warn("启动巡航模式失败:", error);
+ });
+}
+
function clearSelectionAndInfo() {
clearLockedObject();
+ interruptCruisePresentation();
hideInfoCard();
}
@@ -689,6 +1416,32 @@ function getBGPRelatedRegions(marker) {
return [];
}
+function applyBGPEventSatelliteHighlights(marker) {
+ const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
+ getBGPRelatedRegions(marker),
+ { limit: 6, maxAngleDeg: 20 },
+ );
+ marker.userData.related_satellite_count = relatedSatelliteIndices.length;
+ highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
+}
+
+function applyBGPRelatedCablesAndLandingPoints(marker, camera) {
+ const relatedCableNames = getBGPRelatedCableNames(marker);
+ clearAllCableStates();
+ relatedCableNames.forEach((name) => {
+ getCableLines().forEach((cable) => {
+ if (cable.userData?.name === name) {
+ setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
+ }
+ });
+ });
+ applyLandingPointVisualState(
+ relatedCableNames.length > 0 ? relatedCableNames : null,
+ relatedCableNames.length === 0,
+ camera,
+ );
+}
+
function applyCableVisualState() {
const allCables = getCableLines();
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
@@ -697,28 +1450,21 @@ function applyCableVisualState() {
const cableId = cable.userData.cableId;
const state = getCableState(cableId);
+ const hasFocus =
+ (lockedObjectType === "cable" && lockedObject) ||
+ (lockedObjectType === "satellite" && lockedSatellite) ||
+ (lockedObjectType === "bgp" && lockedObject) ||
+ (isCruiseModeActive() && cruiseCardPinned);
+
switch (state) {
case CABLE_STATE.LOCKED:
- cable.material.opacity =
- Math.max(
- 0.92,
- CABLE_CONFIG.lockedOpacityMin +
- pulse *
- (CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin),
- );
- cable.material.color.setRGB(0.86, 0.96, 1.0);
- break;
case CABLE_STATE.HOVERED:
cable.material.opacity = 1;
cable.material.color.setRGB(0.92, 0.98, 1.0);
break;
case CABLE_STATE.NORMAL:
default:
- if (
- (lockedObjectType === "cable" && lockedObject) ||
- (lockedObjectType === "satellite" && lockedSatellite) ||
- (lockedObjectType === "bgp" && lockedObject)
- ) {
+ if (hasFocus) {
cable.material.opacity = CABLE_CONFIG.otherOpacity;
const origColor = cable.userData.originalColor;
const brightness = CABLE_CONFIG.otherBrightness;
@@ -847,7 +1593,9 @@ async function ensureSatellitesEnabled() {
}
clearSatelliteData();
- const satelliteCount = await loadSatellites();
+ const loadResult = await loadSatellites({
+ limit: getInitialSatelliteLoadLimit(),
+ });
if (
requestToken !== satelliteToggleToken ||
@@ -858,17 +1606,37 @@ async function ensureSatellitesEnabled() {
return 0;
}
- updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
- toggleSatellites(true);
- updateSatelliteToggleUi(true, satelliteCount);
+ updateSatelliteToggleUi(true, loadResult.count);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
- return satelliteCount;
+ scheduleSatellitePositionWarmup(() => {
+ if (
+ requestToken === satelliteToggleToken &&
+ satellitesEnabled &&
+ !destroyed
+ ) {
+ toggleSatellites(true);
+ }
+ });
+
+ if (shouldHydrateFullSatelliteSet(loadResult)) {
+ const hydrationToken = ++satelliteHydrationToken;
+ hydrateAllSatellitesInBackground(
+ () =>
+ hydrationToken === satelliteHydrationToken &&
+ requestToken === satelliteToggleToken &&
+ satellitesEnabled &&
+ !destroyed,
+ );
+ }
+
+ return loadResult.count;
}
function disableSatellites() {
satellitesEnabled = false;
satelliteToggleToken += 1;
+ satelliteHydrationToken += 1;
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
setLegendItems("satellites", getSatelliteLegendItems());
@@ -1023,7 +1791,49 @@ function addLights() {
}
// Yield control to the browser so the renderer can paint a frame before the next step
-const yieldFrame = (ms = 60) => new Promise((r) => setTimeout(r, ms));
+const yieldFrame = (ms = 24) => new Promise((r) => setTimeout(r, ms));
+
+function scheduleSatellitePositionWarmup(onReady) {
+ window.requestAnimationFrame(() => {
+ updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
+ if (typeof onReady === "function") {
+ onReady();
+ }
+ });
+}
+
+function getInitialSatelliteLoadLimit() {
+ const configuredInitial = Number.isFinite(SATELLITE_CONFIG.initialLoadCount)
+ ? Math.max(1, Math.floor(SATELLITE_CONFIG.initialLoadCount))
+ : null;
+
+ if (SATELLITE_CONFIG.maxCount > 0) {
+ return configuredInitial === null
+ ? SATELLITE_CONFIG.maxCount
+ : Math.min(configuredInitial, SATELLITE_CONFIG.maxCount);
+ }
+
+ return configuredInitial;
+}
+
+function shouldHydrateFullSatelliteSet(loadResult) {
+ if (!SATELLITE_CONFIG.hydrateFullAfterInitialLoad) return false;
+ if (!loadResult || loadResult.requestedLimit === null) return false;
+ return loadResult.count >= loadResult.requestedLimit;
+}
+
+async function hydrateAllSatellitesInBackground(guardFn) {
+ try {
+ const loadResult = await loadSatellites({ limit: null });
+ if (!guardFn()) return;
+ updateSatelliteToggleUi(true, loadResult.count);
+ setLegendItems("satellites", getSatelliteLegendItems());
+ refreshLegend();
+ scheduleSatellitePositionWarmup();
+ } catch (error) {
+ console.warn("后台补全卫星全量数据失败:", error);
+ }
+}
async function loadData() {
if (!scene || !camera || !renderer) return;
@@ -1045,28 +1855,39 @@ async function loadData() {
setLoadingMessage("正在初始化...");
setLoading(true);
- await yieldFrame();
+ await yieldFrame(18);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
const errors = [];
- // Step 1 — Landing points
+ // Step 1 — Earth texture
+ setLoadingMessage("正在加载地球纹理...");
+ await yieldFrame(12);
+ try {
+ await loadEarthTexture();
+ } catch (err) {
+ // texture failure is non-fatal
+ }
+ if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
+ await yieldFrame(16);
+
+ // Step 2 — Landing points
if (cablesEnabled) {
setLoadingMessage("正在加载登陆点...");
- await yieldFrame(30);
+ await yieldFrame(12);
try {
await loadLandingPoints(scene, earth, { silent: true });
} catch (err) {
errors.push({ label: "登陆点", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
- await yieldFrame();
+ await yieldFrame(16);
}
- // Step 2 — Cables
+ // Step 3 — Cables
if (cablesEnabled) {
setLoadingMessage("正在加载海缆...");
- await yieldFrame(30);
+ await yieldFrame(12);
try {
const cableCount = await loadGeoJSONFromPath(scene, earth, {
silent: true,
@@ -1081,60 +1902,70 @@ async function loadData() {
errors.push({ label: "海缆", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
- await yieldFrame();
+ await yieldFrame(16);
}
- // Step 3 — Satellites
+ // Step 4 — Satellites
if (satellitesEnabled) {
setLoadingMessage("正在加载卫星...");
- await yieldFrame(30);
+ await yieldFrame(12);
try {
clearSatelliteData();
- const satelliteCount = await loadSatellites();
+ const loadResult = await loadSatellites({
+ limit: getInitialSatelliteLoadLimit(),
+ });
if (loadToken === currentLoadToken && satellitesEnabled) {
- updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
- toggleSatellites(true);
- updateSatelliteToggleUi(true, satelliteCount);
+ updateSatelliteToggleUi(true, loadResult.count);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
+ scheduleSatellitePositionWarmup(() => {
+ if (
+ loadToken === currentLoadToken &&
+ satellitesEnabled &&
+ !destroyed
+ ) {
+ toggleSatellites(true);
+ }
+ });
+
+ if (shouldHydrateFullSatelliteSet(loadResult)) {
+ const hydrationToken = ++satelliteHydrationToken;
+ hydrateAllSatellitesInBackground(
+ () =>
+ hydrationToken === satelliteHydrationToken &&
+ loadToken === currentLoadToken &&
+ satellitesEnabled &&
+ !destroyed,
+ );
+ }
}
} catch (err) {
errors.push({ label: "卫星", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
- await yieldFrame();
+ await yieldFrame(16);
}
- // Step 4 — BGP
+ // Step 5 — BGP
setLoadingMessage("正在加载BGP态势...");
- await yieldFrame(30);
+ await yieldFrame(12);
try {
const bgpResult = await loadBGPAnomalies(scene, earth);
if (loadToken === currentLoadToken) {
toggleBGP(true);
updateBGPHud(bgpResult);
+ syncCruiseKnownEventIds();
}
} catch (err) {
errors.push({ label: "BGP态势", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
- await yieldFrame();
-
- // Step 5 — Earth texture (loads last so data layers appear on the white sphere first)
- setLoadingMessage("正在加载地球纹理...");
- await yieldFrame(30);
- try {
- await loadEarthTexture();
- } catch (err) {
- // texture failure is non-fatal
- }
- if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
- await yieldFrame();
+ await yieldFrame(16);
// Step 6 — Terrain (if enabled)
if (getShowTerrain()) {
setLoadingMessage("正在渲染地形...");
- await yieldFrame(50);
+ await yieldFrame(24);
}
updateStatsSummary();
@@ -1147,6 +1978,12 @@ async function loadData() {
setLoading(false);
isDataLoading = false;
+ if (getRotationMode() === ROTATION_MODE.CRUISE && getAutoRotate()) {
+ advanceCruiseEvent({ interrupt: true }).catch((error) => {
+ console.warn("初始化后启动巡航失败:", error);
+ });
+ }
+
if (errors.length > 0) {
const errorMessage = buildLoadErrorMessage(errors);
showError(errorMessage);
@@ -1238,10 +2075,12 @@ function setupEventListeners() {
const handleMouseLeave = () => onMouseLeave();
const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy();
+ const handleRotationMode = (event) => handleRotationModeChange(event);
bindListener(window, "resize", handleResize);
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
+ bindListener(window, "earth:rotation-mode-change", handleRotationMode);
bindListener(window, "mousemove", handleMouseMove);
bindListener(renderer.domElement, "mousedown", handleMouseDown);
bindListener(window, "mouseup", handleMouseUp);
@@ -1267,6 +2106,7 @@ function updateHudScale() {
function onWindowResize() {
updateHudScale();
syncRendererViewport();
+ repositionCruiseConnector();
}
function getFrontFacingCables(cableLines) {
@@ -1316,7 +2156,7 @@ function onMouseMove(event) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
- } else if (!lockedObject && !lockedSatellite) {
+ } else if (!lockedObject && !lockedSatellite && !cruiseCardPinned) {
hideInfoCard();
}
hideTooltip();
@@ -1442,7 +2282,7 @@ function onMouseMove(event) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
- } else if (!lockedObjectType) {
+ } else if (!lockedObjectType && !cruiseCardPinned) {
resetTransientBGPStates();
hideInfoCard();
}
@@ -1532,6 +2372,7 @@ function onClick(event) {
: null;
if (clickedBGPMarker?.userData?.type === "bgp") {
+ interruptCruisePresentation();
clearLockedObject();
const clickedMarker = clickedBGPMarker;
@@ -1545,14 +2386,7 @@ function onClick(event) {
lastBGPClickPos = { x: event.clientX, y: event.clientY };
setAutoRotate(false);
showBGPEventOverlay(clickedMarker, earth);
- {
- const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
- getBGPRelatedRegions(clickedMarker),
- { limit: 6, maxAngleDeg: 20 },
- );
- clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length;
- highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc");
- }
+ applyBGPEventSatelliteHighlights(clickedMarker);
const incidentSummary = getBGPInfrastructureSummary(clickedMarker);
showBGPInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
@@ -1563,6 +2397,7 @@ function onClick(event) {
}
if (clickedBGPMarker?.userData?.type === "bgp_collector") {
+ interruptCruisePresentation();
clearLockedObject();
const clickedMarker = clickedBGPMarker;
@@ -1586,6 +2421,7 @@ function onClick(event) {
}
if (cableIntersects.length > 0 && getShowCables()) {
+ interruptCruisePresentation();
clearLockedObject();
const clickedCable = cableIntersects[0].object;
@@ -1595,6 +2431,19 @@ function onClick(event) {
lockedObject = clickedCable;
lockedObjectType = "cable";
setAutoRotate(false);
+ {
+ const cableLandingRegions = getLandingPoints()
+ .filter((lp) => lp.userData.cableNames?.includes(clickedCable.userData.name))
+ .map((lp) => {
+ const { lat, lon } = vector3ToLatLon(lp.position);
+ return { latitude: lat, longitude: lon };
+ });
+ const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
+ cableLandingRegions,
+ { limit: 6, maxAngleDeg: 20 },
+ );
+ highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
+ }
handleCableClick(clickedCable);
showCableInfo(clickedCable, { x: event.clientX, y: event.clientY });
return;
@@ -1631,6 +2480,7 @@ function onClick(event) {
const sat = selectSatellite(selectedIndex);
if (!sat?.properties) return;
+ interruptCruisePresentation();
clearLockedObject();
lockedObject = sat;
@@ -1656,6 +2506,7 @@ function onClick(event) {
}
if (!isLongDrag) {
+ interruptCruisePresentation();
clearLockedObject();
hideInfoCard();
setAutoRotate(true);
@@ -1673,7 +2524,7 @@ function animate() {
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY;
- if (getAutoRotate() && earth) {
+ if (getAutoRotate() && getRotationMode() === ROTATION_MODE.ROTATE && earth) {
earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16);
// Keep the drag target aligned with autorotation only when the user is not
@@ -1713,7 +2564,10 @@ function animate() {
}
applyCableVisualState();
- updateBGPVisualState(lockedObjectType, lockedObject, camera);
+ const activeCruiseMarker = (isCruiseModeActive() && cruiseCardPinned && cruiseCurrentMarkerId)
+ ? getBGPAnomalyMarkers().find((m) => m.userData?.id === cruiseCurrentMarkerId) ?? null
+ : null;
+ updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false, camera);
@@ -1722,23 +2576,12 @@ function animate() {
) {
applyLandingPointVisualState(null, true, camera);
} else if (lockedObjectType === "bgp" && lockedObject) {
- const relatedCableNames = getBGPRelatedCableNames(lockedObject);
- clearAllCableStates();
- relatedCableNames.forEach((name) => {
- getCableLines().forEach((cable) => {
- if (cable.userData?.name === name) {
- setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
- }
- });
- });
- applyLandingPointVisualState(
- relatedCableNames.length > 0 ? relatedCableNames : null,
- relatedCableNames.length === 0,
- camera,
- );
+ applyBGPRelatedCablesAndLandingPoints(lockedObject, camera);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
clearAllCableStates();
resetLandingPointVisualState(camera);
+ } else if (activeCruiseMarker) {
+ applyBGPRelatedCablesAndLandingPoints(activeCruiseMarker, camera);
} else {
resetLandingPointVisualState(camera);
}
@@ -1749,7 +2592,6 @@ function animate() {
updateCelestialLayer(new Date(), camera);
setEarthSunDirection(getSunDirection());
updateNewsViewFocus(getCurrentViewCenterCoords());
-
const satPositions = getSatellitePositions();
if (
lockedObjectType === "satellite" &&
@@ -1764,6 +2606,7 @@ function animate() {
updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current);
}
+ repositionCruiseConnector();
renderer.render(scene, camera);
}
diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js
index 891faada..aa13fc90 100644
--- a/frontend/public/earth/js/satellites.js
+++ b/frontend/public/earth/js/satellites.js
@@ -6,6 +6,7 @@ import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
let satellitePoints = null;
+let satelliteBackdropPoints = null;
let satelliteTrails = null;
let satelliteData = [];
let showSatellites = false;
@@ -17,6 +18,7 @@ let lockedRingSprite = null;
let lockedDotSprite = null;
let predictedOrbitLine = null;
let relatedSatelliteSprites = [];
+let highlightedSatelliteIndices = null;
let earthObjRef = null;
let sceneRef = null;
let cameraRef = null;
@@ -24,6 +26,7 @@ let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
+let satelliteSatrecCache = new Map();
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32;
@@ -117,6 +120,10 @@ export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
}
+function getBreathingPulse(phase) {
+ return 0.5 + 0.5 * Math.sin(phase);
+}
+
export function getSatelliteLegendItems() {
const presentKeys = new Set();
@@ -204,6 +211,37 @@ function createDotTexture() {
return texture;
}
+function createBackdropDotTexture() {
+ const canvas = document.createElement("canvas");
+ canvas.width = DOT_TEXTURE_SIZE;
+ canvas.height = DOT_TEXTURE_SIZE;
+ const ctx = canvas.getContext("2d");
+ const center = DOT_TEXTURE_SIZE / 2;
+ const radius = center - 1;
+
+ const gradient = ctx.createRadialGradient(
+ center,
+ center,
+ 0,
+ center,
+ center,
+ radius,
+ );
+ gradient.addColorStop(0, "rgba(7, 14, 27, 0.98)");
+ gradient.addColorStop(0.55, "rgba(7, 14, 27, 0.88)");
+ gradient.addColorStop(0.85, "rgba(7, 14, 27, 0.34)");
+ gradient.addColorStop(1, "rgba(7, 14, 27, 0)");
+
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(center, center, radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.needsUpdate = true;
+ return texture;
+}
+
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
const size = DOT_TEXTURE_SIZE * 2;
const canvas = document.createElement("canvas");
@@ -226,8 +264,21 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
export function createSatellites(scene, earthObj) {
initSatelliteScene(scene, earthObj);
const dotTexture = createDotTexture();
+ const backdropTexture = createBackdropDotTexture();
const pointsGeometry = new THREE.BufferGeometry();
+ const backdropGeometry = new THREE.BufferGeometry();
+
+ const backdropMaterial = new THREE.PointsMaterial({
+ size: SATELLITE_CONFIG.dotSize * 1.28,
+ map: backdropTexture,
+ color: 0x0b1626,
+ transparent: true,
+ opacity: 0.42,
+ sizeAttenuation: false,
+ alphaTest: 0.04,
+ depthWrite: false,
+ });
const pointsMaterial = new THREE.PointsMaterial({
size: SATELLITE_CONFIG.dotSize,
@@ -237,29 +288,45 @@ export function createSatellites(scene, earthObj) {
opacity: 0.9,
sizeAttenuation: false,
alphaTest: 0.1,
+ depthWrite: false,
});
+ satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
+ satelliteBackdropPoints.visible = false;
+ satelliteBackdropPoints.userData = { type: "satelliteBackdropPoints" };
+ satelliteBackdropPoints.renderOrder = 5;
+
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false;
satellitePoints.userData = { type: "satellitePoints" };
+ satellitePoints.renderOrder = 6;
const originalScale = { x: 1, y: 1, z: 1 };
- satellitePoints.onBeforeRender = () => {
+ const syncPointScale = () => {
if (earthObj && earthObj.scale.x !== 1) {
- satellitePoints.scale.set(
- originalScale.x / earthObj.scale.x,
- originalScale.y / earthObj.scale.y,
- originalScale.z / earthObj.scale.z,
- );
+ const scaleX = originalScale.x / earthObj.scale.x;
+ const scaleY = originalScale.y / earthObj.scale.y;
+ const scaleZ = originalScale.z / earthObj.scale.z;
+ satellitePoints.scale.set(scaleX, scaleY, scaleZ);
+ if (satelliteBackdropPoints) {
+ satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
+ }
} else {
- satellitePoints.scale.set(
- originalScale.x,
- originalScale.y,
- originalScale.z,
- );
+ satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
+ if (satelliteBackdropPoints) {
+ satelliteBackdropPoints.scale.set(
+ originalScale.x,
+ originalScale.y,
+ originalScale.z,
+ );
+ }
}
};
+ satelliteBackdropPoints.onBeforeRender = syncPointScale;
+ satellitePoints.onBeforeRender = syncPointScale;
+
+ earthObj.add(satelliteBackdropPoints);
earthObj.add(satellitePoints);
const trailGeometry = new THREE.BufferGeometry();
@@ -281,7 +348,12 @@ export function createSatellites(scene, earthObj) {
return satellitePoints;
}
-function getRequestedSatelliteLimit() {
+function getRequestedSatelliteLimit(limitOverride) {
+ if (limitOverride === null) return null;
+ if (Number.isFinite(limitOverride) && limitOverride > 0) {
+ return Math.floor(limitOverride);
+ }
+
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
}
@@ -295,13 +367,50 @@ function createSatellitePositionState() {
}
function ensureSatelliteCapacity(count) {
- if (!satellitePoints || !satelliteTrails) return;
+ if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
const nextCapacity = Math.max(count, 0);
if (nextCapacity === satelliteCapacity) return;
+ const previousPointPositions =
+ satellitePoints.geometry.attributes.position?.array || null;
+ const previousBackdropPositions =
+ satelliteBackdropPoints.geometry.attributes.position?.array || null;
+ const previousColors = satellitePoints.geometry.attributes.color?.array || null;
+ const previousTrailPositions =
+ satelliteTrails.geometry.attributes.position?.array || null;
+ const previousTrailColors =
+ satelliteTrails.geometry.attributes.color?.array || null;
+ const previousSatellitePositions = satellitePositions;
+ const previousCapacity = satelliteCapacity;
+
const positions = new Float32Array(nextCapacity * 3);
+ const backdropPositions = new Float32Array(nextCapacity * 3);
const colors = new Float32Array(nextCapacity * 3);
+ if (previousPointPositions) {
+ positions.set(
+ previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
+ );
+ }
+ if (previousBackdropPositions) {
+ backdropPositions.set(
+ previousBackdropPositions.subarray(
+ 0,
+ Math.min(previousBackdropPositions.length, backdropPositions.length),
+ ),
+ );
+ }
+ if (previousColors) {
+ colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
+ }
+ satelliteBackdropPoints.geometry.setAttribute(
+ "position",
+ new THREE.BufferAttribute(backdropPositions, 3),
+ );
+ satelliteBackdropPoints.geometry.setDrawRange(
+ 0,
+ Math.min(previousCapacity, nextCapacity),
+ );
satellitePoints.geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
@@ -310,10 +419,26 @@ function ensureSatelliteCapacity(count) {
"color",
new THREE.BufferAttribute(colors, 3),
);
- satellitePoints.geometry.setDrawRange(0, 0);
+ satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
+ if (previousTrailPositions) {
+ trailPositions.set(
+ previousTrailPositions.subarray(
+ 0,
+ Math.min(previousTrailPositions.length, trailPositions.length),
+ ),
+ );
+ }
+ if (previousTrailColors) {
+ trailColors.set(
+ previousTrailColors.subarray(
+ 0,
+ Math.min(previousTrailColors.length, trailColors.length),
+ ),
+ );
+ }
satelliteTrails.geometry.setAttribute(
"position",
new THREE.BufferAttribute(trailPositions, 3),
@@ -323,10 +448,19 @@ function ensureSatelliteCapacity(count) {
new THREE.BufferAttribute(trailColors, 3),
);
- satellitePositions = Array.from(
- { length: nextCapacity },
- createSatellitePositionState,
- );
+ satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
+ const previousState = previousSatellitePositions[index];
+ if (!previousState) {
+ return createSatellitePositionState();
+ }
+
+ return {
+ current: previousState.current.clone(),
+ trail: previousState.trail.slice(),
+ trailIndex: previousState.trailIndex,
+ trailCount: previousState.trailCount,
+ };
+ });
satelliteCapacity = nextCapacity;
}
@@ -337,7 +471,7 @@ function computeSatellitePosition(satellite, time) {
return null;
}
- const satrec = buildSatrecFromProperties(props, time);
+ const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
@@ -382,6 +516,45 @@ function buildSatrecFromProperties(props, fallbackTime) {
return twoline2satrec(tleLines.line1, tleLines.line2);
}
+function getSatelliteSatrecCacheKey(props) {
+ if (!props?.norad_cat_id) {
+ return null;
+ }
+
+ if (props.tle_line1 && props.tle_line2) {
+ return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
+ }
+
+ if (props.epoch) {
+ return [
+ "elements",
+ props.norad_cat_id,
+ props.epoch,
+ props.inclination,
+ props.raan,
+ props.eccentricity,
+ props.arg_of_perigee,
+ props.mean_anomaly,
+ props.mean_motion,
+ ].join(":");
+ }
+
+ return null;
+}
+
+function getOrBuildSatrec(props, fallbackTime) {
+ const cacheKey = getSatelliteSatrecCacheKey(props);
+ if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
+ return satelliteSatrecCache.get(cacheKey);
+ }
+
+ const satrec = buildSatrecFromProperties(props, fallbackTime);
+ if (cacheKey && satrec && !satrec.error) {
+ satelliteSatrecCache.set(cacheKey, satrec);
+ }
+ return satrec;
+}
+
function computeTleChecksum(line) {
let sum = 0;
@@ -491,8 +664,8 @@ function generateFallbackPosition(satellite, index, total) {
return new THREE.Vector3(x, y, z);
}
-export async function loadSatellites() {
- const limit = getRequestedSatelliteLimit();
+export async function loadSatellites(options = {}) {
+ const limit = getRequestedSatelliteLimit(options.limit);
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
if (limit !== null) {
url.searchParams.set("limit", String(limit));
@@ -505,13 +678,17 @@ export async function loadSatellites() {
const data = await response.json();
satelliteData = data.features || [];
+ satelliteSatrecCache = new Map();
ensureSatelliteCapacity(satelliteData.length);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
- return satelliteData.length;
+ return {
+ count: satelliteData.length,
+ requestedLimit: limit,
+ };
}
export function updateSatellitePositions(deltaTime = 0, force = false) {
- if (!satellitePoints || satelliteData.length === 0) return;
+ if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
const shouldUpdateTrails =
showSatellites || showTrails || lockedSatelliteIndex !== null;
@@ -528,6 +705,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positionUpdateAccumulator = 0;
const positions = satellitePoints.geometry.attributes.position.array;
+ const backdropPositions =
+ satelliteBackdropPoints.geometry.attributes.position.array;
const colors = satellitePoints.geometry.attributes.color.array;
const trailPositions = satelliteTrails.geometry.attributes.position.array;
const trailColors = satelliteTrails.geometry.attributes.color.array;
@@ -559,13 +738,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3] = pos.x;
positions[i * 3 + 1] = pos.y;
positions[i * 3 + 2] = pos.z;
+ backdropPositions[i * 3] = pos.x;
+ backdropPositions[i * 3 + 1] = pos.y;
+ backdropPositions[i * 3 + 2] = pos.z;
const rule = getSatelliteLegendRule(props);
const { r, g, b } = getSatelliteRuleColor(rule);
- colors[i * 3] = r;
- colors[i * 3 + 1] = g;
- colors[i * 3 + 2] = b;
+ if (highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i)) {
+ const lum = r * 0.299 + g * 0.587 + b * 0.114;
+ colors[i * 3] = lum * 0.75 + r * 0.25;
+ colors[i * 3 + 1] = lum * 0.75 + g * 0.25;
+ colors[i * 3 + 2] = lum * 0.75 + b * 0.25;
+ } else {
+ colors[i * 3] = r;
+ colors[i * 3 + 1] = g;
+ colors[i * 3 + 2] = b;
+ }
const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) {
@@ -601,6 +790,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3] = 0;
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 0;
+ backdropPositions[i * 3] = 0;
+ backdropPositions[i * 3 + 1] = 0;
+ backdropPositions[i * 3 + 2] = 0;
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
@@ -613,6 +805,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
satellitePoints.geometry.attributes.position.needsUpdate = true;
satellitePoints.geometry.attributes.color.needsUpdate = true;
satellitePoints.geometry.setDrawRange(0, count);
+ satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
+ satelliteBackdropPoints.geometry.setDrawRange(0, count);
satelliteTrails.geometry.attributes.position.needsUpdate = true;
satelliteTrails.geometry.attributes.color.needsUpdate = true;
@@ -631,6 +825,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
export function toggleSatellites(visible) {
showSatellites = visible;
+ if (satelliteBackdropPoints) {
+ satelliteBackdropPoints.visible = visible;
+ }
if (satellitePoints) {
satellitePoints.visible = visible;
}
@@ -821,10 +1018,15 @@ export function hideLockedRing() {
export function updateLockedRingPosition(position) {
if (!position) return;
+ if (!lockedRingSprite || !lockedDotSprite) {
+ showHoverRing(position, true);
+ }
if (lockedRingSprite) {
lockedRingSprite.position.copy(position);
+ const ringPulse = getBreathingPulse(breathingPhase);
const breathScale =
- 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
+ 1 +
+ (ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
lockedRingSprite.scale.set(
SATELLITE_CONFIG.ringSize * breathScale,
SATELLITE_CONFIG.ringSize * breathScale,
@@ -832,20 +1034,21 @@ export function updateLockedRingPosition(position) {
);
lockedRingSprite.material.opacity =
SATELLITE_CONFIG.breathingOpacityMin +
- Math.sin(breathingPhase) *
+ ringPulse *
(SATELLITE_CONFIG.breathingOpacityMax -
SATELLITE_CONFIG.breathingOpacityMin);
}
if (lockedDotSprite) {
lockedDotSprite.position.copy(position);
+ const dotPulse = getBreathingPulse(breathingPhase);
const dotBreathScale =
1 +
- Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
+ (dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
lockedDotSprite.material.opacity =
SATELLITE_CONFIG.dotOpacityMin +
- Math.sin(breathingPhase) *
+ dotPulse *
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
}
}
@@ -881,6 +1084,15 @@ export function setSatelliteRingState(index, state, position) {
}
}
+function applyDimMaterialState(isDimmed) {
+ if (satellitePoints) {
+ satellitePoints.material.opacity = isDimmed ? 0.32 : 0.9;
+ }
+ if (satelliteBackdropPoints) {
+ satelliteBackdropPoints.material.opacity = isDimmed ? 0.12 : 0.42;
+ }
+}
+
export function clearRelatedSatelliteHighlights() {
relatedSatelliteSprites.forEach((item) => {
if (item.sprite) {
@@ -888,12 +1100,16 @@ export function clearRelatedSatelliteHighlights() {
}
});
relatedSatelliteSprites = [];
+ highlightedSatelliteIndices = null;
+ applyDimMaterialState(false);
}
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
clearRelatedSatelliteHighlights();
if (!Array.isArray(indices) || indices.length === 0) return;
+ highlightedSatelliteIndices = new Set(indices);
+ applyDimMaterialState(true);
indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current;
if (!pos) return;
@@ -1049,10 +1265,12 @@ export function hidePredictedOrbit() {
export function clearSatelliteData() {
satelliteData = [];
+ satelliteSatrecCache = new Map();
selectedSatellite = null;
lockedSatelliteIndex = null;
hoveredSatelliteIndex = null;
positionUpdateAccumulator = 0;
+ breathingPhase = 0;
satellitePositions.forEach((position) => {
position.current.set(0, 0, 0);
@@ -1075,6 +1293,16 @@ export function clearSatelliteData() {
satellitePoints.geometry.setDrawRange(0, 0);
}
+ if (satelliteBackdropPoints) {
+ const backdropPositionAttr =
+ satelliteBackdropPoints.geometry.attributes.position;
+ if (backdropPositionAttr?.array) {
+ backdropPositionAttr.array.fill(0);
+ backdropPositionAttr.needsUpdate = true;
+ }
+ satelliteBackdropPoints.geometry.setDrawRange(0, 0);
+ }
+
if (satelliteTrails) {
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
const trailColorAttr = satelliteTrails.geometry.attributes.color;
@@ -1097,6 +1325,11 @@ export function clearSatelliteData() {
export function resetSatelliteState() {
clearSatelliteData();
+ if (satelliteBackdropPoints) {
+ disposeObject3D(satelliteBackdropPoints);
+ satelliteBackdropPoints = null;
+ }
+
if (satellitePoints) {
disposeObject3D(satellitePoints);
satellitePoints = null;
@@ -1109,6 +1342,7 @@ export function resetSatelliteState() {
satellitePositions = [];
satelliteCapacity = 0;
+ satelliteSatrecCache = new Map();
showSatellites = false;
showTrails = true;
}
diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js
index 6dae6f35..dcf303a9 100644
--- a/frontend/public/earth/js/ui.js
+++ b/frontend/public/earth/js/ui.js
@@ -72,6 +72,10 @@ function buildStatusContent(statusEl, message, type) {
statusEl.appendChild(text);
}
+function buildPersistentErrorContent(errorEl, message) {
+ buildStatusContent(errorEl, message, "error");
+}
+
function hideStatusElement(statusEl, onHidden) {
statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => {
@@ -259,16 +263,21 @@ export function hideTooltip() {
export function showError(message) {
const errorEl = getElement("error-message");
if (!errorEl) return;
- errorEl.textContent = message;
- setElementDisplay(errorEl, true);
+ buildPersistentErrorContent(errorEl, message);
+ errorEl.className = `${STATUS_BASE_CLASS} earth-error-message error`;
+ setElementDisplay(errorEl, true, "inline-flex");
+ errorEl.offsetHeight;
+ errorEl.classList.add("visible");
}
// Hide error message
export function hideError() {
const errorEl = getElement("error-message");
if (errorEl) {
+ errorEl.classList.remove("visible");
setElementDisplay(errorEl, false);
- errorEl.textContent = "";
+ errorEl.className = "earth-error-message";
+ errorEl.innerHTML = "";
}
}
diff --git a/planet.sh b/planet.sh
index 4b9be282..4b10ef02 100755
--- a/planet.sh
+++ b/planet.sh
@@ -1054,6 +1054,14 @@ start_ai_provider_service() {
exit 1
}
+ai_provider_service_healthy() {
+ local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
+
+ docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
+ curl -s --max-time "$HTTP_CHECK_MAX_TIME" \
+ "http://localhost:${ai_provider_port}/health" >/dev/null 2>&1
+}
+
ensure_database_services_healthy() {
local retry=1
@@ -1130,7 +1138,15 @@ start_backend_service() {
log_success "启动数据库已就绪"
sleep 3
- start_ai_provider_service "$ai_provider_port"
+ # Backend depends on AI Provider reachability, but a backend-only restart
+ # should reuse the existing healthy provider instead of rebuilding or
+ # restarting it.
+ if ai_provider_service_healthy "$ai_provider_port"; then
+ log_note "AI Provider 已健康,复用现有服务,跳过启动/重建"
+ else
+ log_note "AI Provider 当前不健康,先执行托底启动"
+ start_ai_provider_service "$ai_provider_port"
+ fi
if [ "$backend_port_requested" -eq 1 ]; then
kill_port_if_requested "$backend_port" "后端"
diff --git a/pyproject.toml b/pyproject.toml
index 6ad4c4d2..f2e5fb1d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planet"
-version = "0.30.0"
+version = "0.31.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [
diff --git a/uv.lock b/uv.lock
index 91f56f4b..cdfa66db 100644
--- a/uv.lock
+++ b/uv.lock
@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
-version = "0.30.0"
+version = "0.31.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },