release: bump version to 0.43.0

This commit is contained in:
linkong
2026-04-28 16:10:17 +08:00
parent 1cd2dab0ee
commit ac69d5d354
69 changed files with 6954 additions and 1141 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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: '更新时间' }
]
}
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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));

View 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;
});
}