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; 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 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 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 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 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(); vesselIconLayer.clearData(earth); } 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); const markerData = features .map((feature) => buildVesselMarkerData(feature)) .filter(Boolean) .slice(0, VESSEL_CONFIG.maxRenderedMarkers); vesselIconLayer.setData(markerData); vesselIconLayer.attach(earth); vesselIconLayer.setVisible(showVessels); return { totalCount: getVesselCount(), 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]) => 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); }