diff --git a/VERSION b/VERSION index c25c8e5b..26bea73e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.30.0 +0.31.0 diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index e390402a..23487b65 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -786,9 +786,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)): @router.get("/geo/landing-points") async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): try: - records = await _load_current_collected_data(db, "arcgis_landing_points") - relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation") - cable_records = await _load_current_collected_data(db, "arcgis_cables") + records_by_source = await _load_current_collected_data_by_sources( + db, + [ + "arcgis_landing_points", + "arcgis_cable_landing_relation", + "arcgis_cables", + ], + ) + records = records_by_source.get("arcgis_landing_points", []) + relation_records = records_by_source.get( + "arcgis_cable_landing_relation", + [], + ) + cable_records = records_by_source.get("arcgis_cables", []) city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps( relation_records, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6dffd0f9..dab3b5a3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,21 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.31.0] — 2026-04-21 + +### ✨ Features +- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式) +- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆 +- BGP 事件图标新增填充 W 形波动符号(flap 类型),替换原有难以辨认的贝塞尔细线 +- 巡航/点击激活时其余卫星自动降饱和度 + 增加透明度以突出焦点;海缆未受影响时同步变暗 + +### 🔧 Improvements +- 修复巡航轮播期间 BGP 事件 polling 刷新导致标记闪烁消失的问题(clearBGPData 延迟到请求完成后执行) +- 点击与巡航锁定颜色统一为 hover 色(0.92, 0.98, 1.0 全透明),移除锁定态脉冲动画 +- 巡航连接折线转折点从尖角调整为钝角(linkElbowDropPx),提升连线可读性 + +--- + ## [0.29.1] — 2026-04-20 ## [0.30.0] — 2026-04-21 diff --git a/docs/version-history.md b/docs/version-history.md index efe76523..5af0ea57 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.30.0` +- `dev` 当前开发分支历史推导到:`0.31.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 | | `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 | | `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 | | `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 | diff --git a/frontend/package.json b/frontend/package.json index 133d72a3..8bdc04bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.30.0", + "version": "0.31.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 1c3aecd0..8e773d50 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -279,18 +279,8 @@ font-weight: 600; } -.hud-error-message { - color: #ff4444; - margin-top: 10px; - font-size: 0.9rem; - display: none; - padding: 10px; - background-color: rgba(255, 68, 68, 0.1); - border-radius: 5px; - border-left: 3px solid #ff4444; -} - -.earth-status-message { +.earth-status-message, +.earth-error-message { position: absolute; top: calc(20px * var(--hud-scale)); left: 50%; @@ -325,11 +315,18 @@ opacity 0.28s ease; } -.earth-status-message.visible { +.earth-status-message.visible, +.earth-error-message.visible { transform: translate(-50%, 0); opacity: 1; } +.earth-error-message { + top: calc(62px * var(--hud-scale)); + z-index: 211; + min-width: min(calc(220px * var(--hud-scale)), 58vw); +} + /* ── Indicator: single dot (transient) or three dots (loading) ── */ .earth-status-indicator { @@ -541,7 +538,7 @@ .earth-settings-kicker { color: var(--hud-text-soft); - font-size: var(--hud-kicker-size); + font-size: var(--hud-panel-header-title-size); letter-spacing: 0.16em; text-transform: uppercase; } @@ -634,6 +631,52 @@ gap: 14px; } +.earth-settings-segmented { + display: inline-flex; + align-self: flex-start; + padding: 4px; + border-radius: 999px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent), + rgba(255, 255, 255, 0.03); + border: 1px solid rgba(212, 227, 244, 0.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + gap: 4px; +} + +.earth-settings-segmented-btn { + border: 0; + background: transparent; + color: var(--hud-text-soft); + padding: 8px 14px; + border-radius: 999px; + font: inherit; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.earth-settings-segmented-btn:hover { + color: var(--hud-text); + transform: translateY(-1px); +} + +.earth-settings-segmented-btn.is-active { + color: var(--hud-title); + background: + radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%), + linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 8px 18px rgba(0, 0, 0, 0.2); +} + .earth-settings-slider { flex: 1 1 auto; width: 100%; diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index 3ff1096e..0558cd91 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -161,6 +161,76 @@ pointer-events: auto; } +.info-card-cruise-link { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + opacity: 0; + pointer-events: none; + transition: opacity 0.22s ease; + z-index: 49; +} + +.info-card-cruise-link polyline { + fill: none; + stroke: rgba(255, 255, 255, 0.98); + stroke-width: 2.15; + stroke-linecap: round; + stroke-linejoin: round; + filter: + drop-shadow(0 0 1px rgba(6, 14, 28, 0.72)) + drop-shadow(0 0 2px rgba(6, 14, 28, 0.56)) + drop-shadow(0 0 6px rgba(8, 20, 36, 0.1)); +} + +.info-card-cruise-link circle { + fill: rgba(255, 255, 255, 0.98); + stroke: rgba(7, 16, 32, 0.72); + stroke-width: 1.0; + filter: + drop-shadow(0 0 1px rgba(6, 14, 28, 0.72)) + drop-shadow(0 0 2px rgba(6, 14, 28, 0.54)) + drop-shadow(0 0 6px rgba(8, 20, 36, 0.1)); + transform-box: fill-box; + transform-origin: center; +} + +.info-card-cruise-link.is-visible { + opacity: 1; +} + +.info-card-cruise-link.is-animating polyline { + animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.info-card-cruise-link.is-animating circle { + animation: cruiseConnectorNodeIn 0.22s ease forwards; + animation-delay: 0.22s; + opacity: 0; +} + +@keyframes cruiseConnectorDraw { + from { + stroke-dashoffset: var(--connector-length, 0px); + } + to { + stroke-dashoffset: 0px; + } +} + +@keyframes cruiseConnectorNodeIn { + from { + opacity: 0; + transform: scale(0.72); + } + to { + opacity: 1; + transform: scale(1); + } +} + /* ── Info Card ────────────────────────────────────────────────── */ .info-card { diff --git a/frontend/public/earth/css/tv-panel.css b/frontend/public/earth/css/tv-panel.css index 214260f1..23b5708e 100644 --- a/frontend/public/earth/css/tv-panel.css +++ b/frontend/public/earth/css/tv-panel.css @@ -133,8 +133,8 @@ max-height: 0; opacity: 0; pointer-events: none; - margin-top: calc(-1 * var(--hud-gap-sm)); - margin-bottom: calc(-1 * var(--hud-gap-sm)); + margin-top: 0; + margin-bottom: 0; } .tv-panel-meta { @@ -169,7 +169,7 @@ .tv-panel-player { position: relative; - flex: 1 0 auto; + flex: 1 1 auto; min-height: calc(220px * var(--hud-scale)); border-radius: calc(16px * var(--hud-scale)); overflow: hidden; diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index d373e6ac..7d2726fb 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -150,7 +150,7 @@ -
+
@@ -424,6 +424,35 @@
+
+
旋转
+
+
+
+ 旋转模式 + 旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦 +
+
+ + +
+
+
+
视图
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" },