AIS, ADSB, and Range

This commit is contained in:
2026-06-25 15:02:56 -04:00
parent 40b6a5488f
commit a2441fdb37
8 changed files with 372 additions and 37 deletions

View File

@@ -12,11 +12,11 @@ import {adjustedInterval} from '@ztimson/utils';
const API = BASE + '/api'
const COLORS: Record<string, string> = {
'Cargo': '#00ff00',
'Military': '#ff0000',
'Private': '#8450ea',
'Passenger': '#009dff',
'Unknown': '#fad106',
'cargo': '#00ff00',
'military': '#ff0000',
'private': '#8450ea',
'passenger': '#009dff',
'unknown': '#fad106',
}
const SPEED_UNITS = {
@@ -59,9 +59,8 @@ function getAltColor(alt: number): [number, number, number] {
}
function buildPlaneIcon(plane: any, shapes: any): string {
const color = COLORS[plane.class] || COLORS['Unknown']
const shape = shapes[plane.typecode]
if(!shape) return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><polygon points="12,2 22,22 12,17 2,22" fill="${color}" stroke="#000" stroke-width="1"/></svg>`)}`
const color = COLORS[plane.type] || COLORS['unknown']
const shape = shapes[plane.aircraft?.toLowerCase()] || shapes['unknown'];
const paths = (Array.isArray(shape.path) ? shape.path : [shape.path]).map((p: string) => `<path d="${p}" fill="${color}" stroke="#000" stroke-width="${shape.stroke || 1}"/>`).join('')
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${shape.w * 2}" height="${shape.h * 2}" viewBox="${shape.viewBox}">${paths}</svg>`)}`
}

214
client/src/services/ais.ts Normal file
View File

@@ -0,0 +1,214 @@
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<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',
}
function buildBoatIcon(boat: any): string {
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
const heading = boat.heading ?? boat.course ?? 0
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"/>
</svg>
`)}`
}
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 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 } = <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)
}
}
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)
}
}

View File

@@ -0,0 +1,40 @@
import {BASE} from '@/services/api.ts';
import Map from 'ol/Map'
import { Feature } from 'ol'
import { Polygon } from 'ol/geom'
import { fromLonLat } from 'ol/proj'
import { Style, Stroke } from 'ol/style'
import { Vector as VectorLayer } from 'ol/layer'
import { Vector as VectorSource } from 'ol/source'
export class RangeLayer {
private map: Map
private layer!: VectorLayer<VectorSource>
visible = false
constructor(map: Map) { this.map = map }
async show() {
if (this.visible) return
this.visible = true
const points: [number, number][] = await fetch(`${BASE}/api/range`).then(r => r.json())
const coords = points.map(([lat, lon]: number[]) => fromLonLat([<number>lon, <number>lat]))
coords.push(<any>coords[0]) // close the polygon
const feature = new Feature({ geometry: new Polygon([coords]) })
feature.setStyle(new Style({
stroke: new Stroke({ color: '#00ffcc', width: 1.5 })
}))
const source = new VectorSource({ features: [feature] })
this.layer = new VectorLayer({ source, zIndex: 50 })
this.map.addLayer(this.layer)
}
hide() {
if (!this.visible) return
this.visible = false
this.map.removeLayer(this.layer)
}
}