release: bump version to 0.31.0

This commit is contained in:
linkong
2026-04-21 18:35:40 +08:00
parent 0f89372d71
commit b7647379de
21 changed files with 1833 additions and 293 deletions

View File

@@ -1 +1 @@
0.30.0
0.31.0

View File

@@ -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,

View File

@@ -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

View File

@@ -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 去框并收敛昼夜与选中态可读性 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.30.0",
"version": "0.31.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -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%;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -150,7 +150,7 @@
</div>
</div>
<div id="error-message" class="hud-error-message"></div>
<div id="error-message" class="earth-error-message" aria-live="assertive" aria-atomic="true"></div>
<div id="right-toolbar-group" class="earth-toolbar-group">
<div id="control-toolbar" class="earth-toolbar">
@@ -424,6 +424,35 @@
</button>
</div>
<div class="earth-settings-content hud-panel__body">
<section class="earth-settings-section">
<div class="earth-settings-section-title">旋转</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">旋转模式</span>
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
<button
type="button"
class="earth-settings-segmented-btn is-active"
data-rotation-mode="rotate"
aria-pressed="true"
>
旋转模式
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-rotation-mode="cruise"
aria-pressed="false"
>
巡航模式
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list">

View File

@@ -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) => {

View File

@@ -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");

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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() {

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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 = "";
}
}

View File

@@ -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" "后端"

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.30.0"
version = "0.31.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.30.0"
version = "0.31.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },