import { BASE } from '@/services/api.ts' import { createApp, ref } from 'vue' import Map from 'ol/Map' import { Feature } from 'ol' import Point from 'ol/geom/Point' import { fromLonLat } from 'ol/proj' import { Style, Icon } from 'ol/style' import { Vector as VectorLayer } from 'ol/layer' import { Vector as VectorSource } from 'ol/source' import { adjustedInterval } from '@ztimson/utils' import ShipPopup from '@/components/Ship.vue' const API = BASE + '/api' let highestZIndex = 1000 const SHIP_COLORS: Record = { 'Base Station': '#00aaff', 'Class A': '#00ff00', 'Class B': '#8450ea', 'SAR Aircraft': '#ff0000', 'AtoN': '#00aaff', 'Class B/CS': '#8450ea', 'Sart/Epirb/MOB': '#00aaff', 'Unknown': '#fad106', } function buildBoatIcon(boat: any): string { const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'] if (['Base Station', 'AtoN'].includes(boat.type)) { return `data:image/svg+xml,${encodeURIComponent(` `)}` } return `data:image/svg+xml,${encodeURIComponent(` `)}` } export class AISLayer { private map: Map private layer!: VectorLayer private popups: any = {} private data: any[] = [] private clickHandler: ((e: any) => void) | null = null private refreshTimer: any = null visible = false constructor(map: Map) { this.map = map } async show() { if (this.visible) return this.visible = true this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 }) this.map.addLayer(this.layer) await this._fetch() this._draw() this._attachClick() this.refreshTimer = adjustedInterval(async () => { await this._fetch() this._draw() this._refreshPopups() }, 5_000) } hide() { if (!this.visible) return this.visible = false if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null } if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null } for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi) this.map.removeLayer(this.layer) } private async _fetch() { this.data = await fetch(`${API}/ais`).then(r => r.json()) || [] } private _draw() { const source = this.layer.getSource()! source.clear() for (const boat of this.data) { if (boat.lat == null || boat.lon == null) continue const coord = fromLonLat([boat.lon, boat.lat]) const f = new Feature({ geometry: new Point(coord) }) f.set('mmsi', boat.mmsi) f.set('boatData', boat) f.setStyle(new Style({ image: new Icon({ src: buildBoatIcon(boat), scale: 0.8, rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180), anchor: [0.5, 0.5], }), })) source.addFeature(f) } } private _calcPopupPos(boat: any): { x: number; y: number } { const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat])) if (!pixel) return { x: 10, y: 60 } const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect() return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 } } private _openPopup(boat: any) { const mmsi = String(boat.mmsi) if (this.popups[mmsi]) return const boatRef = ref(boat) const posRef = ref(this._calcPopupPos(boat)) const container = document.createElement('div') document.body.appendChild(container) const app = createApp(ShipPopup, { boat: boatRef.value, position: posRef.value, onClose: () => this._closePopup(mmsi), onBringToFront: () => { highestZIndex++ container.style.zIndex = String(highestZIndex) }, }) app.mount(container) this.popups[mmsi] = { boatRef, posRef, unmount: () => { app.unmount(); container.remove() }, } } private _closePopup(mmsi: string) { const popup = this.popups[mmsi] if (popup) { popup.unmount(); delete this.popups[mmsi] } } private _refreshPopups() { for (const mmsi of Object.keys(this.popups)) { const boat = this.data.find(b => String(b.mmsi) === mmsi) if (!boat) { this._closePopup(mmsi); continue } this.popups[mmsi].boatRef.value = boat } } private _attachClick() { this.clickHandler = (evt) => { const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null) if (!f) return const boat = f.get('boatData') const mmsi = String(boat.mmsi) if (this.popups[mmsi]) { this._closePopup(mmsi); return } this._openPopup(boat) } this.map.on('singleclick', this.clickHandler) } }