Files
weather-station/client/src/services/ais.ts

172 lines
5.6 KiB
TypeScript

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<string, string> = {
'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(`
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<polygon points="16,2 30,16 16,30 2,16" fill="${color}" stroke="#000" stroke-width="1.5"/>
<circle cx="16" cy="9" r="1.8" fill="#000"/>
<line x1="16" y1="9" x2="16" y2="23" stroke="#000" stroke-width="1.8"/>
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
</svg>
`)}`
}
return `data:image/svg+xml,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
<circle cx="12" cy="12" r="1.5" fill="#000"/>
<line x1="12" y1="12" x2="12" y2="22" stroke="#000" stroke-width="1.5"/>
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
</svg>
`)}`
}
export class AISLayer {
private map: Map
private layer!: VectorLayer<VectorSource>
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)
}
}