Files
planet/frontend/public/earth/js/cables.js
2026-04-30 04:42:29 +08:00

716 lines
20 KiB
JavaScript

// cables.js - Cable loading and rendering module
import * as THREE from "three";
import {
CONFIG,
CABLE_COLORS,
PATHS,
CABLE_STATE,
CABLE_CONFIG,
} from "./constants.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from "./info-card.js";
import { setLegendItems, setLegendMode } from "./legend.js";
export let cableLines = [];
export let landingPoints = [];
export let lockedCable = null;
let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
const _lpEarthWorldPos = new THREE.Vector3();
const _lpWorldPos = new THREE.Vector3();
const _lpCameraRel = new THREE.Vector3();
const _lpPointRel = new THREE.Vector3();
const _lpCameraToPoint = new THREE.Vector3();
const LANDING_POINT_SPRITE_HEIGHT = 3;
const LANDING_POINT_SPRITE_ASPECT = 1;
const LANDING_POINT_SIZE_REFERENCE_FOV = 75;
const LANDING_POINT_SIZE_SCALE_MIN = 0.36;
const LANDING_POINT_SIZE_SCALE_MAX = 3;
const LANDING_POINT_ATLAS_CELL_SIZE = 128;
let landingPointTexture = null;
function getLandingPointPulse() {
return (
Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1
) * 0.5;
}
function getLandingPointDimColor() {
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness;
const color = new THREE.Color(
(dimColor.r * brightness) / 255,
(dimColor.g * brightness) / 255,
(dimColor.b * brightness) / 255,
);
return `#${color.getHexString()}`;
}
function createLandingPointBallTexture() {
const canvas = document.createElement("canvas");
canvas.width = LANDING_POINT_ATLAS_CELL_SIZE;
canvas.height = LANDING_POINT_ATLAS_CELL_SIZE;
const context = canvas.getContext("2d");
const center = LANDING_POINT_ATLAS_CELL_SIZE / 2;
const radius = 46;
context.clearRect(0, 0, canvas.width, canvas.height);
const shadow = context.createRadialGradient(
center - 16,
center - 18,
8,
center,
center,
radius,
);
shadow.addColorStop(0, "rgba(255, 255, 255, 1)");
shadow.addColorStop(0.48, "rgba(238, 238, 238, 0.98)");
shadow.addColorStop(0.82, "rgba(178, 178, 178, 0.94)");
shadow.addColorStop(1, "rgba(92, 92, 92, 0.88)");
context.beginPath();
context.arc(center, center, radius, 0, Math.PI * 2);
context.fillStyle = shadow;
context.fill();
context.beginPath();
context.ellipse(center - 14, center - 18, 14, 9, -0.45, 0, Math.PI * 2);
context.fillStyle = "rgba(255, 255, 255, 0.38)";
context.fill();
context.beginPath();
context.arc(center, center, radius - 1, 0, Math.PI * 2);
context.strokeStyle = "rgba(255, 255, 255, 0.24)";
context.lineWidth = 2;
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return texture;
}
async function getLandingPointTexture() {
if (landingPointTexture) return landingPointTexture;
landingPointTexture = createLandingPointBallTexture();
return landingPointTexture;
}
function getLandingPointDistanceScale(camera) {
if (!camera) return 1;
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
referenceFov: LANDING_POINT_SIZE_REFERENCE_FOV,
min: LANDING_POINT_SIZE_SCALE_MIN,
max: LANDING_POINT_SIZE_SCALE_MAX,
});
}
function setLandingPointScale(point, camera = null) {
const height = LANDING_POINT_SPRITE_HEIGHT * getLandingPointDistanceScale(camera);
point.scale.set(height * LANDING_POINT_SPRITE_ASPECT, height, 1);
}
function setLandingPointMaterialState(point, { color, opacity }) {
point.material.color.set(color);
point.material.opacity = opacity;
}
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
material.forEach(disposeMaterial);
return;
}
if (material.map && !material.userData?.sharedMap) {
material.map.dispose();
}
material.dispose();
}
function disposeObject(object, parent) {
if (!object) return;
const owner = parent || object.parent;
if (owner) {
owner.remove(object);
}
if (object.geometry && !object.userData?.sharedGeometry) {
object.geometry.dispose();
}
if (object.material) {
disposeMaterial(object.material);
}
}
function getCableColor(properties) {
if (properties.color) {
if (
typeof properties.color === "string" &&
properties.color.startsWith("#")
) {
return parseInt(properties.color.substring(1), 16);
}
if (typeof properties.color === "number") {
return properties.color;
}
}
const cableName =
properties.Name ||
properties.name ||
properties.cableName ||
properties.shortname ||
"";
if (cableName.includes("Americas II")) {
return CABLE_COLORS["Americas II"];
}
if (cableName.includes("AU Aleutian A")) {
return CABLE_COLORS["AU Aleutian A"];
}
if (cableName.includes("AU Aleutian B")) {
return CABLE_COLORS["AU Aleutian B"];
}
return CABLE_COLORS.default;
}
function createCableLine(points, color, properties) {
if (points.length < 2) return null;
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
lineGeometry.computeBoundingSphere();
const lineMaterial = new THREE.LineBasicMaterial({
color,
linewidth: CABLE_CONFIG.line.lineWidth,
transparent: true,
opacity: CABLE_CONFIG.line.opacity,
depthTest: true,
depthWrite: true,
});
const cableLine = new THREE.Line(lineGeometry, lineMaterial);
const cableId =
properties.cable_id ||
properties.id ||
properties.Name ||
properties.name ||
Math.random().toString(36);
cableLine.userData = {
type: "cable",
cableId,
name:
properties.Name ||
properties.name ||
properties.cableName ||
properties.shortname ||
"Unknown",
owner: properties.owner || properties.owners || "-",
status: properties.status || "-",
length: properties.length || "-",
coords: "-",
rfs: properties.rfs || "-",
originalColor: color,
localCenter:
lineGeometry.boundingSphere?.center?.clone() || new THREE.Vector3(),
};
cableLine.renderOrder = CABLE_CONFIG.line.renderOrder;
if (!cableIdMap.has(cableId)) {
cableIdMap.set(cableId, []);
}
cableIdMap.get(cableId).push(cableLine);
return cableLine;
}
function calculateGreatCirclePoints(
lat1,
lon1,
lat2,
lon2,
radius,
segments = 50,
) {
const points = [];
const phi1 = (lat1 * Math.PI) / 180;
const lambda1 = (lon1 * Math.PI) / 180;
const phi2 = (lat2 * Math.PI) / 180;
const lambda2 = (lon2 * Math.PI) / 180;
const dLambda = Math.min(
Math.abs(lambda2 - lambda1),
2 * Math.PI - Math.abs(lambda2 - lambda1),
);
const cosDelta =
Math.sin(phi1) * Math.sin(phi2) +
Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda);
let delta = Math.acos(Math.max(-1, Math.min(1, cosDelta)));
if (delta < CABLE_CONFIG.line.nearPointThreshold) {
const p1 = latLonToVector3(lat1, lon1, radius);
const p2 = latLonToVector3(lat2, lon2, radius);
return [p1, p2];
}
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const sinDelta = Math.sin(delta);
const A = Math.sin((1 - t) * delta) / sinDelta;
const B = Math.sin(t * delta) / sinDelta;
const x1 = Math.cos(phi1) * Math.cos(lambda1);
const y1 = Math.cos(phi1) * Math.sin(lambda1);
const z1 = Math.sin(phi1);
const x2 = Math.cos(phi2) * Math.cos(lambda2);
const y2 = Math.cos(phi2) * Math.sin(lambda2);
const z2 = Math.sin(phi2);
let x = A * x1 + B * x2;
let y = A * y1 + B * y2;
let z = A * z1 + B * z2;
const norm = Math.sqrt(x * x + y * y + z * z);
x = (x / norm) * radius;
y = (y / norm) * radius;
z = (z / norm) * radius;
const lat = (Math.asin(z / radius) * 180) / Math.PI;
let lon = (Math.atan2(y, x) * 180) / Math.PI;
if (lon > 180) lon -= 360;
if (lon < -180) lon += 360;
points.push(latLonToVector3(lat, lon, radius));
}
return points;
}
export function clearCableLines(earthObj = null) {
cableLines.forEach((line) => disposeObject(line, earthObj));
cableLines = [];
cableIdMap = new Map();
cableStates.clear();
}
export function clearLandingPoints(earthObj = null) {
landingPoints.forEach((point) => disposeObject(point, earthObj));
landingPoints = [];
}
export function clearCableData(earthObj = null) {
clearCableSelection();
clearCableLines(earthObj);
clearLandingPoints(earthObj);
}
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
const { silent = false } = options;
console.log("正在加载电缆数据...");
if (!silent) {
showStatusMessage("正在加载电缆数据...", "warning");
}
const response = await fetch(PATHS.cablesApi);
if (!response.ok) {
throw new Error(`电缆接口返回 HTTP ${response.status}`);
}
const data = await response.json();
if (!data.features || !Array.isArray(data.features)) {
throw new Error("无效的电缆 GeoJSON 格式");
}
clearCableLines(earthObj);
for (const feature of data.features) {
const geometry = feature.geometry;
const properties = feature.properties || {};
if (!geometry || !geometry.coordinates) continue;
const color = getCableColor(properties);
if (geometry.type === "MultiLineString") {
for (const lineCoords of geometry.coordinates) {
if (!lineCoords || lineCoords.length < 2) continue;
const points = [];
for (let i = 0; i < lineCoords.length - 1; i++) {
const lon1 = lineCoords[i][0];
const lat1 = lineCoords[i][1];
const lon2 = lineCoords[i + 1][0];
const lat2 = lineCoords[i + 1][1];
const segment = calculateGreatCirclePoints(
lat1,
lon1,
lat2,
lon2,
CONFIG.earthRadius + CABLE_CONFIG.line.altitudeOffset,
CABLE_CONFIG.line.greatCircleSegments,
);
points.push(...(i === 0 ? segment : segment.slice(1)));
}
const line = createCableLine(points, color, properties);
if (line) {
cableLines.push(line);
earthObj.add(line);
}
}
} else if (geometry.type === "LineString") {
const points = [];
for (let i = 0; i < geometry.coordinates.length - 1; i++) {
const lon1 = geometry.coordinates[i][0];
const lat1 = geometry.coordinates[i][1];
const lon2 = geometry.coordinates[i + 1][0];
const lat2 = geometry.coordinates[i + 1][1];
const segment = calculateGreatCirclePoints(
lat1,
lon1,
lat2,
lon2,
CONFIG.earthRadius + CABLE_CONFIG.line.altitudeOffset,
CABLE_CONFIG.line.greatCircleSegments,
);
points.push(...(i === 0 ? segment : segment.slice(1)));
}
const line = createCableLine(points, color, properties);
if (line) {
cableLines.push(line);
earthObj.add(line);
}
}
}
const cableCount = data.features.length;
const inServiceCount = data.features.filter(
(feature) =>
feature.properties &&
(feature.properties.status === "active" ||
feature.properties.status === "In Service"),
).length;
const statusEl = document.getElementById("cable-status-summary");
setEarthStatValue("cable-count", `${cableCount}`);
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
updateEarthStats({
cableCount: cableLines.length,
landingPointCount: landingPoints.length,
terrainOn: false,
textureQuality: "8K 卫星图",
});
if (!silent) {
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
}
return cableLines.length;
}
export async function loadLandingPoints(scene, earthObj, options = {}) {
const { silent = false } = options;
console.log("正在加载登陆点数据...");
const response = await fetch(PATHS.landingPointsApi);
if (!response.ok) {
throw new Error(`登陆点接口返回 HTTP ${response.status}`);
}
const data = await response.json();
if (!data.features || !Array.isArray(data.features)) {
throw new Error("无效的登陆点 GeoJSON 格式");
}
clearLandingPoints(earthObj);
const markerTexture = await getLandingPointTexture();
for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue;
const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {};
if (
typeof lon !== "number" ||
typeof lat !== "number" ||
Number.isNaN(lon) ||
Number.isNaN(lat) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
continue;
}
const position = latLonToVector3(
lat,
lon,
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
);
if (
Number.isNaN(position.x) ||
Number.isNaN(position.y) ||
Number.isNaN(position.z)
) {
continue;
}
const marker = new THREE.Sprite(
new THREE.SpriteMaterial({
map: markerTexture,
color: CABLE_CONFIG.landingPoint.color,
transparent: true,
opacity: CABLE_CONFIG.landingPoint.opacity,
depthTest: false,
depthWrite: false,
alphaTest: 0.01,
}),
);
marker.material.userData.sharedMap = true;
marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder;
marker.center.set(0.5, 0.5);
marker.position.copy(position);
marker.userData = {
type: "landingPoint",
name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [],
country: properties.country || "未知国家",
status: properties.status || "Unknown",
latitude: lat,
longitude: lon,
landing_visual_state: "normal",
};
setLandingPointScale(marker);
earthObj.add(marker);
landingPoints.push(marker);
}
const validCount = landingPoints.length;
setEarthStatValue("landing-point-count", `${validCount}`);
if (!silent) {
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
}
return validCount;
}
export function handleCableClick(cable) {
lockedCable = cable;
setLegendItems("cables", getCableLegendItems());
const data = cable.userData;
setLegendMode("cables");
showInfoCard("cable", {
name: data.name,
owner: data.owner,
status: data.status,
length: data.length,
coords: data.coords,
rfs: data.rfs,
});
showStatusMessage(`已锁定: ${data.name}`, "info");
}
export function clearCableSelection() {
lockedCable = null;
setLegendItems("cables", getCableLegendItems());
}
export function getCableLines() {
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) {
return cableIdMap.get(cableId) || [];
}
export function getLandingPoints() {
return landingPoints;
}
export function getCableState(cableId) {
return cableStates.get(cableId) || CABLE_STATE.NORMAL;
}
export function setCableState(cableId, state) {
cableStates.set(cableId, state);
}
export function clearAllCableStates() {
cableStates.clear();
}
export function getCableStateInfo() {
const states = {};
cableStates.forEach((state, cableId) => {
states[cableId] = state;
});
return states;
}
export function getLandingPointsByCableName(cableName) {
return landingPoints.filter((lp) =>
lp.userData.cableNames?.includes(cableName),
);
}
export function getAllLandingPoints() {
return landingPoints;
}
function isFacingCamera(lp, camera) {
lp.getWorldPosition(_lpWorldPos);
if (lp.parent) {
lp.parent.getWorldPosition(_lpEarthWorldPos);
} else {
_lpEarthWorldPos.set(0, 0, 0);
}
_lpCameraRel.copy(camera.position).sub(_lpEarthWorldPos);
_lpPointRel.copy(_lpWorldPos).sub(_lpEarthWorldPos);
_lpCameraToPoint.subVectors(_lpPointRel, _lpCameraRel);
const distanceSq = _lpCameraToPoint.lengthSq();
if (distanceSq <= 0) return true;
const distance = Math.sqrt(distanceSq);
_lpCameraToPoint.multiplyScalar(1 / distance);
// Sprite rendering keeps the full pin visible. Hide it when the anchor point
// is occluded by the globe so back-side landing points do not bleed through.
const occlusionRadius =
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset * 0.45;
const cameraProjection = _lpCameraRel.dot(_lpCameraToPoint);
const cameraRadiusSq = _lpCameraRel.lengthSq();
const discriminant =
cameraProjection * cameraProjection -
(cameraRadiusSq - occlusionRadius * occlusionRadius);
if (discriminant < 0) return true;
const nearestIntersection = -cameraProjection - Math.sqrt(discriminant);
return nearestIntersection <= 0 || nearestIntersection >= distance;
}
export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) {
const relatedNames = Array.isArray(lockedCableName)
? lockedCableName.filter(Boolean)
: lockedCableName
? [lockedCableName]
: [];
landingPoints.forEach((lp) => {
const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
const isRelated =
!dimAll &&
Array.isArray(lp.userData.cableNames) &&
lp.userData.cableNames.some((name) => relatedNames.includes(name));
lp.visible = isVisible;
setLandingPointScale(lp, camera);
if (isRelated) {
const pulse = getLandingPointPulse();
setLandingPointMaterialState(lp, {
color: 0xffd27a,
opacity: Math.max(
0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
),
});
lp.userData.landing_visual_state = "related";
} else {
setLandingPointMaterialState(lp, {
color: getLandingPointDimColor(),
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
});
lp.userData.landing_visual_state = isVisible ? "dimmed" : "hidden";
}
});
}
export function resetLandingPointVisualState(camera = null) {
landingPoints.forEach((lp) => {
const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
lp.visible = isVisible;
setLandingPointScale(lp, camera);
setLandingPointMaterialState(lp, {
color: CABLE_CONFIG.landingPoint.color,
opacity: CABLE_CONFIG.landingPoint.opacity,
});
lp.userData.landing_visual_state = isVisible ? "normal" : "hidden";
});
}
export function toggleCables(show) {
cablesVisible = show;
cableLines.forEach((cable) => {
cable.visible = cablesVisible;
});
landingPoints.forEach((lp) => {
lp.visible = cablesVisible;
});
}
export function getShowCables() {
return cablesVisible;
}