486 lines
15 KiB
JavaScript
486 lines
15 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 vesselStreamSocket = null;
|
|
let vesselStreamReconnectTimer = null;
|
|
let vesselDataByKey = new Map();
|
|
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 buildVesselFeatureFromDelta(item) {
|
|
const lat = Number(item?.lat ?? item?.latitude);
|
|
const lon = Number(item?.lon ?? item?.lng ?? item?.longitude);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
|
return {
|
|
type: "Feature",
|
|
id: item.mmsi,
|
|
geometry: {
|
|
type: "Point",
|
|
coordinates: [lon, lat],
|
|
},
|
|
properties: {
|
|
...item,
|
|
mmsi: item.mmsi,
|
|
mmsi_display: item.mmsi_display || (item.mmsi !== undefined && item.mmsi !== null ? String(item.mmsi) : undefined),
|
|
},
|
|
};
|
|
}
|
|
|
|
function rebuildVesselLayerFromCache(earth) {
|
|
if (!earth) return;
|
|
vesselIconLayer.setData(Array.from(vesselDataByKey.values()));
|
|
vesselIconLayer.attach(earth);
|
|
vesselIconLayer.setVisible(showVessels);
|
|
}
|
|
|
|
function applyVesselDeltas(earth, vessels = []) {
|
|
let changed = false;
|
|
vessels.forEach((item) => {
|
|
const feature = buildVesselFeatureFromDelta(item);
|
|
if (!feature) return;
|
|
const marker = buildVesselMarkerData(feature);
|
|
if (!marker) return;
|
|
vesselDataByKey.set(markerDataToDedupeKey(marker), marker);
|
|
changed = true;
|
|
});
|
|
if (changed) {
|
|
rebuildVesselLayerFromCache(earth);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function getVesselStreamUrl() {
|
|
if (typeof window === "undefined") return "ws://localhost:8000/ws";
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
return `${protocol}//${window.location.host}/ws`;
|
|
}
|
|
|
|
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,
|
|
},
|
|
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,
|
|
}),
|
|
});
|
|
|
|
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 params = new URLSearchParams();
|
|
const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers);
|
|
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
|
params.set("limit", String(requestedLimit));
|
|
}
|
|
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);
|
|
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 } = {}) {
|
|
if (vesselStreamSocket || typeof WebSocket === "undefined") return;
|
|
const connect = () => {
|
|
if (!showVessels || vesselStreamSocket) return;
|
|
const socket = new WebSocket(getVesselStreamUrl());
|
|
vesselStreamSocket = socket;
|
|
socket.onopen = () => {
|
|
vesselRealtimeStats = {
|
|
...vesselRealtimeStats,
|
|
connected: true,
|
|
};
|
|
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
|
socket.send(JSON.stringify({ type: "subscribe", data: { channels: ["vessels"] } }));
|
|
};
|
|
socket.onmessage = (event) => {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(event.data);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (message.type === "heartbeat" && message.data?.action === "ping") {
|
|
socket.send(JSON.stringify({ type: "heartbeat" }));
|
|
return;
|
|
}
|
|
if (message.type !== "data_frame" || message.channel !== "vessels") return;
|
|
const payload = message.payload || {};
|
|
if (payload.action === "reload") {
|
|
loadVessels(null, earth)
|
|
.then((result) => {
|
|
vesselRealtimeStats = {
|
|
...vesselRealtimeStats,
|
|
connected: true,
|
|
updates: vesselRealtimeStats.updates + 1,
|
|
lastUpdateAt: new Date(),
|
|
lastBatchSize: 0,
|
|
};
|
|
onUpdate?.({ totalCount: result?.totalCount ?? getVesselCount(), payload, stream: getVesselRealtimeStats() });
|
|
})
|
|
.catch(() => {});
|
|
return;
|
|
}
|
|
if (payload.action !== "upsert" || !Array.isArray(payload.vessels)) return;
|
|
if (applyVesselDeltas(earth, payload.vessels)) {
|
|
vesselRealtimeStats = {
|
|
connected: true,
|
|
updates: vesselRealtimeStats.updates + 1,
|
|
lastUpdateAt: new Date(),
|
|
lastBatchSize: payload.vessels.length,
|
|
};
|
|
onUpdate?.({ totalCount: getVesselCount(), payload, stream: getVesselRealtimeStats() });
|
|
}
|
|
};
|
|
socket.onclose = () => {
|
|
if (vesselStreamSocket === socket) {
|
|
vesselStreamSocket = null;
|
|
}
|
|
vesselRealtimeStats = {
|
|
...vesselRealtimeStats,
|
|
connected: false,
|
|
};
|
|
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
|
if (showVessels) {
|
|
vesselStreamReconnectTimer = window.setTimeout(connect, 3000);
|
|
}
|
|
};
|
|
socket.onerror = () => {
|
|
socket.close();
|
|
};
|
|
};
|
|
connect();
|
|
}
|
|
|
|
export function stopVesselRealtime() {
|
|
if (vesselStreamReconnectTimer) {
|
|
window.clearTimeout(vesselStreamReconnectTimer);
|
|
vesselStreamReconnectTimer = null;
|
|
}
|
|
if (vesselStreamSocket) {
|
|
const socket = vesselStreamSocket;
|
|
vesselStreamSocket = null;
|
|
socket.close();
|
|
}
|
|
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);
|
|
}
|