AIS, ADSB, and Range
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<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 {RangeLayer} from '@/services/range.ts';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import View from 'ol/View'
|
||||
@@ -43,6 +45,8 @@ let map: Map
|
||||
let stationLayer: VectorLayer<VectorSource>
|
||||
let radarInterval: ReturnType<typeof setInterval>
|
||||
let airTraffic: AirTrafficLayer
|
||||
let aisLayer: AISLayer
|
||||
let range: RangeLayer
|
||||
|
||||
const hourTickIndices = computed(() => {
|
||||
const indices: number[] = []
|
||||
@@ -205,8 +209,12 @@ onMounted(async () => {
|
||||
controls: [],
|
||||
})
|
||||
|
||||
aisLayer = new AISLayer(map);
|
||||
aisLayer.show();
|
||||
airTraffic = new AirTrafficLayer(map)
|
||||
airTraffic.show()
|
||||
range = new RangeLayer(map);
|
||||
range.show();
|
||||
|
||||
for (const id of activeOverlays.value) {
|
||||
if (id === 'wind') continue
|
||||
|
||||
@@ -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
214
client/src/services/ais.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
40
client/src/services/range.ts
Normal file
40
client/src/services/range.ts
Normal 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.
@@ -13,7 +13,8 @@ const SHAPES_PATH = resolve(DATA, 'shapes.json');
|
||||
const history = new Map();
|
||||
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 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.exec(`
|
||||
CREATE TABLE IF NOT EXISTS aircraft (
|
||||
icao24 TEXT PRIMARY KEY,
|
||||
built TEXT,
|
||||
categoryDescription TEXT,
|
||||
country TEXT,
|
||||
engines TEXT,
|
||||
class TEXT,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
modes TEXT,
|
||||
operator TEXT,
|
||||
operatorCallsign TEXT,
|
||||
owner TEXT,
|
||||
registration TEXT,
|
||||
serialNumber TEXT,
|
||||
aircraft TEXT
|
||||
)
|
||||
`);
|
||||
icao24 TEXT PRIMARY KEY,
|
||||
built TEXT,
|
||||
categoryDescription TEXT,
|
||||
country TEXT,
|
||||
engines TEXT,
|
||||
class TEXT,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
modes TEXT,
|
||||
operator TEXT,
|
||||
operatorCallsign TEXT,
|
||||
owner TEXT,
|
||||
registration TEXT,
|
||||
serialNumber 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) {
|
||||
const res = await fetch(CSV_URL);
|
||||
@@ -62,7 +77,7 @@ export async function initAircraftDb() {
|
||||
@class, @manufacturer, @model, @modes, @operator,
|
||||
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
||||
)
|
||||
`);
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction(rows => {
|
||||
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) {
|
||||
if (!text) return false;
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -110,7 +132,7 @@ export function classifyAircraft(row) {
|
||||
const ownerFields = [row.owner];
|
||||
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 (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
|
||||
if (row.owner && !row.operator) return 'private';
|
||||
@@ -121,7 +143,7 @@ export function classifyAircraft(row) {
|
||||
export function enrichAircraft(a) {
|
||||
if (!a.hex) return a;
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -148,6 +170,14 @@ export async function getADSB() {
|
||||
return aircraft.map(enrichAircraft);
|
||||
}
|
||||
|
||||
export async function getAirTrafficHistory(icao) {
|
||||
export async function getADSBHistory(icao) {
|
||||
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 || [];
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
const {ADSB_URL} = cfg();
|
||||
if(!ADSB_URL) return {data: []};
|
||||
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
|
||||
const j = await r.json();
|
||||
|
||||
const {keys, values} = j;
|
||||
const {values} = j;
|
||||
const boats = values.map(row => {
|
||||
const obj = {type: 'vessel'};
|
||||
keys.forEach((key, i) => obj[key] = row[i]);
|
||||
return obj;
|
||||
const [
|
||||
mmsi, lat, lon, distance, bearing, rssi, count, drift,
|
||||
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;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {apiReference} from '@scalar/express-api-reference';
|
||||
import {spec} from './spec.mjs';
|
||||
import {existsSync} from 'fs';
|
||||
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 {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
|
||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||
@@ -156,8 +156,9 @@ app.get('/api/space', async (req, res) => {
|
||||
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
||||
|
||||
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/range', async (req, res) => res.json(await getADSBRange()));
|
||||
app.get('/api/vehicles', async (req, res) => res.json(await getShapes()));
|
||||
|
||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user