// 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; }