fix: polish earth legend and info panel interactions

This commit is contained in:
linkong
2026-03-27 16:01:12 +08:00
parent b448a1e560
commit 62f2d9f403
14 changed files with 466 additions and 30 deletions

View File

@@ -1 +1 @@
0.21.5-dev 0.21.6

View File

@@ -7,6 +7,33 @@ This project follows the repository versioning rule:
- `feature` -> `+0.1.0` - `feature` -> `+0.1.0`
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## 0.21.6
Released: 2026-03-27
### Highlights
- Refined the Earth page interaction loop with object-driven legend switching, clearer selection feedback, and cleaner HUD copy/layout behavior.
- Improved the Earth info surfaces so status toasts, info-card interactions, and title/subtitle presentation feel more intentional and easier to scan.
### Added
- Added click-to-copy support for info-card labels so clicking a field label copies the matching field value.
- Added runtime-generated legend content for cables and satellites based on current Earth data and selection state.
### Improved
- Improved Earth legend behavior so selected cables and selected satellite categories are promoted to the top of the legend list.
- Improved legend overflow handling by constraining the visible list and using scroll for additional entries.
- Improved info-panel heading layout with centered title/subtitle styling and better subtitle hierarchy.
- Improved status-message behavior with replayable slide-in notifications when messages change in quick succession.
### Fixed
- Fixed info-card content spacing by targeting the actual `#info-card-content` node instead of a non-matching class selector.
- Fixed cable legend generation so it follows backend-returned cable names and colors instead of stale hard-coded placeholder categories.
- Fixed reset-view and legend-related HUD behaviors so selection and legend state stay in sync when users interact with real Earth objects.
## 0.21.5 ## 0.21.5
Released: 2026-03-27 Released: 2026-03-27

View File

@@ -16,7 +16,7 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.21.3` - `dev` 当前开发分支历史推导到:`0.21.6`
## Timeline ## Timeline
@@ -66,6 +66,9 @@
| `0.21.1` | bugfix | `dev` | `pending` | polish Earth toolbar controls, icons, and loading copy | | `0.21.1` | bugfix | `dev` | `pending` | polish Earth toolbar controls, icons, and loading copy |
| `0.21.2` | bugfix | `dev` | `pending` | redesign Earth HUD with liquid-glass controls, dynamic legend switching, and info-card interaction polish | | `0.21.2` | bugfix | `dev` | `pending` | redesign Earth HUD with liquid-glass controls, dynamic legend switching, and info-card interaction polish |
| `0.21.3` | bugfix | `dev` | `30a29a6e` | harden `planet.sh` startup controls, add selective restart and interactive user creation | | `0.21.3` | bugfix | `dev` | `30a29a6e` | harden `planet.sh` startup controls, add selective restart and interactive user creation |
| `0.21.4` | bugfix | `dev` | `7ec9586f` | add Earth HUD backup snapshots and icon assets |
| `0.21.5` | bugfix | `dev` | `a761dfc5` | refine Earth legend item presentation |
| `0.21.6` | bugfix | `dev` | `pending` | improve Earth legend generation, info-card interactions, and HUD messaging polish |
## Maintenance Commits Not Counted as Version Bumps ## Maintenance Commits Not Counted as Version Bumps

View File

@@ -14,14 +14,34 @@
margin-bottom: 5px; margin-bottom: 5px;
color: #4db8ff; color: #4db8ff;
text-shadow: 0 0 10px rgba(77, 184, 255, 0.5); text-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
text-align: center;
} }
#info-panel .subtitle { #info-panel .subtitle {
color: #aaa;
margin-bottom: 20px; margin-bottom: 20px;
font-size: 0.9rem;
border-bottom: 1px solid rgba(255,255,255,0.1); border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 10px; padding-bottom: 12px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
#info-panel .subtitle-main {
color: #d7e7f5;
font-size: 0.95rem;
line-height: 1.35;
font-weight: 500;
letter-spacing: 0.02em;
}
#info-panel .subtitle-meta {
color: #8ea5bc;
font-size: 0.74rem;
line-height: 1.3;
letter-spacing: 0.08em;
text-transform: uppercase;
} }
#info-panel .cable-info { #info-panel .cable-info {

View File

@@ -19,6 +19,24 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
max-height: 202px;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(160, 220, 255, 0.4) transparent;
}
#legend .legend-list::-webkit-scrollbar {
width: 6px;
}
#legend .legend-list::-webkit-scrollbar-track {
background: transparent;
}
#legend .legend-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 237, 255, 0.28), rgba(110, 176, 255, 0.34));
border-radius: 999px;
} }
#legend .legend-item { #legend .legend-item {

View File

@@ -33,7 +33,10 @@
<div id="container"> <div id="container">
<div id="info-panel"> <div id="info-panel">
<h1>智能星球计划</h1> <h1>智能星球计划</h1>
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div> <div class="subtitle">
<span class="subtitle-main">现实层宇宙全息感知系统</span>
<span class="subtitle-meta">卫星 · 海底光缆 · 算力基础设施</span>
</div>
<div id="info-card" class="info-card" style="display: none;"> <div id="info-card" class="info-card" style="display: none;">
<div class="info-card-header"> <div class="info-card-header">
@@ -90,6 +93,12 @@
</span> </span>
<span class="tooltip">显示卫星</span> <span class="tooltip">显示卫星</span>
</button> </button>
<button id="toggle-bgp" class="toolbar-btn floating-btn liquid-glass-surface active" title="显示/隐藏BGP观测">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">radar</span>
</span>
<span class="tooltip">隐藏BGP观测</span>
</button>
<button id="toggle-cables" class="toolbar-btn floating-btn liquid-glass-surface active" title="显示/隐藏线缆"> <button id="toggle-cables" class="toolbar-btn floating-btn liquid-glass-surface active" title="显示/隐藏线缆">
<span class="icon" aria-hidden="true"> <span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">cable</span> <span class="material-symbols-rounded">cable</span>
@@ -194,6 +203,10 @@
<span class="stats-label">卫星:</span> <span class="stats-label">卫星:</span>
<span class="stats-value" id="satellite-count">0 颗</span> <span class="stats-value" id="satellite-count">0 颗</span>
</div> </div>
<div class="stats-item">
<span class="stats-label">BGP异常:</span>
<span class="stats-value" id="bgp-anomaly-count">0 条</span>
</div>
<div class="stats-item"> <div class="stats-item">
<span class="stats-label">视角距离:</span> <span class="stats-label">视角距离:</span>
<span class="stats-value" id="camera-distance">300 km</span> <span class="stats-value" id="camera-distance">300 km</span>
@@ -207,7 +220,7 @@
<div id="loading"> <div id="loading">
<div id="loading-spinner"></div> <div id="loading-spinner"></div>
<div id="loading-title">正在初始化全球态势数据...</div> <div id="loading-title">正在初始化全球态势数据...</div>
<div id="loading-subtitle" style="font-size:0.9rem; margin-top:10px; color:#aaa;">同步卫星、海底光缆登陆点数据</div> <div id="loading-subtitle" style="font-size:0.9rem; margin-top:10px; color:#aaa;">同步卫星、海底光缆登陆点与BGP异常数据</div>
</div> </div>
<div id="status-message" class="status-message" style="display: none;"></div> <div id="status-message" class="status-message" style="display: none;"></div>
<div id="tooltip" class="tooltip"></div> <div id="tooltip" class="tooltip"></div>

View File

@@ -6,7 +6,7 @@ import { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from "./constants.js";
import { latLonToVector3 } from "./utils.js"; import { latLonToVector3 } from "./utils.js";
import { updateEarthStats, showStatusMessage } from "./ui.js"; import { updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from "./info-card.js"; import { showInfoCard } from "./info-card.js";
import { setLegendMode } from "./legend.js"; import { setLegendItems, setLegendMode } from "./legend.js";
export let cableLines = []; export let cableLines = [];
export let landingPoints = []; export let landingPoints = [];
@@ -57,7 +57,11 @@ function getCableColor(properties) {
} }
const cableName = const cableName =
properties.Name || properties.cableName || properties.shortname || ""; properties.Name ||
properties.name ||
properties.cableName ||
properties.shortname ||
"";
if (cableName.includes("Americas II")) { if (cableName.includes("Americas II")) {
return CABLE_COLORS["Americas II"]; return CABLE_COLORS["Americas II"];
} }
@@ -91,11 +95,17 @@ function createCableLine(points, color, properties) {
properties.cable_id || properties.cable_id ||
properties.id || properties.id ||
properties.Name || properties.Name ||
properties.name ||
Math.random().toString(36); Math.random().toString(36);
cableLine.userData = { cableLine.userData = {
type: "cable", type: "cable",
cableId, cableId,
name: properties.Name || properties.cableName || "Unknown", name:
properties.Name ||
properties.name ||
properties.cableName ||
properties.shortname ||
"Unknown",
owner: properties.owner || properties.owners || "-", owner: properties.owner || properties.owners || "-",
status: properties.status || "-", status: properties.status || "-",
length: properties.length || "-", length: properties.length || "-",
@@ -382,6 +392,7 @@ export async function loadLandingPoints(scene, earthObj) {
export function handleCableClick(cable) { export function handleCableClick(cable) {
lockedCable = cable; lockedCable = cable;
setLegendItems("cables", getCableLegendItems());
const data = cable.userData; const data = cable.userData;
setLegendMode("cables"); setLegendMode("cables");
@@ -399,12 +410,51 @@ export function handleCableClick(cable) {
export function clearCableSelection() { export function clearCableSelection() {
lockedCable = null; lockedCable = null;
setLegendItems("cables", getCableLegendItems());
} }
export function getCableLines() { export function getCableLines() {
return cableLines; return cableLines;
} }
export function getCableLegendItems() {
const legendMap = new Map();
cableLines.forEach((cable) => {
const color = cable.userData?.originalColor;
const label = cable.userData?.name || "未知线缆";
if (typeof color === "number" && !legendMap.has(label)) {
legendMap.set(label, {
label,
color: `#${color.toString(16).padStart(6, "0")}`,
});
}
});
if (legendMap.size === 0) {
return [{ label: "其他电缆", color: "#ffff44" }];
}
const items = Array.from(legendMap.values()).sort((a, b) =>
a.label.localeCompare(b.label, "zh-CN"),
);
const selectedName = lockedCable?.userData?.name;
if (!selectedName) {
return items;
}
const selectedIndex = items.findIndex((item) => item.label === selectedName);
if (selectedIndex <= 0) {
return items;
}
const [selectedItem] = items.splice(selectedIndex, 1);
items.unshift(selectedItem);
return items;
}
export function getCablesById(cableId) { export function getCablesById(cableId) {
return cableIdMap.get(cableId) || []; return cableIdMap.get(cableId) || [];
} }

View File

@@ -26,6 +26,7 @@ export const EARTH_CONFIG = {
export const PATHS = { export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables', cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points', landingPointsApi: '/api/v1/visualization/geo/landing-points',
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
geoJSON: './geo.json', geoJSON: './geo.json',
landingPointsStatic: './landing-point-geo.geojson', landingPointsStatic: './landing-point-geo.geojson',
}; };
@@ -69,6 +70,37 @@ export const SATELLITE_CONFIG = {
dotOpacityMax: 1.0 dotOpacityMax: 1.0
}; };
export const BGP_CONFIG = {
defaultFetchLimit: 200,
maxRenderedMarkers: 200,
altitudeOffset: 1.2,
baseScale: 6.2,
hoverScale: 1.16,
dimmedScale: 0.92,
pulseSpeed: 0.0045,
normalPulseAmplitude: 0.08,
lockedPulseAmplitude: 0.28,
opacity: {
normal: 0.78,
hover: 1.0,
dimmed: 0.24,
lockedMin: 0.65,
lockedMax: 1.0
},
severityColors: {
critical: 0xff4d4f,
high: 0xff9f43,
medium: 0xffd166,
low: 0x4dabf7
},
severityScales: {
critical: 1.18,
high: 1.08,
medium: 1.0,
low: 0.94
}
};
export const PREDICTED_ORBIT_CONFIG = { export const PREDICTED_ORBIT_CONFIG = {
sampleInterval: 10, sampleInterval: 10,
opacity: 0.8 opacity: 0.8

View File

@@ -11,6 +11,7 @@ import {
getSatelliteCount, getSatelliteCount,
} from "./satellites.js"; } from "./satellites.js";
import { toggleCables, getShowCables } from "./cables.js"; import { toggleCables, getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
export let autoRotate = true; export let autoRotate = true;
export let zoomLevel = 1.0; export let zoomLevel = 1.0;
@@ -293,6 +294,7 @@ function setupTerrainControls() {
const infoTrigger = document.getElementById("info-trigger"); const infoTrigger = document.getElementById("info-trigger");
const terrainBtn = document.getElementById("toggle-terrain"); const terrainBtn = document.getElementById("toggle-terrain");
const satellitesBtn = document.getElementById("toggle-satellites"); const satellitesBtn = document.getElementById("toggle-satellites");
const bgpBtn = document.getElementById("toggle-bgp");
const trailsBtn = document.getElementById("toggle-trails"); const trailsBtn = document.getElementById("toggle-trails");
const cablesBtn = document.getElementById("toggle-cables"); const cablesBtn = document.getElementById("toggle-cables");
const layoutBtn = document.getElementById("layout-toggle"); const layoutBtn = document.getElementById("layout-toggle");
@@ -337,6 +339,22 @@ function setupTerrainControls() {
showStatusMessage(showSats ? "卫星已显示" : "卫星已隐藏", "info"); showStatusMessage(showSats ? "卫星已显示" : "卫星已隐藏", "info");
}); });
bindListener(bgpBtn, "click", function () {
const showNextBGP = !getShowBGP();
if (!showNextBGP) {
clearLockedObject();
}
toggleBGP(showNextBGP);
this.classList.toggle("active", showNextBGP);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showNextBGP ? "隐藏BGP观测" : "显示BGP观测";
const bgpCountEl = document.getElementById("bgp-anomaly-count");
if (bgpCountEl) {
bgpCountEl.textContent = `${getBGPCount()}`;
}
showStatusMessage(showNextBGP ? "BGP观测已显示" : "BGP观测已隐藏", "info");
});
bindListener(trailsBtn, "click", function () { bindListener(trailsBtn, "click", function () {
const isActive = this.classList.contains("active"); const isActive = this.classList.contains("active");
const nextShowTrails = !isActive; const nextShowTrails = !isActive;

View File

@@ -30,6 +30,25 @@ const CARD_CONFIG = {
{ key: 'apogee', label: '远地点', unit: 'km' } { key: 'apogee', label: '远地点', unit: 'km' }
] ]
}, },
bgp: {
icon: '📡',
title: 'BGP异常详情',
className: 'bgp',
fields: [
{ key: 'anomaly_type', label: '异常类型' },
{ key: 'severity', label: '严重度' },
{ key: 'status', label: '状态' },
{ key: 'prefix', label: '前缀' },
{ key: 'origin_asn', label: '原始 ASN' },
{ key: 'new_origin_asn', label: '新 ASN' },
{ key: 'confidence', label: '置信度' },
{ key: 'collector', label: '采集器' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' },
{ key: 'created_at', label: '创建时间' },
{ key: 'summary', label: '摘要' }
]
},
supercomputer: { supercomputer: {
icon: '🖥️', icon: '🖥️',
title: '超算详情', title: '超算详情',

View File

@@ -1,25 +1,21 @@
const LEGEND_MODES = { const LEGEND_MODES = {
cables: { cables: {
title: "线缆图例", title: "线缆图例",
items: [
{ color: "#ff4444", label: "Americas II" },
{ color: "#44ff44", label: "AU Aleutian A" },
{ color: "#4444ff", label: "AU Aleutian B" },
{ color: "#ffff44", label: "其他电缆" },
],
}, },
satellites: { satellites: {
title: "卫星图例", title: "卫星图例",
items: [ },
{ color: "#4db8ff", label: "卫星本体" }, bgp: {
{ color: "#9be7ff", label: "卫星轨迹" }, title: "BGP观测图例",
{ color: "#7dffb3", label: "悬停高亮" },
{ color: "#ffd166", label: "选中目标" },
],
}, },
}; };
let currentLegendMode = "cables"; let currentLegendMode = "cables";
let legendItemsByMode = {
cables: [],
satellites: [],
bgp: [],
};
export function initLegend() { export function initLegend() {
renderLegend(currentLegendMode); renderLegend(currentLegendMode);
@@ -27,7 +23,6 @@ export function initLegend() {
export function setLegendMode(mode) { export function setLegendMode(mode) {
const nextMode = LEGEND_MODES[mode] ? mode : "cables"; const nextMode = LEGEND_MODES[mode] ? mode : "cables";
if (nextMode === currentLegendMode) return;
currentLegendMode = nextMode; currentLegendMode = nextMode;
renderLegend(currentLegendMode); renderLegend(currentLegendMode);
} }
@@ -36,12 +31,25 @@ export function getLegendMode() {
return currentLegendMode; return currentLegendMode;
} }
export function refreshLegend() {
renderLegend(currentLegendMode);
}
export function setLegendItems(mode, items) {
if (!LEGEND_MODES[mode]) return;
legendItemsByMode[mode] = Array.isArray(items) ? items : [];
if (mode === currentLegendMode) {
renderLegend(currentLegendMode);
}
}
function renderLegend(mode) { function renderLegend(mode) {
const legend = document.getElementById("legend"); const legend = document.getElementById("legend");
if (!legend) return; if (!legend) return;
const config = LEGEND_MODES[mode] || LEGEND_MODES.cables; const config = LEGEND_MODES[mode] || LEGEND_MODES.cables;
const itemsHtml = config.items const items = legendItemsByMode[mode] || [];
const itemsHtml = items
.map( .map(
(item) => ` (item) => `
<div class="legend-item"> <div class="legend-item">

View File

@@ -30,6 +30,7 @@ import {
handleCableClick, handleCableClick,
clearCableSelection, clearCableSelection,
getCableLines, getCableLines,
getCableLegendItems,
getCableState, getCableState,
setCableState, setCableState,
clearAllCableStates, clearAllCableStates,
@@ -44,6 +45,9 @@ import {
updateSatellitePositions, updateSatellitePositions,
toggleSatellites, toggleSatellites,
getShowSatellites, getShowSatellites,
getSatelliteLegendItems,
setSelectedSatelliteLegend,
clearSelectedSatelliteLegend,
getSatelliteCount, getSatelliteCount,
selectSatellite, selectSatellite,
getSatellitePoints, getSatellitePoints,
@@ -60,6 +64,18 @@ import {
resetSatelliteState, resetSatelliteState,
clearSatelliteData, clearSatelliteData,
} from "./satellites.js"; } from "./satellites.js";
import {
loadBGPAnomalies,
getBGPMarkers,
getBGPLegendItems,
getBGPCount,
getShowBGP,
clearBGPSelection,
setBGPMarkerState,
updateBGPVisualState,
clearBGPData,
toggleBGP,
} from "./bgp.js";
import { import {
setupControls, setupControls,
getAutoRotate, getAutoRotate,
@@ -74,7 +90,12 @@ import {
hideInfoCard, hideInfoCard,
setInfoCardNoBorder, setInfoCardNoBorder,
} from "./info-card.js"; } from "./info-card.js";
import { initLegend, setLegendMode } from "./legend.js"; import {
initLegend,
setLegendMode,
refreshLegend,
setLegendItems,
} from "./legend.js";
export let scene; export let scene;
export let camera; export let camera;
@@ -86,6 +107,7 @@ let previousMousePosition = { x: 0, y: 0 };
let targetRotation = { x: 0, y: 0 }; let targetRotation = { x: 0, y: 0 };
let inertialVelocity = { x: 0, y: 0 }; let inertialVelocity = { x: 0, y: 0 };
let hoveredCable = null; let hoveredCable = null;
let hoveredBGP = null;
let hoveredSatellite = null; let hoveredSatellite = null;
let hoveredSatelliteIndex = null; let hoveredSatelliteIndex = null;
let lockedSatellite = null; let lockedSatellite = null;
@@ -110,6 +132,7 @@ const interactionMouse = new THREE.Vector2();
const scratchCameraToEarth = new THREE.Vector3(); const scratchCameraToEarth = new THREE.Vector3();
const scratchCableCenter = new THREE.Vector3(); const scratchCableCenter = new THREE.Vector3();
const scratchCableDirection = new THREE.Vector3(); const scratchCableDirection = new THREE.Vector3();
const scratchBGPDirection = new THREE.Vector3();
const cleanupFns = []; const cleanupFns = [];
const DRAG_ROTATION_FACTOR = 0.005; const DRAG_ROTATION_FACTOR = 0.005;
@@ -169,6 +192,7 @@ function disposeSceneObject(object) {
function clearRuntimeSelection() { function clearRuntimeSelection() {
hoveredCable = null; hoveredCable = null;
hoveredBGP = null;
hoveredSatellite = null; hoveredSatellite = null;
hoveredSatelliteIndex = null; hoveredSatelliteIndex = null;
lockedObject = null; lockedObject = null;
@@ -176,14 +200,17 @@ function clearRuntimeSelection() {
lockedSatellite = null; lockedSatellite = null;
lockedSatelliteIndex = null; lockedSatelliteIndex = null;
setLockedSatelliteIndex(null); setLockedSatelliteIndex(null);
clearSelectedSatelliteLegend();
} }
export function clearLockedObject() { export function clearLockedObject() {
hidePredictedOrbit(); hidePredictedOrbit();
clearAllCableStates(); clearAllCableStates();
clearCableSelection(); clearCableSelection();
clearBGPSelection();
setSatelliteRingState(null, "none", null); setSatelliteRingState(null, "none", null);
clearRuntimeSelection(); clearRuntimeSelection();
setLegendItems("satellites", getSatelliteLegendItems());
} }
function isSameCable(cable1, cable2) { function isSameCable(cable1, cable2) {
@@ -213,6 +240,8 @@ function showSatelliteInfo(props) {
const perigee = (6371 * (1 - ecc)).toFixed(0); const perigee = (6371 * (1 - ecc)).toFixed(0);
const apogee = (6371 * (1 + ecc)).toFixed(0); const apogee = (6371 * (1 + ecc)).toFixed(0);
setSelectedSatelliteLegend(props);
setLegendItems("satellites", getSatelliteLegendItems());
setLegendMode("satellites"); setLegendMode("satellites");
showInfoCard("satellite", { showInfoCard("satellite", {
name: props?.name || "-", name: props?.name || "-",
@@ -224,6 +253,24 @@ function showSatelliteInfo(props) {
}); });
} }
function showBGPInfo(marker) {
setLegendMode("bgp");
showInfoCard("bgp", {
anomaly_type: marker.userData.anomaly_type,
severity: marker.userData.rawSeverity || marker.userData.severity,
status: marker.userData.status,
prefix: marker.userData.prefix,
origin_asn: marker.userData.origin_asn,
new_origin_asn: marker.userData.new_origin_asn,
confidence: marker.userData.confidence,
collector: marker.userData.collector,
country: marker.userData.country,
city: marker.userData.city,
created_at: marker.userData.created_at,
summary: marker.userData.summary,
});
}
function applyCableVisualState() { function applyCableVisualState() {
const allCables = getCableLines(); const allCables = getCableLines();
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5; const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
@@ -248,7 +295,8 @@ function applyCableVisualState() {
default: default:
if ( if (
(lockedObjectType === "cable" && lockedObject) || (lockedObjectType === "cable" && lockedObject) ||
(lockedObjectType === "satellite" && lockedSatellite) (lockedObjectType === "satellite" && lockedSatellite) ||
(lockedObjectType === "bgp" && lockedObject)
) { ) {
cable.material.opacity = CABLE_CONFIG.otherOpacity; cable.material.opacity = CABLE_CONFIG.otherOpacity;
const origColor = cable.userData.originalColor; const origColor = cable.userData.originalColor;
@@ -289,6 +337,7 @@ function updateStatsSummary() {
cableCount: getCableLines().length, cableCount: getCableLines().length,
landingPointCount: landingPointCount:
document.getElementById("landing-point-count")?.textContent || 0, document.getElementById("landing-point-count")?.textContent || 0,
bgpAnomalyCount: `${getBGPCount()}`,
terrainOn: getShowTerrain(), terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图", textureQuality: "8K 卫星图",
}); });
@@ -337,6 +386,9 @@ export function init() {
addLights(); addLights();
initInfoCard(); initInfoCard();
initLegend(); initLegend();
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("bgp", getBGPLegendItems());
const earthObj = createEarth(scene); const earthObj = createEarth(scene);
targetRotation = { targetRotation = {
x: earthObj.rotation.x, x: earthObj.rotation.x,
@@ -400,8 +452,8 @@ async function loadData(showWhiteSphere = false) {
setLoadingMessage( setLoadingMessage(
showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...", showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...",
showWhiteSphere showWhiteSphere
? "重新同步卫星、海底光缆登陆点数据" ? "重新同步卫星、海底光缆登陆点与BGP异常数据"
: "同步卫星、海底光缆登陆点数据", : "同步卫星、海底光缆登陆点与BGP异常数据",
); );
setLoading(true); setLoading(true);
clearLockedObject(); clearLockedObject();
@@ -434,6 +486,22 @@ async function loadData(showWhiteSphere = false) {
} }
return satelliteCount; return satelliteCount;
})(), })(),
(async () => {
clearBGPData(earth);
const bgpResult = await loadBGPAnomalies(scene, earth);
toggleBGP(true);
const bgpBtn = document.getElementById("toggle-bgp");
if (bgpBtn) {
bgpBtn.classList.add("active");
const tooltip = bgpBtn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = "隐藏BGP观测";
}
const bgpCountEl = document.getElementById("bgp-anomaly-count");
if (bgpCountEl) {
bgpCountEl.textContent = `${bgpResult.totalCount}`;
}
return bgpResult;
})(),
]); ]);
if (loadToken !== currentLoadToken) { if (loadToken !== currentLoadToken) {
@@ -451,6 +519,9 @@ async function loadData(showWhiteSphere = false) {
if (results[2].status === "rejected") { if (results[2].status === "rejected") {
errors.push({ label: "卫星", reason: results[2].reason }); errors.push({ label: "卫星", reason: results[2].reason });
} }
if (results[3].status === "rejected") {
errors.push({ label: "BGP异常", reason: results[3].reason });
}
if (errors.length > 0) { if (errors.length > 0) {
const errorMessage = buildLoadErrorMessage(errors); const errorMessage = buildLoadErrorMessage(errors);
@@ -462,6 +533,10 @@ async function loadData(showWhiteSphere = false) {
} }
updateStatsSummary(); updateStatsSummary();
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
setLoading(false); setLoading(false);
isDataLoading = false; isDataLoading = false;
@@ -524,6 +599,18 @@ function getFrontFacingCables(cableLines) {
}); });
} }
function getFrontFacingBGPMarkers(markers) {
const earth = getEarth();
if (!earth) return markers;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return markers.filter((marker) => {
scratchBGPDirection.copy(marker.position).normalize();
return scratchCameraToEarth.dot(scratchBGPDirection) > 0;
});
}
function onMouseMove(event) { function onMouseMove(event) {
const earth = getEarth(); const earth = getEarth();
if (!earth) return; if (!earth) return;
@@ -551,6 +638,10 @@ function onMouseMove(event) {
const frontCables = getFrontFacingCables(getCableLines()); const frontCables = getFrontFacingCables(getCableLines());
const cableIntersects = interactionRaycaster.intersectObjects(frontCables); const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
const frontFacingBGPMarkers = getFrontFacingBGPMarkers(getBGPMarkers());
const bgpIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPMarkers)
: [];
let hoveredSat = null; let hoveredSat = null;
let hoveredSatIndexFromIntersect = null; let hoveredSatIndexFromIntersect = null;
@@ -568,6 +659,16 @@ function onMouseMove(event) {
} }
} }
if (
hoveredBGP &&
(!bgpIntersects.length || bgpIntersects[0]?.object !== hoveredBGP)
) {
if (hoveredBGP !== lockedObject) {
setBGPMarkerState(hoveredBGP, "normal");
}
hoveredBGP = null;
}
if ( if (
hoveredCable && hoveredCable &&
(!cableIntersects.length || (!cableIntersects.length ||
@@ -589,7 +690,16 @@ function onMouseMove(event) {
hoveredSatelliteIndex = null; hoveredSatelliteIndex = null;
} }
if (cableIntersects.length > 0 && getShowCables()) { if (bgpIntersects.length > 0 && getShowBGP()) {
const marker = bgpIntersects[0].object;
hoveredBGP = marker;
if (marker !== lockedObject) {
setBGPMarkerState(marker, "hover");
}
showBGPInfo(marker);
setInfoCardNoBorder(true);
hideTooltip();
} else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object; const cable = cableIntersects[0].object;
hoveredCable = cable; hoveredCable = cable;
if (!isSameCable(cable, lockedObject)) { if (!isSameCable(cable, lockedObject)) {
@@ -613,6 +723,8 @@ function onMouseMove(event) {
} }
showSatelliteInfo(hoveredSat.properties); showSatelliteInfo(hoveredSat.properties);
setInfoCardNoBorder(true); setInfoCardNoBorder(true);
} else if (lockedObjectType === "bgp" && lockedObject) {
showBGPInfo(lockedObject);
} else if (lockedObjectType === "cable" && lockedObject) { } else if (lockedObjectType === "cable" && lockedObject) {
showCableInfo(lockedObject); showCableInfo(lockedObject);
} else if (lockedObjectType === "satellite" && lockedSatellite) { } else if (lockedObjectType === "satellite" && lockedSatellite) {
@@ -686,10 +798,31 @@ function onClick(event) {
const cableIntersects = interactionRaycaster.intersectObjects( const cableIntersects = interactionRaycaster.intersectObjects(
getFrontFacingCables(getCableLines()), getFrontFacingCables(getCableLines()),
); );
const frontFacingBGPMarkers = getFrontFacingBGPMarkers(getBGPMarkers());
const bgpIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPMarkers)
: [];
const satIntersects = getShowSatellites() const satIntersects = getShowSatellites()
? interactionRaycaster.intersectObject(getSatellitePoints()) ? interactionRaycaster.intersectObject(getSatellitePoints())
: []; : [];
if (bgpIntersects.length > 0 && getShowBGP()) {
clearLockedObject();
const clickedMarker = bgpIntersects[0].object;
setBGPMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "bgp";
setAutoRotate(false);
showBGPInfo(clickedMarker);
showStatusMessage(
`已选择BGP异常: ${clickedMarker.userData.collector}`,
"info",
);
return;
}
if (cableIntersects.length > 0 && getShowCables()) { if (cableIntersects.length > 0 && getShowCables()) {
clearLockedObject(); clearLockedObject();
@@ -816,10 +949,15 @@ function animate() {
} }
applyCableVisualState(); applyCableVisualState();
updateBGPVisualState(lockedObjectType, lockedObject);
if (lockedObjectType === "cable" && lockedObject) { if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false); applyLandingPointVisualState(lockedObject.userData.name, false);
} else if (lockedObjectType === "satellite" && lockedSatellite) { } else if (
lockedObjectType === "satellite" && lockedSatellite
) {
applyLandingPointVisualState(null, true);
} else if (lockedObjectType === "bgp" && lockedObject) {
applyLandingPointVisualState(null, true); applyLandingPointVisualState(null, true);
} else { } else {
resetLandingPointVisualState(); resetLandingPointVisualState();
@@ -864,6 +1002,7 @@ export function destroy() {
clearLockedObject(); clearLockedObject();
clearCableData(getEarth()); clearCableData(getEarth());
clearBGPData(getEarth());
resetSatelliteState(); resetSatelliteState();
clearUiState(); clearUiState();

View File

@@ -22,6 +22,7 @@ let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null; let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0; let positionUpdateAccumulator = 0;
let satelliteCapacity = 0; let satelliteCapacity = 0;
let selectedSatelliteLegendKey = null;
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32; const DOT_TEXTURE_SIZE = 32;
@@ -33,10 +34,95 @@ const scratchToSatellite = new THREE.Vector3();
export let breathingPhase = 0; export let breathingPhase = 0;
const SATELLITE_LEGEND_RULES = [
{
key: "starlink",
label: "Starlink",
color: "#00e6ff",
match: (props) => (props?.name || "").includes("STARLINK"),
},
{
key: "geo",
label: "GEO / 倾角 20-30",
color: "#ffcc00",
match: (props) => {
const inclination = props?.inclination || 53;
return inclination > 20 && inclination < 30;
},
},
{
key: "iridium",
label: "Iridium",
color: "#ff8000",
match: (props) => (props?.name || "").includes("IRIDIUM"),
},
{
key: "mid-inclination",
label: "倾角 50-70",
color: "#00ff4d",
match: (props) => {
const inclination = props?.inclination || 53;
return inclination > 50 && inclination < 70;
},
},
{
key: "other",
label: "其他卫星",
color: "#ffffff",
match: () => true,
},
];
export function updateBreathingPhase(deltaTime = 16) { export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16); breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
} }
export function getSatelliteLegendItems() {
const presentKeys = new Set();
satelliteData.forEach((satellite) => {
const props = satellite?.properties || {};
const rule = SATELLITE_LEGEND_RULES.find((item) => item.match(props));
if (rule) {
presentKeys.add(rule.key);
}
});
if (presentKeys.size === 0) {
return SATELLITE_LEGEND_RULES.map(({ label, color }) => ({ label, color }));
}
const items = SATELLITE_LEGEND_RULES
.filter((item) => presentKeys.has(item.key))
.map(({ key, label, color }) => ({ key, label, color }));
if (!selectedSatelliteLegendKey) {
return items.map(({ label, color }) => ({ label, color }));
}
const selectedIndex = items.findIndex(
(item) => item.key === selectedSatelliteLegendKey,
);
if (selectedIndex > 0) {
const [selectedItem] = items.splice(selectedIndex, 1);
items.unshift(selectedItem);
}
return items.map(({ label, color }) => ({ label, color }));
}
export function setSelectedSatelliteLegend(props) {
const rule = SATELLITE_LEGEND_RULES.find((item) =>
item.match(props || {}),
);
selectedSatelliteLegendKey = rule?.key || null;
}
export function clearSelectedSatelliteLegend() {
selectedSatelliteLegendKey = null;
}
function disposeMaterial(material) { function disposeMaterial(material) {
if (!material) return; if (!material) return;
if (Array.isArray(material)) { if (Array.isArray(material)) {

View File

@@ -85,12 +85,15 @@ export function updateZoomDisplay(zoomLevel, distance) {
export function updateEarthStats(stats) { export function updateEarthStats(stats) {
const cableCountEl = document.getElementById("cable-count"); const cableCountEl = document.getElementById("cable-count");
const landingPointCountEl = document.getElementById("landing-point-count"); const landingPointCountEl = document.getElementById("landing-point-count");
const bgpAnomalyCountEl = document.getElementById("bgp-anomaly-count");
const terrainStatusEl = document.getElementById("terrain-status"); const terrainStatusEl = document.getElementById("terrain-status");
const textureQualityEl = document.getElementById("texture-quality"); const textureQualityEl = document.getElementById("texture-quality");
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0; if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
if (landingPointCountEl) if (landingPointCountEl)
landingPointCountEl.textContent = stats.landingPointCount || 0; landingPointCountEl.textContent = stats.landingPointCount || 0;
if (bgpAnomalyCountEl)
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
if (terrainStatusEl) if (terrainStatusEl)
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭"; terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
if (textureQualityEl) if (textureQualityEl)