release: bump version to 0.31.0
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 去框并收敛昼夜与选中态可读性 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.30.0",
|
||||
"version": "0.31.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
378
frontend/public/earth/js/controls.js
vendored
378
frontend/public/earth/js/controls.js
vendored
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
planet.sh
18
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" "后端"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user