-
状态:
-
-
+
+
+
+
+ —
+ 海缆系统
+
+
+ —
+ 登陆点
+
+
+ —
+ 在轨卫星
+
+
+ —
+ BGP 事件
+
+
+ —
+ BGP 观测站
+
+
+ —
+ 运行中
+
-
- 登陆点:
- 0个
-
-
- 地形:
- 开启
-
-
- 卫星:
- 0 颗
-
-
- BGP事件:
- 0 条
-
-
- 观测站:
- 0 个
-
-
- BGP态势:
- 暂无观测数据
-
-
- 视角距离:
- 300 km
-
-
- 纹理质量:
- 8K 卫星图
+
+
+
+
+
+
+
+
-
-
-
正在初始化全球态势数据...
-
同步卫星、海底光缆、登陆点与BGP态势数据
+
+
+
+
+ close
+
+
+
+
+
+
+ refresh
+
+
+ open_in_new
+
+
+
+
+
+
暂无可播放直播源,请先在系统配置中添加频道。
+
+
+
+
+
+
+
+
+
正在初始化全球态势数据...
+
同步卫星、海底光缆、登陆点与BGP态势数据
+
+
+
+
-
-
diff --git a/frontend/public/earth/js/brand.js b/frontend/public/earth/js/brand.js
new file mode 100644
index 00000000..2d6b242d
--- /dev/null
+++ b/frontend/public/earth/js/brand.js
@@ -0,0 +1,57 @@
+const DEFAULT_BRAND_LANGUAGE = "zh";
+
+const BRANDS = {
+ zh: {
+ ariaLabel: "智能星球计划品牌标识",
+ titleAlt: "智能星球计划",
+ titleSrc: "assets/brand/title-zh.png",
+ subtitle: "现实层宇宙全息感知系统",
+ description: "卫星 · 海底光缆 · 算力基础设施",
+ },
+ en: {
+ ariaLabel: "Intelligent Planet Program brand banner",
+ titleAlt: "Intelligent Planet Program",
+ titleSrc: "assets/brand/title-en.png",
+ subtitle: "Physical-Universe Holography",
+ description: "Satellites · Cables · Compute Infra",
+ },
+};
+
+function getBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
+ return BRANDS[variant] ?? BRANDS[DEFAULT_BRAND_LANGUAGE];
+}
+
+export function renderBrand(variant = DEFAULT_BRAND_LANGUAGE) {
+ const config = getBrandConfig(variant);
+
+ return `
+
+
+
+
+
+ ${config.subtitle}
+ ${config.description}
+
+
+
+ `.trim();
+}
+
+export function mountBrand(target, variant = DEFAULT_BRAND_LANGUAGE) {
+ if (!target) return;
+ target.innerHTML = renderBrand(variant);
+}
diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js
index b03cb448..c62d45ad 100644
--- a/frontend/public/earth/js/cables.js
+++ b/frontend/public/earth/js/cables.js
@@ -327,7 +327,9 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
const cableCount = data.features.length;
const inServiceCount = data.features.filter(
(feature) =>
- feature.properties && feature.properties.status === "In Service",
+ feature.properties &&
+ (feature.properties.status === "active" ||
+ feature.properties.status === "In Service"),
).length;
const cableCountEl = document.getElementById("cable-count");
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js
index 05008c6b..dd7c1fa5 100644
--- a/frontend/public/earth/js/constants.js
+++ b/frontend/public/earth/js/constants.js
@@ -12,6 +12,14 @@ export const CONFIG = {
dragRotationScaleMax: 2.0,
};
+export const HUD_CONFIG = {
+ scaleReferenceWidth: 1920,
+ scaleReferenceHeight: 1080,
+ minScale: 0.7,
+ maxScale: 1,
+ brandLanguage: "zh",
+};
+
// Earth coordinate constants
export const EARTH_CONFIG = {
tilt: 23.5, // earth tilt angle (degrees)
@@ -214,3 +222,38 @@ export const GRID_CONFIG = {
longitudeStep: 30,
gridStep: 5
};
+
+export const EARTH_MATERIAL_CONFIG = {
+ // Diffuse color multiplies with texture — pure white = full saturation,
+ // slightly grey-blue pulls perceived saturation down without a custom shader.
+ color: 0xcdd8e6,
+ specular: 0x1a2d45,
+ shininess: 12,
+ emissive: 0x050a12,
+ opacity: 0.96,
+
+ // Depth-mask occluder keeps far-side objects hidden behind the earth
+ occluderRadiusFactor: 0.999,
+ occluderSegments: 48,
+
+ // Fresnel atmosphere glow — inner rim
+ atmosInnerRadiusFactor: 1.018,
+ atmosInnerSegments: 64,
+ atmosInnerColor: [0.25, 0.62, 1.0],
+ atmosInnerRimPower: 3.2,
+ atmosInnerIntensity: 0.72,
+
+ // Fresnel atmosphere glow — outer corona
+ atmosOuterRadiusFactor: 1.07,
+ atmosOuterSegments: 48,
+ atmosOuterColor: [0.18, 0.45, 0.9],
+ atmosOuterRimPower: 5.0,
+ atmosOuterIntensity: 0.28,
+
+ // Texture candidates — tried in order, first success wins
+ textureUrls: [
+ './assets/8k_earth_daymap.jpg',
+ 'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
+ 'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
+ ],
+};
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index ea07bf13..ec7a9bdf 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -17,6 +17,7 @@ import {
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
+import { ensureTVPanelReady } from "./tv.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
@@ -26,11 +27,18 @@ export let layoutExpanded = false;
let earthObj = null;
let listeners = [];
let cleanupFns = [];
+const HUD_PANEL_IDS = [
+ "legend",
+ "earth-stats",
+ "tv-panel",
+ "layer-toggles",
+];
+const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
+const PANEL_LAYOUT_ANIMATION_MS = 420;
function getFloatingGroups() {
return [
document.getElementById("zoom-control-group"),
- document.getElementById("info-control-group"),
].filter(Boolean);
}
@@ -44,6 +52,12 @@ function isFloatingMenuVisible() {
});
}
+function isSettingsModalOpen() {
+ return document
+ .getElementById("settings-modal")
+ ?.classList.contains("is-open");
+}
+
function closeFloatingMenus() {
getFloatingGroups().forEach((group) => {
group.classList.remove("open");
@@ -55,6 +69,184 @@ function closeFloatingMenus() {
}
}
+function openSettingsModal() {
+ const modal = document.getElementById("settings-modal");
+ if (!modal) return;
+ closeFloatingMenus();
+ modal.classList.add("is-open");
+ modal.setAttribute("aria-hidden", "false");
+}
+
+function closeSettingsModal() {
+ const modal = document.getElementById("settings-modal");
+ if (!modal) return;
+ modal.classList.remove("is-open");
+ modal.setAttribute("aria-hidden", "true");
+}
+
+function setHudPanelVisibility(panelId, visible) {
+ const panel = document.getElementById(panelId);
+ if (!panel) return;
+ panel.classList.toggle("hud-panel-hidden", !visible);
+ syncSettingsToggle(panelId, visible);
+ if (panelId === "tv-panel") {
+ updateTVToggleUI(visible);
+ if (visible) {
+ ensureTVPanelReady().catch((error) => {
+ console.error("初始化电视直播面板失败:", error);
+ });
+ }
+ }
+}
+
+function syncSettingsToggle(panelId, visible) {
+ const input = document.querySelector(
+ `[data-settings-panel="${panelId}"]`,
+ );
+ if (input instanceof HTMLInputElement) {
+ input.checked = visible;
+ }
+}
+
+function syncAllHudPanelToggles() {
+ HUD_PANEL_IDS.forEach((panelId) => {
+ const panel = document.getElementById(panelId);
+ syncSettingsToggle(panelId, !panel?.classList.contains("hud-panel-hidden"));
+ });
+}
+
+function setupSettingsControls() {
+ const settingsTrigger = document.getElementById("settings-trigger");
+ const settingsClose = document.getElementById("settings-close");
+ const settingsBackdrop = document.getElementById("settings-backdrop");
+ const settingsModal = document.getElementById("settings-modal");
+
+ bindListener(settingsTrigger, "click", () => {
+ openSettingsModal();
+ });
+
+ bindListener(settingsClose, "click", () => {
+ closeSettingsModal();
+ });
+
+ bindListener(settingsBackdrop, "click", () => {
+ closeSettingsModal();
+ });
+
+ bindListener(settingsModal, "click", (event) => {
+ const sheet = event.target.closest(".earth-settings-sheet");
+ if (!sheet) {
+ closeSettingsModal();
+ }
+ });
+
+ const toggleInputs = document.querySelectorAll("[data-settings-panel]");
+ toggleInputs.forEach((input) => {
+ bindListener(input, "change", (event) => {
+ const target = event.currentTarget;
+ if (!(target instanceof HTMLInputElement)) return;
+ const panelId = target.dataset.settingsPanel;
+ if (!panelId) return;
+ setHudPanelVisibility(panelId, target.checked);
+ });
+ });
+
+ syncAllHudPanelToggles();
+}
+
+function setupHudPanelControls() {
+ const closeButtons = document.querySelectorAll("[data-close-panel]");
+ closeButtons.forEach((button) => {
+ bindListener(button, "click", (event) => {
+ event.stopPropagation();
+ const target = event.currentTarget;
+ if (!(target instanceof HTMLElement)) return;
+ const panelId = target.dataset.closePanel;
+ if (!panelId) return;
+ setHudPanelVisibility(panelId, false);
+ });
+ });
+}
+
+function setupDraggableHudPanels() {
+ const app = document.getElementById("container");
+ const draggablePanels = document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR);
+ if (!app || draggablePanels.length === 0) return;
+
+ draggablePanels.forEach((panel) => {
+ const handle = panel.querySelector(".hud-panel-drag-handle");
+ if (!handle) return;
+
+ let isDragging = false;
+ let startPointerX = 0;
+ let startPointerY = 0;
+ let startLeft = 0;
+ let startTop = 0;
+
+ const stopDragging = () => {
+ isDragging = false;
+ panel.classList.remove("is-dragging");
+ document.body.style.userSelect = "";
+ };
+
+ const onMove = (event) => {
+ if (!isDragging) return;
+ const appRect = app.getBoundingClientRect();
+ const panelRect = panel.getBoundingClientRect();
+ const nextLeft = Math.min(
+ Math.max(startLeft + (event.clientX - startPointerX), 0),
+ appRect.width - panelRect.width,
+ );
+ const nextTop = Math.min(
+ Math.max(startTop + (event.clientY - startPointerY), 0),
+ appRect.height - panelRect.height,
+ );
+
+ panel.style.left = `${nextLeft}px`;
+ panel.style.top = `${nextTop}px`;
+ panel.style.right = "auto";
+ panel.style.bottom = "auto";
+ panel.style.transform = "none";
+ panel.dataset.dragged = "true";
+ };
+
+ bindListener(handle, "pointerdown", (event) => {
+ if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close")) return;
+ isDragging = true;
+ startPointerX = event.clientX;
+ startPointerY = event.clientY;
+ const appRect = app.getBoundingClientRect();
+ const panelRect = panel.getBoundingClientRect();
+
+ // If panel is inside a flow container (not a direct child of app), reparent
+ // it so absolute positioning is relative to the app container.
+ if (panel.parentElement !== app) {
+ const capturedWidth = panelRect.width;
+ panel.style.position = "absolute";
+ panel.style.width = `${capturedWidth}px`;
+ app.appendChild(panel);
+ }
+
+ startLeft = panelRect.left - appRect.left;
+ startTop = panelRect.top - appRect.top;
+ panel.style.left = `${startLeft}px`;
+ panel.style.top = `${startTop}px`;
+ panel.style.right = "auto";
+ panel.style.bottom = "auto";
+ panel.style.transform = "none";
+ panel.dataset.dragged = "true";
+ panel.classList.add("is-dragging");
+ document.body.style.userSelect = "none";
+ handle.setPointerCapture?.(event.pointerId);
+ });
+
+ bindListener(handle, "pointermove", onMove);
+ bindListener(handle, "pointerup", stopDragging);
+ bindListener(handle, "pointercancel", stopDragging);
+ bindListener(handle, "lostpointercapture", stopDragging);
+ });
+}
+
function clearForcedFloatingClose() {
getFloatingGroups().forEach((group) => {
group.classList.remove("force-closed");
@@ -76,12 +268,23 @@ function setFloatingMenuOpen(group, shouldOpen) {
}
function setButtonTooltip(button, text) {
- const tooltip = button?.querySelector(".tooltip");
+ const tooltip = button?.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = text;
}
}
+export function updateLayerButtonState(button, isActive) {
+ if (!button) return;
+ button.classList.toggle("active", isActive);
+ button.setAttribute("aria-checked", isActive ? "true" : "false");
+ // Legacy badge text (kept for compatibility)
+ const state = button.querySelector(".earth-layer-btn__state");
+ if (state) {
+ state.textContent = isActive ? "ON" : "OFF";
+ }
+}
+
function clearSelectionIfHiding(shouldHide) {
if (shouldHide) {
clearLockedObject();
@@ -100,6 +303,13 @@ function bindFloatingMenu(trigger, group) {
});
}
+function updateTVToggleUI(visible) {
+ const btn = document.getElementById("toggle-tv");
+ if (!btn) return;
+ btn.classList.toggle("active", visible);
+ setButtonTooltip(btn, visible ? "关闭新闻直播" : "打开新闻直播");
+}
+
function bindListener(element, eventName, handler, options) {
if (!element) return;
element.addEventListener(eventName, handler, options);
@@ -365,11 +575,91 @@ function setupRotateControls(camera) {
});
}
+function filterLayerRows(query, emptyStateEl, clearBtn) {
+ const rows = document.querySelectorAll("#layer-panel-list .layer-row");
+ let visibleCount = 0;
+ rows.forEach((row) => {
+ const name = (row.dataset.layerName || "").toLowerCase();
+ const matches = !query || name.includes(query);
+ row.hidden = !matches;
+ if (matches) visibleCount++;
+ });
+ if (emptyStateEl) emptyStateEl.hidden = visibleCount > 0;
+ if (clearBtn) clearBtn.hidden = !query;
+}
+
+function setupLayerPanel() {
+ const panel = document.getElementById("layer-toggles");
+ const collapseBtn = document.getElementById("layer-panel-collapse");
+ const searchInput = document.getElementById("layer-search-input");
+ const searchClear = document.getElementById("layer-search-clear");
+ const emptyState = document.getElementById("layer-panel-empty");
+ if (!panel) return;
+
+ bindListener(collapseBtn, "click", (e) => {
+ e.stopPropagation();
+ const isCollapsed = panel.classList.toggle("layer-panel--collapsed");
+ collapseBtn.title = isCollapsed ? "展开" : "折叠";
+ collapseBtn.setAttribute("aria-label", isCollapsed ? "展开图层列表" : "折叠图层列表");
+ const icon = collapseBtn.querySelector(".material-symbols-rounded");
+ if (icon) icon.textContent = isCollapsed ? "expand_less" : "expand_more";
+ });
+
+ if (searchInput) {
+ bindListener(searchInput, "input", () => {
+ const query = searchInput.value.trim().toLowerCase();
+ filterLayerRows(query, emptyState, searchClear);
+ });
+
+ if (searchClear) {
+ bindListener(searchClear, "click", () => {
+ searchInput.value = "";
+ filterLayerRows("", emptyState, searchClear);
+ searchInput.focus();
+ });
+ }
+ }
+}
+
+function createLayerRow({ id, icon, label, meta, defaultActive }) {
+ const row = document.createElement("div");
+ row.className = "layer-row";
+ row.dataset.layerName = `${label} ${meta || ""}`.trim().toLowerCase();
+ row.innerHTML = `
+
${icon}
+
+ ${label}
+ ${meta ? `${meta} ` : ""}
+
+
+
+
+ `;
+ return row;
+}
+
+export function registerLayer({ id, icon, label, meta = "", defaultActive = false, onToggle }) {
+ const list = document.getElementById("layer-panel-list");
+ if (!list) return;
+ if (document.getElementById(id)) return; // avoid duplicates
+
+ const row = createLayerRow({ id, icon, label, meta, defaultActive });
+ list.appendChild(row);
+
+ const btn = row.querySelector("button");
+ if (btn && typeof onToggle === "function") {
+ btn.addEventListener("click", function () {
+ const isActive = this.classList.toggle("active");
+ this.setAttribute("aria-checked", isActive ? "true" : "false");
+ onToggle(isActive);
+ });
+ }
+}
+
function setupTerrainControls() {
const container = document.getElementById("container");
const searchBtn = document.getElementById("search-action");
- const infoGroup = document.getElementById("info-control-group");
- const infoTrigger = document.getElementById("info-trigger");
const terrainBtn = document.getElementById("toggle-terrain");
const satellitesBtn = document.getElementById("toggle-satellites");
const bgpBtn = document.getElementById("toggle-bgp");
@@ -379,9 +669,13 @@ function setupTerrainControls() {
const reloadBtn = document.getElementById("reload-data");
const zoomGroup = document.getElementById("zoom-control-group");
const zoomTrigger = document.getElementById("zoom-trigger");
+ setupSettingsControls();
+ setupHudPanelControls();
+ setupDraggableHudPanels();
+ setupLayerPanel();
if (trailsBtn) {
- trailsBtn.classList.add("active");
+ updateLayerButtonState(trailsBtn, true);
setButtonTooltip(trailsBtn, "隐藏轨迹");
}
@@ -392,7 +686,7 @@ function setupTerrainControls() {
bindListener(terrainBtn, "click", function () {
showTerrain = !showTerrain;
toggleTerrain(showTerrain);
- this.classList.toggle("active", showTerrain);
+ updateLayerButtonState(this, showTerrain);
setButtonTooltip(this, showTerrain ? "隐藏地形" : "显示地形");
const terrainStatus = document.getElementById("terrain-status");
if (terrainStatus)
@@ -422,7 +716,7 @@ function setupTerrainControls() {
const showNextBGP = !getShowBGP();
clearSelectionIfHiding(!showNextBGP);
toggleBGP(showNextBGP);
- this.classList.toggle("active", showNextBGP);
+ updateLayerButtonState(this, showNextBGP);
setButtonTooltip(this, showNextBGP ? "隐藏BGP观测" : "显示BGP观测");
const bgpCountEl = document.getElementById("bgp-anomaly-count");
if (bgpCountEl) {
@@ -435,7 +729,7 @@ function setupTerrainControls() {
const isActive = this.classList.contains("active");
const nextShowTrails = !isActive;
toggleTrails(nextShowTrails);
- this.classList.toggle("active", nextShowTrails);
+ updateLayerButtonState(this, nextShowTrails);
setButtonTooltip(this, nextShowTrails ? "隐藏轨迹" : "显示轨迹");
showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info");
});
@@ -455,10 +749,9 @@ function setupTerrainControls() {
});
bindFloatingMenu(zoomTrigger, zoomGroup);
- bindFloatingMenu(infoTrigger, infoGroup);
bindListener(document, "click", (event) => {
- const openGroups = [zoomGroup, infoGroup].filter((group) =>
+ const openGroups = [zoomGroup].filter((group) =>
group?.classList.contains("open"),
);
if (openGroups.length === 0) return;
@@ -480,6 +773,13 @@ function setupTerrainControls() {
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
});
+ const tvVisible = !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden");
+ updateTVToggleUI(tvVisible);
+ if (tvVisible) {
+ ensureTVPanelReady().catch((error) => {
+ console.error("初始化电视直播面板失败:", error);
+ });
+ }
updateLayoutUI(container);
}
@@ -487,6 +787,11 @@ function setupKeyboardControls() {
bindListener(document, "keydown", (event) => {
if (event.key !== "Escape") return;
+ if (isSettingsModalOpen()) {
+ closeSettingsModal();
+ return;
+ }
+
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return;
@@ -566,7 +871,7 @@ function updateRotateUI() {
if (btn) {
btn.classList.toggle("active", autoRotate);
btn.classList.toggle("is-stopped", !autoRotate);
- const tooltip = btn.querySelector(".tooltip");
+ const tooltip = btn.querySelector(".earth-toolbar-tooltip");
if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
}
}
@@ -599,15 +904,81 @@ function updateLayoutUI(container) {
const btn = document.getElementById("layout-toggle");
if (btn) {
btn.classList.toggle("active", layoutExpanded);
- const tooltip = btn.querySelector(".tooltip");
+ const tooltip = btn.querySelector(".earth-toolbar-tooltip");
const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局";
btn.title = nextLabel;
if (tooltip) tooltip.textContent = nextLabel;
}
}
-function toggleLayoutExpanded(container) {
- layoutExpanded = !layoutExpanded;
- updateLayoutUI(container);
- return layoutExpanded;
+function resetPanelInlineLayout(panel) {
+ panel.style.left = "";
+ panel.style.top = "";
+ panel.style.right = "";
+ panel.style.bottom = "";
+ panel.style.transform = "";
+ delete panel.dataset.dragged;
+}
+
+function isPanelVisible(panel) {
+ return !panel.classList.contains("hud-panel-hidden");
+}
+
+function animatePanelLayoutTransition(container, expand) {
+ const panels = Array.from(
+ container.querySelectorAll(DRAGGABLE_PANEL_SELECTOR),
+ );
+ if (panels.length === 0) {
+ layoutExpanded = expand;
+ updateLayoutUI(container);
+ return expand;
+ }
+
+ const visiblePanels = panels.filter(isPanelVisible);
+ const firstRects = new Map(
+ visiblePanels.map((panel) => [panel, panel.getBoundingClientRect()]),
+ );
+
+ panels.forEach((panel) => panel.classList.add("is-layout-animating"));
+ layoutExpanded = expand;
+ panels.forEach(resetPanelInlineLayout);
+ updateLayoutUI(container);
+
+ visiblePanels.forEach((panel) => {
+ const firstRect = firstRects.get(panel);
+ if (!firstRect) return;
+
+ const lastRect = panel.getBoundingClientRect();
+ const deltaX = firstRect.left - lastRect.left;
+ const deltaY = firstRect.top - lastRect.top;
+
+ if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) {
+ return;
+ }
+
+ panel.animate(
+ [
+ {
+ translate: `${deltaX}px ${deltaY}px`,
+ },
+ {
+ translate: "0 0",
+ },
+ ],
+ {
+ duration: PANEL_LAYOUT_ANIMATION_MS,
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)",
+ },
+ );
+ });
+
+ window.setTimeout(() => {
+ panels.forEach((panel) => panel.classList.remove("is-layout-animating"));
+ }, PANEL_LAYOUT_ANIMATION_MS);
+
+ return expand;
+}
+
+function toggleLayoutExpanded(container) {
+ return animatePanelLayoutTransition(container, !layoutExpanded);
}
diff --git a/frontend/public/earth/js/earth.js b/frontend/public/earth/js/earth.js
index 2522eb48..87c08337 100644
--- a/frontend/public/earth/js/earth.js
+++ b/frontend/public/earth/js/earth.js
@@ -1,7 +1,7 @@
// earth.js - 3D Earth creation module
import * as THREE from 'three';
-import { CONFIG, EARTH_CONFIG } from './constants.js';
+import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
import { latLonToVector3 } from './utils.js';
export let earth = null;
@@ -9,82 +9,108 @@ export let clouds = null;
export let terrain = null;
const textureLoader = new THREE.TextureLoader();
+let _earthMaterial = null;
export function createEarth(scene) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
-
+
+ const C = EARTH_MATERIAL_CONFIG;
+
const material = new THREE.MeshPhongMaterial({
- color: 0xffffff,
- specular: 0x111111,
- shininess: 10,
- emissive: 0x000000,
+ color: C.color,
+ specular: C.specular,
+ shininess: C.shininess,
+ emissive: C.emissive,
transparent: true,
- opacity: 0.8,
- side: THREE.DoubleSide
+ opacity: C.opacity,
+ side: THREE.FrontSide,
+ depthWrite: true,
+ depthTest: true,
});
-
+ _earthMaterial = material;
+
earth = new THREE.Mesh(geometry, material);
+ earth.renderOrder = 0;
earth.rotation.x = EARTH_CONFIG.tiltRad;
scene.add(earth);
-
- const textureUrls = [
- './assets/8k_earth_daymap.jpg',
- 'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
- 'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
- 'https://assets.codepen.io/982762/earth_texture_2048.jpg'
- ];
-
- let textureLoaded = false;
-
- textureLoader.load(
- textureUrls[0],
- function(texture) {
- console.log('高分辨率地球纹理加载成功');
- textureLoaded = true;
-
- texture.wrapS = THREE.RepeatWrapping;
- texture.wrapT = THREE.ClampToEdgeWrapping;
- texture.anisotropy = 16;
- texture.minFilter = THREE.LinearMipmapLinearFilter;
- texture.magFilter = THREE.LinearFilter;
-
- material.map = texture;
- material.needsUpdate = true;
-
- document.getElementById('loading').style.display = 'none';
- },
- function(xhr) {
- console.log('纹理加载中: ' + (xhr.loaded / xhr.total * 100) + '%');
- },
- function(err) {
- console.log('第一个纹理加载失败,尝试第二个...');
-
- textureLoader.load(
- textureUrls[1],
- function(texture) {
- console.log('第二个纹理加载成功');
- textureLoaded = true;
-
- texture.wrapS = THREE.RepeatWrapping;
- texture.wrapT = THREE.ClampToEdgeWrapping;
- texture.anisotropy = 16;
- texture.minFilter = THREE.LinearMipmapLinearFilter;
- texture.magFilter = THREE.LinearFilter;
-
- material.map = texture;
- material.needsUpdate = true;
-
- document.getElementById('loading').style.display = 'none';
- },
- null,
- function(err) {
- console.log('所有纹理加载失败');
- document.getElementById('loading').style.display = 'none';
- }
- );
- }
+
+ // Depth-mask occluder — invisible sphere slightly inside the earth,
+ // writes to the depth buffer so far-side cables/satellites are occluded.
+ const occluderGeometry = new THREE.SphereGeometry(
+ CONFIG.earthRadius * C.occluderRadiusFactor,
+ C.occluderSegments,
+ C.occluderSegments,
);
-
+ const occluderMaterial = new THREE.MeshBasicMaterial({
+ colorWrite: false,
+ side: THREE.FrontSide,
+ });
+ const occluder = new THREE.Mesh(occluderGeometry, occluderMaterial);
+ occluder.renderOrder = -1;
+ earth.add(occluder);
+
+ // Shared Fresnel vertex shader for both atmosphere layers
+ const ATMOS_VERTEX_SHADER = `
+ varying vec3 vNormal;
+ void main() {
+ vNormal = normalize(normalMatrix * normal);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `;
+
+ // Fresnel atmosphere — inner rim
+ const [ir, ig, ib] = C.atmosInnerColor;
+ const atmosInnerGeo = new THREE.SphereGeometry(
+ CONFIG.earthRadius * C.atmosInnerRadiusFactor,
+ C.atmosInnerSegments,
+ C.atmosInnerSegments,
+ );
+ const atmosInnerMat = new THREE.ShaderMaterial({
+ vertexShader: ATMOS_VERTEX_SHADER,
+ fragmentShader: `
+ varying vec3 vNormal;
+ void main() {
+ float rim = 1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0)));
+ float intensity = pow(rim, ${C.atmosInnerRimPower.toFixed(1)});
+ gl_FragColor = vec4(${ir.toFixed(2)}, ${ig.toFixed(2)}, ${ib.toFixed(2)}, intensity * ${C.atmosInnerIntensity.toFixed(2)});
+ }
+ `,
+ blending: THREE.AdditiveBlending,
+ side: THREE.BackSide,
+ transparent: true,
+ depthWrite: false,
+ });
+ const atmosInner = new THREE.Mesh(atmosInnerGeo, atmosInnerMat);
+ atmosInner.renderOrder = 1;
+ earth.add(atmosInner);
+
+ // Fresnel atmosphere — outer corona
+ const [outerR, outerG, outerB] = C.atmosOuterColor;
+ const atmosOuterGeo = new THREE.SphereGeometry(
+ CONFIG.earthRadius * C.atmosOuterRadiusFactor,
+ C.atmosOuterSegments,
+ C.atmosOuterSegments,
+ );
+ const atmosOuterMat = new THREE.ShaderMaterial({
+ vertexShader: ATMOS_VERTEX_SHADER,
+ fragmentShader: `
+ varying vec3 vNormal;
+ void main() {
+ float rim = 1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0)));
+ float intensity = pow(rim, ${C.atmosOuterRimPower.toFixed(1)});
+ gl_FragColor = vec4(${outerR.toFixed(2)}, ${outerG.toFixed(2)}, ${outerB.toFixed(2)}, intensity * ${C.atmosOuterIntensity.toFixed(2)});
+ }
+ `,
+ blending: THREE.AdditiveBlending,
+ side: THREE.BackSide,
+ transparent: true,
+ depthWrite: false,
+ });
+ const atmosOuter = new THREE.Mesh(atmosOuterGeo, atmosOuterMat);
+ atmosOuter.renderOrder = 1;
+ earth.add(atmosOuter);
+
+ // Texture is loaded separately via loadEarthTexture() for staged loading
return earth;
}
@@ -238,3 +264,40 @@ export function getEarth() {
export function getClouds() {
return clouds;
}
+
+export function clearEarthTexture() {
+ if (!_earthMaterial) return;
+ _earthMaterial.map = null;
+ _earthMaterial.needsUpdate = true;
+}
+
+export function loadEarthTexture() {
+ return new Promise((resolve) => {
+ if (!_earthMaterial) { resolve(); return; }
+
+ const urls = EARTH_MATERIAL_CONFIG.textureUrls;
+ const tryLoad = (index) => {
+ if (index >= urls.length) {
+ console.warn('所有地球纹理加载失败');
+ resolve();
+ return;
+ }
+ textureLoader.load(
+ urls[index],
+ (texture) => {
+ texture.wrapS = THREE.RepeatWrapping;
+ texture.wrapT = THREE.ClampToEdgeWrapping;
+ texture.anisotropy = 16;
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ _earthMaterial.map = texture;
+ _earthMaterial.needsUpdate = true;
+ resolve();
+ },
+ null,
+ () => tryLoad(index + 1),
+ );
+ };
+ tryLoad(0);
+ });
+}
diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js
index 43d89247..73abbec4 100644
--- a/frontend/public/earth/js/info-card.js
+++ b/frontend/public/earth/js/info-card.js
@@ -101,6 +101,47 @@ const CARD_CONFIG = {
}
};
+function getPanel() {
+ return document.getElementById('info-panel');
+}
+
+function positionPanel(panel, x, y) {
+ if (!panel) return;
+ const margin = 12;
+ const offset = 14;
+ const vpW = window.innerWidth;
+ const vpH = window.innerHeight;
+
+ const scale = parseFloat(
+ getComputedStyle(document.documentElement).getPropertyValue('--hud-scale')
+ ) || 1;
+ const estW = Math.min(300 * scale, vpW - 32);
+ const estH = Math.min(420 * scale, vpH * 0.7);
+
+ let left = x + offset;
+ let top = y + offset;
+
+ if (left + estW > vpW - margin) left = x - estW - offset;
+ if (top + estH > vpH - margin) top = Math.max(margin, vpH - estH - margin);
+
+ panel.style.left = `${Math.max(margin, left)}px`;
+ panel.style.top = `${Math.max(margin, top)}px`;
+ panel.style.right = 'auto';
+ panel.style.bottom = 'auto';
+}
+
+function showPanel(x, y) {
+ const panel = getPanel();
+ if (!panel) return;
+ if (x != null && y != null) positionPanel(panel, x, y);
+ panel.classList.add('is-visible');
+}
+
+function hidePanel() {
+ const panel = getPanel();
+ if (panel) panel.classList.remove('is-visible');
+}
+
export function initInfoCard() {
const card = document.getElementById('info-card');
const content = document.getElementById('info-card-content');
@@ -128,6 +169,15 @@ export function initInfoCard() {
card.addEventListener(eventName, stopEvent, { passive: false });
});
+ // Close button wires the panel hide
+ const closeBtn = card.querySelector('.info-card-close');
+ if (closeBtn) {
+ closeBtn.addEventListener('click', (event) => {
+ event.stopPropagation();
+ hideInfoCard();
+ });
+ }
+
card.dataset.interactionBound = 'true';
}
@@ -165,7 +215,7 @@ export function setInfoCardNoBorder(noBorder = true) {
}
}
-export function showInfoCard(type, data) {
+export function showInfoCard(type, data, options = {}) {
const config = CARD_CONFIG[type];
if (!config) {
console.warn('Unknown info card type:', type);
@@ -185,17 +235,17 @@ export function showInfoCard(type, data) {
let html = '';
for (const field of config.fields) {
let value = data[field.key];
-
+
if (value === undefined || value === null || value === '') {
value = '-';
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
-
+
if (field.unit && value !== '-') {
value = value + ' ' + field.unit;
}
-
+
html += `
${field.label}
@@ -205,14 +255,11 @@ export function showInfoCard(type, data) {
}
content.innerHTML = html;
- card.style.display = 'block';
+ showPanel(options.x, options.y);
}
export function hideInfoCard() {
- const card = document.getElementById('info-card');
- if (card) {
- card.style.display = 'none';
- }
+ hidePanel();
currentType = null;
}
diff --git a/frontend/public/earth/js/legend.js b/frontend/public/earth/js/legend.js
index 94e44535..6cd15be3 100644
--- a/frontend/public/earth/js/legend.js
+++ b/frontend/public/earth/js/legend.js
@@ -1,13 +1,7 @@
const LEGEND_MODES = {
- cables: {
- title: "线缆图例",
- },
- satellites: {
- title: "卫星图例",
- },
- bgp: {
- title: "BGP观测图例",
- },
+ cables: { title: "海缆" },
+ satellites: { title: "卫星" },
+ bgp: { title: "BGP" },
};
let currentLegendMode = "cables";
@@ -18,13 +12,35 @@ let legendItemsByMode = {
};
export function initLegend() {
+ // Tab click → switch mode
+ const tabsEl = document.getElementById("legend-tabs");
+ if (tabsEl) {
+ tabsEl.addEventListener("click", (e) => {
+ const btn = e.target.closest(".legend-tab");
+ if (!btn) return;
+ const mode = btn.dataset.legendMode;
+ if (mode) setLegendMode(mode);
+ });
+ }
+
+ // Collapse toggle
+ const collapseBtn = document.getElementById("legend-collapse");
+ const legend = document.getElementById("legend");
+ if (collapseBtn && legend) {
+ collapseBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ legend.classList.toggle("legend--collapsed");
+ });
+ }
+
renderLegend(currentLegendMode);
}
export function setLegendMode(mode) {
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
currentLegendMode = nextMode;
- renderLegend(currentLegendMode);
+ syncTabs(nextMode);
+ renderLegend(nextMode);
}
export function getLegendMode() {
@@ -43,25 +59,25 @@ export function setLegendItems(mode, items) {
}
}
-function renderLegend(mode) {
- const legend = document.getElementById("legend");
- if (!legend) return;
+function syncTabs(mode) {
+ const tabs = document.querySelectorAll("#legend-tabs .legend-tab");
+ tabs.forEach((tab) => {
+ tab.classList.toggle("legend-tab--active", tab.dataset.legendMode === mode);
+ });
+}
+
+function renderLegend(mode) {
+ const listEl = document.querySelector("#legend .legend-list");
+ if (!listEl) return;
- const config = LEGEND_MODES[mode] || LEGEND_MODES.cables;
const items = legendItemsByMode[mode] || [];
- const itemsHtml = items
+ listEl.innerHTML = items
.map(
(item) => `
- `,
+
+
${item.label}
+
`,
)
.join("");
-
- legend.innerHTML = `
-
${config.title}
-
${itemsHtml}
- `;
}
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index 653be064..97b48c30 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -1,7 +1,7 @@
import * as THREE from "three";
import { createNoise3D } from "simplex-noise";
-import { CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
+import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
showStatusMessage,
@@ -23,6 +23,8 @@ import {
createStars,
createGridLines,
getEarth,
+ loadEarthTexture,
+ clearEarthTexture,
} from "./earth.js";
import {
loadGeoJSONFromPath,
@@ -110,12 +112,12 @@ import {
resetView,
getZoomLevel,
teardownControls,
+ updateLayerButtonState,
} from "./controls.js";
import {
initInfoCard,
showInfoCard,
hideInfoCard,
- setInfoCardNoBorder,
} from "./info-card.js";
import {
initLegend,
@@ -123,6 +125,8 @@ import {
refreshLegend,
setLegendItems,
} from "./legend.js";
+import { mountBrand } from "./brand.js";
+import { initTVPanel } from "./tv.js";
export let scene;
export let camera;
@@ -174,17 +178,22 @@ const cleanupFns = [];
const DRAG_SMOOTHING_FACTOR = 0.18;
const INERTIA_DAMPING = 0.92;
const INERTIA_MIN_VELOCITY = 0.00008;
+const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
+const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
+const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
const HUD_INTERACTIVE_SELECTORS = [
+ ".earth-left-column",
+ ".earth-left-column *",
"#info-panel",
"#info-panel *",
"#right-toolbar-group",
"#right-toolbar-group *",
- "#coordinates-display",
- "#coordinates-display *",
"#legend",
"#legend *",
"#earth-stats",
"#earth-stats *",
+ "#tv-panel",
+ "#tv-panel *",
];
function bindListener(target, eventName, handler, options) {
@@ -195,6 +204,17 @@ function bindListener(target, eventName, handler, options) {
);
}
+function getViewportAspect() {
+ return window.innerWidth / window.innerHeight;
+}
+
+function syncRendererViewport() {
+ if (!camera || !renderer) return;
+ camera.aspect = getViewportAspect();
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+}
+
function isEventOnHud(event) {
const target = event?.target;
if (!(target instanceof Element)) return false;
@@ -399,7 +419,7 @@ function getPrimaryBGPClickTarget(
return anomalyMarker;
}
-function showCableInfo(cable) {
+function showCableInfo(cable, coords) {
setLegendMode("cables");
showInfoCard("cable", {
name: cable.userData.name,
@@ -408,10 +428,16 @@ function showCableInfo(cable) {
length: cable.userData.length,
coords: cable.userData.coords,
rfs: cable.userData.rfs,
- });
+ }, coords);
}
-function showSatelliteInfo(props) {
+function getCableBriefHtml(cable) {
+ const name = cable.userData.name || "未知海缆";
+ const status = cable.userData.status || "";
+ return `
${name} ${status ? `
${status}` : ""}`;
+}
+
+function showSatelliteInfo(props, coords) {
const meanMotion = props?.mean_motion || 0;
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-";
const ecc = props?.eccentricity || 0;
@@ -428,10 +454,16 @@ function showSatelliteInfo(props) {
period,
perigee,
apogee,
- });
+ }, coords);
}
-function showBGPInfo(marker) {
+function getSatelliteBriefHtml(props) {
+ const name = props?.name || "未知卫星";
+ const id = props?.norad_cat_id ? `NORAD: ${props.norad_cat_id}` : "";
+ return `
${name} ${id ? `
${id}` : ""}`;
+}
+
+function showBGPInfo(marker, coords) {
setLegendMode("bgp");
const impactedRegions =
Array.isArray(marker.userData.impacted_regions) &&
@@ -496,10 +528,18 @@ function showBGPInfo(marker) {
formatBGPLocation(marker.userData.city, marker.userData.country),
created_at: formatBGPObservedTime(marker.userData.created_at_raw),
summary: narrative,
- });
+ }, coords);
}
-function showBGPCollectorInfo(marker) {
+function getBGPBriefHtml(marker) {
+ const type = formatBGPAnomalyTypeLabel(
+ marker.userData.incident_type || marker.userData.anomaly_type,
+ );
+ const collector = marker.userData.collector || "";
+ return `
${type} ${collector ? `
${collector}` : ""}`;
+}
+
+function showBGPCollectorInfo(marker, coords) {
setLegendMode("bgp");
showInfoCard("bgp_collector", {
collector: marker.userData.collector,
@@ -517,7 +557,54 @@ function showBGPCollectorInfo(marker) {
latest_observed_at: formatBGPObservedTime(marker.userData.latest_observed_at),
baseline_scope: formatBGPScope(marker.userData.baseline_scope),
status: formatBGPCollectorStatus(marker.userData.status || "online"),
- });
+ }, coords);
+}
+
+function getBGPCollectorBriefHtml(marker) {
+ const name = marker.userData.collector || "观测站";
+ const count = marker.userData.anomaly_count ?? 0;
+ return `
${name} ${count} 条事件`;
+}
+
+function getBGPStatusText(bgpResult) {
+ if (bgpResult.totalCount > 0) {
+ return `${bgpResult.totalCount} 起活跃事件`;
+ }
+ if (bgpResult.anomalyCount > 0) {
+ return `${bgpResult.anomalyCount} 条活跃异常`;
+ }
+ return "当前无活跃事件";
+}
+
+function updateBGPHud(bgpResult) {
+ const bgpBtn = document.getElementById("toggle-bgp");
+ if (bgpBtn) {
+ bgpBtn.classList.add("active");
+ const tooltip = bgpBtn.querySelector(".earth-toolbar-tooltip");
+ if (tooltip) {
+ tooltip.textContent = ACTIVE_BGP_TOOLTIP_TEXT;
+ }
+ }
+
+ const bgpCountEl = document.getElementById("bgp-anomaly-count");
+ if (bgpCountEl) {
+ bgpCountEl.textContent = `${bgpResult.totalCount} 起`;
+ }
+
+ const bgpCollectorEl = document.getElementById("bgp-collector-count");
+ if (bgpCollectorEl) {
+ bgpCollectorEl.textContent = `${bgpResult.collectorCount} 个`;
+ }
+
+ const bgpStatusEl = document.getElementById("bgp-status-summary");
+ if (bgpStatusEl) {
+ bgpStatusEl.textContent = getBGPStatusText(bgpResult);
+ }
+}
+
+function clearSelectionAndInfo() {
+ clearLockedObject();
+ hideInfoCard();
}
function getBGPRelatedCableNames(marker) {
@@ -654,8 +741,8 @@ function buildLoadErrorMessage(errors) {
function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) {
const satBtn = document.getElementById("toggle-satellites");
if (satBtn) {
- satBtn.classList.toggle("active", enabled);
- const tooltip = satBtn.querySelector(".tooltip");
+ updateLayerButtonState(satBtn, enabled);
+ const tooltip = satBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) tooltip.textContent = enabled ? "隐藏卫星" : "显示卫星";
}
@@ -668,8 +755,8 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
function updateCableToggleUi(enabled) {
const cableBtn = document.getElementById("toggle-cables");
if (cableBtn) {
- cableBtn.classList.toggle("active", enabled);
- const tooltip = cableBtn.querySelector(".tooltip");
+ updateLayerButtonState(cableBtn, enabled);
+ const tooltip = cableBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) tooltip.textContent = enabled ? "隐藏线缆" : "显示线缆";
}
@@ -704,10 +791,9 @@ async function ensureCablesEnabled() {
const requestToken = ++cableToggleToken;
clearCableData(earth);
- const [cableCount] = await Promise.all([
- loadGeoJSONFromPath(scene, earth),
- loadLandingPoints(scene, earth),
- ]);
+ // Load landing points first so they appear before cable lines
+ await loadLandingPoints(scene, earth);
+ const cableCount = await loadGeoJSONFromPath(scene, earth);
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
clearCableData(earth);
@@ -799,11 +885,15 @@ export function init() {
destroyed = false;
initialized = true;
simplex = createNoise3D();
+ updateHudScale();
+ const brandRoot = document.getElementById("brand-root");
+ mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
+ initTVPanel();
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
75,
- window.innerWidth / window.innerHeight,
+ getViewportAspect(),
0.1,
1000,
);
@@ -815,7 +905,7 @@ export function init() {
alpha: false,
powerPreference: "high-performance",
});
- renderer.setSize(window.innerWidth, window.innerHeight);
+ syncRendererViewport();
renderer.setClearColor(0x0a0a1a, 1);
renderer.setPixelRatio(window.devicePixelRatio);
@@ -881,7 +971,10 @@ function addLights() {
scene.add(pointLight);
}
-async function loadData(showWhiteSphere = false) {
+// Yield control to the browser so the renderer can paint a frame before the next step
+const yieldFrame = (ms = 60) => new Promise((r) => setTimeout(r, ms));
+
+async function loadData() {
if (!scene || !camera || !renderer) return;
if (isDataLoading) return;
@@ -891,71 +984,104 @@ async function loadData(showWhiteSphere = false) {
const loadToken = ++currentLoadToken;
isDataLoading = true;
hideError();
- setLoadingMessage(
- showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...",
- showWhiteSphere
- ? "重新同步卫星、海底光缆、登陆点与BGP态势数据"
- : "同步卫星、海底光缆、登陆点与BGP态势数据",
- );
+ clearSelectionAndInfo();
+
+ // Always begin as a white sphere so every layer appears explicitly
+ clearEarthTexture();
+ clearBGPData(earth);
+ clearCableData(earth);
+ clearSatelliteData();
+
+ setLoadingMessage("正在初始化...", "清除旧数据");
setLoading(true);
- clearLockedObject();
- hideInfoCard();
-
- if (showWhiteSphere && earth.material) {
- earthTexture = earth.material.map;
- earth.material.map = null;
- earth.material.color.setHex(0xffffff);
- earth.material.needsUpdate = true;
- }
-
- const results = await Promise.allSettled([
- cablesEnabled ? ensureCablesEnabled() : Promise.resolve(0),
- satellitesEnabled ? ensureSatellitesEnabled() : Promise.resolve(0),
- (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} 起`;
- }
- const bgpCollectorEl = document.getElementById("bgp-collector-count");
- if (bgpCollectorEl) {
- bgpCollectorEl.textContent = `${bgpResult.collectorCount} 个`;
- }
- const bgpStatusEl = document.getElementById("bgp-status-summary");
- if (bgpStatusEl) {
- bgpStatusEl.textContent =
- bgpResult.totalCount > 0
- ? `${bgpResult.totalCount} 起活跃事件`
- : bgpResult.anomalyCount > 0
- ? `${bgpResult.anomalyCount} 条活跃异常`
- : "当前无活跃事件";
- }
- return bgpResult;
- })(),
- ]);
-
- if (loadToken !== currentLoadToken) {
- isDataLoading = false;
- return;
- }
+ await yieldFrame();
+ if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
const errors = [];
- if (results[0].status === "rejected") {
- errors.push({ label: "电缆", reason: results[0].reason });
+
+ // Step 1 — Landing points
+ if (cablesEnabled) {
+ setLoadingMessage("正在加载登陆点...", "同步海底光缆登陆站数据");
+ await yieldFrame(30);
+ try {
+ await loadLandingPoints(scene, earth);
+ } catch (err) {
+ errors.push({ label: "登陆点", reason: err });
+ }
+ if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
+ await yieldFrame();
}
- if (results[1].status === "rejected") {
- errors.push({ label: "卫星", reason: results[1].reason });
+
+ // Step 2 — Cables
+ if (cablesEnabled) {
+ setLoadingMessage("正在加载海缆...", "同步海底光缆网络数据");
+ await yieldFrame(30);
+ try {
+ const cableCount = await loadGeoJSONFromPath(scene, earth);
+ if (loadToken === currentLoadToken && cablesEnabled) {
+ toggleCables(true);
+ updateCableToggleUi(true);
+ setLegendItems("cables", getCableLegendItems());
+ refreshLegend();
+ }
+ } catch (err) {
+ errors.push({ label: "海缆", reason: err });
+ }
+ if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
+ await yieldFrame();
}
- if (results[2].status === "rejected") {
- errors.push({ label: "BGP态势", reason: results[2].reason });
+
+ // Step 3 — Satellites
+ if (satellitesEnabled) {
+ setLoadingMessage("正在加载卫星...", "同步在轨卫星轨道数据");
+ await yieldFrame(30);
+ try {
+ clearSatelliteData();
+ const satelliteCount = await loadSatellites();
+ if (loadToken === currentLoadToken && satellitesEnabled) {
+ updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
+ toggleSatellites(true);
+ updateSatelliteToggleUi(true, satelliteCount);
+ setLegendItems("satellites", getSatelliteLegendItems());
+ refreshLegend();
+ }
+ } catch (err) {
+ errors.push({ label: "卫星", reason: err });
+ }
+ if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
+ await yieldFrame();
+ }
+
+ // Step 4 — BGP
+ setLoadingMessage("正在加载BGP态势...", "同步全球路由观测数据");
+ await yieldFrame(30);
+ try {
+ const bgpResult = await loadBGPAnomalies(scene, earth);
+ if (loadToken === currentLoadToken) {
+ toggleBGP(true);
+ updateBGPHud(bgpResult);
+ }
+ } catch (err) {
+ errors.push({ label: "BGP态势", reason: err });
+ }
+ if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
+ await yieldFrame();
+
+ // Step 5 — Earth texture (loads last so data layers appear on the white sphere first)
+ setLoadingMessage("正在加载地球纹理...", "加载8K卫星地图");
+ await yieldFrame(30);
+ try {
+ await loadEarthTexture();
+ } catch (err) {
+ // texture failure is non-fatal
+ }
+ if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
+ await yieldFrame();
+
+ // Step 6 — Terrain (if enabled)
+ if (getShowTerrain()) {
+ setLoadingMessage("正在渲染地形...", "生成地表高程数据");
+ await yieldFrame(50);
}
if (errors.length > 0) {
@@ -964,7 +1090,7 @@ async function loadData(showWhiteSphere = false) {
showStatusMessage(errorMessage, "error");
} else {
hideError();
- showStatusMessage("数据已重新加载", "success");
+ showStatusMessage("数据已加载", "success");
}
updateStatsSummary();
@@ -976,18 +1102,12 @@ async function loadData(showWhiteSphere = false) {
refreshLegend();
setLoading(false);
isDataLoading = false;
-
- if (showWhiteSphere && earth.material) {
- earth.material.map = earthTexture;
- earth.material.color.setHex(0xffffff);
- earth.material.needsUpdate = true;
- }
}
const POSITION_UPDATE_FORCE_DELTA = 250;
export async function reloadData() {
- await loadData(true);
+ await loadData();
}
export async function setCablesEnabled(enabled) {
@@ -997,8 +1117,7 @@ export async function setCablesEnabled(enabled) {
}
if (!enabled) {
- clearLockedObject();
- hideInfoCard();
+ clearSelectionAndInfo();
disableCables();
showStatusMessage("线缆已隐藏", "info");
return 0;
@@ -1032,8 +1151,7 @@ export async function setSatellitesEnabled(enabled) {
}
if (!enabled) {
- clearLockedObject();
- hideInfoCard();
+ clearSelectionAndInfo();
disableSatellites();
return 0;
}
@@ -1078,11 +1196,24 @@ function setupEventListeners() {
bindListener(renderer.domElement, "click", handleClick);
}
+function updateHudScale() {
+ const widthScale = window.innerWidth / HUD_CONFIG.scaleReferenceWidth;
+ const heightScale = window.innerHeight / HUD_CONFIG.scaleReferenceHeight;
+ const nextScale = THREE.MathUtils.clamp(
+ Math.min(widthScale, heightScale),
+ HUD_CONFIG.minScale,
+ HUD_CONFIG.maxScale,
+ );
+
+ document.documentElement.style.setProperty(
+ "--hud-scale",
+ nextScale.toFixed(3),
+ );
+}
+
function onWindowResize() {
- if (!camera || !renderer) return;
- camera.aspect = window.innerWidth / window.innerHeight;
- camera.updateProjectionMatrix();
- renderer.setSize(window.innerWidth, window.innerHeight);
+ updateHudScale();
+ syncRendererViewport();
}
function getFrontFacingCables(cableLines) {
@@ -1127,18 +1258,12 @@ function onMouseMove(event) {
if (isEventOnHud(event)) {
clearTransientHoverState();
-
+ // Info card stays visible at its click position; just maintain BGP visual state
if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
- showBGPInfo(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
- showBGPCollectorInfo(lockedObject);
- } else if (lockedObjectType === "cable" && lockedObject) {
- showCableInfo(lockedObject);
- } else if (lockedObjectType === "satellite" && lockedSatellite) {
- showSatelliteInfo(lockedSatellite.properties);
- } else {
+ } else if (!lockedObject && !lockedSatellite) {
hideInfoCard();
}
hideTooltip();
@@ -1222,6 +1347,8 @@ function onMouseMove(event) {
clearTransientHoverState();
}
+ let objectTooltipShown = false;
+
if (
hoveredBGPMarker &&
getShowBGP() &&
@@ -1230,21 +1357,19 @@ function onMouseMove(event) {
) {
applyBGPHoverState(hoveredBGPMarker);
if (hoveredBGPMarker.userData?.type === "bgp") {
- showBGPInfo(hoveredBGPMarker);
+ showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPBriefHtml(hoveredBGPMarker));
} else {
- showBGPCollectorInfo(hoveredBGPMarker);
+ showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPCollectorBriefHtml(hoveredBGPMarker));
}
- setInfoCardNoBorder(true);
- hideTooltip();
+ objectTooltipShown = true;
} else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object;
hoveredCable = cable;
if (!isSameCable(cable, lockedObject)) {
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
}
- showCableInfo(cable);
- setInfoCardNoBorder(true);
- hideTooltip();
+ showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getCableBriefHtml(cable));
+ objectTooltipShown = true;
} else if (hoveredSat?.properties) {
hoveredSatellite = hoveredSat;
hoveredSatelliteIndex = hoveredSatIndexFromIntersect;
@@ -1258,50 +1383,38 @@ function onMouseMove(event) {
);
}
}
- showSatelliteInfo(hoveredSat.properties);
- setInfoCardNoBorder(true);
+ showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getSatelliteBriefHtml(hoveredSat.properties));
+ objectTooltipShown = true;
} else if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
- showBGPInfo(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
- showBGPCollectorInfo(lockedObject);
- } else if (lockedObjectType === "cable" && lockedObject) {
- showCableInfo(lockedObject);
- } else if (lockedObjectType === "satellite" && lockedSatellite) {
- const satPositions = getSatellitePositions();
- if (lockedSatelliteIndex !== null && satPositions?.[lockedSatelliteIndex]) {
- setSatelliteRingState(
- lockedSatelliteIndex,
- "locked",
- satPositions[lockedSatelliteIndex].current,
- );
- }
- showSatelliteInfo(lockedSatellite.properties);
- } else {
+ } else if (!lockedObjectType) {
resetTransientBGPStates();
hideInfoCard();
}
- const earthPoint = screenToEarthCoords(
- event.clientX,
- event.clientY,
- camera,
- earth,
- document.body,
- interactionRaycaster,
- interactionMouse,
- );
- if (earthPoint) {
- const coords = vector3ToLatLon(earthPoint);
- updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
- showTooltip(
- event.clientX + 10,
- event.clientY + 10,
- `纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${coords.alt.toFixed(1)} km`,
+ if (!objectTooltipShown) {
+ const earthPoint = screenToEarthCoords(
+ event.clientX,
+ event.clientY,
+ camera,
+ earth,
+ document.body,
+ interactionRaycaster,
+ interactionMouse,
);
- } else {
- hideTooltip();
+ if (earthPoint) {
+ const coords = vector3ToLatLon(earthPoint);
+ updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
+ showTooltip(
+ event.clientX + TOOLTIP_COORDS_OFFSET,
+ event.clientY + TOOLTIP_COORDS_OFFSET,
+ `纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${coords.alt.toFixed(1)} km`,
+ );
+ } else {
+ hideTooltip();
+ }
}
}
@@ -1388,7 +1501,7 @@ function onClick(event) {
highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc");
}
const incidentSummary = getBGPInfrastructureSummary(clickedMarker);
- showBGPInfo(clickedMarker);
+ showBGPInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择BGP事件: ${clickedMarker.userData.collector} · ${incidentSummary.regionCount}个区域 / ${incidentSummary.cableCount}条相关海缆`,
"info",
@@ -1411,7 +1524,7 @@ function onClick(event) {
setAutoRotate(false);
showBGPCollectorCoverageOverlay(clickedMarker, earth);
clickedMarker.userData.related_satellite_count = 0;
- showBGPCollectorInfo(clickedMarker);
+ showBGPCollectorInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择观测站: ${clickedMarker.userData.collector}`,
"info",
@@ -1430,6 +1543,7 @@ function onClick(event) {
lockedObjectType = "cable";
setAutoRotate(false);
handleCableClick(clickedCable);
+ showCableInfo(clickedCable, { x: event.clientX, y: event.clientY });
return;
}
@@ -1483,13 +1597,14 @@ function onClick(event) {
);
}
- showSatelliteInfo(sat.properties);
+ showSatelliteInfo(sat.properties, { x: event.clientX, y: event.clientY });
showStatusMessage("已选择: " + sat.properties.name, "info");
return;
}
if (!isLongDrag) {
clearLockedObject();
+ hideInfoCard();
setAutoRotate(true);
}
}
diff --git a/frontend/public/earth/js/tv.js b/frontend/public/earth/js/tv.js
new file mode 100644
index 00000000..5dd6c35f
--- /dev/null
+++ b/frontend/public/earth/js/tv.js
@@ -0,0 +1,617 @@
+import Hls from "hls.js";
+import { showStatusMessage } from "./ui.js";
+
+const TV_STREAMS_API = "/api/v1/tv/streams";
+const TV_PROXY_API = "/api/v1/tv/proxy";
+const TV_STATUS_MESSAGE = {
+ idle: "等待加载直播源",
+ syncing: "正在同步直播源...",
+ empty: "暂无可播放直播源",
+ iframeReady: "直播页已加载",
+ videoReady: "视频流已加载",
+ videoError: "当前视频流不可播放,请尝试其他频道",
+ externalOnly: "当前频道仅支持外部打开",
+ loadFailed: "电视直播源加载失败",
+};
+
+let tvPayload = null;
+let currentSourceId = "";
+let initialized = false;
+let refreshPromise = null;
+let hlsPlayer = null;
+let hlsRecoveryAttempts = 0;
+
+const HLS_MAX_RECOVERY_ATTEMPTS = 3;
+const HLS_RETRY_CONFIG = {
+ maxNumRetry: 4,
+ retryDelayMs: 1500,
+ maxRetryDelayMs: 8000,
+ backoff: "exponential",
+};
+
+function getElements() {
+ return {
+ panel: document.getElementById("tv-panel"),
+ toggleBtn: document.getElementById("toggle-tv"),
+ resizeHandle: document.getElementById("tv-resize-handle"),
+ select: document.getElementById("tv-source-select"),
+ title: document.getElementById("tv-source-title"),
+ meta: document.getElementById("tv-source-meta"),
+ catalog: document.getElementById("tv-source-catalog"),
+ status: document.getElementById("tv-source-status"),
+ notes: document.getElementById("tv-source-notes"),
+ iframe: document.getElementById("tv-iframe"),
+ video: document.getElementById("tv-video"),
+ empty: document.getElementById("tv-empty-state"),
+ refreshBtn: document.getElementById("tv-refresh"),
+ openBtn: document.getElementById("tv-open-external"),
+ };
+}
+
+function clearPanelPositioningForResize(panel) {
+ panel.style.left = `${panel.offsetLeft}px`;
+ panel.style.top = `${panel.offsetTop}px`;
+ panel.style.right = "auto";
+ panel.style.bottom = "auto";
+ panel.style.transform = "none";
+ panel.dataset.dragged = "true";
+}
+
+function getHudScale() {
+ const scale = Number.parseFloat(
+ getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
+ );
+ return Number.isFinite(scale) && scale > 0 ? scale : 1;
+}
+
+function setupResizeHandle() {
+ const { panel, resizeHandle } = getElements();
+ const container = document.getElementById("container");
+ if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) {
+ return;
+ }
+
+ let resizing = false;
+ let startX = 0;
+ let startY = 0;
+ let startWidth = 0;
+ let startHeight = 0;
+
+ const stopResize = () => {
+ resizing = false;
+ panel.classList.remove("is-resizing");
+ document.body.style.userSelect = "";
+ };
+
+ resizeHandle.addEventListener("pointerdown", (event) => {
+ if (document.getElementById("container")?.classList.contains("layout-expanded")) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ resizing = true;
+ startX = event.clientX;
+ startY = event.clientY;
+
+ clearPanelPositioningForResize(panel);
+
+ const rect = panel.getBoundingClientRect();
+ startWidth = rect.width;
+ startHeight = rect.height;
+ panel.classList.add("is-resizing");
+ document.body.style.userSelect = "none";
+ resizeHandle.setPointerCapture?.(event.pointerId);
+ });
+
+ resizeHandle.addEventListener("pointermove", (event) => {
+ if (!resizing) return;
+ const containerRect = container.getBoundingClientRect();
+ const panelRect = panel.getBoundingClientRect();
+ const currentLeft = panelRect.left - containerRect.left;
+ const currentTop = panelRect.top - containerRect.top;
+ const hudScale = getHudScale();
+ const minWidth = Math.max(320, Math.round(360 * hudScale));
+ const minHeight = Math.max(260, Math.round(340 * hudScale));
+ const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12);
+ const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12);
+ const nextWidth = Math.min(
+ maxWidth,
+ Math.max(minWidth, startWidth + (event.clientX - startX)),
+ );
+ const nextHeight = Math.min(
+ maxHeight,
+ Math.max(minHeight, startHeight + (event.clientY - startY)),
+ );
+
+ panel.style.width = `${nextWidth}px`;
+ panel.style.minHeight = `${nextHeight}px`;
+ });
+
+ resizeHandle.addEventListener("pointerup", stopResize);
+ resizeHandle.addEventListener("pointercancel", stopResize);
+ resizeHandle.addEventListener("lostpointercapture", stopResize);
+}
+
+function updateToggleButton(visible) {
+ const { toggleBtn } = getElements();
+ if (!toggleBtn) return;
+ toggleBtn.classList.toggle("active", visible);
+ const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
+ if (tooltip) {
+ tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播";
+ }
+}
+
+function syncSettingsToggle(visible) {
+ const input = document.querySelector('[data-settings-panel="tv-panel"]');
+ if (input instanceof HTMLInputElement) {
+ input.checked = visible;
+ }
+}
+
+function setPanelVisible(visible) {
+ const { panel } = getElements();
+ if (!panel) return;
+ panel.classList.toggle("hud-panel-hidden", !visible);
+ updateToggleButton(visible);
+ syncSettingsToggle(visible);
+}
+
+function getEmbeddedUrl(source) {
+ if (!source) return "";
+ if (source.source_type === "youtube" && source.youtube_video_id) {
+ const videoId = encodeURIComponent(source.youtube_video_id);
+ return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&playsinline=1&rel=0`;
+ }
+ if (source.source_type === "external") return "";
+ if (source.source_type === "video" || source.source_type === "hls") {
+ return "";
+ }
+ return source.embed_url || source.homepage_url || "";
+}
+
+function buildProxyUrl(url) {
+ if (!url) return "";
+ return `${TV_PROXY_API}?url=${encodeURIComponent(url)}`;
+}
+
+function getVideoUrl(source) {
+ if (!source) return "";
+ if (source.source_type !== "video" && source.source_type !== "hls") {
+ return "";
+ }
+ return buildProxyUrl(source.stream_url || source.embed_url || "");
+}
+
+function destroyHlsPlayer() {
+ if (hlsPlayer) {
+ hlsPlayer.destroy();
+ hlsPlayer = null;
+ }
+ hlsRecoveryAttempts = 0;
+}
+
+function showEmbeddedFallback(source, reasonMessage = TV_STATUS_MESSAGE.videoError) {
+ const { iframe, video, empty } = getElements();
+ const embeddedUrl = getEmbeddedUrl(source);
+ if (!embeddedUrl) {
+ setPanelMessage(reasonMessage);
+ return false;
+ }
+
+ destroyHlsPlayer();
+
+ if (video) {
+ video.removeAttribute("src");
+ video.hidden = true;
+ video.load();
+ }
+
+ if (iframe) {
+ iframe.hidden = false;
+ if (iframe.src !== embeddedUrl) {
+ iframe.src = embeddedUrl;
+ }
+ }
+
+ if (empty) {
+ empty.hidden = true;
+ }
+
+ setPanelMessage("直播放流不可用,已回退到官网直播页");
+ return true;
+}
+
+function canPlayNativeHls(video, sourceUrl) {
+ if (!(video instanceof HTMLVideoElement) || !sourceUrl) return false;
+ const isLikelyHls = sourceUrl.includes(".m3u8") || sourceUrl.includes("mpegurl");
+ if (!isLikelyHls) return false;
+ return video.canPlayType("application/vnd.apple.mpegurl") !== "";
+}
+
+function tryStartPlayback(video) {
+ if (!(video instanceof HTMLVideoElement)) return;
+ video.autoplay = true;
+ video.muted = true;
+ const playPromise = video.play();
+ if (playPromise && typeof playPromise.catch === "function") {
+ playPromise.catch((error) => {
+ console.warn("TV 自动播放未成功:", error);
+ setPanelMessage("已加载视频流,点击播放继续");
+ });
+ }
+}
+
+function attachVideoSource(video, source) {
+ const sourceUrl = getVideoUrl(source);
+ if (!(video instanceof HTMLVideoElement) || !sourceUrl) return;
+
+ destroyHlsPlayer();
+ video.autoplay = true;
+ video.muted = true;
+
+ if (source.source_type === "hls") {
+ if (canPlayNativeHls(video, sourceUrl)) {
+ video.src = sourceUrl;
+ video.load();
+ tryStartPlayback(video);
+ return;
+ }
+
+ if (Hls.isSupported()) {
+ hlsPlayer = new Hls({
+ enableWorker: true,
+ lowLatencyMode: false,
+ manifestLoadingTimeOut: 20000,
+ levelLoadingTimeOut: 20000,
+ fragLoadingTimeOut: 25000,
+ fragLoadingMaxRetry: 3,
+ fragLoadingRetryDelay: 1500,
+ levelLoadingMaxRetry: 3,
+ levelLoadingRetryDelay: 1500,
+ manifestLoadingMaxRetry: 2,
+ manifestLoadingRetryDelay: 1500,
+ liveSyncDurationCount: 4,
+ liveMaxLatencyDurationCount: 10,
+ manifestLoadPolicy: {
+ default: {
+ maxTimeToFirstByteMs: 12000,
+ maxLoadTimeMs: 20000,
+ timeoutRetry: {
+ ...HLS_RETRY_CONFIG,
+ maxNumRetry: 2,
+ },
+ errorRetry: {
+ ...HLS_RETRY_CONFIG,
+ maxNumRetry: 2,
+ },
+ },
+ },
+ playlistLoadPolicy: {
+ default: {
+ maxTimeToFirstByteMs: 12000,
+ maxLoadTimeMs: 20000,
+ timeoutRetry: HLS_RETRY_CONFIG,
+ errorRetry: HLS_RETRY_CONFIG,
+ },
+ },
+ fragLoadPolicy: {
+ default: {
+ maxTimeToFirstByteMs: 12000,
+ maxLoadTimeMs: 30000,
+ timeoutRetry: HLS_RETRY_CONFIG,
+ errorRetry: HLS_RETRY_CONFIG,
+ },
+ },
+ });
+ hlsPlayer.loadSource(sourceUrl);
+ hlsPlayer.attachMedia(video);
+ hlsPlayer.on(Hls.Events.MANIFEST_PARSED, () => {
+ hlsRecoveryAttempts = 0;
+ setPanelMessage(TV_STATUS_MESSAGE.videoReady);
+ tryStartPlayback(video);
+ });
+ hlsPlayer.on(Hls.Events.ERROR, (_event, data) => {
+ console.error("HLS 播放失败:", data);
+ if (!data?.fatal) {
+ if (data?.type === Hls.ErrorTypes.NETWORK_ERROR) {
+ setPanelMessage("直播流网络波动,正在重试...");
+ return;
+ }
+ if (data?.type === Hls.ErrorTypes.MEDIA_ERROR) {
+ setPanelMessage("直播流正在恢复...");
+ return;
+ }
+ }
+
+ if (data?.fatal && hlsRecoveryAttempts < HLS_MAX_RECOVERY_ATTEMPTS) {
+ hlsRecoveryAttempts += 1;
+ if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
+ setPanelMessage(`直播流连接异常,正在重试 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`);
+ hlsPlayer?.startLoad();
+ return;
+ }
+ if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
+ setPanelMessage(`直播流解码异常,正在恢复 (${hlsRecoveryAttempts}/${HLS_MAX_RECOVERY_ATTEMPTS})...`);
+ hlsPlayer?.recoverMediaError();
+ return;
+ }
+ }
+
+ if (!showEmbeddedFallback(source)) {
+ setPanelMessage(TV_STATUS_MESSAGE.videoError);
+ }
+ });
+ return;
+ }
+ }
+
+ video.src = sourceUrl;
+ video.load();
+ tryStartPlayback(video);
+}
+
+function getExternalUrl(source) {
+ return source?.homepage_url || source?.youtube_channel || source?.embed_url || source?.stream_url || "";
+}
+
+function updateOpenButton(source) {
+ const { openBtn } = getElements();
+ if (!openBtn) return;
+ const targetUrl = getExternalUrl(source);
+ openBtn.disabled = !targetUrl;
+ openBtn.onclick = targetUrl
+ ? () => {
+ window.open(targetUrl, "_blank", "noopener,noreferrer");
+ }
+ : null;
+}
+
+function findSourceById(sourceId) {
+ return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
+}
+
+function getCurrentSource() {
+ return findSourceById(currentSourceId);
+}
+
+function setPanelMessage(message) {
+ const { status } = getElements();
+ if (status) {
+ status.textContent = message || TV_STATUS_MESSAGE.idle;
+ }
+}
+
+function resetIframe(iframe) {
+ if (!(iframe instanceof HTMLIFrameElement)) return;
+ iframe.removeAttribute("src");
+ iframe.hidden = true;
+}
+
+function resetVideo(video) {
+ if (!(video instanceof HTMLVideoElement)) return;
+ video.removeAttribute("src");
+ video.hidden = true;
+ video.load();
+}
+
+function showEmptyState(empty, message) {
+ if (!(empty instanceof HTMLElement)) return;
+ empty.hidden = false;
+ empty.textContent = message;
+}
+
+function hideEmptyState(empty) {
+ if (empty instanceof HTMLElement) {
+ empty.hidden = true;
+ }
+}
+
+function renderVideoSource(video, iframe, source) {
+ resetIframe(iframe);
+ if (video instanceof HTMLVideoElement) {
+ video.hidden = false;
+ attachVideoSource(video, source);
+ }
+}
+
+function renderEmbeddedSource(iframe, video, embeddedUrl) {
+ destroyHlsPlayer();
+ resetVideo(video);
+ if (iframe instanceof HTMLIFrameElement) {
+ iframe.hidden = false;
+ if (iframe.src !== embeddedUrl) {
+ iframe.src = embeddedUrl;
+ }
+ }
+}
+
+function renderSourceOptions() {
+ const { select } = getElements();
+ if (!select) return;
+ const sources = tvPayload?.sources || [];
+ const fragment = document.createDocumentFragment();
+
+ sources.forEach((source) => {
+ const marker = source.id === tvPayload?.default_source_id ? " · 默认" : "";
+ const option = document.createElement("option");
+ option.value = source.id;
+ option.textContent = `${source.name}${marker}`;
+ fragment.appendChild(option);
+ });
+
+ select.replaceChildren(fragment);
+ if (currentSourceId) {
+ select.value = currentSourceId;
+ }
+}
+
+function renderSource(source) {
+ const { title, meta, catalog, notes, iframe, video, empty } = getElements();
+ const embeddedUrl = getEmbeddedUrl(source);
+ const videoUrl = getVideoUrl(source);
+ const externalUrl = getExternalUrl(source);
+ const isVideo = Boolean(videoUrl);
+ const isExternalOnly = Boolean(source) && !embeddedUrl && !videoUrl && Boolean(externalUrl);
+
+ if (title) {
+ title.textContent = source?.name || "暂无可用频道";
+ }
+ if (meta) {
+ meta.textContent = source
+ ? `${source.provider} · ${source.region} · ${source.language} · ${source.source_type}`
+ : "当前未配置可播放新闻直播源";
+ }
+ if (catalog) {
+ const sourceCount = tvPayload?.source_count || tvPayload?.sources?.length || 0;
+ const latestUpdatedAt = tvPayload?.latest_updated_at || tvPayload?.generated_at || "";
+ const latestLabel = latestUpdatedAt
+ ? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
+ : "尚未同步";
+ const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : "";
+ catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`;
+ }
+ if (notes) {
+ notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
+ }
+
+ if (!source || (!embeddedUrl && !videoUrl)) {
+ destroyHlsPlayer();
+ resetIframe(iframe);
+ resetVideo(video);
+ showEmptyState(
+ empty,
+ isExternalOnly
+ ? "当前频道仅支持跳转官网或外部播放器打开。"
+ : "暂无可播放直播源,请先在系统配置中添加频道。",
+ );
+ setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty);
+ updateOpenButton(source);
+ return;
+ }
+
+ if (isVideo && videoUrl) {
+ renderVideoSource(video, iframe, source);
+ } else {
+ renderEmbeddedSource(iframe, video, embeddedUrl);
+ }
+
+ hideEmptyState(empty);
+
+ setPanelMessage(
+ source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
+ );
+ updateOpenButton(source);
+}
+
+function resolveInitialSourceId() {
+ if (findSourceById(currentSourceId)) {
+ return currentSourceId;
+ }
+ return tvPayload?.selected_source?.id || tvPayload?.default_source_id || tvPayload?.sources?.[0]?.id || "";
+}
+
+function renderPanel() {
+ currentSourceId = resolveInitialSourceId();
+ renderSourceOptions();
+ renderSource(findSourceById(currentSourceId));
+}
+
+export async function loadTVStreams() {
+ const response = await fetch(TV_STREAMS_API, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to load TV streams: ${response.status}`);
+ }
+ tvPayload = await response.json();
+ return tvPayload;
+}
+
+export async function refreshTVPanel() {
+ if (refreshPromise) {
+ return refreshPromise;
+ }
+
+ setPanelMessage(TV_STATUS_MESSAGE.syncing);
+ refreshPromise = (async () => {
+ try {
+ await loadTVStreams();
+ renderPanel();
+ } catch (error) {
+ console.error("加载电视直播源失败:", error);
+ setPanelMessage(TV_STATUS_MESSAGE.loadFailed);
+ renderSource(findSourceById(currentSourceId));
+ } finally {
+ refreshPromise = null;
+ }
+ })();
+
+ return refreshPromise;
+}
+
+export async function ensureTVPanelReady() {
+ if (!initialized) {
+ initTVPanel();
+ }
+ if (!tvPayload) {
+ await refreshTVPanel();
+ return;
+ }
+ renderPanel();
+}
+
+export function initTVPanel() {
+ if (initialized) return;
+ initialized = true;
+
+ const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements();
+
+ updateToggleButton(!panel?.classList.contains("hud-panel-hidden"));
+ syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden"));
+
+ toggleBtn?.addEventListener("click", async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true;
+ setPanelVisible(nextVisible);
+ if (nextVisible) {
+ await ensureTVPanelReady();
+ showStatusMessage("新闻直播窗口已打开", "info");
+ } else {
+ showStatusMessage("新闻直播窗口已关闭", "info");
+ }
+ });
+
+ select?.addEventListener("change", (event) => {
+ const target = event.currentTarget;
+ if (!(target instanceof HTMLSelectElement)) return;
+ currentSourceId = target.value;
+ renderSource(findSourceById(currentSourceId));
+ });
+
+ refreshBtn?.addEventListener("click", () => {
+ refreshTVPanel();
+ });
+
+ iframe?.addEventListener("load", () => {
+ if (iframe.hidden) return;
+ setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
+ });
+
+ video?.addEventListener("loadedmetadata", () => {
+ if (video.hidden) return;
+ setPanelMessage(TV_STATUS_MESSAGE.videoReady);
+ });
+
+ video?.addEventListener("error", () => {
+ const currentSource = getCurrentSource();
+ if (!showEmbeddedFallback(currentSource)) {
+ setPanelMessage(TV_STATUS_MESSAGE.videoError);
+ }
+ });
+
+ setupResizeHandle();
+}
diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js
index d472a18f..480a4117 100644
--- a/frontend/public/earth/js/ui.js
+++ b/frontend/public/earth/js/ui.js
@@ -3,12 +3,18 @@
let statusTimeoutId = null;
let statusHideTimeoutId = null;
let statusReplayTimeoutId = null;
+const STATUS_BASE_CLASS = "earth-status-message";
-// Show status message
-export function showStatusMessage(message, type = "info") {
- const statusEl = document.getElementById("status-message");
- if (!statusEl) return;
+function getElement(id) {
+ return document.getElementById(id);
+}
+function setElementDisplay(element, visible, displayValue = "block") {
+ if (!element) return;
+ element.style.display = visible ? displayValue : "none";
+}
+
+function clearStatusTimers() {
if (statusTimeoutId) {
clearTimeout(statusTimeoutId);
statusTimeoutId = null;
@@ -23,18 +29,26 @@ export function showStatusMessage(message, type = "info") {
clearTimeout(statusReplayTimeoutId);
statusReplayTimeoutId = null;
}
+}
+
+// Show status message
+export function showStatusMessage(message, type = "info") {
+ const statusEl = getElement("status-message");
+ if (!statusEl) return;
+
+ clearStatusTimers();
const startShow = () => {
statusEl.textContent = message;
- statusEl.className = `status-message ${type}`;
- statusEl.style.display = "block";
+ statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
+ setElementDisplay(statusEl, true);
statusEl.offsetHeight;
statusEl.classList.add("visible");
statusTimeoutId = setTimeout(() => {
statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => {
- statusEl.style.display = "none";
+ setElementDisplay(statusEl, false);
statusEl.textContent = "";
statusHideTimeoutId = null;
}, 280);
@@ -56,9 +70,9 @@ export function showStatusMessage(message, type = "info") {
// Update coordinates display
export function updateCoordinatesDisplay(lat, lon, alt = 0) {
- const longitudeEl = document.getElementById("longitude-value");
- const latitudeEl = document.getElementById("latitude-value");
- const mouseCoordsEl = document.getElementById("mouse-coords");
+ const longitudeEl = getElement("longitude-value");
+ const latitudeEl = getElement("latitude-value");
+ const mouseCoordsEl = getElement("mouse-coords");
if (longitudeEl) longitudeEl.textContent = lon.toFixed(2) + "°";
if (latitudeEl) latitudeEl.textContent = lat.toFixed(2) + "°";
@@ -70,10 +84,10 @@ export function updateCoordinatesDisplay(lat, lon, alt = 0) {
// Update zoom display
export function updateZoomDisplay(zoomLevel, distance) {
const percent = Math.round(zoomLevel * 100);
- const zoomValueEl = document.getElementById("zoom-value");
- const zoomLevelEl = document.getElementById("zoom-level");
- const slider = document.getElementById("zoom-slider");
- const cameraDistanceEl = document.getElementById("camera-distance");
+ const zoomValueEl = getElement("zoom-value");
+ const zoomLevelEl = getElement("zoom-level");
+ const slider = getElement("zoom-slider");
+ const cameraDistanceEl = getElement("camera-distance");
if (zoomValueEl) zoomValueEl.textContent = percent + "%";
if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%";
@@ -83,13 +97,13 @@ export function updateZoomDisplay(zoomLevel, distance) {
// Update earth stats
export function updateEarthStats(stats) {
- const cableCountEl = document.getElementById("cable-count");
- const landingPointCountEl = document.getElementById("landing-point-count");
- const bgpAnomalyCountEl = document.getElementById("bgp-anomaly-count");
- const bgpCollectorCountEl = document.getElementById("bgp-collector-count");
- const bgpStatusSummaryEl = document.getElementById("bgp-status-summary");
- const terrainStatusEl = document.getElementById("terrain-status");
- const textureQualityEl = document.getElementById("texture-quality");
+ const cableCountEl = getElement("cable-count");
+ const landingPointCountEl = getElement("landing-point-count");
+ const bgpAnomalyCountEl = getElement("bgp-anomaly-count");
+ const bgpCollectorCountEl = getElement("bgp-collector-count");
+ const bgpStatusSummaryEl = getElement("bgp-status-summary");
+ const terrainStatusEl = getElement("terrain-status");
+ const textureQualityEl = getElement("texture-quality");
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
if (landingPointCountEl)
@@ -108,14 +122,14 @@ export function updateEarthStats(stats) {
// Show/hide loading
export function setLoading(loading) {
- const loadingEl = document.getElementById("loading");
+ const loadingEl = getElement("loading");
if (!loadingEl) return;
- loadingEl.style.display = loading ? "block" : "none";
+ setElementDisplay(loadingEl, loading);
}
export function setLoadingMessage(title, subtitle = "") {
- const titleEl = document.getElementById("loading-title");
- const subtitleEl = document.getElementById("loading-subtitle");
+ const titleEl = getElement("loading-title");
+ const subtitleEl = getElement("loading-subtitle");
if (titleEl) {
titleEl.textContent = title;
@@ -128,48 +142,46 @@ export function setLoadingMessage(title, subtitle = "") {
// Show tooltip
export function showTooltip(x, y, content) {
- const tooltip = document.getElementById("tooltip");
+ const tooltip = getElement("tooltip");
if (!tooltip) return;
tooltip.innerHTML = content;
tooltip.style.left = x + "px";
tooltip.style.top = y + "px";
- tooltip.style.display = "block";
+ setElementDisplay(tooltip, true);
}
// Hide tooltip
export function hideTooltip() {
- const tooltip = document.getElementById("tooltip");
+ const tooltip = getElement("tooltip");
if (tooltip) {
- tooltip.style.display = "none";
+ setElementDisplay(tooltip, false);
}
}
// Show error message
export function showError(message) {
- const errorEl = document.getElementById("error-message");
+ const errorEl = getElement("error-message");
if (!errorEl) return;
errorEl.textContent = message;
- errorEl.style.display = "block";
+ setElementDisplay(errorEl, true);
}
// Hide error message
export function hideError() {
- const errorEl = document.getElementById("error-message");
+ const errorEl = getElement("error-message");
if (errorEl) {
- errorEl.style.display = "none";
+ setElementDisplay(errorEl, false);
errorEl.textContent = "";
}
}
export function clearUiState() {
- if (statusTimeoutId) {
- clearTimeout(statusTimeoutId);
- statusTimeoutId = null;
- }
+ clearStatusTimers();
- const statusEl = document.getElementById("status-message");
+ const statusEl = getElement("status-message");
if (statusEl) {
- statusEl.style.display = "none";
+ statusEl.className = STATUS_BASE_CLASS;
+ setElementDisplay(statusEl, false);
statusEl.textContent = "";
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d4457f0a..83dec72f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,34 +1,57 @@
+import { Suspense, lazy } from 'react'
+
+import { Spin } from 'antd'
import { Routes, Route, Navigate } from 'react-router-dom'
+
import { useAuthStore } from './stores/auth'
import Login from './pages/Login/Login'
-import Dashboard from './pages/Dashboard/Dashboard'
-import Users from './pages/Users/Users'
-import DataSources from './pages/DataSources/DataSources'
-import DataList from './pages/DataList/DataList'
-import Earth from './pages/Earth/Earth'
-import Settings from './pages/Settings/Settings'
-import BGP from './pages/BGP/BGP'
+
+const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts'))
+const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts'))
+const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts'))
+const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'))
+const Users = lazy(() => import('./pages/Users/Users'))
+const DataSources = lazy(() => import('./pages/DataSources/DataSources'))
+const DataList = lazy(() => import('./pages/DataList/DataList'))
+const Earth = lazy(() => import('./pages/Earth/Earth'))
+const Settings = lazy(() => import('./pages/Settings/Settings'))
+const BGP = lazy(() => import('./pages/BGP/BGP'))
+const Playground = lazy(() => import('./pages/Playground/Playground'))
function App() {
const { token } = useAuthStore()
- const isEarthPage = window.location.pathname === '/earth'
+ const publicPaths = new Set(['/', '/earth'])
+ const isPublicRoute = publicPaths.has(window.location.pathname)
- if (!token && !isEarthPage) {
+ if (!token && !isPublicRoute) {
return
}
return (
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+
+
+
+
)
}
diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx
index 83fe521e..c0daa9f3 100644
--- a/frontend/src/components/AppLayout/AppLayout.tsx
+++ b/frontend/src/components/AppLayout/AppLayout.tsx
@@ -1,21 +1,31 @@
-import { ReactNode, useState } from 'react'
+import { ReactNode, useMemo, useState } from 'react'
import { Layout, Menu, Typography, Button, Space } from 'antd'
import {
+ AlertOutlined,
DashboardOutlined,
DatabaseOutlined,
UserOutlined,
SettingOutlined,
BarChartOutlined,
DeploymentUnitOutlined,
+ RobotOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
+ GlobalOutlined,
+ AppstoreOutlined,
+ ToolOutlined,
+ InboxOutlined,
} from '@ant-design/icons'
import { useLocation, useNavigate } from 'react-router-dom'
+import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
import { useAuthStore } from '../../stores/auth'
import packageJson from '../../../package.json'
+import Scrollbar from '../Scrollbar/Scrollbar'
const { Sider, Content } = Layout
const { Text } = Typography
+const DEFAULT_OPEN_MENU_KEY = 'collection'
+let cachedOpenKeys: string[] = [DEFAULT_OPEN_MENU_KEY]
interface AppLayoutProps {
children: ReactNode
@@ -26,18 +36,69 @@ function AppLayout({ children }: AppLayoutProps) {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [collapsed, setCollapsed] = useState(false)
+ const [openKeys, setOpenKeys] = useState