Files
planet/frontend/public/earth/js/cables.js
2026-04-27 23:23:23 +08:00

726 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;
let landingPointTexture = null;
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();
function createLandingPointTexture() {
const size = CABLE_CONFIG.landingPoint.textureSize;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const iconPath = new Path2D(
[
"M400 704",
"C386 704 375 697 367 684",
"L173 378",
"C117 290 144 173 229 111",
"C278 75 337 57 400 57",
"C463 57 522 75 571 111",
"C656 173 683 290 627 378",
"L433 684",
"C425 697 414 704 400 704",
"Z",
].join(" "),
);
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.translate(size * 0.12, size * 0.02);
ctx.scale(size / 1000, size / 1000);
ctx.fillStyle = "#ffffff";
ctx.fill(iconPath);
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(400, 320, 86, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function getLandingPointTexture() {
if (!landingPointTexture) {
landingPointTexture = createLandingPointTexture();
}
return landingPointTexture;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getLandingPointDistanceScale(point, camera) {
if (
!point ||
!camera ||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
) return 1;
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
});
}
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 setLandingPointMaterialState(point, { color, opacity, emissive, emissiveIntensity }) {
point.material.color.set(color);
point.material.opacity = opacity;
if (point.material.emissive && emissive !== undefined) {
point.material.emissive.setHex(emissive);
}
if ("emissiveIntensity" in point.material && emissiveIntensity !== undefined) {
point.material.emissiveIntensity = emissiveIntensity;
}
}
function setLandingPointScale(point, heightScale) {
const aspect = CABLE_CONFIG.landingPoint.iconAspectRatio;
point.scale.set(heightScale * aspect, heightScale, 1);
}
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);
let validCount = 0;
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: getLandingPointTexture(),
color: CABLE_CONFIG.landingPoint.color,
transparent: true,
opacity: CABLE_CONFIG.landingPoint.opacity,
depthTest: false,
depthWrite: false,
}),
);
marker.material.userData.sharedMap = true;
marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder;
marker.center.set(
CABLE_CONFIG.landingPoint.anchorX,
CABLE_CONFIG.landingPoint.anchorY,
);
marker.position.copy(position);
marker.userData = {
type: "landingPoint",
name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [],
country: properties.country || "未知国家",
status: properties.status || "Unknown",
baseScale: CABLE_CONFIG.landingPoint.baseScale,
};
setLandingPointScale(marker, CABLE_CONFIG.landingPoint.baseScale);
earthObj.add(marker);
landingPoints.push(marker);
validCount++;
}
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);
// The pin sprite is rendered without depth testing so its full shape does
// not get sliced by the globe. Instead, hide it when the camera-to-anchor
// segment is occluded by a slightly inflated globe, matching the behavior of
// the BGP and compute-center markers near the limb.
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 pulse =
(Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1) * 0.5;
const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness;
const relatedNames = Array.isArray(lockedCableName)
? lockedCableName.filter(Boolean)
: lockedCableName
? [lockedCableName]
: [];
landingPoints.forEach((lp) => {
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
const isRelated =
!dimAll &&
Array.isArray(lp.userData.cableNames) &&
lp.userData.cableNames.some((name) => relatedNames.includes(name));
if (isRelated) {
setLandingPointMaterialState(lp, {
color: 0xffd27a,
emissive: 0x7a4a00,
emissiveIntensity:
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2),
opacity: Math.max(
0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
),
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(
lp,
(CABLE_CONFIG.landingPointVisual.related.scaleBase +
pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) *
baseScale *
distanceScale,
);
} else {
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
const r = dimColor.r * brightness;
const g = dimColor.g * brightness;
const b = dimColor.b * brightness;
setLandingPointMaterialState(lp, {
color: new THREE.Color(r / 255, g / 255, b / 255),
emissive: CABLE_CONFIG.landingPointVisual.dimmed.emissive,
emissiveIntensity: CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity,
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(lp, baseScale * distanceScale);
}
});
}
export function resetLandingPointVisualState(camera = null) {
landingPoints.forEach((lp) => {
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
setLandingPointMaterialState(lp, {
color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
opacity: CABLE_CONFIG.landingPoint.opacity,
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(lp, baseScale * distanceScale);
});
}
export function toggleCables(show) {
cablesVisible = show;
cableLines.forEach((cable) => {
cable.visible = cablesVisible;
});
landingPoints.forEach((lp) => {
lp.visible = cablesVisible;
});
}
export function getShowCables() {
return cablesVisible;
}