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, }), }); const DEFAULT_VESSEL_VIEWPORT = { bbox: [-180, -90, 180, 90], zoom: 2, }; 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); const bbox = Array.isArray(options.bbox) && options.bbox.length === 4 ? options.bbox : DEFAULT_VESSEL_VIEWPORT.bbox; const zoom = Number.isFinite(Number(options.zoom)) ? Number(options.zoom) : DEFAULT_VESSEL_VIEWPORT.zoom; params.set("bbox", bbox.join(",")); params.set("zoom", String(zoom)); 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: { channel: "vessels", bbox: DEFAULT_VESSEL_VIEWPORT.bbox, zoom: DEFAULT_VESSEL_VIEWPORT.zoom, limit: VESSEL_CONFIG.maxRenderedMarkers, }, })); }; 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); }