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

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { AirTrafficLayer } from '@/services/airtraffic.ts' import { AirTrafficLayer } from '@/services/adsb.ts'
import { AISLayer } from '@/services/ais.ts'
import {api} from '@/services/api.ts'; import {api} from '@/services/api.ts';
import {RangeLayer} from '@/services/range.ts';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Map from 'ol/Map' import Map from 'ol/Map'
import View from 'ol/View' import View from 'ol/View'
@@ -43,6 +45,8 @@ let map: Map
let stationLayer: VectorLayer<VectorSource> let stationLayer: VectorLayer<VectorSource>
let radarInterval: ReturnType<typeof setInterval> let radarInterval: ReturnType<typeof setInterval>
let airTraffic: AirTrafficLayer let airTraffic: AirTrafficLayer
let aisLayer: AISLayer
let range: RangeLayer
const hourTickIndices = computed(() => { const hourTickIndices = computed(() => {
const indices: number[] = [] const indices: number[] = []
@@ -205,8 +209,12 @@ onMounted(async () => {
controls: [], controls: [],
}) })
aisLayer = new AISLayer(map);
aisLayer.show();
airTraffic = new AirTrafficLayer(map) airTraffic = new AirTrafficLayer(map)
airTraffic.show() airTraffic.show()
range = new RangeLayer(map);
range.show();
for (const id of activeOverlays.value) { for (const id of activeOverlays.value) {
if (id === 'wind') continue if (id === 'wind') continue

View File

@@ -12,11 +12,11 @@ import {adjustedInterval} from '@ztimson/utils';
const API = BASE + '/api' const API = BASE + '/api'
const COLORS: Record<string, string> = { const COLORS: Record<string, string> = {
'Cargo': '#00ff00', 'cargo': '#00ff00',
'Military': '#ff0000', 'military': '#ff0000',
'Private': '#8450ea', 'private': '#8450ea',
'Passenger': '#009dff', 'passenger': '#009dff',
'Unknown': '#fad106', 'unknown': '#fad106',
} }
const SPEED_UNITS = { const SPEED_UNITS = {
@@ -59,9 +59,8 @@ function getAltColor(alt: number): [number, number, number] {
} }
function buildPlaneIcon(plane: any, shapes: any): string { function buildPlaneIcon(plane: any, shapes: any): string {
const color = COLORS[plane.class] || COLORS['Unknown'] const color = COLORS[plane.type] || COLORS['unknown']
const shape = shapes[plane.typecode] const shape = shapes[plane.aircraft?.toLowerCase()] || shapes['unknown'];
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 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('') 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>`)}` 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)
}
}

Binary file not shown.

View File

@@ -13,7 +13,8 @@ const SHAPES_PATH = resolve(DATA, 'shapes.json');
const history = new Map(); const history = new Map();
const MAX_HISTORY = 500; const MAX_HISTORY = 500;
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv'; const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
const MIL_RANGES_URL = ':8080/db-3.14.1707/ranges.js';
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC']; const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight']; const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
@@ -31,23 +32,37 @@ export async function initAircraftDb() {
db = new Database(DB_PATH); db = new Database(DB_PATH);
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS aircraft ( CREATE TABLE IF NOT EXISTS aircraft (
icao24 TEXT PRIMARY KEY, icao24 TEXT PRIMARY KEY,
built TEXT, built TEXT,
categoryDescription TEXT, categoryDescription TEXT,
country TEXT, country TEXT,
engines TEXT, engines TEXT,
class TEXT, class TEXT,
manufacturer TEXT, manufacturer TEXT,
model TEXT, model TEXT,
modes TEXT, modes TEXT,
operator TEXT, operator TEXT,
operatorCallsign TEXT, operatorCallsign TEXT,
owner TEXT, owner TEXT,
registration TEXT, registration TEXT,
serialNumber TEXT, serialNumber TEXT,
aircraft TEXT aircraft TEXT
) );
`); CREATE TABLE IF NOT EXISTS military_ranges (
start TEXT NOT NULL,
end TEXT NOT NULL
);
`);
// Always refresh military ranges on startup
const { ADSB_URL } = cfg();
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
if (!res.ok) throw new Error(`Failed to fetch military ranges: HTTP ${res.status}`);
const { military } = await res.json();
db.exec('DELETE FROM military_ranges');
const insertRange = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
const insertRanges = db.transaction(ranges => { for (const [s, e] of ranges) insertRange.run(s.toUpperCase(), e.toUpperCase()); });
insertRanges(military);
if (missing) { if (missing) {
const res = await fetch(CSV_URL); const res = await fetch(CSV_URL);
@@ -62,7 +77,7 @@ export async function initAircraftDb() {
@class, @manufacturer, @model, @modes, @operator, @class, @manufacturer, @model, @modes, @operator,
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft @operatorCallsign, @owner, @registration, @serialNumber, @aircraft
) )
`); `);
const insertMany = db.transaction(rows => { const insertMany = db.transaction(rows => {
for (const row of rows) insert.run(row); for (const row of rows) insert.run(row);
@@ -94,6 +109,13 @@ export async function initAircraftDb() {
} }
} }
function isIcaoInMilitaryRange(icao) {
if (!icao) return false;
const hex = icao.toUpperCase();
const match = db.prepare('SELECT 1 FROM military_ranges WHERE start <= ? AND end >= ? LIMIT 1').get(hex, hex);
return !!match;
}
function wordMatch(text, keyword) { function wordMatch(text, keyword) {
if (!text) return false; if (!text) return false;
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -110,7 +132,7 @@ export function classifyAircraft(row) {
const ownerFields = [row.owner]; const ownerFields = [row.owner];
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription]; const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
if (MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military'; if (isIcaoInMilitaryRange(row.icao24) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo'; if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger'; if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
if (row.owner && !row.operator) return 'private'; if (row.owner && !row.operator) return 'private';
@@ -121,7 +143,7 @@ export function classifyAircraft(row) {
export function enrichAircraft(a) { export function enrichAircraft(a) {
if (!a.hex) return a; if (!a.hex) return a;
const row = db.prepare('SELECT * FROM aircraft WHERE icao24 = ?').get(a.hex.toUpperCase()); const row = db.prepare('SELECT * FROM aircraft WHERE icao24 = ?').get(a.hex.toUpperCase());
if (!row) return a; if (!row) return { ...a, type: isIcaoInMilitaryRange(a.hex) ? 'military' : 'unknown' };
return { ...a, ...row, type: classifyAircraft(row) }; return { ...a, ...row, type: classifyAircraft(row) };
} }
@@ -148,6 +170,14 @@ export async function getADSB() {
return aircraft.map(enrichAircraft); return aircraft.map(enrichAircraft);
} }
export async function getAirTrafficHistory(icao) { export async function getADSBHistory(icao) {
return { history: history.get(icao?.toLowerCase()) || [] }; return { history: history.get(icao?.toLowerCase()) || [] };
} }
export async function getADSBRange() {
const { ADSB_URL } = cfg();
if (!ADSB_URL) return [];
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
const j = await r.json();
return j?.actualRange?.last24h?.points || [];
}

View File

@@ -1,14 +1,57 @@
import {cfg} from './config.mjs';
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
const SENDER_TYPE = {
0: 'Unknown',
1: 'Base Station',
2: 'Class A',
3: 'Class B',
4: 'SAR Aircraft',
5: 'Aid to Navigation',
6: 'Class B CS',
7: 'Sart/Epirb/MOB'
};
function decodeMsgTypes(status_raw) {
const types = [];
for (let i = 0; i < 32; i++) {
if (status_raw & (1 << i)) types.push(i); // was i + 1
}
return types;
}
export async function getAIS() { export async function getAIS() {
const {ADSB_URL} = cfg(); const {ADSB_URL} = cfg();
if(!ADSB_URL) return {data: []}; if(!ADSB_URL) return {data: []};
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`); const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
const j = await r.json(); const j = await r.json();
const {keys, values} = j; const {values} = j;
const boats = values.map(row => { const boats = values.map(row => {
const obj = {type: 'vessel'}; const [
keys.forEach((key, i) => obj[key] = row[i]); mmsi, lat, lon, distance, bearing, rssi, count, drift,
return obj; unknown_bool, speed, heading, course, altitude,
destination, eta, dimension, receiver, sources, channels,
msg_type, msg_type2, status_raw, country,
ship_type, callsign, imo, draught,
unknown1, unknown2, unknown3,
name, name2, name3,
last_signal, count2, sender_type, channels2, unknown4, in_range
] = row;
return {
mmsi, type: SENDER_TYPE[sender_type],
lat, lon, altitude,
heading, drift, speed,
distance, bearing, course,
destination, eta,
rssi, count, receiver, sources,
msg_type: decodeMsgTypes(status_raw),
channels: CHANNEL_MAP[channels] ?? channels,
ship_type, imo, draught,
country, callsign, name: name || name2 || name3,
last_signal, in_range
};
}); });
return boats; return boats;

View File

@@ -9,7 +9,7 @@ import {apiReference} from '@scalar/express-api-reference';
import {spec} from './spec.mjs'; import {spec} from './spec.mjs';
import {existsSync} from 'fs'; import {existsSync} from 'fs';
import {getAIS} from './ais.mjs'; import {getAIS} from './ais.mjs';
import {getADSB, getAirTrafficHistory, getShapes, initAircraftDb} from './adsb.mjs'; import {getADSB, getADSBHistory, getADSBRange, getShapes, initAircraftDb} from './adsb.mjs';
import {getWeatherCondition} from './openweather.mjs'; import {getWeatherCondition} from './openweather.mjs';
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs'; import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
import {dailyWeather, hourlyWeather} from './openmeteo.mjs'; import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
@@ -156,8 +156,9 @@ app.get('/api/space', async (req, res) => {
// ── ADSB Proxy ──────────────────────────────────────────────────────────────── // ── ADSB Proxy ────────────────────────────────────────────────────────────────
app.get('/api/adsb', async (req, res) => res.json(await getADSB())); app.get('/api/adsb', async (req, res) => res.json(await getADSB()));
app.get('/api/adsb/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao))); app.get('/api/adsb/:icao', async (req, res) => res.json(await getADSBHistory(req.params.icao)));
app.get('/api/ais', async (req, res) => res.json(await getAIS())); app.get('/api/ais', async (req, res) => res.json(await getAIS()));
app.get('/api/range', async (req, res) => res.json(await getADSBRange()));
app.get('/api/vehicles', async (req, res) => res.json(await getShapes())); app.get('/api/vehicles', async (req, res) => res.json(await getShapes()));
// ── DOCS ────────────────────────────────────────────────────────────────────── // ── DOCS ──────────────────────────────────────────────────────────────────────