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); }