Better AutoMode
This commit is contained in:
@@ -50,7 +50,7 @@ const description = computed(() => props.plane.desc || [props.plane.manufacturer
|
|||||||
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
||||||
|
|
||||||
const altitudeHistory = computed(() => (props.history || [])
|
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))})))
|
.map(p => ({...p, altitude: altitude.value.convert(Number(p.altitude))})))
|
||||||
|
|
||||||
const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value)))
|
const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value)))
|
||||||
|
|||||||
+148
-354
@@ -63,19 +63,15 @@ const overlayLayers: {[key: string]: any} = {};
|
|||||||
const MAP_STATE_KEY = 'situation-center-map-state';
|
const MAP_STATE_KEY = 'situation-center-map-state';
|
||||||
const MAP_LAYERS_KEY = 'situation-center-map-layers';
|
const MAP_LAYERS_KEY = 'situation-center-map-layers';
|
||||||
const AUTO_MODE_KEY = 'situation-center-auto-mode';
|
const AUTO_MODE_KEY = 'situation-center-auto-mode';
|
||||||
|
const AUTO_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
const AUTO_CHECK_MS = 2_000;
|
const AUTO_CATEGORIES = ['sondes', 'aircraft', 'marine', 'satellites'] as const;
|
||||||
const AUTO_DISTANCE_MARGIN = 0.75;
|
|
||||||
|
|
||||||
const AUTO_CATEGORIES = ['aircraft', 'marine', 'satellites'] as const;
|
|
||||||
|
|
||||||
type AutoCategory = typeof AUTO_CATEGORIES[number];
|
type AutoCategory = typeof AUTO_CATEGORIES[number];
|
||||||
|
|
||||||
interface AutoObject {
|
interface AutoObject {
|
||||||
category: AutoCategory;
|
category: AutoCategory;
|
||||||
id: string;
|
id: string;
|
||||||
discoveredAt: number;
|
score: number;
|
||||||
priority: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let map: Map;
|
let map: Map;
|
||||||
@@ -92,10 +88,40 @@ let aurora: Aurora;
|
|||||||
const autoMode = ref(false);
|
const autoMode = ref(false);
|
||||||
const autoCategory = ref<AutoCategory | null>(null);
|
const autoCategory = ref<AutoCategory | null>(null);
|
||||||
const autoTarget = ref<any>(null);
|
const autoTarget = ref<any>(null);
|
||||||
|
|
||||||
let autoTargetKey: string | null = 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() {
|
function loadMapState() {
|
||||||
try {
|
try {
|
||||||
@@ -128,11 +154,7 @@ function saveMapState() {
|
|||||||
const zoom = view.getZoom();
|
const zoom = view.getZoom();
|
||||||
if (typeof zoom !== 'number') return;
|
if (typeof zoom !== 'number') return;
|
||||||
|
|
||||||
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({
|
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({latitude, longitude, zoom}));
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
zoom,
|
|
||||||
}));
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore localStorage failures.
|
// Ignore localStorage failures.
|
||||||
}
|
}
|
||||||
@@ -233,90 +255,54 @@ function buildRainLayer(url: string): TileLayer<XYZ> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRain() {
|
function removeRainLayer() {
|
||||||
const url = await fetchRadarUrl();
|
if (!overlayLayers['rain']) return;
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
if (overlayLayers['rain']) {
|
|
||||||
map.removeLayer(overlayLayers['rain']);
|
map.removeLayer(overlayLayers['rain']);
|
||||||
delete 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);
|
const layer = buildRainLayer(url);
|
||||||
overlayLayers['rain'] = layer;
|
overlayLayers['rain'] = layer;
|
||||||
map.addLayer(layer);
|
map.addLayer(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOverlay(id: string) {
|
// One handler per overlay id — toggleOverlay/restoreOverlayState just drive these.
|
||||||
if (id === 'aircraft') {
|
const OVERLAY_HANDLERS: Record<string, {show: () => void; hide: () => void}> = {
|
||||||
if (activeOverlays.value.has('aircraft')) {
|
aircraft: {show: () => { airTraffic.show(); range.show(); }, hide: () => { airTraffic.hide(); range.hide(); }},
|
||||||
activeOverlays.value.delete('aircraft');
|
marine: {show: () => aisLayer.show(), hide: () => aisLayer.hide()},
|
||||||
airTraffic.hide();
|
satellites: {show: () => sats.show(), hide: () => sats.hide()},
|
||||||
range.hide();
|
sondes: {show: () => sondes.show(), hide: () => sondes.hide()},
|
||||||
} else {
|
aurora: {show: () => aurora.show(), hide: () => aurora.hide()},
|
||||||
activeOverlays.value.add('aircraft');
|
wind: {show: showWindOverlay, hide: hideWindOverlay},
|
||||||
airTraffic.show();
|
rain: {show: refreshRain, hide: removeRainLayer},
|
||||||
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');
|
|
||||||
|
|
||||||
if (overlayLayers['rain']) {
|
function toggleOverlay(id: string) {
|
||||||
map.removeLayer(overlayLayers['rain']);
|
const handler = OVERLAY_HANDLERS[id];
|
||||||
delete overlayLayers['rain'];
|
if (!handler) return;
|
||||||
}
|
|
||||||
|
if (activeOverlays.value.has(id)) {
|
||||||
|
activeOverlays.value.delete(id);
|
||||||
|
handler.hide();
|
||||||
} else {
|
} else {
|
||||||
activeOverlays.value.add('rain');
|
activeOverlays.value.add(id);
|
||||||
refreshRain();
|
handler.show();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveLayers();
|
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) {
|
function toggleMenu(id: string) {
|
||||||
openMenu.value = openMenu.value === id ? null : id;
|
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));
|
const ring = new Feature(new CircleGeom(center, nm * NM));
|
||||||
|
|
||||||
ring.setStyle(new Style({
|
ring.setStyle(new Style({
|
||||||
stroke: new Stroke({
|
stroke: new Stroke({color: ringStroke, width: 1, lineDash: [6, 4]}),
|
||||||
color: ringStroke,
|
|
||||||
width: 1,
|
|
||||||
lineDash: [6, 4],
|
|
||||||
}),
|
|
||||||
fill: new Fill({color: 'transparent'}),
|
fill: new Fill({color: 'transparent'}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -353,10 +335,7 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
|
|||||||
image: new CircleStyle({
|
image: new CircleStyle({
|
||||||
radius: 7,
|
radius: 7,
|
||||||
fill: new Fill({color: dark ? '#ffffff' : '#000000'}),
|
fill: new Fill({color: dark ? '#ffffff' : '#000000'}),
|
||||||
stroke: new Stroke({
|
stroke: new Stroke({color: dark ? '#000000' : '#ffffff', width: 2}),
|
||||||
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});
|
return new VectorLayer({source, zIndex: 20});
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreOverlayState() {
|
function getAutoObjects(): AutoObject[] {
|
||||||
if (activeOverlays.value.has('aircraft')) {
|
const objects: AutoObject[] = [];
|
||||||
airTraffic.show();
|
|
||||||
range.show();
|
for (const category of AUTO_CATEGORIES) {
|
||||||
} else {
|
if (!activeOverlays.value.has(category)) continue;
|
||||||
airTraffic.hide();
|
|
||||||
range.hide();
|
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();
|
return objects;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAutoLayer(category: AutoCategory): any {
|
function focusAutoTarget(category: AutoCategory, target: any) {
|
||||||
if (category === 'aircraft') return airTraffic;
|
const config = AUTO_CONFIG[category];
|
||||||
if (category === 'marine') return aisLayer;
|
const position = config.position(target);
|
||||||
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);
|
|
||||||
if (!position) return;
|
if (!position) return;
|
||||||
|
|
||||||
const center = fromLonLat(position);
|
map.getView().animate({center: fromLonLat(position), zoom: config.zoom, duration: 900});
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAutoTarget() {
|
function clearAutoTarget() {
|
||||||
closeAutoPopup();
|
if (autoCategory.value && autoTarget.value) {
|
||||||
|
const config = AUTO_CONFIG[autoCategory.value];
|
||||||
|
config.layer().closePopup(config.id(autoTarget.value));
|
||||||
|
}
|
||||||
|
|
||||||
autoCategory.value = null;
|
autoCategory.value = null;
|
||||||
autoTarget.value = null;
|
autoTarget.value = null;
|
||||||
autoTargetKey = 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() {
|
function stopAutoMode() {
|
||||||
if (autoInterval) {
|
if (autoInterval) {
|
||||||
clearInterval(autoInterval);
|
clearInterval(autoInterval);
|
||||||
@@ -461,174 +436,12 @@ function stopAutoMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearAutoTarget();
|
clearAutoTarget();
|
||||||
|
|
||||||
autoMode.value = false;
|
autoMode.value = false;
|
||||||
autoKnown = {};
|
|
||||||
autoLastKeys = new Set();
|
|
||||||
|
|
||||||
saveAutoMode();
|
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() {
|
function toggleAutoMode() {
|
||||||
if (autoMode.value) stopAutoMode();
|
autoMode.value ? stopAutoMode() : startAutoMode();
|
||||||
else startAutoMode();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -642,11 +455,7 @@ onMounted(async () => {
|
|||||||
longitude: position?.longitude || 0,
|
longitude: position?.longitude || 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
stationLayer = buildStationLayer(
|
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark);
|
||||||
current.value.latitude,
|
|
||||||
current.value.longitude,
|
|
||||||
props.dark,
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialLatitude = savedMapState?.latitude ?? current.value.latitude;
|
const initialLatitude = savedMapState?.latitude ?? current.value.latitude;
|
||||||
const initialLongitude = savedMapState?.longitude ?? current.value.longitude;
|
const initialLongitude = savedMapState?.longitude ?? current.value.longitude;
|
||||||
@@ -654,14 +463,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
map = new Map({
|
map = new Map({
|
||||||
target: mapEl.value!,
|
target: mapEl.value!,
|
||||||
layers: [
|
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||||
buildBaseLayer(props.dark),
|
interactions: defaultInteractions({altShiftDragRotate: false, pinchRotate: false}),
|
||||||
stationLayer,
|
|
||||||
],
|
|
||||||
interactions: defaultInteractions({
|
|
||||||
altShiftDragRotate: false,
|
|
||||||
pinchRotate: false,
|
|
||||||
}),
|
|
||||||
view: new View({
|
view: new View({
|
||||||
center: fromLonLat([initialLongitude, initialLatitude]),
|
center: fromLonLat([initialLongitude, initialLatitude]),
|
||||||
zoom: initialZoom,
|
zoom: initialZoom,
|
||||||
@@ -683,11 +486,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
radarInterval = setInterval(refreshRain, 5 * 60 * 1000);
|
radarInterval = setInterval(refreshRain, 5 * 60 * 1000);
|
||||||
|
|
||||||
if (loadAutoMode()) {
|
if (loadAutoMode()) setTimeout(() => { if (!autoMode.value) startAutoMode(); }, 1_000);
|
||||||
setTimeout(() => {
|
|
||||||
if (!autoMode.value) startAutoMode();
|
|
||||||
}, 1_000);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -710,12 +509,7 @@ watch(() => props.dark, dark => {
|
|||||||
map.getLayers().setAt(0, buildBaseLayer(dark));
|
map.getLayers().setAt(0, buildBaseLayer(dark));
|
||||||
map.removeLayer(stationLayer);
|
map.removeLayer(stationLayer);
|
||||||
|
|
||||||
stationLayer = buildStationLayer(
|
stationLayer = buildStationLayer(current.value.latitude || 0, current.value.longitude || 0, dark);
|
||||||
current.value.latitude || 0,
|
|
||||||
current.value.longitude || 0,
|
|
||||||
dark,
|
|
||||||
);
|
|
||||||
|
|
||||||
map.addLayer(stationLayer);
|
map.addLayer(stationLayer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+103
-149
@@ -1,4 +1,5 @@
|
|||||||
import {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 {h, render as vueRender} from 'vue';
|
||||||
import Map from 'ol/Map';
|
import Map from 'ol/Map';
|
||||||
import {Feature} from 'ol';
|
import {Feature} from 'ol';
|
||||||
@@ -8,21 +9,13 @@ import {fromLonLat} from 'ol/proj';
|
|||||||
import {Style, Icon, Stroke} from 'ol/style';
|
import {Style, Icon, Stroke} from 'ol/style';
|
||||||
import {Vector as VectorLayer} from 'ol/layer';
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import {Vector as VectorSource} from 'ol/source';
|
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 AircraftPopup from '@/components/Aircraft.vue';
|
||||||
import {bringToFront} from './zindex';
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
const API = BASE + '/api';
|
const API = BASE + '/api';
|
||||||
const EARTH_R = 6371;
|
|
||||||
const AUTO_RANGE = 92.6;
|
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 {
|
function isPriorityAircraft(plane: any): boolean {
|
||||||
if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true;
|
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;
|
this.map = map;
|
||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
private _attachClick() {
|
||||||
if (this.visible) return;
|
this.clickHandler = evt => {
|
||||||
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
this.visible = true;
|
const plane = f.get('planeData');
|
||||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
|
||||||
this.map.addLayer(this.layer);
|
|
||||||
|
|
||||||
await this._fetch();
|
if (this.popups[plane.icao]) {
|
||||||
this._draw();
|
this.closePopup(plane.icao);
|
||||||
this._attachClick();
|
return;
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
|
||||||
try {
|
|
||||||
await this._fetch();
|
|
||||||
this._draw();
|
|
||||||
this._refreshPopups();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}, 1_000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
this.openPopup(plane);
|
||||||
if (!this.visible) return;
|
};
|
||||||
|
|
||||||
this.visible = false;
|
this.map.on('singleclick', this.clickHandler);
|
||||||
|
|
||||||
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(),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
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) {
|
private async _fetchTrace(plane: any) {
|
||||||
const icao = plane.icao;
|
const icao = plane.icao;
|
||||||
|
|
||||||
@@ -229,6 +147,7 @@ export class AirTrafficLayer {
|
|||||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
|
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
|
||||||
|
|
||||||
const traceHistory = history;
|
const traceHistory = history;
|
||||||
|
|
||||||
if (traceHistory.length < 2) {
|
if (traceHistory.length < 2) {
|
||||||
const popup = this.popups[icao];
|
const popup = this.popups[icao];
|
||||||
if (popup) popup.update(plane, history);
|
if (popup) popup.update(plane, history);
|
||||||
@@ -275,9 +194,76 @@ export class AirTrafficLayer {
|
|||||||
if (popup) popup.update(plane, history);
|
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);
|
const icao = String(plane.icao);
|
||||||
if (this.popups[icao]) return;
|
if(this.popups[icao]) return;
|
||||||
|
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -289,7 +275,7 @@ export class AirTrafficLayer {
|
|||||||
position: mobile
|
position: mobile
|
||||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
: {x: 16, y: 16},
|
: {x: 16, y: 16},
|
||||||
onClose: () => this._closePopup(icao),
|
onClose: () => this.closePopup(icao),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,52 +292,20 @@ export class AirTrafficLayer {
|
|||||||
this._fetchTrace(plane);
|
this._fetchTrace(plane);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(icao: string) {
|
closePopup(icao: string) {
|
||||||
const popup = this.popups[icao];
|
const popup = this.popups[icao];
|
||||||
|
if(popup) {
|
||||||
if (popup) {
|
|
||||||
popup.unmount();
|
popup.unmount();
|
||||||
delete this.popups[icao];
|
delete this.popups[icao];
|
||||||
}
|
}
|
||||||
|
|
||||||
const segs = this.traceSegs[icao], layer = this.traceLayers[icao];
|
const segs = this.traceSegs[icao], layer = this.traceLayers[icao];
|
||||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f));
|
if(segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f));
|
||||||
|
if(layer) {
|
||||||
if (layer) {
|
|
||||||
this.map.removeLayer(layer);
|
this.map.removeLayer(layer);
|
||||||
delete this.traceLayers[icao];
|
delete this.traceLayers[icao];
|
||||||
}
|
}
|
||||||
|
|
||||||
delete this.traceSegs[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 {BASE} from '@/services/api.ts';
|
||||||
|
import {greatCircleDist} from '@/services/units.ts';
|
||||||
import {h, render as vueRender} from 'vue';
|
import {h, render as vueRender} from 'vue';
|
||||||
import Map from 'ol/Map';
|
import Map from 'ol/Map';
|
||||||
import {Feature} from 'ol';
|
import {Feature} from 'ol';
|
||||||
@@ -7,12 +8,11 @@ import {fromLonLat} from 'ol/proj';
|
|||||||
import {Style, Icon} from 'ol/style';
|
import {Style, Icon} from 'ol/style';
|
||||||
import {Vector as VectorLayer} from 'ol/layer';
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import {Vector as VectorSource} from 'ol/source';
|
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 ShipPopup from '@/components/Ship.vue';
|
||||||
import {bringToFront} from './zindex';
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
const API = BASE + '/api';
|
const API = BASE + '/api';
|
||||||
const EARTH_R = 6371;
|
|
||||||
const AUTO_RANGE = 100;
|
const AUTO_RANGE = 100;
|
||||||
|
|
||||||
const SHIP_COLORS: Record<string, string> = {
|
const SHIP_COLORS: Record<string, string> = {
|
||||||
@@ -26,13 +26,6 @@ const SHIP_COLORS: Record<string, string> = {
|
|||||||
'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 {
|
function isPriorityBoat(boat: any): boolean {
|
||||||
if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true;
|
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;
|
this.map = map;
|
||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
private _attachClick() {
|
||||||
if (this.visible) return;
|
this.clickHandler = evt => {
|
||||||
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
this.visible = true;
|
const boat = f.get('boatData');
|
||||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
|
||||||
this.map.addLayer(this.layer);
|
|
||||||
|
|
||||||
await this._fetch();
|
if (this.popups[boat.mmsi]) {
|
||||||
this._draw();
|
this.closePopup(boat.mmsi);
|
||||||
this._attachClick();
|
return;
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
|
||||||
try {
|
|
||||||
await this._fetch();
|
|
||||||
this._draw();
|
|
||||||
this._refreshPopups();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}, 5_000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
this.openPopup(boat);
|
||||||
if (!this.visible) return;
|
};
|
||||||
|
|
||||||
this.visible = false;
|
this.map.on('singleclick', this.clickHandler);
|
||||||
|
|
||||||
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()) || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
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);
|
const mmsi = String(boat.mmsi);
|
||||||
if (this.popups[mmsi]) return;
|
if (this.popups[mmsi]) return;
|
||||||
|
|
||||||
@@ -222,7 +209,7 @@ export class AISLayer {
|
|||||||
position: mobile
|
position: mobile
|
||||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
: {x: 16, y: 16},
|
: {x: 16, y: 16},
|
||||||
onClose: () => this._closePopup(mmsi),
|
onClose: () => this.closePopup(mmsi),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,44 +224,11 @@ export class AISLayer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(mmsi: string) {
|
closePopup(mmsi: string) {
|
||||||
const popup = this.popups[mmsi];
|
const popup = this.popups[mmsi];
|
||||||
|
|
||||||
if (popup) {
|
if (popup) {
|
||||||
popup.unmount();
|
popup.unmount();
|
||||||
delete this.popups[mmsi];
|
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 {api, BASE} from '@/services/api.ts';
|
||||||
|
import {greatCircleDist} from '@/services/units.ts';
|
||||||
import {h, render as vueRender} from 'vue';
|
import {h, render as vueRender} from 'vue';
|
||||||
import Map from 'ol/Map';
|
import Map from 'ol/Map';
|
||||||
import {Feature} from 'ol';
|
import {Feature} from 'ol';
|
||||||
@@ -7,13 +8,12 @@ import {fromLonLat} from 'ol/proj';
|
|||||||
import {Style, Stroke, Icon, Fill} from 'ol/style';
|
import {Style, Stroke, Icon, Fill} from 'ol/style';
|
||||||
import {Vector as VectorLayer} from 'ol/layer';
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import {Vector as VectorSource} from 'ol/source';
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
import {adjustedInterval} from '@ztimson/utils';
|
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||||
import TinyGSPopup from '@/components/Satellite.vue';
|
import Satellite from '@/components/Satellite.vue';
|
||||||
import {bringToFront} from './zindex';
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
const API = BASE + '/api';
|
const API = BASE + '/api';
|
||||||
const EARTH_R = 6371;
|
const EARTH_R = 6371;
|
||||||
const AUTO_ELEVATION = 70;
|
|
||||||
|
|
||||||
interface TinyGSReading {
|
interface TinyGSReading {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -41,13 +41,6 @@ interface RangeEstimate {
|
|||||||
groundDist: 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null {
|
function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null {
|
||||||
const d = greatCircleDist(gsLat, gsLon, satLat, satLon);
|
const d = greatCircleDist(gsLat, gsLon, satLat, satLon);
|
||||||
const gamma = d / EARTH_R;
|
const gamma = d / EARTH_R;
|
||||||
@@ -101,119 +94,18 @@ export class SatsLayer {
|
|||||||
api.position().then(gs => this.gs = gs);
|
api.position().then(gs => this.gs = gs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
private _attachClick() {
|
||||||
if (this.visible) return;
|
this.clickHandler = evt => {
|
||||||
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
this.visible = true;
|
const sat = f.get('satData') as TinyGSSatellite;
|
||||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
|
||||||
this.map.addLayer(this.layer);
|
|
||||||
|
|
||||||
await this._fetch();
|
if (this.popups[sat.id]) return this.closePopup(sat.id);
|
||||||
this._draw();
|
this.openPopup(sat);
|
||||||
this._attachClick();
|
};
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
this.map.on('singleclick', this.clickHandler);
|
||||||
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()) || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
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 {
|
private _calcRange(sat: TinyGSSatellite): RangeEstimate | null {
|
||||||
if (!this.gs) return null;
|
if (!this.gs) return null;
|
||||||
return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el);
|
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) {
|
private _removeReceptionCircle(id: number) {
|
||||||
const layer = this.receptionCircles[id];
|
const layer = this.receptionCircles[id];
|
||||||
|
|
||||||
if (layer) {
|
if (layer) {
|
||||||
this.map.removeLayer(layer);
|
this.map.removeLayer(layer);
|
||||||
delete this.receptionCircles[id];
|
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;
|
if (this.popups[sat.id]) return;
|
||||||
|
|
||||||
const history = [...sat.history, sat];
|
const history = [...sat.history, sat];
|
||||||
@@ -312,15 +279,14 @@ export class SatsLayer {
|
|||||||
position: mobile
|
position: mobile
|
||||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
: {x: 16, y: 16},
|
: {x: 16, y: 16},
|
||||||
onClose: () => this._closePopup(sat.id),
|
onClose: () => this.closePopup(sat.id),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
});
|
});
|
||||||
|
|
||||||
vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container);
|
vueRender(h(Satellite, makeProps(history, range, eta)), container);
|
||||||
|
|
||||||
this.popups[sat.id] = {
|
this.popups[sat.id] = {
|
||||||
container,
|
update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(Satellite, makeProps(h_, r, e)), container),
|
||||||
update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container),
|
|
||||||
unmount: () => {
|
unmount: () => {
|
||||||
vueRender(null, container);
|
vueRender(null, container);
|
||||||
container.remove();
|
container.remove();
|
||||||
@@ -328,39 +294,13 @@ export class SatsLayer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(id: number) {
|
closePopup(id: number) {
|
||||||
const popup = this.popups[id];
|
const popup = this.popups[id];
|
||||||
if(popup) {
|
if (popup) {
|
||||||
popup.unmount();
|
popup.unmount();
|
||||||
delete this.popups[id];
|
delete this.popups[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
this._removeReceptionCircle(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 {h, render as vueRender} from 'vue';
|
||||||
import Map from 'ol/Map';
|
import Map from 'ol/Map';
|
||||||
import {Feature} from 'ol';
|
import {Feature} from 'ol';
|
||||||
@@ -7,7 +8,7 @@ import {fromLonLat} from 'ol/proj';
|
|||||||
import {Style, Stroke, Icon} from 'ol/style';
|
import {Style, Stroke, Icon} from 'ol/style';
|
||||||
import {Vector as VectorLayer} from 'ol/layer';
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import {Vector as VectorSource} from 'ol/source';
|
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 SondePopup from '@/components/WeatherBalloon.vue';
|
||||||
import {bringToFront} from './zindex';
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
@@ -52,99 +53,22 @@ export class SondesLayer {
|
|||||||
this.map = map;
|
this.map = map;
|
||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
private _attachClick() {
|
||||||
if (this.visible) return;
|
this.clickHandler = evt => {
|
||||||
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
this.visible = true;
|
const sonde = f.get('sondeData') as Sonde;
|
||||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
|
||||||
this.map.addLayer(this.layer);
|
|
||||||
|
|
||||||
await this._fetch();
|
if (this.popups[sonde.id]) {
|
||||||
this._draw();
|
this.closePopup(sonde.id);
|
||||||
this._attachClick();
|
return;
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
|
||||||
try {
|
|
||||||
await this._fetch();
|
|
||||||
this._draw();
|
|
||||||
this._refreshPopups();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}, 5_000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
this.openPopup(sonde);
|
||||||
if (!this.visible) return;
|
};
|
||||||
|
|
||||||
this.visible = false;
|
this.map.on('singleclick', this.clickHandler);
|
||||||
|
|
||||||
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()) || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
private _draw() {
|
||||||
@@ -203,17 +127,72 @@ export class SondesLayer {
|
|||||||
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _distance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
private async _fetch() {
|
||||||
const toRad = (d: number) => d * Math.PI / 180;
|
this.data = await fetch(`${API}/sondes`).then(r => r.json()) || [];
|
||||||
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 _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;
|
if (this.popups[sonde.id]) return;
|
||||||
|
|
||||||
const history = [...sonde.history, sonde];
|
const history = [...sonde.history, sonde];
|
||||||
@@ -227,14 +206,13 @@ export class SondesLayer {
|
|||||||
position: mobile
|
position: mobile
|
||||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
: {x: 16, y: 16},
|
: {x: 16, y: 16},
|
||||||
onClose: () => this._closePopup(sonde.id),
|
onClose: () => this.closePopup(sonde.id),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
});
|
});
|
||||||
|
|
||||||
vueRender(h(SondePopup, makeProps(history)), container);
|
vueRender(h(SondePopup, makeProps(history)), container);
|
||||||
|
|
||||||
this.popups[sonde.id] = {
|
this.popups[sonde.id] = {
|
||||||
container,
|
|
||||||
update: (h_: SondeReading[]) => vueRender(h(SondePopup, makeProps(h_)), container),
|
update: (h_: SondeReading[]) => vueRender(h(SondePopup, makeProps(h_)), container),
|
||||||
unmount: () => {
|
unmount: () => {
|
||||||
vueRender(null, container);
|
vueRender(null, container);
|
||||||
@@ -243,43 +221,11 @@ export class SondesLayer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(id: string) {
|
closePopup(id: string) {
|
||||||
const popup = this.popups[id];
|
const popup = this.popups[id];
|
||||||
|
|
||||||
if (popup) {
|
if (popup) {
|
||||||
popup.unmount();
|
popup.unmount();
|
||||||
delete this.popups[id];
|
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' },
|
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) {
|
export function getPressureTrend(delta: number) {
|
||||||
if (delta > 0.5) return 'Rising';
|
if (delta > 0.5) return 'Rising';
|
||||||
if (delta < -0.5) return 'Falling';
|
if (delta < -0.5) return 'Falling';
|
||||||
@@ -76,12 +84,12 @@ export function getUVLabel(uv: number) {
|
|||||||
return 'Extreme';
|
return 'Extreme';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatValue(key: string, value: number | string | null): string {
|
const EARTH_R = 6371;
|
||||||
if (value === null || value === undefined) return '—'
|
export function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const meta = METRICS[key]
|
const toRad = (d: number) => d * Math.PI / 180;
|
||||||
if (!meta) return String(value)
|
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
|
||||||
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
return `${value}${meta.unit}`
|
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))]
|
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
|
"lib": ["DOM","ESNext"],
|
||||||
|
|
||||||
// Path mapping for cleaner imports.
|
// Path mapping for cleaner imports.
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
Reference in New Issue
Block a user