Files
planet/frontend/public/earth/js/vessels.js
2026-04-29 18:07:35 +08:00

506 lines
15 KiB
JavaScript

import * as THREE from "three";
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
const vesselGroup = new THREE.Group();
const vesselMarkers = [];
const textureCache = new Map();
let vesselPoints = null;
let vesselPointObjects = [];
let hoverOverlaySprite = null;
let lockedOverlaySprite = null;
let showVessels = false;
let activeTrackLine = null;
let lastVisualStateKey = "";
let visualStateVersion = 0;
const VESSEL_RENDER_ORDER = 4.4;
const VESSEL_POINT_SIZE = 34;
const VESSEL_ATLAS_CELL_SIZE = 128;
const VESSEL_COURSE_BINS = 32;
function invalidateVesselVisualState() {
visualStateVersion += 1;
lastVisualStateKey = "";
}
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 drawVesselShape(context, anchored, glow, color = "#ffffff") {
context.fillStyle = color;
context.globalAlpha = anchored ? 0.55 : 0.96;
if (glow) {
context.shadowColor = color;
context.shadowBlur = anchored ? 8 : 14;
}
context.beginPath();
if (anchored) {
context.arc(0, 0, 24, 0, Math.PI * 2);
} else {
context.moveTo(0, -37);
context.lineTo(28, 32);
context.lineTo(0, 17);
context.lineTo(-28, 32);
context.closePath();
}
context.fill();
}
function createVesselPointTexture(anchored, courseBin = 0) {
const textureKey = `vessel-point:${anchored ? "anchored" : "moving"}:${courseBin}`;
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
const canvas = document.createElement("canvas");
canvas.width = VESSEL_ATLAS_CELL_SIZE;
canvas.height = VESSEL_ATLAS_CELL_SIZE;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.save();
context.translate(canvas.width / 2, canvas.height / 2);
if (!anchored) {
context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2);
}
drawVesselShape(context, anchored, false);
context.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function createVesselOverlayTexture(marker, glow = true) {
const kind = marker?.userData?.vessel_kind || "other";
const anchored = Boolean(marker?.userData?.anchored);
const courseBin = marker ? getCourseBin(marker) : 0;
const textureKey = [
"vessel-overlay",
kind,
anchored ? "anchored" : "moving",
courseBin,
glow ? "glow" : "plain",
].join(":");
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
const canvas = document.createElement("canvas");
canvas.width = VESSEL_ATLAS_CELL_SIZE;
canvas.height = VESSEL_ATLAS_CELL_SIZE;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.save();
context.translate(canvas.width / 2, canvas.height / 2);
if (!anchored) {
context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2);
}
drawVesselShape(
context,
anchored,
glow,
VESSEL_CONFIG.colors[kind] || VESSEL_CONFIG.colors.other,
);
context.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
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 marker = new THREE.Object3D();
marker.position.copy(
latLonToVector3(
markerData.latitude,
markerData.longitude,
CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset,
),
);
marker.userData = {
...markerData,
type: "vessel",
vessel_kind: markerData.type,
baseScale: VESSEL_CONFIG.marker.baseScale,
state: "normal",
};
vesselMarkers.push(marker);
}
function colorToRgbArray(colorValue) {
const color = new THREE.Color(colorValue || VESSEL_CONFIG.colors.other);
return [color.r, color.g, color.b];
}
function getCourseBin(marker) {
if (marker.userData.anchored) return 0;
const course = Number(marker.userData.course || 0);
const normalized = ((course % 360) + 360) % 360;
return Math.round((normalized / 360) * VESSEL_COURSE_BINS) % VESSEL_COURSE_BINS;
}
function buildVesselPoints() {
vesselPoints = new THREE.Group();
vesselPoints.visible = showVessels;
vesselPoints.renderOrder = VESSEL_RENDER_ORDER;
vesselPoints.userData = { type: "vessel_points" };
vesselPointObjects = [];
const groups = new Map();
vesselMarkers.forEach((marker, index) => {
const anchored = Boolean(marker.userData.anchored);
const courseBin = getCourseBin(marker);
const key = `${anchored ? "anchored" : "moving"}:${courseBin}`;
if (!groups.has(key)) {
groups.set(key, {
anchored,
courseBin,
markers: [],
markerIndexes: [],
});
}
const group = groups.get(key);
group.markers.push(marker);
group.markerIndexes.push(index);
});
groups.forEach((group) => {
const count = group.markers.length;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
group.markers.forEach((marker, index) => {
positions[index * 3] = marker.position.x;
positions[index * 3 + 1] = marker.position.y;
positions[index * 3 + 2] = marker.position.z;
const [r, g, b] = colorToRgbArray(VESSEL_CONFIG.colors[marker.userData.vessel_kind]);
colors[index * 3] = r;
colors[index * 3 + 1] = g;
colors[index * 3 + 2] = b;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
map: createVesselPointTexture(group.anchored, group.courseBin),
size: VESSEL_POINT_SIZE,
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: VESSEL_CONFIG.marker.baseOpacity,
depthWrite: false,
depthTest: true,
alphaTest: 0.01,
});
const points = new THREE.Points(geometry, material);
points.renderOrder = VESSEL_RENDER_ORDER;
points.frustumCulled = false;
points.userData = {
type: "vessel_points",
};
vesselPointObjects.push(points);
vesselPoints.add(points);
});
vesselGroup.add(vesselPoints);
}
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 disposeVesselRenderObjects() {
if (vesselPoints?.parent) {
vesselPoints.parent.remove(vesselPoints);
}
vesselPointObjects.forEach((points) => {
points.geometry?.dispose?.();
points.material?.dispose?.();
});
hoverOverlaySprite?.geometry?.dispose?.();
hoverOverlaySprite?.material?.dispose?.();
hoverOverlaySprite?.parent?.remove?.(hoverOverlaySprite);
lockedOverlaySprite?.geometry?.dispose?.();
lockedOverlaySprite?.material?.dispose?.();
lockedOverlaySprite?.parent?.remove?.(lockedOverlaySprite);
vesselPoints = null;
vesselPointObjects = [];
hoverOverlaySprite = null;
lockedOverlaySprite = null;
}
export function getVesselMarkers() {
return vesselMarkers;
}
export function getVesselCount() {
return vesselMarkers.length;
}
export function getShowVessels() {
return showVessels;
}
export function toggleVessels(show) {
showVessels = Boolean(show);
invalidateVesselVisualState();
vesselGroup.visible = showVessels;
if (vesselPoints) {
vesselPoints.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;
if (marker.userData.state === state) return;
marker.userData.state = state;
invalidateVesselVisualState();
}
export function clearVesselData(earth) {
invalidateVesselVisualState();
clearVesselSelection();
vesselMarkers.length = 0;
disposeVesselRenderObjects();
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));
buildVesselPoints();
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, shape: "vessel" },
{ label: "油轮", color: VESSEL_CONFIG.colors.tanker, shape: "vessel" },
{ label: "客船", color: VESSEL_CONFIG.colors.passenger, shape: "vessel" },
{ label: "渔船", color: VESSEL_CONFIG.colors.fishing, shape: "vessel" },
{ label: "军舰", color: VESSEL_CONFIG.colors.military, shape: "vessel" },
{ label: "停泊/低速", color: VESSEL_CONFIG.colors.other, shape: "dot" },
{ label: "其他船只", color: VESSEL_CONFIG.colors.other, shape: "vessel" },
];
}
function ensureOverlaySprite(kind) {
const existing = kind === "locked" ? lockedOverlaySprite : hoverOverlaySprite;
if (existing) return existing;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(3), 3),
);
const material = new THREE.PointsMaterial({
size: VESSEL_POINT_SIZE,
sizeAttenuation: false,
transparent: true,
depthWrite: false,
depthTest: true,
opacity: 1,
alphaTest: 0.01,
});
const overlay = new THREE.Points(geometry, material);
overlay.renderOrder = VESSEL_RENDER_ORDER + (kind === "locked" ? 0.2 : 0.1);
overlay.frustumCulled = false;
overlay.visible = false;
vesselGroup.add(overlay);
if (kind === "locked") {
lockedOverlaySprite = overlay;
} else {
hoverOverlaySprite = overlay;
}
return overlay;
}
function updateOverlaySprite(overlay, marker, opacity) {
if (!overlay) return;
if (!marker) {
overlay.visible = false;
return;
}
const texture = createVesselOverlayTexture(marker, true);
if (overlay.material.map !== texture) {
overlay.material.map = texture;
overlay.material.needsUpdate = true;
}
overlay.material.opacity = opacity;
overlay.material.size = VESSEL_POINT_SIZE;
const positionAttribute = overlay.geometry.getAttribute("position");
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
positionAttribute.needsUpdate = true;
overlay.visible = showVessels;
}
export function updateVesselVisualState(lockedObjectType, lockedObject, camera) {
if (!showVessels || vesselMarkers.length === 0 || !vesselPoints) {
if (lastVisualStateKey !== "hidden") {
if (vesselPoints) vesselPoints.visible = false;
if (hoverOverlaySprite) hoverOverlaySprite.visible = false;
if (lockedOverlaySprite) lockedOverlaySprite.visible = false;
lastVisualStateKey = "hidden";
}
return;
}
vesselPoints.visible = true;
const hasFocus = lockedObjectType === "vessel" && lockedObject;
const lockedKey = hasFocus
? lockedObject?.userData?.mmsi || lockedObject?.uuid || "locked"
: "none";
const stateKey = [
"visible",
lockedObjectType || "none",
lockedKey,
visualStateVersion,
].join(":");
if (stateKey === lastVisualStateKey) return;
lastVisualStateKey = stateKey;
vesselPointObjects.forEach((points) => {
points.visible = showVessels;
points.material.opacity = hasFocus
? VESSEL_CONFIG.marker.dimmedOpacity
: VESSEL_CONFIG.marker.baseOpacity;
points.material.size = VESSEL_POINT_SIZE;
});
const hoverMarker = vesselMarkers.find(
(marker) => marker.userData?.state === "hover" && marker !== lockedObject,
);
updateOverlaySprite(
ensureOverlaySprite("hover"),
hoverMarker,
0.98,
);
updateOverlaySprite(
ensureOverlaySprite("locked"),
hasFocus ? lockedObject : null,
1,
);
}