Better AutoMode

This commit is contained in:
2026-09-15 11:47:29 -04:00
parent 28478251cd
commit 4c7a7fd1f5
8 changed files with 530 additions and 933 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ const description = computed(() => props.plane.desc || [props.plane.manufacturer
const operator = computed(() => props.plane.operator || props.plane.owner || '')
const altitudeHistory = computed(() => (props.history || [])
.filter(p => !p.live && p.ts != null && p.altitude != null)
.filter(p => !p.live && p.ts != null)
.map(p => ({...p, altitude: altitude.value.convert(Number(p.altitude))})))
const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value)))
+147 -353
View File
@@ -63,19 +63,15 @@ const overlayLayers: {[key: string]: any} = {};
const MAP_STATE_KEY = 'situation-center-map-state';
const MAP_LAYERS_KEY = 'situation-center-map-layers';
const AUTO_MODE_KEY = 'situation-center-auto-mode';
const AUTO_INTERVAL_MS = 15_000;
const AUTO_CHECK_MS = 2_000;
const AUTO_DISTANCE_MARGIN = 0.75;
const AUTO_CATEGORIES = ['aircraft', 'marine', 'satellites'] as const;
const AUTO_CATEGORIES = ['sondes', 'aircraft', 'marine', 'satellites'] as const;
type AutoCategory = typeof AUTO_CATEGORIES[number];
interface AutoObject {
category: AutoCategory;
id: string;
discoveredAt: number;
priority: boolean;
score: number;
}
let map: Map;
@@ -92,10 +88,40 @@ let aurora: Aurora;
const autoMode = ref(false);
const autoCategory = ref<AutoCategory | null>(null);
const autoTarget = ref<any>(null);
let autoTargetKey: string | null = null;
let autoKnown: Record<string, AutoObject> = {};
let autoLastKeys = new Set<string>();
// Per-category lookup so auto-mode logic never branches on category directly.
const AUTO_CONFIG: Record<AutoCategory, {
layer: () => any;
id: (target: any) => string;
position: (target: any) => [number, number] | null;
zoom: number;
}> = {
sondes: {
layer: () => sondes,
id: t => String(t.id),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 8,
},
aircraft: {
layer: () => airTraffic,
id: t => String(t.icao),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 10,
},
marine: {
layer: () => aisLayer,
id: t => String(t.mmsi),
position: t => t.lat != null && t.lon != null ? [t.lon, t.lat] : null,
zoom: 12,
},
satellites: {
layer: () => sats,
id: t => String(t.id),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 6,
},
};
function loadMapState() {
try {
@@ -128,11 +154,7 @@ function saveMapState() {
const zoom = view.getZoom();
if (typeof zoom !== 'number') return;
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({
latitude,
longitude,
zoom,
}));
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({latitude, longitude, zoom}));
} catch {
// Ignore localStorage failures.
}
@@ -233,90 +255,54 @@ function buildRainLayer(url: string): TileLayer<XYZ> {
});
}
async function refreshRain() {
const url = await fetchRadarUrl();
if (!url) return;
if (overlayLayers['rain']) {
function removeRainLayer() {
if (!overlayLayers['rain']) return;
map.removeLayer(overlayLayers['rain']);
delete overlayLayers['rain'];
}
if (!activeOverlays.value.has('rain')) return;
async function refreshRain() {
const url = await fetchRadarUrl();
removeRainLayer();
if (!url || !activeOverlays.value.has('rain')) return;
const layer = buildRainLayer(url);
overlayLayers['rain'] = layer;
map.addLayer(layer);
}
function toggleOverlay(id: string) {
if (id === 'aircraft') {
if (activeOverlays.value.has('aircraft')) {
activeOverlays.value.delete('aircraft');
airTraffic.hide();
range.hide();
} else {
activeOverlays.value.add('aircraft');
airTraffic.show();
range.show();
}
} else if (id === 'marine') {
if (activeOverlays.value.has('marine')) {
activeOverlays.value.delete('marine');
aisLayer.hide();
} else {
activeOverlays.value.add('marine');
aisLayer.show();
}
} else if (id === 'satellites') {
if (activeOverlays.value.has('satellites')) {
activeOverlays.value.delete('satellites');
sats.hide();
} else {
activeOverlays.value.add('satellites');
sats.show();
}
} else if (id === 'sondes') {
if(activeOverlays.value.has('sondes')) {
activeOverlays.value.delete('sondes');
sondes.hide();
} else {
activeOverlays.value.add('sondes');
sondes.show();
}
} else if (id === 'aurora') {
if (activeOverlays.value.has('aurora')) {
activeOverlays.value.delete('aurora');
aurora.hide();
} else {
activeOverlays.value.add('aurora');
aurora.show();
}
} else if (id === 'wind') {
if (activeOverlays.value.has('wind')) {
activeOverlays.value.delete('wind');
hideWindOverlay();
} else {
activeOverlays.value.add('wind');
showWindOverlay();
}
} else if (id === 'rain') {
if (activeOverlays.value.has('rain')) {
activeOverlays.value.delete('rain');
// One handler per overlay id — toggleOverlay/restoreOverlayState just drive these.
const OVERLAY_HANDLERS: Record<string, {show: () => void; hide: () => void}> = {
aircraft: {show: () => { airTraffic.show(); range.show(); }, hide: () => { airTraffic.hide(); range.hide(); }},
marine: {show: () => aisLayer.show(), hide: () => aisLayer.hide()},
satellites: {show: () => sats.show(), hide: () => sats.hide()},
sondes: {show: () => sondes.show(), hide: () => sondes.hide()},
aurora: {show: () => aurora.show(), hide: () => aurora.hide()},
wind: {show: showWindOverlay, hide: hideWindOverlay},
rain: {show: refreshRain, hide: removeRainLayer},
};
if (overlayLayers['rain']) {
map.removeLayer(overlayLayers['rain']);
delete overlayLayers['rain'];
}
function toggleOverlay(id: string) {
const handler = OVERLAY_HANDLERS[id];
if (!handler) return;
if (activeOverlays.value.has(id)) {
activeOverlays.value.delete(id);
handler.hide();
} else {
activeOverlays.value.add('rain');
refreshRain();
}
activeOverlays.value.add(id);
handler.show();
}
saveLayers();
}
function restoreOverlayState() {
for (const id of Object.keys(OVERLAY_HANDLERS)) {
activeOverlays.value.has(id) ? (<any>OVERLAY_HANDLERS)[id].show() : (<any>OVERLAY_HANDLERS)[id].hide();
}
}
function toggleMenu(id: string) {
openMenu.value = openMenu.value === id ? null : id;
}
@@ -336,11 +322,7 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
const ring = new Feature(new CircleGeom(center, nm * NM));
ring.setStyle(new Style({
stroke: new Stroke({
color: ringStroke,
width: 1,
lineDash: [6, 4],
}),
stroke: new Stroke({color: ringStroke, width: 1, lineDash: [6, 4]}),
fill: new Fill({color: 'transparent'}),
}));
@@ -353,10 +335,7 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
image: new CircleStyle({
radius: 7,
fill: new Fill({color: dark ? '#ffffff' : '#000000'}),
stroke: new Stroke({
color: dark ? '#000000' : '#ffffff',
width: 2,
}),
stroke: new Stroke({color: dark ? '#000000' : '#ffffff', width: 2}),
}),
}));
@@ -365,95 +344,91 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
return new VectorLayer({source, zIndex: 20});
}
function restoreOverlayState() {
if (activeOverlays.value.has('aircraft')) {
airTraffic.show();
range.show();
} else {
airTraffic.hide();
range.hide();
function getAutoObjects(): AutoObject[] {
const objects: AutoObject[] = [];
for (const category of AUTO_CATEGORIES) {
if (!activeOverlays.value.has(category)) continue;
const config = AUTO_CONFIG[category];
const layer = config.layer();
if (!layer.visible) continue;
for (const target of layer.getCandidates(current.value.latitude, current.value.longitude)) {
objects.push({category, id: config.id(target), score: target.score});
}
}
if (activeOverlays.value.has('marine')) aisLayer.show();
else aisLayer.hide();
if (activeOverlays.value.has('satellites')) sats.show();
else sats.hide();
if (activeOverlays.value.has('sondes')) sondes.show();
else sondes.hide();
if (activeOverlays.value.has('aurora')) aurora.show();
else aurora.hide();
if (activeOverlays.value.has('wind')) showWindOverlay();
if (activeOverlays.value.has('rain')) refreshRain();
return objects;
}
function getAutoLayer(category: AutoCategory): any {
if (category === 'aircraft') return airTraffic;
if (category === 'marine') return aisLayer;
return sats;
}
function getAutoId(category: AutoCategory, target: any): string {
if (category === 'aircraft') return String(target.icao);
if (category === 'marine') return String(target.mmsi);
return String(target.satellite);
}
function getAutoTarget(category: AutoCategory, id: any) {
if (category === 'aircraft') return airTraffic.getById(id);
if (category === 'marine') return aisLayer.getById(id);
return sats.getById(id);
}
function getAutoPosition(category: AutoCategory, target: any): [number, number] | null {
if (category === 'aircraft') return target.latitude != null && target.longitude != null
? [target.longitude, target.latitude]
: null;
if (category === 'marine') return target.lat != null && target.lon != null
? [target.lon, target.lat]
: null;
return target.latitude != null && target.longitude != null
? [target.longitude, target.latitude]
: null;
}
function getAutoZoom(category: AutoCategory): number {
if (category === 'aircraft') return 10;
if (category === 'marine') return 12;
return 6;
}
function focusAutoTarget(category: AutoCategory, target: any, animate = true) {
const position = getAutoPosition(category, target);
function focusAutoTarget(category: AutoCategory, target: any) {
const config = AUTO_CONFIG[category];
const position = config.position(target);
if (!position) return;
const center = fromLonLat(position);
map.getView().animate({
center,
zoom: getAutoZoom(category),
duration: animate ? 900 : 0,
});
}
function closeAutoPopup() {
if (!autoCategory.value || !autoTarget.value) return;
getAutoLayer(autoCategory.value).closeAutoPopup(getAutoId(autoCategory.value, autoTarget.value));
map.getView().animate({center: fromLonLat(position), zoom: config.zoom, duration: 900});
}
function clearAutoTarget() {
closeAutoPopup();
if (autoCategory.value && autoTarget.value) {
const config = AUTO_CONFIG[autoCategory.value];
config.layer().closePopup(config.id(autoTarget.value));
}
autoCategory.value = null;
autoTarget.value = null;
autoTargetKey = null;
}
function setAutoTarget(object: AutoObject) {
const config = AUTO_CONFIG[object.category];
const target = config.layer().getById(object.id);
if (!target) return;
clearAutoTarget();
autoCategory.value = object.category;
autoTarget.value = target;
autoTargetKey = `${object.category}:${object.id}`;
config.layer().openPopup(target);
focusAutoTarget(object.category, target);
}
function pickBestAndFocus() {
if (!autoMode.value) return;
const objects = getAutoObjects();
if (!objects.length) return clearAutoTarget();
const best: any = objects.sort((a, b) => b.score - a.score)[0];
const bestKey = `${best.category}:${best.id}`;
if (bestKey === autoTargetKey) {
const fresh = AUTO_CONFIG[best.category].layer().getById(best.id);
if (!fresh) return clearAutoTarget();
autoTarget.value = fresh;
focusAutoTarget(best.category, fresh);
return;
}
setAutoTarget(best);
}
function startAutoMode() {
if (autoMode.value) return;
autoMode.value = true;
saveAutoMode();
clearAutoTarget();
pickBestAndFocus();
autoInterval = setInterval(pickBestAndFocus, AUTO_INTERVAL_MS);
}
function stopAutoMode() {
if (autoInterval) {
clearInterval(autoInterval);
@@ -461,174 +436,12 @@ function stopAutoMode() {
}
clearAutoTarget();
autoMode.value = false;
autoKnown = {};
autoLastKeys = new Set();
saveAutoMode();
}
function getAutoObjects(): AutoObject[] {
const objects: AutoObject[] = [];
for (const category of AUTO_CATEGORIES) {
if (!activeOverlays.value.has(category)) continue;
const layer = getAutoLayer(category);
if (!layer.visible) continue;
const targets = layer.getAutoCandidates(
current.value.latitude,
current.value.longitude,
);
for (const target of targets) {
const id = getAutoId(category, target);
const key = `${category}:${id}`;
const priority = category !== 'satellites' && layer.isPriority(target);
if (!autoKnown[key]) {
autoKnown[key] = {
category,
id,
discoveredAt: Date.now(),
priority,
};
} else {
autoKnown[key].priority = priority;
}
objects.push(autoKnown[key]);
}
}
return objects;
}
function getAutoDistance(object: AutoObject): number {
const target = getAutoTarget(object.category, object.id);
if (!target) return Infinity;
return getAutoLayer(object.category).getAutoDistance(
current.value.latitude,
current.value.longitude,
target,
);
}
function getNewest(objects: AutoObject[]): AutoObject | null {
return objects.slice().sort((a, b) => b.discoveredAt - a.discoveredAt)[0] ?? null;
}
function getClosest(objects: AutoObject[]): AutoObject | null {
return objects.slice().sort((a, b) => getAutoDistance(a) - getAutoDistance(b))[0] ?? null;
}
function setAutoTarget(object: AutoObject) {
const target = getAutoTarget(object.category, object.id);
if (!target) return;
clearAutoTarget();
autoCategory.value = object.category;
autoTarget.value = target;
autoTargetKey = `${object.category}:${object.id}`;
getAutoLayer(object.category).openAutoPopup(target);
focusAutoTarget(object.category, target);
}
function shouldNewTargetSteal(currentObject: AutoObject, newObject: AutoObject): boolean {
const currentDistance = getAutoDistance(currentObject);
const newDistance = getAutoDistance(newObject);
if (!isFinite(currentDistance)) return true;
if (!isFinite(newDistance)) return false;
return newDistance <= currentDistance * AUTO_DISTANCE_MARGIN;
}
function reconcileAutoMode() {
if (!autoMode.value) return;
const objects = getAutoObjects();
const currentKeys = new Set(objects.map(o => `${o.category}:${o.id}`));
const discovered = objects.filter(o => !autoLastKeys.has(`${o.category}:${o.id}`));
for (const key of Object.keys(autoKnown)) {
if (!currentKeys.has(key)) delete autoKnown[key];
}
autoLastKeys = currentKeys;
const military = objects
.filter(o => o.priority)
.sort((a, b) => getAutoDistance(a) - getAutoDistance(b));
if (military.length) {
const best: any = military[0];
const key = `${best.category}:${best.id}`;
if (key !== autoTargetKey) setAutoTarget(best);
return;
}
if (autoTargetKey && currentKeys.has(autoTargetKey)) {
const currentObject = autoKnown[autoTargetKey];
if (!currentObject) return;
if (!discovered.length) return;
const newest = getNewest(discovered);
if (!newest) return;
if (`${newest.category}:${newest.id}` === autoTargetKey) return;
if (shouldNewTargetSteal(currentObject, newest)) setAutoTarget(newest);
return;
}
if (objects.length) {
const newest = getNewest(objects);
if (newest) setAutoTarget(newest);
}
}
function startAutoMode() {
if (autoMode.value) return;
autoMode.value = true;
saveAutoMode();
autoKnown = {};
autoLastKeys = new Set();
clearAutoTarget();
reconcileAutoMode();
autoInterval = setInterval(() => {
reconcileAutoMode();
if (!autoCategory.value || !autoTarget.value) return;
const fresh = getAutoTarget(
autoCategory.value,
getAutoId(autoCategory.value, autoTarget.value),
);
if (!fresh) {
clearAutoTarget();
reconcileAutoMode();
return;
}
autoTarget.value = fresh;
focusAutoTarget(autoCategory.value, fresh);
}, AUTO_CHECK_MS);
}
function toggleAutoMode() {
if (autoMode.value) stopAutoMode();
else startAutoMode();
autoMode.value ? stopAutoMode() : startAutoMode();
}
onMounted(async () => {
@@ -642,11 +455,7 @@ onMounted(async () => {
longitude: position?.longitude || 0,
};
stationLayer = buildStationLayer(
current.value.latitude,
current.value.longitude,
props.dark,
);
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark);
const initialLatitude = savedMapState?.latitude ?? current.value.latitude;
const initialLongitude = savedMapState?.longitude ?? current.value.longitude;
@@ -654,14 +463,8 @@ onMounted(async () => {
map = new Map({
target: mapEl.value!,
layers: [
buildBaseLayer(props.dark),
stationLayer,
],
interactions: defaultInteractions({
altShiftDragRotate: false,
pinchRotate: false,
}),
layers: [buildBaseLayer(props.dark), stationLayer],
interactions: defaultInteractions({altShiftDragRotate: false, pinchRotate: false}),
view: new View({
center: fromLonLat([initialLongitude, initialLatitude]),
zoom: initialZoom,
@@ -683,11 +486,7 @@ onMounted(async () => {
radarInterval = setInterval(refreshRain, 5 * 60 * 1000);
if (loadAutoMode()) {
setTimeout(() => {
if (!autoMode.value) startAutoMode();
}, 1_000);
}
if (loadAutoMode()) setTimeout(() => { if (!autoMode.value) startAutoMode(); }, 1_000);
});
onUnmounted(() => {
@@ -710,12 +509,7 @@ watch(() => props.dark, dark => {
map.getLayers().setAt(0, buildBaseLayer(dark));
map.removeLayer(stationLayer);
stationLayer = buildStationLayer(
current.value.latitude || 0,
current.value.longitude || 0,
dark,
);
stationLayer = buildStationLayer(current.value.latitude || 0, current.value.longitude || 0, dark);
map.addLayer(stationLayer);
});
</script>
+99 -145
View File
@@ -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);
}
}, 1_000);
if (this.popups[plane.icao]) {
this.closePopup(plane.icao);
return;
}
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,7 +194,74 @@ 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;
@@ -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,9 +292,8 @@ export class AirTrafficLayer {
this._fetchTrace(plane);
}
private _closePopup(icao: string) {
closePopup(icao: string) {
const popup = this.popups[icao];
if(popup) {
popup.unmount();
delete this.popups[icao];
@@ -316,7 +301,6 @@ export class AirTrafficLayer {
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];
@@ -324,34 +308,4 @@ export class AirTrafficLayer {
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
View File
@@ -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);
}
}, 5_000);
if (this.popups[boat.mmsi]) {
this.closePopup(boat.mmsi);
return;
}
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);
}
}
+94 -154
View File
@@ -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) {
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
View File
@@ -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);
}
}, 5_000);
if (this.popups[sonde.id]) {
this.closePopup(sonde.id);
return;
}
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);
}
}
+14 -6
View File
@@ -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))]
+1
View File
@@ -7,6 +7,7 @@
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"noImplicitAny": false,
"lib": ["DOM","ESNext"],
// Path mapping for cleaner imports.
"paths": {