import * as THREE from "three"; import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js"; import { latLonToVector3 } from "./utils.js"; const vesselGroup = new THREE.Group(); const vesselMarkers = []; const textureCache = new Map(); let vesselPoints = null; let vesselPointObjects = []; let hoverOverlaySprite = null; let lockedOverlaySprite = null; let showVessels = false; let activeTrackLine = null; let lastVisualStateKey = ""; let visualStateVersion = 0; const VESSEL_RENDER_ORDER = 4.4; const VESSEL_POINT_SIZE = 34; const VESSEL_ATLAS_CELL_SIZE = 128; const VESSEL_COURSE_BINS = 32; function invalidateVesselVisualState() { visualStateVersion += 1; lastVisualStateKey = ""; } 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 createVesselPointTexture(anchored, courseBin = 0) { const textureKey = `vessel-point:${anchored ? "anchored" : "moving"}:${courseBin}`; if (textureCache.has(textureKey)) return textureCache.get(textureKey); const canvas = document.createElement("canvas"); canvas.width = VESSEL_ATLAS_CELL_SIZE; canvas.height = VESSEL_ATLAS_CELL_SIZE; const context = canvas.getContext("2d"); context.clearRect(0, 0, canvas.width, canvas.height); context.save(); context.translate(canvas.width / 2, canvas.height / 2); if (!anchored) { context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2); } drawVesselShape(context, anchored, false); context.restore(); const texture = new THREE.CanvasTexture(canvas); texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.needsUpdate = true; textureCache.set(textureKey, texture); return texture; } function createVesselOverlayTexture(marker, glow = true) { const kind = marker?.userData?.vessel_kind || "other"; const anchored = Boolean(marker?.userData?.anchored); const courseBin = marker ? getCourseBin(marker) : 0; const textureKey = [ "vessel-overlay", kind, anchored ? "anchored" : "moving", courseBin, glow ? "glow" : "plain", ].join(":"); if (textureCache.has(textureKey)) return textureCache.get(textureKey); const canvas = document.createElement("canvas"); canvas.width = VESSEL_ATLAS_CELL_SIZE; canvas.height = VESSEL_ATLAS_CELL_SIZE; const context = canvas.getContext("2d"); context.clearRect(0, 0, canvas.width, canvas.height); context.save(); context.translate(canvas.width / 2, canvas.height / 2); if (!anchored) { context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2); } drawVesselShape( context, anchored, glow, VESSEL_CONFIG.colors[kind] || VESSEL_CONFIG.colors.other, ); context.restore(); const texture = new THREE.CanvasTexture(canvas); texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; 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 marker = new THREE.Object3D(); marker.position.copy( latLonToVector3( markerData.latitude, markerData.longitude, CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset, ), ); marker.userData = { ...markerData, type: "vessel", vessel_kind: markerData.type, baseScale: VESSEL_CONFIG.marker.baseScale, state: "normal", }; vesselMarkers.push(marker); } function colorToRgbArray(colorValue) { const color = new THREE.Color(colorValue || VESSEL_CONFIG.colors.other); return [color.r, color.g, color.b]; } 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 buildVesselPoints() { vesselPoints = new THREE.Group(); vesselPoints.visible = showVessels; vesselPoints.renderOrder = VESSEL_RENDER_ORDER; vesselPoints.userData = { type: "vessel_points" }; vesselPointObjects = []; const groups = new Map(); vesselMarkers.forEach((marker, index) => { const anchored = Boolean(marker.userData.anchored); const courseBin = getCourseBin(marker); const key = `${anchored ? "anchored" : "moving"}:${courseBin}`; if (!groups.has(key)) { groups.set(key, { anchored, courseBin, markers: [], markerIndexes: [], }); } const group = groups.get(key); group.markers.push(marker); group.markerIndexes.push(index); }); groups.forEach((group) => { const count = group.markers.length; const positions = new Float32Array(count * 3); const colors = new Float32Array(count * 3); group.markers.forEach((marker, index) => { positions[index * 3] = marker.position.x; positions[index * 3 + 1] = marker.position.y; positions[index * 3 + 2] = marker.position.z; const [r, g, b] = colorToRgbArray(VESSEL_CONFIG.colors[marker.userData.vessel_kind]); colors[index * 3] = r; colors[index * 3 + 1] = g; colors[index * 3 + 2] = b; }); const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); geometry.computeBoundingSphere(); const material = new THREE.PointsMaterial({ map: createVesselPointTexture(group.anchored, group.courseBin), size: VESSEL_POINT_SIZE, sizeAttenuation: false, vertexColors: true, transparent: true, opacity: VESSEL_CONFIG.marker.baseOpacity, depthWrite: false, depthTest: true, alphaTest: 0.01, }); const points = new THREE.Points(geometry, material); points.renderOrder = VESSEL_RENDER_ORDER; points.frustumCulled = false; points.userData = { type: "vessel_points", }; vesselPointObjects.push(points); vesselPoints.add(points); }); vesselGroup.add(vesselPoints); } 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 disposeVesselRenderObjects() { if (vesselPoints?.parent) { vesselPoints.parent.remove(vesselPoints); } vesselPointObjects.forEach((points) => { points.geometry?.dispose?.(); points.material?.dispose?.(); }); hoverOverlaySprite?.geometry?.dispose?.(); hoverOverlaySprite?.material?.dispose?.(); hoverOverlaySprite?.parent?.remove?.(hoverOverlaySprite); lockedOverlaySprite?.geometry?.dispose?.(); lockedOverlaySprite?.material?.dispose?.(); lockedOverlaySprite?.parent?.remove?.(lockedOverlaySprite); vesselPoints = null; vesselPointObjects = []; hoverOverlaySprite = null; lockedOverlaySprite = null; } export function getVesselMarkers() { return vesselMarkers; } export function getVesselCount() { return vesselMarkers.length; } export function getShowVessels() { return showVessels; } export function toggleVessels(show) { showVessels = Boolean(show); invalidateVesselVisualState(); vesselGroup.visible = showVessels; if (vesselPoints) { vesselPoints.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; if (marker.userData.state === state) return; marker.userData.state = state; invalidateVesselVisualState(); } export function clearVesselData(earth) { invalidateVesselVisualState(); clearVesselSelection(); vesselMarkers.length = 0; disposeVesselRenderObjects(); 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)); buildVesselPoints(); 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, 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" }, ]; } function ensureOverlaySprite(kind) { const existing = kind === "locked" ? lockedOverlaySprite : hoverOverlaySprite; if (existing) return existing; const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", new THREE.BufferAttribute(new Float32Array(3), 3), ); const material = new THREE.PointsMaterial({ size: VESSEL_POINT_SIZE, sizeAttenuation: false, transparent: true, depthWrite: false, depthTest: true, opacity: 1, alphaTest: 0.01, }); const overlay = new THREE.Points(geometry, material); overlay.renderOrder = VESSEL_RENDER_ORDER + (kind === "locked" ? 0.2 : 0.1); overlay.frustumCulled = false; overlay.visible = false; vesselGroup.add(overlay); if (kind === "locked") { lockedOverlaySprite = overlay; } else { hoverOverlaySprite = overlay; } return overlay; } function updateOverlaySprite(overlay, marker, opacity) { if (!overlay) return; if (!marker) { overlay.visible = false; return; } const texture = createVesselOverlayTexture(marker, true); if (overlay.material.map !== texture) { overlay.material.map = texture; overlay.material.needsUpdate = true; } overlay.material.opacity = opacity; overlay.material.size = VESSEL_POINT_SIZE; const positionAttribute = overlay.geometry.getAttribute("position"); positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z); positionAttribute.needsUpdate = true; overlay.visible = showVessels; } export function updateVesselVisualState(lockedObjectType, lockedObject, camera) { if (!showVessels || vesselMarkers.length === 0 || !vesselPoints) { if (lastVisualStateKey !== "hidden") { if (vesselPoints) vesselPoints.visible = false; if (hoverOverlaySprite) hoverOverlaySprite.visible = false; if (lockedOverlaySprite) lockedOverlaySprite.visible = false; lastVisualStateKey = "hidden"; } return; } vesselPoints.visible = true; const hasFocus = lockedObjectType === "vessel" && lockedObject; const lockedKey = hasFocus ? lockedObject?.userData?.mmsi || lockedObject?.uuid || "locked" : "none"; const stateKey = [ "visible", lockedObjectType || "none", lockedKey, visualStateVersion, ].join(":"); if (stateKey === lastVisualStateKey) return; lastVisualStateKey = stateKey; vesselPointObjects.forEach((points) => { points.visible = showVessels; points.material.opacity = hasFocus ? VESSEL_CONFIG.marker.dimmedOpacity : VESSEL_CONFIG.marker.baseOpacity; points.material.size = VESSEL_POINT_SIZE; }); const hoverMarker = vesselMarkers.find( (marker) => marker.userData?.state === "hover" && marker !== lockedObject, ); updateOverlaySprite( ensureOverlaySprite("hover"), hoverMarker, 0.98, ); updateOverlaySprite( ensureOverlaySprite("locked"), hasFocus ? lockedObject : null, 1, ); }