Files
planet/frontend/public/earth/js/vessels.js
linkong 8c204717cd
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.70.0
2026-06-04 17:16:23 +08:00

375 lines
12 KiB
JavaScript

import * as THREE from "three";
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import { latLonToVector3 } from "./utils.js";
let showVessels = false;
let activeTrackLine = null;
let vesselDataByKey = new Map();
let vesselSnapshotGeneration = 0;
let vesselRealtimeStats = {
connected: false,
updates: 0,
lastUpdateAt: null,
lastBatchSize: 0,
};
const VESSEL_RENDER_ORDER = 4.4;
const VESSEL_POINT_SIZE = 34;
const VESSEL_ATLAS_CELL_SIZE = 128;
const VESSEL_COURSE_BINS = 32;
const VESSEL_TRACK_ENDPOINT_EPSILON = 0.001;
function getVesselDedupeKey(feature, markerData) {
const props = feature?.properties || {};
const mmsi = props.mmsi ?? feature?.id ?? markerData?.mmsi;
if (mmsi !== undefined && mmsi !== null && String(mmsi).trim() !== "") {
return `mmsi:${String(mmsi).trim()}`;
}
return [
"position",
Number(markerData.latitude).toFixed(5),
Number(markerData.longitude).toFixed(5),
String(props.name || markerData.name || "").trim().toLowerCase(),
].join(":");
}
function dedupeVesselFeatures(features) {
const seen = new Set();
const markerData = [];
features.forEach((feature) => {
const marker = buildVesselMarkerData(feature);
if (!marker) return;
const key = getVesselDedupeKey(feature, marker);
if (seen.has(key)) return;
seen.add(key);
markerData.push(marker);
});
return markerData;
}
function markerDataToDedupeKey(item) {
const mmsi = item?.mmsi;
if (mmsi !== undefined && mmsi !== null && String(mmsi).trim() !== "") {
return `mmsi:${String(mmsi).trim()}`;
}
return [
"position",
Number(item.latitude).toFixed(5),
Number(item.longitude).toFixed(5),
String(item.name || "").trim().toLowerCase(),
].join(":");
}
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 formatVesselTypeLabel(type, fallback = "") {
const rawFallback = String(fallback || "").trim();
const normalized = String(type || "").trim().toLowerCase();
if (normalized === "cargo") return "Cargo";
if (normalized === "tanker") return "Tanker";
if (normalized === "passenger") return "Passenger";
if (normalized === "fishing") return "Fishing";
if (normalized === "military") return "Military";
return rawFallback && rawFallback.toLowerCase() !== "other" ? rawFallback : "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 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 vesselTypeLabel = formatVesselTypeLabel(type, props.vessel_type_name);
const navStatus = Number(props.nav_status);
const speed = Number(props.sog);
const anchored = navStatus === 1 || navStatus === 5 || (Number.isFinite(speed) && speed < 0.5);
const mmsiString = props.mmsi !== undefined && props.mmsi !== null && String(props.mmsi).trim() !== ""
? String(props.mmsi)
: null;
return {
...props,
mmsi: mmsiString,
mmsi_display: props.mmsi_display ? String(props.mmsi_display) : mmsiString,
latitude,
longitude,
type,
vessel_type_display: vesselTypeLabel,
anchored,
course: Number(props.cog ?? props.heading ?? 0),
};
}
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 buildTrackPoint(lon, lat) {
const latitude = Number(lat);
const longitude = Number(lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return latLonToVector3(
latitude,
longitude,
CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset,
);
}
function appendCurrentMarkerTrackPoint(points, marker) {
if (!(marker?.position instanceof THREE.Vector3)) return;
const markerPosition = marker.position.clone();
const lastPoint = points[points.length - 1];
if (!lastPoint || lastPoint.distanceToSquared(markerPosition) > VESSEL_TRACK_ENDPOINT_EPSILON) {
points.push(markerPosition);
}
}
const vesselIconLayer = createInteractableLayer({
id: "vessels",
objectType: "vessel",
renderOrder: VESSEL_RENDER_ORDER,
altitudeOffset: VESSEL_CONFIG.altitudeOffset,
pointSize: VESSEL_POINT_SIZE,
atlasCellSize: VESSEL_ATLAS_CELL_SIZE,
colors: {
byKind: VESSEL_CONFIG.colors,
normal: VESSEL_CONFIG.colors.other,
},
opacity: {
normal: VESSEL_CONFIG.marker.baseOpacity,
dimmed: VESSEL_CONFIG.marker.dimmedOpacity,
hover: 0.98,
locked: 1,
},
stateScale: {
hover: VESSEL_CONFIG.marker.hoverScale,
locked: VESSEL_CONFIG.marker.lockedScale,
dimmed: VESSEL_CONFIG.marker.dimmedScale,
},
avoidance: false,
icon: {
draw(context, { marker, rotationBin = 0, glow = false, color = "#ffffff" }) {
const anchored = Boolean(marker?.userData?.anchored);
if (!anchored) {
context.rotate((rotationBin / VESSEL_COURSE_BINS) * Math.PI * 2);
}
drawVesselShape(context, anchored, glow, color);
},
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => item.type || "other",
getRotationBin: getCourseBin,
getBucketKey: (marker) => {
const anchored = Boolean(marker.userData.anchored);
const courseBin = getCourseBin(marker);
return `${anchored ? "anchored" : "moving"}:${courseBin}`;
},
getUserData: (item) => ({
...item,
vessel_kind: item.type,
baseScale: VESSEL_CONFIG.marker.baseScale,
}),
cluster: false,
});
const GLOBAL_VESSEL_VIEWPORT = {
bbox: [-180, -85.05112878, 180, 85.05112878],
zoom: 4,
};
export function getVesselMarkers() {
return vesselIconLayer.getMarkers();
}
export function getVesselCount() {
return vesselIconLayer.getCount();
}
export function getVesselRealtimeStats() {
return { ...vesselRealtimeStats };
}
export function getShowVessels() {
return showVessels;
}
export function toggleVessels(show) {
showVessels = Boolean(show);
vesselIconLayer.setVisible(showVessels);
if (activeTrackLine) {
activeTrackLine.visible = showVessels;
}
}
export function clearVesselSelection() {
getVesselMarkers().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") {
vesselIconLayer.setMarkerState(marker, state);
}
export function getVesselPointerIntersections(options) {
return vesselIconLayer.getPointerIntersections(options);
}
export function clearVesselData(earth) {
clearVesselSelection();
vesselDataByKey.clear();
vesselIconLayer.clearData(earth);
}
export async function loadVessels(_scene, earth, options = {}) {
const requestGeneration = ++vesselSnapshotGeneration;
const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers);
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
? options.bbox
: GLOBAL_VESSEL_VIEWPORT.bbox;
const zoom = Number.isFinite(Number(options.zoom))
? Number(options.zoom)
: GLOBAL_VESSEL_VIEWPORT.zoom;
const params = new URLSearchParams({ bbox: bbox.join(","), zoom: String(zoom) });
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
params.set("limit", String(requestedLimit));
}
const response = await fetch(`${PATHS.vesselsApi}?${params.toString()}`, { cache: "no-store" });
if (!response.ok) throw new Error(`Vessels HTTP ${response.status}`);
const payload = await response.json();
const features = Array.isArray(payload?.features) ? payload.features : [];
if (requestGeneration !== vesselSnapshotGeneration) {
return { totalCount: getVesselCount(), stats: {} };
}
clearVesselData(earth);
let markerData = dedupeVesselFeatures(features);
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
markerData = markerData.slice(0, requestedLimit);
}
vesselDataByKey = new Map(markerData.map((item) => [markerDataToDedupeKey(item), item]));
vesselIconLayer.setData(markerData);
vesselIconLayer.attach(earth);
vesselIconLayer.setVisible(showVessels);
return {
totalCount: getVesselCount(),
stats: payload?.stats || {},
};
}
export function startVesselRealtime(_earth, { onUpdate } = {}) {
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
}
export function stopVesselRealtime() {
vesselSnapshotGeneration += 1;
vesselRealtimeStats = {
connected: false,
updates: 0,
lastUpdateAt: null,
lastBatchSize: 0,
};
}
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]) => buildTrackPoint(lon, lat))
.filter(
(point) =>
point &&
Number.isFinite(point.x) &&
Number.isFinite(point.y) &&
Number.isFinite(point.z),
);
appendCurrentMarkerTrackPoint(points, marker);
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" },
];
}
export function updateVesselVisualState(lockedObjectType, lockedObject, camera) {
vesselIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
}