release: bump version to 0.43.0
This commit is contained in:
@@ -198,6 +198,8 @@ export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
|
||||
vesselsApi: '/api/v1/visualization/geo/vessels',
|
||||
vesselTrackApi: (mmsi) => `/api/v1/visualization/vessels/${encodeURIComponent(mmsi)}/track`,
|
||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||
@@ -205,6 +207,36 @@ export const PATHS = {
|
||||
earthClientLogsApi: '/api/v1/system/logs/earth-client',
|
||||
};
|
||||
|
||||
export const VESSEL_CONFIG = {
|
||||
altitudeOffset: 0.56,
|
||||
maxRenderedMarkers: 5000,
|
||||
marker: {
|
||||
baseScale: 7.5,
|
||||
baseOpacity: 0.88,
|
||||
hoverScale: 1.28,
|
||||
lockedScale: 1.48,
|
||||
dimmedScale: 0.78,
|
||||
dimmedOpacity: 0.26,
|
||||
},
|
||||
colors: {
|
||||
cargo: "#4A90D9",
|
||||
tanker: "#E85D04",
|
||||
passenger: "#06D6A0",
|
||||
fishing: "#FFD166",
|
||||
military: "#73797E",
|
||||
other: "#9B9B9B",
|
||||
},
|
||||
sizeStabilization: {
|
||||
min: 0.1,
|
||||
max: 2.4,
|
||||
},
|
||||
track: {
|
||||
altitudeOffset: 0.7,
|
||||
color: 0x7dd3fc,
|
||||
opacity: 0.82,
|
||||
},
|
||||
};
|
||||
|
||||
export const COMPUTE_CENTER_CONFIG = {
|
||||
altitudeOffset: 0.48,
|
||||
maxRenderedMarkers: 300,
|
||||
|
||||
56
frontend/public/earth/js/controls.js
vendored
56
frontend/public/earth/js/controls.js
vendored
@@ -36,6 +36,8 @@ import {
|
||||
getAtmosphereCloudsEnabled,
|
||||
setSatellitesEnabled,
|
||||
getSatellitesEnabled,
|
||||
setVesselsEnabled,
|
||||
getVesselsEnabled,
|
||||
} from "./main.js";
|
||||
import {
|
||||
toggleTrails,
|
||||
@@ -52,6 +54,10 @@ import {
|
||||
getShowComputeCenters,
|
||||
getComputeCenterCount,
|
||||
} from "./compute-centers.js";
|
||||
import {
|
||||
getShowVessels,
|
||||
getVesselCount,
|
||||
} from "./vessels.js";
|
||||
import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import {
|
||||
@@ -1353,6 +1359,39 @@ function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent
|
||||
return enabled;
|
||||
}
|
||||
|
||||
async function setVesselsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
clearSelectionIfHiding(!enabled);
|
||||
try {
|
||||
if (enabled) {
|
||||
setLayerButtonState(button, {
|
||||
active: false,
|
||||
loading: true,
|
||||
tooltip: "船只加载中...",
|
||||
});
|
||||
}
|
||||
await setVesselsEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏船只" : "显示船只",
|
||||
});
|
||||
setEarthStatValue("vessel-count", `${getVesselCount()} 艘`);
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
console.error("切换船只显示失败:", error);
|
||||
setLayerButtonState(button, {
|
||||
active: false,
|
||||
loading: false,
|
||||
tooltip: "显示船只",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
toggleTrails(enabled);
|
||||
const disabledState = getLayerDisabledState("trails");
|
||||
@@ -1527,6 +1566,23 @@ function getBuiltinLayerDefinitions() {
|
||||
setVisible: (visible, options = {}) =>
|
||||
setBGPLayerEnabled(getLayerButton("bgp"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "vessels",
|
||||
buttonId: "toggle-vessels",
|
||||
icon: "directions_boat",
|
||||
label: "船只",
|
||||
meta: "AIS Vessels",
|
||||
keywords: "船只 船舶 ais vessels ships maritime",
|
||||
defaultActive: false,
|
||||
displayOrder: 45,
|
||||
startupPriority: 65,
|
||||
startupMode: "visible",
|
||||
startupLabel: "船只",
|
||||
startupMessage: "正在加载船只...",
|
||||
getVisible: () => getVesselsEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setVesselsLayerEnabled(getLayerButton("vessels"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "satellites",
|
||||
buttonId: "toggle-satellites",
|
||||
|
||||
@@ -201,6 +201,7 @@ function getMobilePopupTitle(type, data) {
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
case 'gpu_cluster': return data.name || 'GPU集群';
|
||||
case 'vessel': return data.name || '船只';
|
||||
default: return '详情';
|
||||
}
|
||||
}
|
||||
@@ -215,6 +216,7 @@ function getMobilePopupSubtitle(type, data) {
|
||||
case 'bgp_collector': return data.location || 'BGP观测站';
|
||||
case 'supercomputer': return data.country || '超级计算机';
|
||||
case 'gpu_cluster': return data.country || 'GPU集群';
|
||||
case 'vessel': return data.vessel_type || 'AIS 船只';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -545,6 +547,23 @@ const CARD_CONFIG = {
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
},
|
||||
vessel: {
|
||||
icon: '🚢',
|
||||
title: '船只详情',
|
||||
className: 'vessel',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'mmsi', label: 'MMSI' },
|
||||
{ key: 'imo', label: 'IMO' },
|
||||
{ key: 'flag', label: '旗帜' },
|
||||
{ key: 'vessel_type', label: '船型' },
|
||||
{ key: 'speed', label: '当前航速', unit: 'kn' },
|
||||
{ key: 'course', label: '航向', unit: '°' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'length', label: '船长', unit: 'm' },
|
||||
{ key: 'received_at', label: '更新时间' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
loadComputeCenters,
|
||||
toggleComputeCenters,
|
||||
} from "./compute-centers.js";
|
||||
import {
|
||||
getVesselLegendItems,
|
||||
loadVessels,
|
||||
toggleVessels,
|
||||
} from "./vessels.js";
|
||||
import {
|
||||
getCountryBoundaryLegendItems,
|
||||
loadCountryBoundaries,
|
||||
@@ -80,10 +85,33 @@ function registerBuiltinLayerStartupTasks() {
|
||||
registerCountryBoundaryStartupTask();
|
||||
registerCableStartupTask();
|
||||
registerComputeCenterStartupTask();
|
||||
registerVesselStartupTask();
|
||||
registerBGPStartupTask();
|
||||
registerSatelliteStartupTask();
|
||||
}
|
||||
|
||||
function registerVesselStartupTask() {
|
||||
registerLayerStartupTask("vessels", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载船只..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
const vesselResult = await loadVessels(context.scene, context.earth);
|
||||
if (!context.isCancelled()) {
|
||||
toggleVessels(context.getShowVessels());
|
||||
context.updateVesselHud(vesselResult);
|
||||
context.setLegendItems("vessels", getVesselLegendItems());
|
||||
context.refreshLegend();
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "船只", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
function registerCableStartupTask() {
|
||||
registerLayerStartupTask("cables", (context) => async (layer) => {
|
||||
if (!context.isCablesEnabled()) return;
|
||||
|
||||
@@ -168,6 +168,19 @@ import {
|
||||
toggleComputeCenters,
|
||||
updateComputeCenterVisualState,
|
||||
} from "./compute-centers.js";
|
||||
import {
|
||||
clearVesselData,
|
||||
clearVesselSelection,
|
||||
getShowVessels,
|
||||
getVesselCount,
|
||||
getVesselLegendItems,
|
||||
getVesselMarkers,
|
||||
loadVessels,
|
||||
setVesselMarkerState,
|
||||
showVesselTrack,
|
||||
toggleVessels,
|
||||
updateVesselVisualState,
|
||||
} from "./vessels.js";
|
||||
import {
|
||||
setupControls,
|
||||
getAutoRotate,
|
||||
@@ -228,6 +241,7 @@ let inertialVelocity = { x: 0, y: 0 };
|
||||
let hoveredCable = null;
|
||||
let hoveredBGP = null;
|
||||
let hoveredComputeCenter = null;
|
||||
let hoveredVessel = null;
|
||||
let hoveredSatellite = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let lockedSatellite = null;
|
||||
@@ -251,6 +265,7 @@ let isDataLoading = false;
|
||||
let currentLoadToken = 0;
|
||||
let cablesEnabled = true;
|
||||
let satellitesEnabled = false;
|
||||
let vesselsEnabled = false;
|
||||
let cableToggleToken = 0;
|
||||
let satelliteToggleToken = 0;
|
||||
let satelliteHydrationToken = 0;
|
||||
@@ -278,6 +293,8 @@ const scratchBGPDirection = new THREE.Vector3();
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const scratchComputeCenterDirection = new THREE.Vector3();
|
||||
const scratchComputeCenterWorldPosition = new THREE.Vector3();
|
||||
const scratchVesselDirection = new THREE.Vector3();
|
||||
const scratchVesselWorldPosition = new THREE.Vector3();
|
||||
const scratchSatelliteWorldPosition = new THREE.Vector3();
|
||||
const scratchSatelliteScreenPosition = new THREE.Vector3();
|
||||
const scratchViewCenterWorld = new THREE.Vector3();
|
||||
@@ -425,6 +442,7 @@ export function clearLockedObject() {
|
||||
clearCableSelection();
|
||||
clearBGPSelection();
|
||||
clearComputeCenterSelection();
|
||||
clearVesselSelection();
|
||||
clearRelatedSatelliteHighlights();
|
||||
setSatelliteRingState(null, "none", null);
|
||||
clearRuntimeSelection();
|
||||
@@ -498,9 +516,11 @@ function resetTransientComputeCenterStates() {
|
||||
function clearTransientHoverState() {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
resetTransientVesselStates();
|
||||
clearCountryBoundaryHover();
|
||||
hoveredBGP = null;
|
||||
hoveredComputeCenter = null;
|
||||
hoveredVessel = null;
|
||||
|
||||
if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) {
|
||||
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
|
||||
@@ -515,6 +535,25 @@ function clearTransientHoverState() {
|
||||
setHoveredSatelliteIndex(null);
|
||||
}
|
||||
|
||||
function getFrontFacingVesselMarkers(markers) {
|
||||
const earth = getEarth();
|
||||
if (!earth) return markers;
|
||||
|
||||
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
|
||||
|
||||
return markers.filter((marker) => {
|
||||
scratchVesselWorldPosition.copy(marker.position);
|
||||
marker.parent?.localToWorld(scratchVesselWorldPosition);
|
||||
scratchVesselDirection
|
||||
.subVectors(scratchVesselWorldPosition, earth.position)
|
||||
.normalize();
|
||||
return (
|
||||
scratchCameraToEarth.dot(scratchVesselDirection) >
|
||||
SATELLITE_CONFIG.frontFacingDotThreshold
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function applyBGPHoverState(marker) {
|
||||
resetTransientBGPStates();
|
||||
if (!marker) {
|
||||
@@ -549,6 +588,30 @@ function applyComputeCenterHoverState(marker) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetTransientVesselStates() {
|
||||
getVesselMarkers().forEach((marker) => {
|
||||
if (marker !== lockedObject) {
|
||||
setVesselMarkerState(marker, "normal");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyVesselHoverState(marker) {
|
||||
resetTransientVesselStates();
|
||||
if (!marker) {
|
||||
hoveredVessel = null;
|
||||
return;
|
||||
}
|
||||
hoveredVessel = marker;
|
||||
if (marker !== lockedObject) {
|
||||
setVesselMarkerState(marker, "hover");
|
||||
}
|
||||
}
|
||||
|
||||
function isSameVessel(marker1, marker2) {
|
||||
return Boolean(marker1 && marker2 && marker1.userData?.mmsi === marker2.userData?.mmsi);
|
||||
}
|
||||
|
||||
function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
|
||||
if (bgpAnomalyIntersects.length > 0) {
|
||||
return bgpAnomalyIntersects[0].object;
|
||||
@@ -665,6 +728,37 @@ function showComputeCenterInfo(marker, coords) {
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function formatVesselStatus(navStatus) {
|
||||
if (navStatus === 1) return "锚泊";
|
||||
if (navStatus === 5) return "停靠";
|
||||
if (navStatus === 0) return "航行中";
|
||||
return navStatus ?? "-";
|
||||
}
|
||||
|
||||
function showVesselInfo(marker, coords) {
|
||||
setLegendMode("vessels");
|
||||
showInfoCard("vessel", {
|
||||
name: marker.userData?.name || `MMSI ${marker.userData?.mmsi}`,
|
||||
mmsi: marker.userData?.mmsi,
|
||||
imo: marker.userData?.imo || "-",
|
||||
flag: marker.userData?.flag || "-",
|
||||
vessel_type: marker.userData?.vessel_type_name || "-",
|
||||
speed: marker.userData?.sog ?? "-",
|
||||
course: marker.userData?.cog ?? marker.userData?.heading ?? "-",
|
||||
status: formatVesselStatus(marker.userData?.nav_status),
|
||||
length: marker.userData?.length ?? "-",
|
||||
received_at: marker.userData?.received_at
|
||||
? new Date(marker.userData.received_at).toLocaleString("zh-CN", { hour12: false })
|
||||
: "-",
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function getVesselBriefHtml(marker) {
|
||||
const name = marker.userData?.name || `MMSI ${marker.userData?.mmsi}`;
|
||||
const speed = marker.userData?.sog ?? "-";
|
||||
return `<strong>${name}</strong><br>${marker.userData?.vessel_type_name || "Vessel"} · ${speed} kn`;
|
||||
}
|
||||
|
||||
function getComputeCenterBriefHtml(marker) {
|
||||
const name = marker.userData?.name || "算力中心";
|
||||
const type = formatComputeCenterTypeLabel(marker.userData?.site_type);
|
||||
@@ -864,6 +958,13 @@ function getComputeCenterFocusCoords(marker) {
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
function getVesselFocusCoords(marker) {
|
||||
const lat = marker?.userData?.latitude;
|
||||
const lon = marker?.userData?.longitude;
|
||||
if (typeof lat !== "number" || typeof lon !== "number") return null;
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
|
||||
if (!coords || !camera) return;
|
||||
await focusEarthView(camera, {
|
||||
@@ -1037,6 +1138,33 @@ async function focusSearchComputeCenter(marker) {
|
||||
);
|
||||
}
|
||||
|
||||
async function focusSearchVessel(marker) {
|
||||
await setVesselsEnabled(true, {
|
||||
suppressStatus: true,
|
||||
suppressLoadingUi: true,
|
||||
});
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const coords = getVesselFocusCoords(marker);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.2));
|
||||
}
|
||||
|
||||
setVesselMarkerState(marker, "locked");
|
||||
lockedObject = marker;
|
||||
lockedObjectType = "vessel";
|
||||
showVesselInfo(marker, getSearchCardCoords());
|
||||
showVesselTrack(marker, getEarth()).catch((error) => {
|
||||
console.warn("船只轨迹加载失败:", error);
|
||||
});
|
||||
showStatusMessage(
|
||||
`已定位船只:${marker.userData?.name || marker.userData?.mmsi || "未知船只"}`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEarthSearchResults(query) {
|
||||
const results = [];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
@@ -1205,6 +1333,33 @@ function resolveEarthSearchResults(query) {
|
||||
});
|
||||
});
|
||||
|
||||
getVesselMarkers().forEach((marker) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
marker.userData?.name,
|
||||
marker.userData?.mmsi,
|
||||
marker.userData?.imo,
|
||||
marker.userData?.flag,
|
||||
marker.userData?.vessel_type_name,
|
||||
"船只 船舶 ais vessel ship maritime",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `vessel:${marker.userData?.mmsi || marker.uuid}`,
|
||||
kind: "vessel",
|
||||
icon: "directions_boat",
|
||||
typeLabel: "船只",
|
||||
title: marker.userData?.name || `MMSI ${marker.userData?.mmsi}`,
|
||||
subtitle: [
|
||||
marker.userData?.vessel_type_name,
|
||||
marker.userData?.flag,
|
||||
marker.userData?.sog !== undefined ? `${marker.userData.sog} kn` : null,
|
||||
].filter(Boolean).join(" · ") || "AIS 船只",
|
||||
score,
|
||||
entity: marker,
|
||||
});
|
||||
});
|
||||
|
||||
return results
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
@@ -1234,6 +1389,10 @@ async function handleSearchSelection(result) {
|
||||
}
|
||||
if (result.kind === "compute_center") {
|
||||
await focusSearchComputeCenter(result.entity);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "vessel") {
|
||||
await focusSearchVessel(result.entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1270,6 +1429,7 @@ function applyEarthStatsSummary(summary) {
|
||||
landingPointCount: `${summary.landingPointCount}个`,
|
||||
satelliteCount: `${summary.satelliteCount} 颗`,
|
||||
computeCenterCount: `${summary.computeCenterCount} 个`,
|
||||
vesselCount: `${summary.vesselCount} 艘`,
|
||||
bgpAnomalyCount: `${summary.bgpEventCount} 起`,
|
||||
bgpCollectorCount: `${summary.bgpCollectorCount} 个`,
|
||||
bgpStatusSummary: formatBGPStatusFromSummary(summary),
|
||||
@@ -1291,6 +1451,7 @@ async function loadEarthStatsSummary() {
|
||||
landingPointCount: toCount(stats.landing_point_count),
|
||||
satelliteCount: toCount(stats.satellite_count),
|
||||
computeCenterCount: toCount(stats.compute_center_count),
|
||||
vesselCount: toCount(stats.vessel_count),
|
||||
bgpEventCount: toCount(stats.bgp_event_count),
|
||||
bgpIncidentCount: toCount(stats.bgp_incident_count),
|
||||
bgpAnomalyCount: toCount(stats.bgp_anomaly_count),
|
||||
@@ -1945,6 +2106,23 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
|
||||
setEarthStatValue("satellite-count", `${resolvedCount} 颗`);
|
||||
}
|
||||
|
||||
function updateVesselHud(result = {}) {
|
||||
const count = Number(result.totalCount ?? getVesselCount() ?? 0);
|
||||
setEarthStatValue("vessel-count", `${count} 艘`);
|
||||
}
|
||||
|
||||
function updateVesselToggleUi(enabled, vesselCount = getVesselCount()) {
|
||||
const vesselBtn = document.getElementById("toggle-vessels");
|
||||
if (vesselBtn) {
|
||||
setLayerButtonState(vesselBtn, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏船只" : "显示船只",
|
||||
});
|
||||
}
|
||||
setEarthStatValue("vessel-count", `${vesselCount || 0} 艘`);
|
||||
}
|
||||
|
||||
function updateCableToggleUi(enabled) {
|
||||
const cableBtn = document.getElementById("toggle-cables");
|
||||
if (cableBtn) {
|
||||
@@ -2063,6 +2241,29 @@ async function ensureSatellitesEnabled() {
|
||||
return loadResult.count;
|
||||
}
|
||||
|
||||
async function ensureVesselsEnabled() {
|
||||
if (!scene || !camera || !renderer || destroyed) return 0;
|
||||
const earth = getEarth();
|
||||
if (!earth) return 0;
|
||||
|
||||
vesselsEnabled = true;
|
||||
const result = await loadVessels(scene, earth);
|
||||
toggleVessels(true);
|
||||
updateVesselToggleUi(true, result.totalCount);
|
||||
setLegendItems("vessels", getVesselLegendItems());
|
||||
refreshLegend();
|
||||
return result.totalCount;
|
||||
}
|
||||
|
||||
function disableVessels() {
|
||||
vesselsEnabled = false;
|
||||
toggleVessels(false);
|
||||
clearVesselSelection();
|
||||
updateVesselToggleUi(false, 0);
|
||||
setLegendItems("vessels", getVesselLegendItems());
|
||||
refreshLegend();
|
||||
}
|
||||
|
||||
function disableSatellites() {
|
||||
satellitesEnabled = false;
|
||||
satelliteToggleToken += 1;
|
||||
@@ -2079,6 +2280,7 @@ function updateStatsSummary() {
|
||||
const landingPointCount =
|
||||
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
|
||||
const satelliteCount = getSatelliteCount() || earthStatsSummary?.satelliteCount || 0;
|
||||
const vesselCount = getVesselCount() || earthStatsSummary?.vesselCount || 0;
|
||||
const computeCenterCount =
|
||||
getComputeCenterCount() || earthStatsSummary?.computeCenterCount || 0;
|
||||
const bgpEventCount = getBGPCount() || earthStatsSummary?.bgpEventCount || 0;
|
||||
@@ -2088,6 +2290,7 @@ function updateStatsSummary() {
|
||||
cableCount: `${cableCount}个`,
|
||||
landingPointCount: `${landingPointCount}个`,
|
||||
satelliteCount: `${satelliteCount} 颗`,
|
||||
vesselCount: `${vesselCount} 艘`,
|
||||
computeCenterCount: `${computeCenterCount} 个`,
|
||||
bgpAnomalyCount: `${bgpEventCount} 起`,
|
||||
bgpCollectorCount: `${bgpCollectorCount} 个`,
|
||||
@@ -2333,6 +2536,7 @@ async function loadData() {
|
||||
clearBGPData(earth);
|
||||
clearCableData(earth);
|
||||
clearComputeCenterData(earth);
|
||||
clearVesselData(earth);
|
||||
clearSatelliteData();
|
||||
clearCountryBoundaryHover();
|
||||
|
||||
@@ -2370,8 +2574,10 @@ async function loadData() {
|
||||
updateCableToggleUi,
|
||||
updateSatelliteToggleUi,
|
||||
updateComputeCenterHud,
|
||||
updateVesselHud,
|
||||
updateBGPHud,
|
||||
getShowComputeCenters,
|
||||
getShowVessels,
|
||||
getShowCountryBoundaries,
|
||||
getShowBGP,
|
||||
isEarthTextureVisible: () => getEarthTextureVisible(),
|
||||
@@ -2429,6 +2635,7 @@ async function loadData() {
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||
setLegendItems("vessels", getVesselLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
refreshLegend();
|
||||
setLoading(false);
|
||||
@@ -2463,6 +2670,10 @@ export function getSatellitesEnabled() {
|
||||
return satellitesEnabled;
|
||||
}
|
||||
|
||||
export function getVesselsEnabled() {
|
||||
return vesselsEnabled;
|
||||
}
|
||||
|
||||
export async function setCablesEnabled(
|
||||
enabled,
|
||||
{ suppressStatus = false, suppressLoadingUi = false } = {},
|
||||
@@ -2668,6 +2879,62 @@ export async function setSatellitesEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
export async function setVesselsEnabled(
|
||||
enabled,
|
||||
{ suppressStatus = false, suppressLoadingUi = false } = {},
|
||||
) {
|
||||
if (enabled === vesselsEnabled) {
|
||||
updateVesselToggleUi(enabled);
|
||||
return getVesselCount();
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
clearSelectionAndInfo();
|
||||
disableVessels();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("船只已隐藏", "info");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!suppressLoadingUi) {
|
||||
setLoadingMessage("正在加载船只数据...");
|
||||
setLoading(true);
|
||||
hideError();
|
||||
}
|
||||
|
||||
try {
|
||||
const vesselCount = await ensureVesselsEnabled();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("船只已显示", "info");
|
||||
}
|
||||
return vesselCount;
|
||||
} catch (error) {
|
||||
vesselsEnabled = false;
|
||||
clearVesselData(getEarth());
|
||||
updateVesselToggleUi(false, 0);
|
||||
const message = `船只加载失败: ${error?.message || String(error)}`;
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "layer-toggle",
|
||||
module: "vessels",
|
||||
message,
|
||||
detail: error,
|
||||
});
|
||||
if (!suppressLoadingUi) {
|
||||
showError(message);
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(message, "error");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!suppressLoadingUi) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
const handleResize = () => onWindowResize();
|
||||
const handleVisibilityChange = () => onVisibilityChange();
|
||||
@@ -2865,6 +3132,11 @@ function onMouseMove(event) {
|
||||
const computeCenterIntersects = getShowComputeCenters()
|
||||
? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers)
|
||||
: [];
|
||||
const vesselIntersects = getShowVessels()
|
||||
? interactionRaycaster.intersectObjects(
|
||||
getFrontFacingVesselMarkers(getVesselMarkers()),
|
||||
)
|
||||
: [];
|
||||
|
||||
let hoveredSat = null;
|
||||
let hoveredSatIndexFromIntersect = null;
|
||||
@@ -2886,6 +3158,8 @@ function onMouseMove(event) {
|
||||
|
||||
const hoveredComputeCenterMarker =
|
||||
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
|
||||
const hoveredVesselMarker =
|
||||
vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
|
||||
|
||||
if (
|
||||
hoveredComputeCenter &&
|
||||
@@ -2893,6 +3167,9 @@ function onMouseMove(event) {
|
||||
) {
|
||||
clearTransientHoverState();
|
||||
}
|
||||
if (hoveredVessel && !isSameVessel(hoveredVessel, hoveredVesselMarker)) {
|
||||
clearTransientHoverState();
|
||||
}
|
||||
|
||||
if (
|
||||
hoveredCable &&
|
||||
@@ -2936,6 +3213,18 @@ function onMouseMove(event) {
|
||||
getComputeCenterBriefHtml(hoveredComputeCenterMarker),
|
||||
);
|
||||
objectTooltipShown = true;
|
||||
} else if (
|
||||
hoveredVesselMarker &&
|
||||
getShowVessels() &&
|
||||
lockedObjectType !== "vessel"
|
||||
) {
|
||||
applyVesselHoverState(hoveredVesselMarker);
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
getVesselBriefHtml(hoveredVesselMarker),
|
||||
);
|
||||
objectTooltipShown = true;
|
||||
} else if (cableIntersects.length > 0 && getShowCables()) {
|
||||
const cable = cableIntersects[0].object;
|
||||
hoveredCable = cable;
|
||||
@@ -2966,9 +3255,12 @@ function onMouseMove(event) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "compute_center" && lockedObject) {
|
||||
applyComputeCenterHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "vessel" && lockedObject) {
|
||||
applyVesselHoverState(lockedObject);
|
||||
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
resetTransientVesselStates();
|
||||
hideInfoCard();
|
||||
}
|
||||
|
||||
@@ -3185,6 +3477,11 @@ function onClick(event) {
|
||||
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
|
||||
)
|
||||
: [];
|
||||
const vesselIntersects = getShowVessels()
|
||||
? interactionRaycaster.intersectObjects(
|
||||
getFrontFacingVesselMarkers(getVesselMarkers()),
|
||||
)
|
||||
: [];
|
||||
const satIntersects = getSatellitePointerIntersections(event);
|
||||
|
||||
const clickedBGPMarker = getShowBGP()
|
||||
@@ -3193,6 +3490,9 @@ function onClick(event) {
|
||||
const clickedComputeCenterMarker = computeCenterIntersects.length > 0
|
||||
? computeCenterIntersects[0].object
|
||||
: null;
|
||||
const clickedVesselMarker = vesselIntersects.length > 0
|
||||
? vesselIntersects[0].object
|
||||
: null;
|
||||
|
||||
if (clickedBGPMarker?.userData?.type === "bgp") {
|
||||
interruptCruisePresentation();
|
||||
@@ -3260,6 +3560,26 @@ function onClick(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickedVesselMarker?.userData?.type === "vessel") {
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
|
||||
const clickedMarker = clickedVesselMarker;
|
||||
setVesselMarkerState(clickedMarker, "locked");
|
||||
lockedObject = clickedMarker;
|
||||
lockedObjectType = "vessel";
|
||||
setAutoRotate(false);
|
||||
showVesselInfo(clickedMarker, { x: event.clientX, y: event.clientY });
|
||||
showVesselTrack(clickedMarker, earth).catch((error) => {
|
||||
console.warn("船只轨迹加载失败:", error);
|
||||
});
|
||||
showStatusMessage(
|
||||
`已选择船只: ${clickedMarker.userData?.name || clickedMarker.userData?.mmsi}`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cableIntersects.length > 0 && getShowCables()) {
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
@@ -3412,6 +3732,7 @@ function animate() {
|
||||
: null;
|
||||
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
||||
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
|
||||
updateVesselVisualState(lockedObjectType, lockedObject, camera);
|
||||
|
||||
if (lockedObjectType === "cable" && lockedObject) {
|
||||
applyLandingPointVisualState(lockedObject.userData.name, false, camera);
|
||||
|
||||
@@ -194,6 +194,7 @@ export function updateEarthStats(stats) {
|
||||
if (has("computeCenterCount")) {
|
||||
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
|
||||
}
|
||||
if (has("vesselCount")) setEarthStatValue("vessel-count", String(stats.vesselCount || 0));
|
||||
if (has("bgpAnomalyCount")) setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
|
||||
if (has("bgpCollectorCount")) {
|
||||
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
|
||||
|
||||
280
frontend/public/earth/js/vessels.js
Normal file
280
frontend/public/earth/js/vessels.js
Normal file
@@ -0,0 +1,280 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const vesselGroup = new THREE.Group();
|
||||
const vesselMarkers = [];
|
||||
const textureCache = new Map();
|
||||
let showVessels = false;
|
||||
let activeTrackLine = null;
|
||||
|
||||
const VESSEL_RENDER_ORDER = 4.4;
|
||||
|
||||
function normalizeVesselType(value, code) {
|
||||
const type = String(value || "").trim().toLowerCase();
|
||||
const numericCode = Number(code);
|
||||
if (type.includes("cargo") || (numericCode >= 70 && numericCode <= 79)) return "cargo";
|
||||
if (type.includes("tanker") || (numericCode >= 80 && numericCode <= 89)) return "tanker";
|
||||
if (type.includes("passenger") || (numericCode >= 60 && numericCode <= 69)) return "passenger";
|
||||
if (type.includes("fishing") || numericCode === 30) return "fishing";
|
||||
if (type.includes("military") || numericCode === 35) return "military";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function createVesselTexture(type, anchored) {
|
||||
const textureKey = `${type}:${anchored ? "anchored" : "moving"}`;
|
||||
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
|
||||
|
||||
const color = VESSEL_CONFIG.colors[type] || VESSEL_CONFIG.colors.other;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 96;
|
||||
canvas.height = 96;
|
||||
const context = canvas.getContext("2d");
|
||||
context.clearRect(0, 0, 96, 96);
|
||||
context.save();
|
||||
context.translate(48, 48);
|
||||
context.fillStyle = color;
|
||||
context.globalAlpha = anchored ? 0.55 : 0.96;
|
||||
context.shadowColor = color;
|
||||
context.shadowBlur = anchored ? 8 : 14;
|
||||
context.beginPath();
|
||||
if (anchored) {
|
||||
context.arc(0, 0, 18, 0, Math.PI * 2);
|
||||
} else {
|
||||
context.moveTo(0, -28);
|
||||
context.lineTo(21, 24);
|
||||
context.lineTo(0, 13);
|
||||
context.lineTo(-21, 24);
|
||||
context.closePath();
|
||||
}
|
||||
context.fill();
|
||||
context.restore();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
textureCache.set(textureKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function buildVesselMarkerData(feature) {
|
||||
const props = feature?.properties || {};
|
||||
const coordinates = feature?.geometry?.coordinates || [];
|
||||
const longitude = Number(coordinates[0]);
|
||||
const latitude = Number(coordinates[1]);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
|
||||
const type = normalizeVesselType(props.vessel_type_name, props.vessel_type);
|
||||
const navStatus = Number(props.nav_status);
|
||||
const speed = Number(props.sog);
|
||||
const anchored = navStatus === 1 || navStatus === 5 || (Number.isFinite(speed) && speed < 0.5);
|
||||
|
||||
return {
|
||||
...props,
|
||||
latitude,
|
||||
longitude,
|
||||
type,
|
||||
anchored,
|
||||
course: Number(props.cog ?? props.heading ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function createVesselMarker(markerData) {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: createVesselTexture(markerData.type, markerData.anchored),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
opacity: VESSEL_CONFIG.marker.baseOpacity,
|
||||
rotation: markerData.anchored
|
||||
? 0
|
||||
: THREE.MathUtils.degToRad(-markerData.course),
|
||||
});
|
||||
const marker = new THREE.Sprite(material);
|
||||
marker.position.copy(
|
||||
latLonToVector3(
|
||||
markerData.latitude,
|
||||
markerData.longitude,
|
||||
CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset,
|
||||
),
|
||||
);
|
||||
marker.scale.setScalar(VESSEL_CONFIG.marker.baseScale);
|
||||
marker.renderOrder = VESSEL_RENDER_ORDER;
|
||||
marker.visible = showVessels;
|
||||
marker.userData = {
|
||||
...markerData,
|
||||
type: "vessel",
|
||||
vessel_kind: markerData.type,
|
||||
baseScale: VESSEL_CONFIG.marker.baseScale,
|
||||
state: "normal",
|
||||
};
|
||||
vesselGroup.add(marker);
|
||||
vesselMarkers.push(marker);
|
||||
}
|
||||
|
||||
function clearGroup(group) {
|
||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = group.children[index];
|
||||
child.material?.dispose?.();
|
||||
child.geometry?.dispose?.();
|
||||
group.remove(child);
|
||||
}
|
||||
}
|
||||
|
||||
function getDistanceScale(camera) {
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: VESSEL_CONFIG.altitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: VESSEL_CONFIG.sizeStabilization.min,
|
||||
max: VESSEL_CONFIG.sizeStabilization.max,
|
||||
});
|
||||
}
|
||||
|
||||
export function getVesselMarkers() {
|
||||
return vesselMarkers;
|
||||
}
|
||||
|
||||
export function getVesselCount() {
|
||||
return vesselMarkers.length;
|
||||
}
|
||||
|
||||
export function getShowVessels() {
|
||||
return showVessels;
|
||||
}
|
||||
|
||||
export function toggleVessels(show) {
|
||||
showVessels = Boolean(show);
|
||||
vesselGroup.visible = showVessels;
|
||||
vesselMarkers.forEach((marker) => {
|
||||
marker.visible = showVessels;
|
||||
});
|
||||
if (activeTrackLine) {
|
||||
activeTrackLine.visible = showVessels;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVesselSelection() {
|
||||
vesselMarkers.forEach((marker) => setVesselMarkerState(marker, "normal"));
|
||||
clearVesselTrack();
|
||||
}
|
||||
|
||||
function clearVesselTrack() {
|
||||
if (activeTrackLine?.parent) {
|
||||
activeTrackLine.parent.remove(activeTrackLine);
|
||||
}
|
||||
activeTrackLine?.geometry?.dispose?.();
|
||||
activeTrackLine?.material?.dispose?.();
|
||||
activeTrackLine = null;
|
||||
}
|
||||
|
||||
export function setVesselMarkerState(marker, state = "normal") {
|
||||
if (!marker || marker.userData?.type !== "vessel") return;
|
||||
marker.userData.state = state;
|
||||
}
|
||||
|
||||
export function clearVesselData(earth) {
|
||||
vesselMarkers.length = 0;
|
||||
clearVesselSelection();
|
||||
clearGroup(vesselGroup);
|
||||
if (earth && vesselGroup.parent === earth) {
|
||||
earth.remove(vesselGroup);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadVessels(_scene, earth, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(options.limit || VESSEL_CONFIG.maxRenderedMarkers));
|
||||
const response = await fetch(`${PATHS.vesselsApi}?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vessels HTTP ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
|
||||
clearVesselData(earth);
|
||||
features
|
||||
.map((feature) => buildVesselMarkerData(feature))
|
||||
.filter(Boolean)
|
||||
.slice(0, VESSEL_CONFIG.maxRenderedMarkers)
|
||||
.forEach((markerData) => createVesselMarker(markerData));
|
||||
|
||||
if (earth && !vesselGroup.parent) {
|
||||
earth.add(vesselGroup);
|
||||
}
|
||||
vesselGroup.visible = showVessels;
|
||||
|
||||
return {
|
||||
totalCount: vesselMarkers.length,
|
||||
stats: payload?.stats || {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function showVesselTrack(marker, earth) {
|
||||
clearVesselTrack();
|
||||
if (!marker?.userData?.mmsi || !earth) return null;
|
||||
|
||||
const response = await fetch(PATHS.vesselTrackApi(marker.userData.mmsi));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vessel track HTTP ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const coordinates = payload?.features?.[0]?.geometry?.coordinates || [];
|
||||
if (coordinates.length < 2) return null;
|
||||
|
||||
const points = coordinates
|
||||
.map(([lon, lat]) =>
|
||||
latLonToVector3(
|
||||
Number(lat),
|
||||
Number(lon),
|
||||
CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset,
|
||||
),
|
||||
)
|
||||
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z));
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: VESSEL_CONFIG.track.color,
|
||||
transparent: true,
|
||||
opacity: VESSEL_CONFIG.track.opacity,
|
||||
depthWrite: false,
|
||||
});
|
||||
activeTrackLine = new THREE.Line(geometry, material);
|
||||
activeTrackLine.renderOrder = VESSEL_RENDER_ORDER - 0.1;
|
||||
earth.add(activeTrackLine);
|
||||
return activeTrackLine;
|
||||
}
|
||||
|
||||
export function getVesselLegendItems() {
|
||||
return [
|
||||
{ label: "货轮", color: VESSEL_CONFIG.colors.cargo },
|
||||
{ label: "油轮", color: VESSEL_CONFIG.colors.tanker },
|
||||
{ label: "客船", color: VESSEL_CONFIG.colors.passenger },
|
||||
{ label: "渔船", color: VESSEL_CONFIG.colors.fishing },
|
||||
{ label: "军舰", color: VESSEL_CONFIG.colors.military },
|
||||
{ label: "其他", color: VESSEL_CONFIG.colors.other },
|
||||
];
|
||||
}
|
||||
|
||||
export function updateVesselVisualState(lockedObjectType, lockedObject, camera) {
|
||||
const hasFocus = lockedObjectType === "vessel" && lockedObject;
|
||||
const distanceScale = getDistanceScale(camera);
|
||||
vesselMarkers.forEach((marker) => {
|
||||
const isLocked = lockedObjectType === "vessel" && lockedObject === marker;
|
||||
const state = marker.userData?.state || "normal";
|
||||
let opacity = VESSEL_CONFIG.marker.baseOpacity;
|
||||
let scaleMultiplier = 1;
|
||||
if (isLocked) {
|
||||
opacity = 1;
|
||||
scaleMultiplier = VESSEL_CONFIG.marker.lockedScale;
|
||||
} else if (state === "hover") {
|
||||
opacity = 0.98;
|
||||
scaleMultiplier = VESSEL_CONFIG.marker.hoverScale;
|
||||
} else if (hasFocus) {
|
||||
opacity = VESSEL_CONFIG.marker.dimmedOpacity;
|
||||
scaleMultiplier = VESSEL_CONFIG.marker.dimmedScale;
|
||||
}
|
||||
marker.material.opacity = showVessels ? opacity : 0;
|
||||
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
|
||||
marker.visible = showVessels;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user