diff --git a/client/src/components/MapView.vue b/client/src/components/MapView.vue
index 479daf1..e373ad3 100644
--- a/client/src/components/MapView.vue
+++ b/client/src/components/MapView.vue
@@ -1,88 +1,216 @@
@@ -351,22 +939,26 @@ watch(() => props.dark, dark => {
-
+
-
@@ -374,14 +966,25 @@ watch(() => props.dark, dark => {
⚙️ Layers
+
-
- {{ o.icon }} {{ o.label }}
+
+
+ {{ group.icon }} {{ group.label }}
+
+ {{ openMenu === group.id ? '▲' : '▼' }}
+
+
+
+
+
+
+
+ {{ autoMode ? '⏹ Stop Auto' : '▶ Auto Mode' }}
diff --git a/client/src/services/adsb.ts b/client/src/services/adsb.ts
index fa39e70..b16e9f0 100644
--- a/client/src/services/adsb.ts
+++ b/client/src/services/adsb.ts
@@ -1,213 +1,346 @@
-import { BASE } from '@/services/api.ts'
-import { h, render as vueRender } from 'vue'
-import Map from 'ol/Map'
-import { Feature } from 'ol'
-import Point from 'ol/geom/Point'
-import LineString from 'ol/geom/LineString'
-import { fromLonLat } from 'ol/proj'
-import { Style, Icon, Stroke } from 'ol/style'
-import { Vector as VectorLayer } from 'ol/layer'
-import { Vector as VectorSource } from 'ol/source'
-import { adjustedInterval } from '@ztimson/utils'
-import AircraftPopup from '@/components/Aircraft.vue'
-import { bringToFront } from './zindex'
+import {BASE} from '@/services/api.ts';
+import {h, render as vueRender} from 'vue';
+import Map from 'ol/Map';
+import {Feature} from 'ol';
+import Point from 'ol/geom/Point';
+import LineString from 'ol/geom/LineString';
+import {fromLonLat} from 'ol/proj';
+import {Style, Icon, Stroke} from 'ol/style';
+import {Vector as VectorLayer} from 'ol/layer';
+import {Vector as VectorSource} from 'ol/source';
+import {adjustedInterval} from '@ztimson/utils';
+import AircraftPopup from '@/components/Aircraft.vue';
+import {bringToFront} from './zindex';
-const API = BASE + '/api'
+const API = BASE + '/api';
+const EARTH_R = 6371;
+const AUTO_RANGE = 92.6;
+
+function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
+ const toRad = (d: number) => d * Math.PI / 180;
+ const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
+ return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+function isPriorityAircraft(plane: any): boolean {
+ if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true;
+
+ const text = [
+ plane.type,
+ plane.category,
+ plane.aircraft_type,
+ plane.operator,
+ plane.owner,
+ plane.flight,
+ plane.callsign,
+ plane.description,
+ ].filter(Boolean).join(' ').toLowerCase();
+
+ return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|usaf|rcaf)\b/.test(text);
+}
function getAltColor(alt: number): [number, number, number] {
- const a = Math.max(0, alt)
- if (a < 10) return [0, 0, 0]
- if (a < 10000) return [135, 206, 250]
+ const a = Math.max(0, alt);
+ if (a < 10) return [0, 0, 0];
+ if (a < 10000) return [135, 206, 250];
+
if (a < 25000) {
- const r = (a - 10000) / 15000
- return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
+ const r = (a - 10000) / 15000;
+ return [Math.round(135 * (1 - r)), Math.round(206 * (1 - r)), Math.round(250 * (1 - r) + 255 * r)];
}
+
if (a < 40000) {
- const r = (a - 25000) / 15000
- return [0, 0, Math.round(255 * (1-r) + 139 * r)]
+ const r = (a - 25000) / 15000;
+ return [0, 0, Math.round(255 * (1 - r) + 139 * r)];
}
- const r = Math.min((a - 40000) / 10000, 1)
- return [Math.round(128 * r), 0, 139]
+
+ const r = Math.min((a - 40000) / 10000, 1);
+ return [Math.round(128 * r), 0, 139];
}
export class AirTrafficLayer {
- private map: Map
- private layer!: VectorLayer
- private traceLayers: Record> = {}
- private traceSegs: Record = {}
- private popups: any = {}
- private historyCache: any = {}
- private data: any[] = []
- private clickHandler: ((e: any) => void) | null = null
- private refreshTimer: any = null
- visible = false
+ private map: Map;
+ private layer!: VectorLayer;
+ private traceLayers: Record> = {};
+ private traceSegs: Record = {};
+ private popups: any = {};
+ private historyCache: any = {};
+ private data: any[] = [];
+ private clickHandler: ((e: any) => void) | null = null;
+ private refreshTimer: any = null;
+ visible = false;
- constructor(map: Map) { this.map = map }
+ 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()
+ 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 () => {
try {
- await this._fetch()
- this._draw()
- this._refreshPopups()
+ await this._fetch();
+ this._draw();
+ this._refreshPopups();
} catch (err) {
- console.error(err)
+ console.error(err);
}
- }, 1_000)
+ }, 1_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 icao of Object.keys(this.popups)) this._closePopup(icao)
- this.map.removeLayer(this.layer)
- Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
- this.traceLayers = {}
- this.traceSegs = {}
+ 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 icao of Object.keys(this.popups)) this._closePopup(icao);
+ if (this.layer) this.map.removeLayer(this.layer);
+ Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l));
+
+ this.traceLayers = {};
+ this.traceSegs = {};
+ }
+
+ getClosest(latitude: number, longitude: number): any | null {
+ let closest: any = null;
+ let closestDistance = Infinity;
+
+ for (const plane of this.data) {
+ if (plane.latitude == null || plane.longitude == null) continue;
+
+ const distance = greatCircleDist(latitude, longitude, plane.latitude, plane.longitude);
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closest = plane;
+ }
+ }
+
+ return closest;
+ }
+
+ getAutoCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
+ return this.data.filter(plane => {
+ if (plane.latitude == null || plane.longitude == null) return false;
+ return greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) <= AUTO_RANGE;
+ }).sort((a, b) => {
+ const priority = Number(isPriorityAircraft(b)) - Number(isPriorityAircraft(a));
+ if (priority) return priority;
+
+ return greatCircleDist(latitude, longitude, a.latitude, a.longitude) - greatCircleDist(latitude, longitude, b.latitude, b.longitude);
+ }).slice(0, limit);
+ }
+
+ isPriority(plane: any): boolean {
+ return isPriorityAircraft(plane);
+ }
+
+ getAutoDistance(latitude: number, longitude: number, plane: any): number {
+ return greatCircleDist(latitude, longitude, plane.latitude, plane.longitude);
+ }
+
+ getById(icao: string): any | null {
+ return this.data.find(p => String(p.icao) === String(icao)) ?? null;
+ }
+
+ openAutoPopup(plane: any) {
+ this._openPopup(plane);
+ }
+
+ closeAutoPopup(icao: string) {
+ this._closePopup(String(icao));
}
private async _fetch() {
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
this.data = (j || []).map((a: any) => ({
...a,
- icao: a.hex,
- latitude: a.lat,
+ icao: a.hex,
+ latitude: a.lat,
longitude: a.lon,
- heading: a.track,
- speed: a.gs,
- climb: a.baro_rate ?? a.geom_rate ?? 0,
- name: a.flight?.trim(),
- }))
+ heading: a.track,
+ speed: a.gs,
+ climb: a.baro_rate ?? a.geom_rate ?? 0,
+ name: a.flight?.trim(),
+ }));
}
private _draw() {
- const source = this.layer.getSource()!
- source.clear()
+ const source = this.layer.getSource()!;
+ source.clear();
+
for (const plane of this.data) {
- if (plane.latitude == null) continue
- const coord = fromLonLat([plane.longitude, plane.latitude])
- const f = new Feature({ geometry: new Point(coord) })
- f.set('icao', plane.icao)
- f.set('planeData', plane)
+ if (plane.latitude == null || plane.longitude == null) continue;
+
+ const coord = fromLonLat([plane.longitude, plane.latitude]);
+ const f = new Feature({geometry: new Point(coord)});
+
+ f.set('icao', plane.icao);
+ f.set('planeData', plane);
f.setStyle(new Style({
image: new Icon({
- src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
- scale: 1.25,
+ src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
+ scale: 1.25,
rotation: (plane.heading ?? 0) * (Math.PI / 180),
- anchor: [0.5, 0.5],
+ anchor: [0.5, 0.5],
}),
- }))
- source.addFeature(f)
+ }));
+
+ source.addFeature(f);
}
}
private async _fetchTrace(plane: any) {
- const icao = plane.icao
- if (!this.historyCache[icao]) {
- const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
- this.historyCache[icao] = j.history || []
- }
- const history = this.historyCache[icao]
- const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
- const last = history[history.length - 1]
- if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
- if (history.length < 2) return
+ const icao = plane.icao;
- let traceLayer = this.traceLayers[icao]
- let vs: VectorSource
+ if (!this.historyCache[icao]) {
+ const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json());
+ this.historyCache[icao] = j.history || [];
+ }
+
+ const history = this.historyCache[icao];
+ const cur = {
+ latitude: plane.latitude,
+ longitude: plane.longitude,
+ altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
+ };
+
+ const last = history[history.length - 1];
+ if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
+ if (history.length < 2) return;
+
+ let traceLayer = this.traceLayers[icao];
+ let vs: VectorSource;
if (!traceLayer) {
- vs = new VectorSource()
- traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
- this.map.addLayer(traceLayer)
- this.traceLayers[icao] = traceLayer
- this.traceSegs[icao] = []
+ vs = new VectorSource();
+ traceLayer = new VectorLayer({source: vs, zIndex: 99});
+ this.map.addLayer(traceLayer);
+ this.traceLayers[icao] = traceLayer;
+ this.traceSegs[icao] = [];
} else {
- vs = traceLayer.getSource()!
+ vs = traceLayer.getSource()!;
}
- // Only draw segments that haven't been drawn yet
- const segs = this.traceSegs[icao]
- const start = segs.length
- for (let i = start; i < history.length - 1; i++) {
- const s = history[i], e = history[i + 1]
- const sc = getAltColor(s.altitude || 0)
- const ec = getAltColor(e.altitude || 0)
- const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 2))
- const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
- f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
- vs.addFeature(f)
- segs.push(f)
- }
- }
+ const segs: any = this.traceSegs[icao];
- private _calcPopupPos(plane: any): { x: number; y: number } {
- const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
- 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 }
+ for (let i = segs.length; i < history.length - 1; i++) {
+ const s = history[i], e = history[i + 1];
+ const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0);
+ const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2));
+
+ const f = new Feature(new LineString([
+ fromLonLat([s.longitude, s.latitude]),
+ fromLonLat([e.longitude, e.latitude]),
+ ]));
+
+ f.setStyle(new Style({
+ stroke: new Stroke({
+ color: `rgba(${avg.join(',')},0.8)`,
+ width: 3,
+ }),
+ }));
+
+ vs.addFeature(f);
+ segs.push(f);
+ }
}
private _openPopup(plane: any) {
- const icao = plane.icao
- if (this.popups[icao]) return
+ const icao = String(plane.icao);
+ if (this.popups[icao]) return;
- const container = document.createElement('div')
- document.body.appendChild(container)
- const mobile = window.innerWidth <= 768
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const mobile = window.innerWidth <= 768;
const makeProps = (p: any) => ({
- plane: p,
- position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: 16, y: 16 },
- onClose: () => this._closePopup(icao),
+ plane: p,
+ position: mobile
+ ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
+ : {x: 16, y: 16},
+ onClose: () => this._closePopup(icao),
onBringToFront: () => bringToFront(container),
- })
+ });
- vueRender(h(AircraftPopup, makeProps(plane)), container)
+ vueRender(h(AircraftPopup, makeProps(plane)), container);
this.popups[icao] = {
- update: (p: any) => vueRender(h(AircraftPopup, makeProps(p)), container),
- unmount: () => { vueRender(null, container); container.remove() },
- }
- this._fetchTrace(plane)
+ update: (p: any) => vueRender(h(AircraftPopup, makeProps(p)), container),
+ unmount: () => {
+ vueRender(null, container);
+ container.remove();
+ },
+ };
+
+ this._fetchTrace(plane);
}
private _closePopup(icao: string) {
- const popup = this.popups[icao]
- if (popup) { popup.unmount(); delete this.popups[icao] }
- const segs = this.traceSegs[icao]
- const layer = this.traceLayers[icao]
- if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
- if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
- delete this.traceSegs[icao]
+ const popup = this.popups[icao];
+
+ if (popup) {
+ popup.unmount();
+ delete this.popups[icao];
+ }
+
+ const segs = this.traceSegs[icao], layer = this.traceLayers[icao];
+ if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f));
+
+ if (layer) {
+ this.map.removeLayer(layer);
+ delete this.traceLayers[icao];
+ }
+
+ delete this.traceSegs[icao];
}
private _refreshPopups() {
for (const icao of Object.keys(this.popups)) {
- const plane = this.data.find(p => p.icao === icao)
- if (!plane) { this._closePopup(icao); continue }
- this.popups[icao].update(plane)
- this._fetchTrace(plane)
+ const plane = this.data.find(p => p.icao === icao);
+
+ if (!plane) {
+ this._closePopup(icao);
+ continue;
+ }
+
+ this.popups[icao].update(plane);
+ this._fetchTrace(plane);
}
}
private _attachClick() {
- this.clickHandler = (evt) => {
- const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null)
- if (!f) return
- const plane = f.get('planeData')
- if (this.popups[plane.icao]) { this._closePopup(plane.icao); return }
- this._openPopup(plane)
- }
- this.map.on('singleclick', this.clickHandler)
+ this.clickHandler = evt => {
+ const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
+ if (!f) return;
+
+ const plane = f.get('planeData');
+ if (this.popups[plane.icao]) {
+ this._closePopup(plane.icao);
+ return;
+ }
+
+ this._openPopup(plane);
+ };
+
+ this.map.on('singleclick', this.clickHandler);
}
}
diff --git a/client/src/services/ais.ts b/client/src/services/ais.ts
index 2d9d842..1487420 100644
--- a/client/src/services/ais.ts
+++ b/client/src/services/ais.ts
@@ -1,31 +1,59 @@
-import { BASE } from '@/services/api.ts'
-import { h, render as vueRender } 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'
-import { bringToFront } from './zindex'
+import {BASE} from '@/services/api.ts';
+import {h, render as vueRender} 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';
+import {bringToFront} from './zindex';
-const API = BASE + '/api'
+const API = BASE + '/api';
+const EARTH_R = 6371;
+const AUTO_RANGE = 100;
const SHIP_COLORS: Record = {
- 'Base Station': '#ffffff',
- 'Class A': '#00ff00',
- 'Class B': '#8450ea',
- 'SAR Aircraft': '#ff0000',
- 'AtoN': '#ffffff',
- 'Class B/CS': '#8450ea',
+ 'Base Station': '#ffffff',
+ 'Class A': '#00ff00',
+ 'Class B': '#8450ea',
+ 'SAR Aircraft': '#ff0000',
+ 'AtoN': '#ffffff',
+ 'Class B/CS': '#8450ea',
'Sart/Epirb/MOB': '#00aaff',
- 'Unknown': '#fad106',
+ 'Unknown': '#fad106',
+};
+
+function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
+ const toRad = (d: number) => d * Math.PI / 180;
+ const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
+ return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+function isPriorityBoat(boat: any): boolean {
+ if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true;
+
+ const text = [
+ boat.type,
+ boat.vesselType,
+ boat.category,
+ boat.name,
+ boat.shipname,
+ boat.operator,
+ boat.owner,
+ boat.callsign,
+ boat.description,
+ ].filter(Boolean).join(' ').toLowerCase();
+
+ return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|rcaf)\b/.test(text);
}
function buildBoatIcon(boat: any): string {
- const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
+ const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'];
+
if (['Base Station', 'AtoN'].includes(boat.type)) {
return `data:image/svg+xml,${encodeURIComponent(`
- `)}`
+ `)}`;
}
+
return `data:image/svg+xml,${encodeURIComponent(`
- `)}`
+ `)}`;
}
export class AISLayer {
- private map: Map
- private layer!: VectorLayer
- private popups: any = {}
- private data: any[] = []
- private clickHandler: ((e: any) => void) | null = null
- private refreshTimer: any = null
- visible = false
+ private map: Map;
+ private layer!: VectorLayer;
+ 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 }
+ 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()
+ 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 () => {
try {
- await this._fetch()
- this._draw()
- this._refreshPopups()
+ await this._fetch();
+ this._draw();
+ this._refreshPopups();
} catch (err) {
console.error(err);
}
- }, 5_000)
+ }, 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)
+ 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);
+ if (this.layer) this.map.removeLayer(this.layer);
+ }
+
+ getClosest(latitude: number, longitude: number): any | null {
+ let closest: any = null;
+ let closestDistance = Infinity;
+
+ for (const boat of this.data) {
+ if (boat.lat == null || boat.lon == null) continue;
+
+ const distance = greatCircleDist(latitude, longitude, boat.lat, boat.lon);
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closest = boat;
+ }
+ }
+
+ return closest;
+ }
+
+ getAutoCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
+ return this.data.filter(boat => {
+ if (boat.lat == null || boat.lon == null) return false;
+ return greatCircleDist(latitude, longitude, boat.lat, boat.lon) <= AUTO_RANGE;
+ }).sort((a, b) => {
+ const priority = Number(isPriorityBoat(b)) - Number(isPriorityBoat(a));
+ if (priority) return priority;
+
+ return greatCircleDist(latitude, longitude, a.lat, a.lon) - greatCircleDist(latitude, longitude, b.lat, b.lon);
+ }).slice(0, limit);
+ }
+
+ isPriority(boat: any): boolean {
+ return isPriorityBoat(boat);
+ }
+
+ getAutoDistance(latitude: number, longitude: number, boat: any): number {
+ return greatCircleDist(latitude, longitude, boat.lat, boat.lon);
+ }
+
+ getById(mmsi: string): any | null {
+ return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null;
+ }
+
+ openAutoPopup(boat: any) {
+ this._openPopup(boat);
+ }
+
+ closeAutoPopup(mmsi: string) {
+ this._closePopup(String(mmsi));
}
private async _fetch() {
- this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
+ this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
}
private _draw() {
- const source = this.layer.getSource()!
- source.clear()
+ 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)
+ 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,
+ src: buildBoatIcon(boat),
+ scale: 0.8,
rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
- anchor: [0.5, 0.5],
+ 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 }
+ source.addFeature(f);
+ }
}
private _openPopup(boat: any) {
- const mmsi = String(boat.mmsi)
- if (this.popups[mmsi]) return
+ const mmsi = String(boat.mmsi);
+ if (this.popups[mmsi]) return;
- const container = document.createElement('div')
- document.body.appendChild(container)
- const mobile = window.innerWidth <= 768
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const mobile = window.innerWidth <= 768;
const makeProps = (b: any) => ({
- boat: b,
- position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: 16, y: 16 },
- onClose: () => this._closePopup(mmsi),
+ boat: b,
+ position: mobile
+ ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
+ : {x: 16, y: 16},
+ onClose: () => this._closePopup(mmsi),
onBringToFront: () => bringToFront(container),
- })
+ });
- vueRender(h(ShipPopup, makeProps(boat)), container)
+ vueRender(h(ShipPopup, makeProps(boat)), container);
this.popups[mmsi] = {
- update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
- unmount: () => { vueRender(null, container); container.remove() },
- }
+ update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
+ unmount: () => {
+ vueRender(null, container);
+ container.remove();
+ },
+ };
}
private _closePopup(mmsi: string) {
- const popup = this.popups[mmsi]
- if (popup) { popup.unmount(); delete this.popups[mmsi] }
+ 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].update(boat)
+ const boat = this.data.find(b => String(b.mmsi) === mmsi);
+
+ if (!boat) {
+ this._closePopup(mmsi);
+ continue;
+ }
+
+ this.popups[mmsi].update(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)
+ 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);
}
}
diff --git a/client/src/services/sats.ts b/client/src/services/sats.ts
index 312993b..333bc33 100644
--- a/client/src/services/sats.ts
+++ b/client/src/services/sats.ts
@@ -1,238 +1,380 @@
import {api, BASE} from '@/services/api.ts';
-import { h, render as vueRender } from 'vue'
-import Map from 'ol/Map'
-import { Feature } from 'ol'
-import { LineString, Point, Circle as CircleGeom } from 'ol/geom'
-import { fromLonLat } from 'ol/proj'
-import { Style, Stroke, Icon, Fill } from 'ol/style'
-import { Vector as VectorLayer } from 'ol/layer'
-import { Vector as VectorSource } from 'ol/source'
-import { adjustedInterval } from '@ztimson/utils'
-import TinyGSPopup from '@/components/Satellite.vue'
-import { bringToFront } from './zindex'
+import {h, render as vueRender} from 'vue';
+import Map from 'ol/Map';
+import {Feature} from 'ol';
+import {LineString, Point, Circle as CircleGeom} from 'ol/geom';
+import {fromLonLat} from 'ol/proj';
+import {Style, Stroke, Icon, Fill} from 'ol/style';
+import {Vector as VectorLayer} from 'ol/layer';
+import {Vector as VectorSource} from 'ol/source';
+import {adjustedInterval} from '@ztimson/utils';
+import TinyGSPopup from '@/components/Satellite.vue';
+import {bringToFront} from './zindex';
-const API = BASE + '/api'
-const EARTH_R = 6371 // km
+const API = BASE + '/api';
+const EARTH_R = 6371;
+const AUTO_ELEVATION = 70;
interface TinyGSReading {
- timestamp: number
- freqMHz: number
- satellite: string
- latitude: number
- longitude: number
- az: number
- el: number
- packetRssi: number
- packetSnr: number
- freqError: number
- crcOk: boolean
+ timestamp: number;
+ freqMHz: number;
+ satellite: string;
+ latitude: number;
+ longitude: number;
+ az: number;
+ el: number;
+ packetRssi: number;
+ packetSnr: number;
+ freqError: number;
+ crcOk: boolean;
}
interface TinyGSSatellite extends TinyGSReading {
- history: TinyGSReading[]
+ history: TinyGSReading[];
}
interface RangeEstimate {
- altitude: number // km, derived from az/el geometry
- slantRange: number // km, straight-line distance to satellite
- horizonRadius: number // km, max ground radius satellite is visible from at this altitude
- groundDist: number // km, great-circle distance to subpoint
+ altitude: number;
+ slantRange: number;
+ horizonRadius: number;
+ groundDist: number;
}
function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
- const toRad = (d: number) => d * Math.PI / 180
- const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1)
- const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
- return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+ const toRad = (d: number) => d * Math.PI / 180;
+ const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
+ return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
-/** Derives altitude/slant range from ground station + satellite subpoint + observed elevation (no TLE required). */
function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null {
- const d = greatCircleDist(gsLat, gsLon, satLat, satLon)
- const gamma = d / EARTH_R
- const el = elDeg * Math.PI / 180
- const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma)
- if (k <= 0) return null // inconsistent geometry (noisy reading)
+ const d = greatCircleDist(gsLat, gsLon, satLat, satLon);
+ const gamma = d / EARTH_R;
+ const el = elDeg * Math.PI / 180;
+ const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma);
- const altitude = EARTH_R * (1 - k) / k
- const slantRange = Math.sqrt(EARTH_R ** 2 + (EARTH_R + altitude) ** 2 - 2 * EARTH_R * (EARTH_R + altitude) * Math.cos(gamma))
- const horizonRadius = Math.sqrt(2 * EARTH_R * altitude + altitude ** 2)
- return { altitude, slantRange, horizonRadius, groundDist: d }
+ if (k <= 0) return null;
+
+ const altitude = EARTH_R * (1 - k) / k;
+ const slantRange = Math.sqrt(EARTH_R ** 2 + (EARTH_R + altitude) ** 2 - 2 * EARTH_R * (EARTH_R + altitude) * Math.cos(gamma));
+ const horizonRadius = Math.sqrt(2 * EARTH_R * altitude + altitude ** 2);
+
+ return {altitude, slantRange, horizonRadius, groundDist: d};
}
-/** Linear regression on recent elevation history to project time until satellite sets below horizon (el = 0). */
function estimateEtaOut(history: TinyGSReading[]): number | null {
- const pts = history.slice(-6)
- if (pts.length < 2) return null
+ const pts: any = history.slice(-6);
+ if (pts.length < 2) return null;
- const t0 = pts[0].timestamp
- const xs = pts.map(p => (p.timestamp - t0) / 1000)
- const ys = pts.map(p => p.el)
- const n = xs.length
- const sumX = xs.reduce((a, b) => a + b, 0), sumY = ys.reduce((a, b) => a + b, 0)
- const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0), sumXX = xs.reduce((a, x) => a + x * x, 0)
- const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX)
- if (!isFinite(slope) || slope >= 0) return null // rising or flat, can't predict setting
+ const t0 = pts[0].timestamp;
+ const xs = pts.map(p => (p.timestamp - t0) / 1000);
+ const ys = pts.map(p => p.el);
+ const n = xs.length;
+ const sumX = xs.reduce((a, b) => a + b, 0);
+ const sumY = ys.reduce((a, b) => a + b, 0);
+ const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
+ const sumXX = xs.reduce((a, x) => a + x * x, 0);
+ const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
- const lastEl = ys[ys.length - 1], lastX = xs[xs.length - 1]
- const secsFromNow = (lastX - lastEl / slope) - lastX
- return secsFromNow > 0 ? secsFromNow * 1000 : null
+ if (!isFinite(slope) || slope >= 0) return null;
+
+ const lastEl: any = ys[ys.length - 1], lastX: any = xs[xs.length - 1];
+ const secsFromNow = (lastX - lastEl / slope) - lastX;
+
+ return secsFromNow > 0 ? secsFromNow * 1000 : null;
}
export class SatsLayer {
- private map: Map
- private layer!: VectorLayer
- private popups: any = {}
- private receptionCircles: Record> = {}
- private data: TinyGSSatellite[] = []
- private clickHandler: ((e: any) => void) | null = null
- private refreshTimer: any = null
- visible = false
- gs;
+ private map: Map;
+ private layer!: VectorLayer;
+ private popups: any = {};
+ private receptionCircles: Record> = {};
+ private data: TinyGSSatellite[] = [];
+ private clickHandler: ((e: any) => void) | null = null;
+ private refreshTimer: any = null;
+ visible = false;
+ gs: any;
constructor(map: Map) {
- this.map = map
+ this.map = map;
api.position().then(gs => this.gs = gs);
}
async show() {
- if (this.visible) return
- this.visible = true
- this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 105 })
- this.map.addLayer(this.layer)
- await this._fetch()
- this._draw()
- this._attachClick()
+ if (this.visible) return;
+
+ this.visible = true;
+ this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
+ this.map.addLayer(this.layer);
+
+ await this._fetch();
+ this._draw();
+ this._attachClick();
+
this.refreshTimer = adjustedInterval(async () => {
try {
- await this._fetch()
- this._draw()
- this._refreshPopups()
+ await this._fetch();
+ this._draw();
+ this._refreshPopups();
} catch (err) {
- console.error(err)
+ console.error(err);
}
- }, 5_000)
+ }, 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 name of Object.keys(this.popups)) this._closePopup(name)
- this.map.removeLayer(this.layer)
+ 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 name of Object.keys(this.popups)) this._closePopup(name);
+ if (this.layer) this.map.removeLayer(this.layer);
+ }
+
+ getClosest(lat: number, lng: number): TinyGSSatellite | null {
+ if (!this.data.length) return null;
+
+ let closest: TinyGSSatellite | null = null;
+ let closestDistance = Infinity;
+
+ for (const sat of this.data) {
+ if (sat.latitude == null || sat.longitude == null) continue;
+
+ const range = this._calcRange(sat);
+
+ if (range && range.slantRange < closestDistance) {
+ closestDistance = range.slantRange;
+ closest = sat;
+ }
+ }
+
+ if (closest || !this.gs) return closest;
+
+ closestDistance = Infinity;
+
+ for (const sat of this.data) {
+ const distance = greatCircleDist(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude);
+
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closest = sat;
+ }
+ }
+
+ return closest;
+ }
+
+ getAutoCandidates(lat: number, lng: number, limit = Infinity): TinyGSSatellite[] {
+ return this.data.filter(sat => {
+ if (sat.latitude == null || sat.longitude == null) return false;
+ return sat.el >= AUTO_ELEVATION;
+ }).sort((a, b) => {
+ const ar = this._calcRange(a), br = this._calcRange(b);
+ const ad = ar?.slantRange ?? greatCircleDist(lat, lng, a.latitude, a.longitude);
+ const bd = br?.slantRange ?? greatCircleDist(lat, lng, b.latitude, b.longitude);
+
+ return ad - bd;
+ }).slice(0, limit);
+ }
+
+ isPriority(sat: TinyGSSatellite): boolean {
+ return false;
+ }
+
+ getAutoDistance(lat: number, lng: number, sat: TinyGSSatellite): number {
+ return this._calcRange(sat)?.slantRange ?? greatCircleDist(lat, lng, sat.latitude, sat.longitude);
+ }
+
+ getRange(sat: TinyGSSatellite): number | null {
+ return this._calcRange(sat)?.slantRange ?? null;
+ }
+
+ getById(name: string): TinyGSSatellite | null {
+ return this.data.find(s => s.satellite === name) ?? null;
+ }
+
+ openAutoPopup(sat: TinyGSSatellite) {
+ this._openPopup(sat);
+ }
+
+ closeAutoPopup(name: string) {
+ this._closePopup(name);
}
private async _fetch() {
- this.data = await fetch(`${API}/sats`).then(r => r.json()) || []
+ this.data = await fetch(`${API}/sats`).then(r => r.json()) || [];
}
private _draw() {
- const source = this.layer.getSource()!
- source.clear()
+ const source = this.layer.getSource()!;
+ source.clear();
for (const sat of this.data) {
- if (sat.latitude == null || sat.longitude == null) continue
+ if (sat.latitude == null || sat.longitude == null) continue;
+
+ const points: any = [...sat.history, sat].map(r => fromLonLat([r.longitude, r.latitude]));
- const points = [...sat.history, sat].map(r => fromLonLat([r.longitude, r.latitude]))
if (points.length > 1) {
- const trail = new Feature({ geometry: new LineString(points) })
- trail.setStyle(new Style({ stroke: new Stroke({ color: 'rgba(212,164,255,0.5)', width: 2, lineDash: [6, 4] }) }))
- source.addFeature(trail)
+ const trail = new Feature({geometry: new LineString(points)});
+ trail.setStyle(new Style({
+ stroke: new Stroke({
+ color: 'rgba(212,164,255,0.5)',
+ width: 2,
+ lineDash: [6, 4],
+ }),
+ }));
+
+ source.addFeature(trail);
}
- const marker = new Feature({ geometry: new Point(points[points.length - 1]) })
- marker.set('satellite', sat.satellite)
- marker.set('satData', sat)
+ const marker = new Feature({geometry: new Point(points[points.length - 1])});
+ marker.set('satellite', sat.satellite);
+ marker.set('satData', sat);
marker.setStyle(new Style({
image: new Icon({
src: '/satellite.png',
scale: 0.1,
anchor: [0.5, 0.5],
}),
- }))
- source.addFeature(marker)
+ }));
+
+ source.addFeature(marker);
}
}
private _calcRange(sat: TinyGSSatellite): RangeEstimate | null {
- if (!this.gs) return null
- return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el)
+ if (!this.gs) return null;
+ return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el);
}
private _drawReceptionCircle(sat: TinyGSSatellite, radiusKm?: number | null) {
- this._removeReceptionCircle(sat.satellite)
- if (!radiusKm) return
- const feature = new Feature({ geometry: new CircleGeom(fromLonLat([sat.longitude, sat.latitude]), radiusKm * 1000) })
+ this._removeReceptionCircle(sat.satellite);
+ if (!radiusKm) return;
+
+ const feature = new Feature({
+ geometry: new CircleGeom(fromLonLat([sat.longitude, sat.latitude]), radiusKm * 1000),
+ });
+
feature.setStyle(new Style({
- stroke: new Stroke({ color: 'rgba(212,164,255,0.6)', width: 2 }),
- fill: new Fill({ color: 'rgba(212,164,255,0.12)' }),
- }))
- const layer = new VectorLayer({ source: new VectorSource({ features: [feature] }), zIndex: 104 })
- this.map.addLayer(layer)
- this.receptionCircles[sat.satellite] = layer
+ stroke: new Stroke({
+ color: 'rgba(212,164,255,0.6)',
+ width: 2,
+ }),
+ fill: new Fill({color: 'rgba(212,164,255,0.12)'}),
+ }));
+
+ const layer = new VectorLayer({
+ source: new VectorSource({features: [feature]}),
+ zIndex: 104,
+ });
+
+ this.map.addLayer(layer);
+ this.receptionCircles[sat.satellite] = layer;
}
private _removeReceptionCircle(name: string) {
- const l = this.receptionCircles[name]
- if (l) { this.map.removeLayer(l); delete this.receptionCircles[name] }
- }
+ const layer = this.receptionCircles[name];
- private _openPopup(sat: TinyGSSatellite) {
- if (this.popups[sat.satellite]) return
-
- const history = [...sat.history, sat]
- const range = this._calcRange(sat)
- const eta = estimateEtaOut(history)
- this._drawReceptionCircle(sat, range?.horizonRadius)
-
- const container = document.createElement('div')
- document.body.appendChild(container)
- const mobile = window.innerWidth <= 768
-
- const makeProps = (h_: any, r: any, e: any) => ({
- history: h_,
- range: r,
- eta: e,
- position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: 16, y: 16 },
- onClose: () => this._closePopup(sat.satellite),
- onBringToFront: () => bringToFront(container),
- })
-
- vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container)
-
- this.popups[sat.satellite] = {
- container,
- update: (h_: any, r: any, e: any) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container),
- unmount: () => { vueRender(null, container); container.remove() },
+ if (layer) {
+ this.map.removeLayer(layer);
+ delete this.receptionCircles[name];
}
}
+ private _openPopup(sat: TinyGSSatellite) {
+ if (this.popups[sat.satellite]) return;
+
+ const history = [...sat.history, sat];
+ const range = this._calcRange(sat);
+ const eta = estimateEtaOut(history);
+
+ this._drawReceptionCircle(sat, range?.horizonRadius);
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const mobile = window.innerWidth <= 768;
+ const makeProps = (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => ({
+ history: h_,
+ range: r,
+ eta: e,
+ position: mobile
+ ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
+ : {x: 16, y: 16},
+ onClose: () => this._closePopup(sat.satellite),
+ onBringToFront: () => bringToFront(container),
+ });
+
+ vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container);
+
+ this.popups[sat.satellite] = {
+ container,
+ update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container),
+ unmount: () => {
+ vueRender(null, container);
+ container.remove();
+ },
+ };
+ }
+
private _closePopup(name: string) {
- const popup = this.popups[name]
- if (popup) { popup.unmount(); delete this.popups[name] }
- this._removeReceptionCircle(name)
+ const popup = this.popups[name];
+
+ if (popup) {
+ popup.unmount();
+ delete this.popups[name];
+ }
+
+ this._removeReceptionCircle(name);
}
private _refreshPopups() {
for (const name of Object.keys(this.popups)) {
- const sat = this.data.find(s => s.satellite === name)
- if (!sat) { this._closePopup(name); continue }
- const history = [...sat.history, sat]
- const range = this._calcRange(sat)
- this.popups[name].update(history, range, estimateEtaOut(history))
- this._drawReceptionCircle(sat, range?.horizonRadius)
+ const sat = this.data.find(s => s.satellite === name);
+
+ if (!sat) {
+ this._closePopup(name);
+ continue;
+ }
+
+ const history = [...sat.history, sat];
+ const range = this._calcRange(sat);
+
+ this.popups[name].update(
+ history,
+ range,
+ estimateEtaOut(history),
+ );
+
+ this._drawReceptionCircle(sat, range?.horizonRadius);
}
}
private _attachClick() {
- this.clickHandler = (evt) => {
- const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null)
- if (!f) return
- const sat = f.get('satData') as TinyGSSatellite
- if (this.popups[sat.satellite]) { this._closePopup(sat.satellite); return }
- this._openPopup(sat)
- }
- this.map.on('singleclick', this.clickHandler)
+ this.clickHandler = evt => {
+ const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null);
+ if (!f) return;
+
+ const sat = f.get('satData') as TinyGSSatellite;
+
+ if (this.popups[sat.satellite]) {
+ this._closePopup(sat.satellite);
+ return;
+ }
+
+ this._openPopup(sat);
+ };
+
+ this.map.on('singleclick', this.clickHandler);
}
}
diff --git a/server/src/radiosonde.mjs b/server/src/radiosonde.mjs
new file mode 100644
index 0000000..7e8fa99
--- /dev/null
+++ b/server/src/radiosonde.mjs
@@ -0,0 +1,54 @@
+import {cfg} from './config.mjs';
+
+let sondeCache = null;
+let sondeCacheTs = 0;
+const SONDE_TTL = 1000;
+
+export async function getSondes() {
+ if (sondeCache && Date.now() - sondeCacheTs < SONDE_TTL) return sondeCache;
+ const {ADSB_URL} = cfg();
+ if (!ADSB_URL) return {data: []};
+
+ const r = await fetch(`${ADSB_URL}:9989/get_telemetry_archive`);
+ const data = await r.json();
+
+ sondeCache = Object.entries(data).map(([id, sonde]) => {
+ const t = sonde.latest_telem ?? {};
+ const path = sonde.path ?? [];
+
+ return {
+ id,
+ type: t.type,
+ subtype: t.subtype,
+ lat: t.lat,
+ lon: t.lon,
+ altitude: t.alt,
+ heading: t.heading,
+ speed: t.vel_h,
+ vertical_speed: t.vel_v,
+ sats: t.sats,
+ timestamp: t.datetime ? Date.parse(t.datetime) : sonde.timestamp * 1000,
+ datetime: t.datetime,
+ frame: t.frame,
+ battery: t.batt,
+ temperature: t.temp,
+ humidity: t.humidity,
+ pressure: t.pressure,
+ frequency: t.freq_float,
+ frequency_hz: t.tx_frequency,
+ snr: t.snr,
+ ppm: t.ppm,
+ aprsid: t.aprsid?.trim(),
+ rs41_mainboard: t.rs41_mainboard,
+ rs41_mainboard_fw: t.rs41_mainboard_fw,
+ version: t.version,
+ ref_datetime: t.ref_datetime,
+ ref_position: t.ref_position,
+ sdr_device_idx: t.sdr_device_idx,
+ path
+ };
+ });
+
+ sondeCacheTs = Date.now();
+ return sondeCache;
+}
diff --git a/server/src/server.mjs b/server/src/server.mjs
index ab4a6bf..b5d8805 100644
--- a/server/src/server.mjs
+++ b/server/src/server.mjs
@@ -15,6 +15,7 @@ import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
import {getTinyGSData} from './sats.mjs';
import {getSpaceWeather} from './space.mjs';
import {Aurora} from './aurora.mjs';
+import {getSondes} from './radiosonde.mjs';
// ── Uncaught error handlers ───────────────────────────────────────────────────
@@ -152,7 +153,7 @@ app.get('/api/space', async (req, res) => {
res.json(filterFields(await getSpaceWeather(), fields));
});
-// ── ADSB/AIS/SAT Proxy ────────────────────────────────────────────────────────────
+// ── ADSB/AIS/SAT/RadioSonde Proxy ────────────────────────────────────────────────────────────
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
app.get('/api/adsb/:icao/image', asyncHandler(async (req, res) => {
@@ -167,6 +168,8 @@ app.get('/api/ais/:mmsi/image', asyncHandler(async (req, res) => {
res.contentType('image/jpeg').send(buffer)
}));
+app.get('/api/sondes', asyncHandler(async (req, res) => res.json(await getSondes())));
+
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
// ── DOCS ──────────────────────────────────────────────────────────────────────