281 lines
8.6 KiB
JavaScript
281 lines
8.6 KiB
JavaScript
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;
|
|
});
|
|
}
|