import {BASE} from '@/services/api.ts'; import Map from 'ol/Map' import { Feature } from 'ol' import Point from 'ol/geom/Point' import { fromLonLat } from 'ol/proj' import { Style, Icon, Text, Fill, Stroke } from 'ol/style' import { Vector as VectorLayer } from 'ol/layer' import { Vector as VectorSource } from 'ol/source' import { adjustedInterval } from '@ztimson/utils' const API = BASE + '/api' const SHIP_COLORS: Record = { 'Base Station': '#00aaff', 'Class A': '#00ff00', 'Class B': '#8450ea', 'SAR Aircraft': '#ff6600', 'Aid to Navigation': '#ffff00', 'Class B CS': '#8450ea', 'Sart/Epirb/MOB': '#ff0000', 'Unknown': '#fad106', } function buildBoatIcon(boat: any): string { const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'] if (boat.type === 'Base Station') { return `data:image/svg+xml,${encodeURIComponent(` `)}` } return `data:image/svg+xml,${encodeURIComponent(` `)}` } function buildPopupHtml(boat: any): string { const name = boat.name?.trim() || boat.callsign?.trim() || `MMSI: ${boat.mmsi}` const rows: [string, any][] = [ ['Type', boat.type], ['MMSI', boat.mmsi], ['Country', boat.country], ['Callsign', boat.callsign || '-'], ['IMO', boat.imo || '-'], ['Ship Type', boat.ship_type ?? '-'], ['Speed', boat.speed != null ? `${boat.speed} kts` : '-'], ['Heading', boat.heading != null ? `${boat.heading}°` : '-'], ['Course', boat.course != null ? `${boat.course}°` : '-'], ['Lat / Lon', `${boat.lat?.toFixed(5)}, ${boat.lon?.toFixed(5)}`], ['Distance', `${boat.distance?.toFixed(2)} nmi`], ['Bearing', `${boat.bearing}°`], ['Destination', boat.destination || '-'], ['ETA', boat.eta || '-'], ['Draught', boat.draught != null ? `${boat.draught}m` : '-'], ['RSSI', `${boat.rssi?.toFixed(1)} dB`], ['Drift', `${boat.drift?.toFixed(2)} ppm`], ['Msg Types', boat.msg_type?.join(', ') || '-'], ['Channels', boat.channels || '-'], ['Sources', boat.sources], ['Receiver', boat.receiver], ['Count', boat.count], ['Last Signal', boat.last_signal != null ? `${boat.last_signal}s ago` : '-'], ['In Range', boat.in_range], ] return `

🚢 ${name}

${boat.type || 'Unknown'}
${rows.map(([label, val]) => `
${label}
${val ?? '-'}
`).join('')}
` } export class AISLayer { private map: Map private layer!: VectorLayer private popups: Record void }> = {} private data: any[] = [] private clickHandler: ((e: any) => void) | null = null private refreshTimer: any = null visible = false constructor(map: Map) { this.map = map } // ── Public ──────────────────────────────────────────────────────────────── 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 ─────────────────────────────────────────────────────────────── 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: 1, rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180), anchor: [0.5, 0.5], }), })) source.addFeature(f) } } private _openPopup(boat: any) { const mmsi = String(boat.mmsi) if (this.popups[mmsi]) return const el = document.createElement('div') el.style.cssText = ` position:absolute; z-index:9999; pointer-events:auto; background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3); border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden; ` const close = document.createElement('button') close.innerHTML = '✕' close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1' close.onclick = () => this._closePopup(mmsi) el.innerHTML = buildPopupHtml(boat) el.appendChild(close) document.body.appendChild(el) this._positionPopup(el, boat) this.popups[mmsi] = { el, destroy: () => el.remove() } } private _positionPopup(el: HTMLDivElement, boat: any) { const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat])) if (!pixel) return const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect() el.style.left = `${rect.left + pixel[0] + 16}px` el.style.top = `${rect.top + pixel[1] - 16}px` } private _closePopup(mmsi: string) { const popup = this.popups[mmsi] if (popup) { popup.destroy(); 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 } const { el } = this.popups[mmsi] el.innerHTML = buildPopupHtml(boat) const close = document.createElement('button') close.innerHTML = '✕' close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1' close.onclick = () => this._closePopup(mmsi) el.appendChild(close) this._positionPopup(el, 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) } }