Switched from influx to victoria metrics (more light weight for pi)
This commit is contained in:
@@ -1,30 +1,33 @@
|
||||
import {BASE} from '@/services/api.ts';
|
||||
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, Text, Fill, Stroke } from 'ol/style'
|
||||
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': '#ff6600',
|
||||
'Aid to Navigation': '#ffff00',
|
||||
'Class B CS': '#8450ea',
|
||||
'Sart/Epirb/MOB': '#ff0000',
|
||||
'Unknown': '#fad106',
|
||||
'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 (boat.type === 'Base Station') {
|
||||
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"/>
|
||||
@@ -35,10 +38,9 @@ function buildBoatIcon(boat: any): string {
|
||||
</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,28 12,22 0,28" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||
<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"/>
|
||||
@@ -47,77 +49,25 @@ function buildBoatIcon(boat: any): string {
|
||||
`)}`
|
||||
}
|
||||
|
||||
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 `
|
||||
<div style="font-family:monospace;color:#ccc;min-width:280px">
|
||||
<div style="padding:12px 16px;border-bottom:1px solid rgba(200,200,220,0.2)">
|
||||
<h3 style="margin:0;color:#7eeee1;font-size:16px">🚢 ${name}</h3>
|
||||
<div style="font-size:11px;color:#888;margin-top:4px">${boat.type || 'Unknown'}</div>
|
||||
</div>
|
||||
<div style="padding:12px 16px;display:grid;grid-template-columns:1fr 1fr;gap:6px 16px">
|
||||
${rows.map(([label, val]) => `
|
||||
<div>
|
||||
<div style="color:#888;font-size:10px;font-weight:bold">${label}</div>
|
||||
<div style="color:#0f0;font-size:13px">${val ?? '-'}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export class AISLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private popups: Record<string, { el: HTMLDivElement, destroy: () => void }> = {}
|
||||
private data: any[] = []
|
||||
private popups: any = {}
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: any = 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.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()
|
||||
@@ -128,16 +78,12 @@ export class AISLayer {
|
||||
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()) || []
|
||||
}
|
||||
@@ -145,17 +91,16 @@ export class AISLayer {
|
||||
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) })
|
||||
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,
|
||||
scale: 0.8,
|
||||
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
@@ -164,55 +109,51 @@ export class AISLayer {
|
||||
}
|
||||
}
|
||||
|
||||
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 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 boatRef = ref(boat)
|
||||
const posRef = ref(this._calcPopupPos(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)
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
el.innerHTML = buildPopupHtml(boat)
|
||||
el.appendChild(close)
|
||||
document.body.appendChild(el)
|
||||
this._positionPopup(el, boat)
|
||||
const app = createApp(ShipPopup, {
|
||||
boat: boatRef.value,
|
||||
position: posRef.value,
|
||||
onClose: () => this._closePopup(mmsi),
|
||||
onBringToFront: () => {
|
||||
highestZIndex++
|
||||
container.style.zIndex = String(highestZIndex)
|
||||
},
|
||||
})
|
||||
|
||||
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`
|
||||
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.destroy(); delete 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 }
|
||||
const { el } = <any>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)
|
||||
this.popups[mmsi].boatRef.value = boat
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user