Better AutoMode
This commit is contained in:
+103
-149
@@ -1,4 +1,5 @@
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import {greatCircleDist} from '@/services/units.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
@@ -8,21 +9,13 @@ 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 {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||
import AircraftPopup from '@/components/Aircraft.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
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;
|
||||
|
||||
@@ -75,112 +68,22 @@ export class AirTrafficLayer {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (this.visible) return;
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
||||
this.map.addLayer(this.layer);
|
||||
const plane = f.get('planeData');
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (this.popups[plane.icao]) {
|
||||
this.closePopup(plane.icao);
|
||||
return;
|
||||
}
|
||||
}, 1_000);
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return;
|
||||
this.openPopup(plane);
|
||||
};
|
||||
|
||||
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,
|
||||
longitude: a.lon,
|
||||
heading: a.track,
|
||||
speed: a.gs,
|
||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||
name: a.flight?.trim(),
|
||||
}));
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
@@ -208,6 +111,21 @@ export class AirTrafficLayer {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
longitude: a.lon,
|
||||
heading: a.track,
|
||||
speed: a.gs,
|
||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||
name: a.flight?.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
private async _fetchTrace(plane: any) {
|
||||
const icao = plane.icao;
|
||||
|
||||
@@ -229,6 +147,7 @@ export class AirTrafficLayer {
|
||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
|
||||
|
||||
const traceHistory = history;
|
||||
|
||||
if (traceHistory.length < 2) {
|
||||
const popup = this.popups[icao];
|
||||
if (popup) popup.update(plane, history);
|
||||
@@ -275,9 +194,76 @@ export class AirTrafficLayer {
|
||||
if (popup) popup.update(plane, history);
|
||||
}
|
||||
|
||||
private _openPopup(plane: any) {
|
||||
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._fetchTrace(plane);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if(this.layer) this.map.removeLayer(this.layer);
|
||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l));
|
||||
this.traceLayers = {};
|
||||
this.traceSegs = {};
|
||||
}
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 1_000);
|
||||
}
|
||||
|
||||
getById(icao: string): any | null {
|
||||
return this.data.find(p => String(p.icao) === String(icao)) ?? null;
|
||||
}
|
||||
|
||||
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
|
||||
function score(plane: any): number {
|
||||
const ratio = Math.min(greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) / AUTO_RANGE, 1);
|
||||
return isPriorityAircraft(plane) ? 2 - ratio : 1 - ratio;
|
||||
}
|
||||
|
||||
return this.data.map(plane => ({...plane, score: score(plane)})).toSorted(sortByProp('score', true));
|
||||
}
|
||||
|
||||
openPopup(plane: any) {
|
||||
const icao = String(plane.icao);
|
||||
if (this.popups[icao]) return;
|
||||
if(this.popups[icao]) return;
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
@@ -289,7 +275,7 @@ export class AirTrafficLayer {
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this._closePopup(icao),
|
||||
onClose: () => this.closePopup(icao),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
@@ -306,52 +292,20 @@ export class AirTrafficLayer {
|
||||
this._fetchTrace(plane);
|
||||
}
|
||||
|
||||
private _closePopup(icao: string) {
|
||||
closePopup(icao: string) {
|
||||
const popup = this.popups[icao];
|
||||
|
||||
if (popup) {
|
||||
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) {
|
||||
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._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);
|
||||
}
|
||||
}
|
||||
|
||||
+86
-132
@@ -1,4 +1,5 @@
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import {greatCircleDist} from '@/services/units.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
@@ -7,12 +8,11 @@ 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 {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||
import ShipPopup from '@/components/Ship.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
const API = BASE + '/api';
|
||||
const EARTH_R = 6371;
|
||||
const AUTO_RANGE = 100;
|
||||
|
||||
const SHIP_COLORS: Record<string, string> = {
|
||||
@@ -26,13 +26,6 @@ const SHIP_COLORS: Record<string, string> = {
|
||||
'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;
|
||||
|
||||
@@ -90,98 +83,22 @@ export class AISLayer {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (this.visible) return;
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
||||
this.map.addLayer(this.layer);
|
||||
const boat = f.get('boatData');
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (this.popups[boat.mmsi]) {
|
||||
this.closePopup(boat.mmsi);
|
||||
return;
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return;
|
||||
this.openPopup(boat);
|
||||
};
|
||||
|
||||
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.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
@@ -209,7 +126,77 @@ export class AISLayer {
|
||||
}
|
||||
}
|
||||
|
||||
private _openPopup(boat: any) {
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (this.layer) this.map.removeLayer(this.layer);
|
||||
}
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
getById(mmsi: string): any | null {
|
||||
return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null;
|
||||
}
|
||||
|
||||
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
|
||||
function score(boat: any): number {
|
||||
const ratio = Math.min(greatCircleDist(latitude, longitude, boat.lat, boat.lon) / AUTO_RANGE, 1);
|
||||
return isPriorityBoat(boat) ? 2 - ratio : 1 - ratio;
|
||||
}
|
||||
|
||||
return this.data.filter(b => b.lat != null && b.lon != null)
|
||||
.map(boat => ({...boat, score: score(boat)}))
|
||||
.toSorted(sortByProp('score', true));
|
||||
}
|
||||
|
||||
openPopup(boat: any) {
|
||||
const mmsi = String(boat.mmsi);
|
||||
if (this.popups[mmsi]) return;
|
||||
|
||||
@@ -222,7 +209,7 @@ export class AISLayer {
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this._closePopup(mmsi),
|
||||
onClose: () => this.closePopup(mmsi),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
@@ -237,44 +224,11 @@ export class AISLayer {
|
||||
};
|
||||
}
|
||||
|
||||
private _closePopup(mmsi: string) {
|
||||
closePopup(mmsi: string) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+95
-155
@@ -1,4 +1,5 @@
|
||||
import {api, BASE} from '@/services/api.ts';
|
||||
import {greatCircleDist} from '@/services/units.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
@@ -7,13 +8,12 @@ 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 {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||
import Satellite from '@/components/Satellite.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
const API = BASE + '/api';
|
||||
const EARTH_R = 6371;
|
||||
const AUTO_ELEVATION = 70;
|
||||
|
||||
interface TinyGSReading {
|
||||
id: number;
|
||||
@@ -41,13 +41,6 @@ interface RangeEstimate {
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -101,119 +94,18 @@ export class SatsLayer {
|
||||
api.position().then(gs => this.gs = gs);
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (this.visible) return;
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
||||
this.map.addLayer(this.layer);
|
||||
const sat = f.get('satData') as TinyGSSatellite;
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
if (this.popups[sat.id]) return this.closePopup(sat.id);
|
||||
this.openPopup(sat);
|
||||
};
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 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).map(Number)) 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(id: number): TinyGSSatellite | null {
|
||||
return this.data.find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
openAutoPopup(sat: TinyGSSatellite) {
|
||||
this._openPopup(sat);
|
||||
}
|
||||
|
||||
closeAutoPopup(id: number) {
|
||||
this._closePopup(id);
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/sats`).then(r => r.json()) || [];
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
@@ -253,6 +145,10 @@ export class SatsLayer {
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/sats`).then(r => r.json()) || [];
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -285,14 +181,85 @@ export class SatsLayer {
|
||||
|
||||
private _removeReceptionCircle(id: number) {
|
||||
const layer = this.receptionCircles[id];
|
||||
|
||||
if (layer) {
|
||||
this.map.removeLayer(layer);
|
||||
delete this.receptionCircles[id];
|
||||
}
|
||||
}
|
||||
|
||||
private _openPopup(sat: TinyGSSatellite) {
|
||||
private _refreshPopups() {
|
||||
for (const id of Object.keys(this.popups).map(Number)) {
|
||||
const sat = this.data.find(s => s.id === id);
|
||||
if (!sat) {
|
||||
this.closePopup(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const history = [...sat.history, sat];
|
||||
const range = this._calcRange(sat);
|
||||
|
||||
this.popups[id].update(history, range, estimateEtaOut(history));
|
||||
this._drawReceptionCircle(sat, range?.horizonRadius);
|
||||
}
|
||||
}
|
||||
|
||||
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 id of Object.keys(this.popups).map(Number)) this.closePopup(id);
|
||||
if (this.layer) this.map.removeLayer(this.layer);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
getById(id: number): TinyGSSatellite | null {
|
||||
return this.data.find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
getCandidates(latitude: number, longitude: number, limit = Infinity): TinyGSSatellite[] {
|
||||
const dist = (sat: TinyGSSatellite) => this._calcRange(sat)?.slantRange ?? greatCircleDist(latitude, longitude, sat.latitude, sat.longitude);
|
||||
|
||||
return this.data.filter(s => s.latitude != null && s.longitude != null)
|
||||
.map(sat => ({...sat, score: -dist(sat)}))
|
||||
.toSorted(sortByProp('score', true));
|
||||
}
|
||||
|
||||
getRange(sat: TinyGSSatellite): number | null {
|
||||
return this._calcRange(sat)?.slantRange ?? null;
|
||||
}
|
||||
|
||||
openPopup(sat: TinyGSSatellite) {
|
||||
if (this.popups[sat.id]) return;
|
||||
|
||||
const history = [...sat.history, sat];
|
||||
@@ -312,15 +279,14 @@ export class SatsLayer {
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this._closePopup(sat.id),
|
||||
onClose: () => this.closePopup(sat.id),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container);
|
||||
vueRender(h(Satellite, makeProps(history, range, eta)), container);
|
||||
|
||||
this.popups[sat.id] = {
|
||||
container,
|
||||
update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container),
|
||||
update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(Satellite, makeProps(h_, r, e)), container),
|
||||
unmount: () => {
|
||||
vueRender(null, container);
|
||||
container.remove();
|
||||
@@ -328,39 +294,13 @@ export class SatsLayer {
|
||||
};
|
||||
}
|
||||
|
||||
private _closePopup(id: number) {
|
||||
closePopup(id: number) {
|
||||
const popup = this.popups[id];
|
||||
if(popup) {
|
||||
if (popup) {
|
||||
popup.unmount();
|
||||
delete this.popups[id];
|
||||
}
|
||||
|
||||
this._removeReceptionCircle(id);
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const name of Object.keys(this.popups).map(Number)) {
|
||||
const sat = this.data.find(s => s.id == 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.id]) return this._closePopup(sat.id);
|
||||
this._openPopup(sat);
|
||||
};
|
||||
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
+80
-134
@@ -1,4 +1,5 @@
|
||||
import {api, BASE} from '@/services/api.ts';
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import {greatCircleDist} from '@/services/units.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
@@ -7,7 +8,7 @@ import {fromLonLat} from 'ol/proj';
|
||||
import {Style, Stroke, Icon} from 'ol/style';
|
||||
import {Vector as VectorLayer} from 'ol/layer';
|
||||
import {Vector as VectorSource} from 'ol/source';
|
||||
import {adjustedInterval} from '@ztimson/utils';
|
||||
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||
import SondePopup from '@/components/WeatherBalloon.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
@@ -52,99 +53,22 @@ export class SondesLayer {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (this.visible) return;
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
||||
this.map.addLayer(this.layer);
|
||||
const sonde = f.get('sondeData') as Sonde;
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (this.popups[sonde.id]) {
|
||||
this.closePopup(sonde.id);
|
||||
return;
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return;
|
||||
this.openPopup(sonde);
|
||||
};
|
||||
|
||||
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 id of Object.keys(this.popups)) this._closePopup(id);
|
||||
if (this.layer) this.map.removeLayer(this.layer);
|
||||
}
|
||||
|
||||
getClosest(lat: number, lng: number): Sonde | null {
|
||||
if (!this.data.length) return null;
|
||||
|
||||
let closest: Sonde | null = null;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (const sonde of this.data) {
|
||||
if (sonde.latitude == null || sonde.longitude == null) continue;
|
||||
|
||||
const distance = this._distance(lat, lng, sonde.latitude, sonde.longitude);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closest = sonde;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
getAutoCandidates(lat: number, lng: number, limit = Infinity): Sonde[] {
|
||||
return this.data.filter(sonde => {
|
||||
return sonde.latitude != null && sonde.longitude != null;
|
||||
}).sort((a, b) => {
|
||||
const ad = this._distance(lat, lng, a.latitude, a.longitude);
|
||||
const bd = this._distance(lat, lng, b.latitude, b.longitude);
|
||||
return ad - bd;
|
||||
}).slice(0, limit);
|
||||
}
|
||||
|
||||
isPriority(sonde: Sonde): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getAutoDistance(lat: number, lng: number, sonde: Sonde): number {
|
||||
return this._distance(lat, lng, sonde.latitude, sonde.longitude);
|
||||
}
|
||||
|
||||
getById(id: string): Sonde | null {
|
||||
return this.data.find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
openAutoPopup(sonde: Sonde) {
|
||||
this._openPopup(sonde);
|
||||
}
|
||||
|
||||
closeAutoPopup(id: string) {
|
||||
this._closePopup(id);
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/sondes`).then(r => r.json()) || [];
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
@@ -203,17 +127,72 @@ export class SondesLayer {
|
||||
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
private _distance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (d: number) => d * Math.PI / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/sondes`).then(r => r.json()) || [];
|
||||
}
|
||||
|
||||
private _openPopup(sonde: Sonde) {
|
||||
private _refreshPopups() {
|
||||
for (const id of Object.keys(this.popups)) {
|
||||
const sonde = this.data.find(s => s.id === id);
|
||||
if (!sonde) {
|
||||
this.closePopup(id);
|
||||
continue;
|
||||
}
|
||||
this.popups[id].update([...sonde.history, sonde]);
|
||||
}
|
||||
}
|
||||
|
||||
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 id of Object.keys(this.popups)) this.closePopup(id);
|
||||
if (this.layer) this.map.removeLayer(this.layer);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
getById(id: string): Sonde | null {
|
||||
return this.data.find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
getCandidates(latitude: number, longitude: number, limit = Infinity): Sonde[] {
|
||||
return this.data.filter(s => s.latitude != null && s.longitude != null)
|
||||
.map(sonde => ({...sonde, score: -greatCircleDist(latitude, longitude, sonde.latitude, sonde.longitude)}))
|
||||
.toSorted(sortByProp('score', true));
|
||||
}
|
||||
|
||||
openPopup(sonde: Sonde) {
|
||||
if (this.popups[sonde.id]) return;
|
||||
|
||||
const history = [...sonde.history, sonde];
|
||||
@@ -227,14 +206,13 @@ export class SondesLayer {
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this._closePopup(sonde.id),
|
||||
onClose: () => this.closePopup(sonde.id),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(SondePopup, makeProps(history)), container);
|
||||
|
||||
this.popups[sonde.id] = {
|
||||
container,
|
||||
update: (h_: SondeReading[]) => vueRender(h(SondePopup, makeProps(h_)), container),
|
||||
unmount: () => {
|
||||
vueRender(null, container);
|
||||
@@ -243,43 +221,11 @@ export class SondesLayer {
|
||||
};
|
||||
}
|
||||
|
||||
private _closePopup(id: string) {
|
||||
closePopup(id: string) {
|
||||
const popup = this.popups[id];
|
||||
|
||||
if (popup) {
|
||||
popup.unmount();
|
||||
delete this.popups[id];
|
||||
}
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const id of Object.keys(this.popups)) {
|
||||
const sonde = this.data.find(s => s.id === id);
|
||||
|
||||
if (!sonde) {
|
||||
this._closePopup(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.popups[id].update([...sonde.history, sonde]);
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
const sonde = f.get('sondeData') as Sonde;
|
||||
|
||||
if (this.popups[sonde.id]) {
|
||||
this._closePopup(sonde.id);
|
||||
return;
|
||||
}
|
||||
|
||||
this._openPopup(sonde);
|
||||
};
|
||||
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,14 @@ export const METRICS: Record<string, MetricMeta> = {
|
||||
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
||||
}
|
||||
|
||||
export function formatValue(key: string, value: number | string | null): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const meta = METRICS[key]
|
||||
if (!meta) return String(value)
|
||||
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
|
||||
return `${value}${meta.unit}`
|
||||
}
|
||||
|
||||
export function getPressureTrend(delta: number) {
|
||||
if (delta > 0.5) return 'Rising';
|
||||
if (delta < -0.5) return 'Falling';
|
||||
@@ -76,12 +84,12 @@ export function getUVLabel(uv: number) {
|
||||
return 'Extreme';
|
||||
}
|
||||
|
||||
export function formatValue(key: string, value: number | string | null): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const meta = METRICS[key]
|
||||
if (!meta) return String(value)
|
||||
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
|
||||
return `${value}${meta.unit}`
|
||||
const EARTH_R = 6371;
|
||||
export 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));
|
||||
}
|
||||
|
||||
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]
|
||||
|
||||
Reference in New Issue
Block a user