1006 lines
23 KiB
Vue
1006 lines
23 KiB
Vue
<script setup lang="ts">
|
||
import {AirTrafficLayer} from '@/services/adsb.ts';
|
||
import {AISLayer} from '@/services/ais.ts';
|
||
import {api} from '@/services/api.ts';
|
||
import {RangeLayer} from '@/services/range.ts';
|
||
import {SatsLayer} from '@/services/sats.ts';
|
||
import VectorTileLayer from 'ol/layer/VectorTile';
|
||
import {onMounted, onUnmounted, ref, watch} from 'vue';
|
||
import Map from 'ol/Map';
|
||
import View from 'ol/View';
|
||
import TileLayer from 'ol/layer/Tile';
|
||
import VectorLayer from 'ol/layer/Vector';
|
||
import VectorSource from 'ol/source/Vector';
|
||
import XYZ from 'ol/source/XYZ';
|
||
import Feature from 'ol/Feature';
|
||
import Point from 'ol/geom/Point';
|
||
import CircleGeom from 'ol/geom/Circle';
|
||
import {defaults as defaultInteractions} from 'ol/interaction';
|
||
import {fromLonLat, toLonLat} from 'ol/proj';
|
||
import {Style, Fill, Stroke, Circle as CircleStyle} from 'ol/style';
|
||
import {applyStyle} from 'ol-mapbox-style';
|
||
import {Aurora} from '@/services/aurora.ts';
|
||
import {SondesLayer} from '@/services/sondes.ts';
|
||
import 'ol/ol.css';
|
||
|
||
const current = ref({latitude: 0, longitude: 0});
|
||
const props = defineProps<{dark: boolean}>();
|
||
const mapEl = ref<HTMLDivElement>();
|
||
const showOverlays = ref(false);
|
||
const openMenu = ref<string | null>(null);
|
||
const NM = 1852;
|
||
const showWind = ref(false);
|
||
const windSrc = ref('');
|
||
|
||
const OVERLAY_GROUPS = [
|
||
{
|
||
id: 'traffic',
|
||
label: 'Traffic',
|
||
icon: '✈️',
|
||
items: [
|
||
{id: 'aircraft', label: 'Aircraft', icon: '✈️'},
|
||
{id: 'marine', label: 'Marine', icon: '🚢'},
|
||
{id: 'satellites', label: 'Satellites', icon: '🛰️'},
|
||
{id: 'sondes', label: 'Weather Balloons', icon: '️🎈'},
|
||
],
|
||
},
|
||
{
|
||
id: 'weather',
|
||
label: 'Weather',
|
||
icon: '🌦️',
|
||
items: [
|
||
{id: 'aurora', label: 'Aurora', icon: '🌌'},
|
||
{id: 'rain', label: 'Rain', icon: '🌧️'},
|
||
{id: 'wind', label: 'Wind', icon: '💨'},
|
||
],
|
||
},
|
||
];
|
||
|
||
const DEFAULT_OVERLAYS = ['aircraft', 'marine', 'satellites', 'rain'];
|
||
const activeOverlays = ref<Set<string>>(new Set(DEFAULT_OVERLAYS));
|
||
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_CHECK_MS = 2_000;
|
||
const AUTO_DISTANCE_MARGIN = 0.75;
|
||
|
||
const AUTO_CATEGORIES = ['aircraft', 'marine', 'satellites'] as const;
|
||
|
||
type AutoCategory = typeof AUTO_CATEGORIES[number];
|
||
|
||
interface AutoObject {
|
||
category: AutoCategory;
|
||
id: string;
|
||
discoveredAt: number;
|
||
priority: boolean;
|
||
}
|
||
|
||
let map: Map;
|
||
let stationLayer: VectorLayer<VectorSource>;
|
||
let radarInterval: ReturnType<typeof setInterval>;
|
||
let autoInterval: ReturnType<typeof setInterval>;
|
||
let airTraffic: AirTrafficLayer;
|
||
let aisLayer: AISLayer;
|
||
let range: RangeLayer;
|
||
let sats: SatsLayer;
|
||
let sondes: SondesLayer;
|
||
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>();
|
||
|
||
function loadMapState() {
|
||
try {
|
||
const raw = localStorage.getItem(MAP_STATE_KEY);
|
||
if (!raw) return null;
|
||
|
||
const state = JSON.parse(raw);
|
||
|
||
if (
|
||
typeof state.latitude !== 'number' ||
|
||
typeof state.longitude !== 'number' ||
|
||
typeof state.zoom !== 'number'
|
||
) return null;
|
||
|
||
return state;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function saveMapState() {
|
||
if (!map) return;
|
||
|
||
try {
|
||
const view = map.getView();
|
||
const center = view.getCenter();
|
||
if (!center) return;
|
||
|
||
const [longitude, latitude] = toLonLat(center);
|
||
const zoom = view.getZoom();
|
||
if (typeof zoom !== 'number') return;
|
||
|
||
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({
|
||
latitude,
|
||
longitude,
|
||
zoom,
|
||
}));
|
||
} catch {
|
||
// Ignore localStorage failures.
|
||
}
|
||
}
|
||
|
||
function loadLayers() {
|
||
try {
|
||
const raw = localStorage.getItem(MAP_LAYERS_KEY);
|
||
if (!raw) return new Set(DEFAULT_OVERLAYS);
|
||
|
||
const layers = JSON.parse(raw);
|
||
if (!Array.isArray(layers)) return new Set(DEFAULT_OVERLAYS);
|
||
|
||
const validLayers = OVERLAY_GROUPS.flatMap(group => group.items.map(item => item.id));
|
||
|
||
return new Set(
|
||
layers.filter((id: unknown): id is string =>
|
||
typeof id === 'string' && validLayers.includes(id),
|
||
),
|
||
);
|
||
} catch {
|
||
return new Set(DEFAULT_OVERLAYS);
|
||
}
|
||
}
|
||
|
||
function saveLayers() {
|
||
try {
|
||
localStorage.setItem(MAP_LAYERS_KEY, JSON.stringify([...activeOverlays.value]));
|
||
} catch {
|
||
// Ignore localStorage failures.
|
||
}
|
||
}
|
||
|
||
function loadAutoMode() {
|
||
try {
|
||
return localStorage.getItem(AUTO_MODE_KEY) === 'true';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function saveAutoMode() {
|
||
try {
|
||
localStorage.setItem(AUTO_MODE_KEY, String(autoMode.value));
|
||
} catch {
|
||
// Ignore localStorage failures.
|
||
}
|
||
}
|
||
|
||
function buildWindSrc(lat: number, lon: number, zoom: number) {
|
||
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`;
|
||
}
|
||
|
||
function syncWindSrc() {
|
||
const view = map.getView();
|
||
const center: any = toLonLat(view.getCenter()!);
|
||
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8);
|
||
}
|
||
|
||
function showWindOverlay() {
|
||
syncWindSrc();
|
||
showWind.value = true;
|
||
map.getInteractions().forEach(i => i.setActive(false));
|
||
}
|
||
|
||
function hideWindOverlay() {
|
||
showWind.value = false;
|
||
map.getInteractions().forEach(i => i.setActive(true));
|
||
}
|
||
|
||
async function fetchRadarUrl(): Promise<string | null> {
|
||
try {
|
||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json');
|
||
const data = await res.json();
|
||
const past = data.radar?.past ?? [];
|
||
const latest = past[past.length - 1];
|
||
if (!latest) return null;
|
||
|
||
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function buildRainLayer(url: string): TileLayer<XYZ> {
|
||
return new TileLayer({
|
||
source: new XYZ({
|
||
url,
|
||
maxZoom: 7,
|
||
tileLoadFunction: (tile: any, src: string) => {
|
||
const img = tile.getImage();
|
||
img.onerror = () => tile.setState(3);
|
||
img.src = src;
|
||
},
|
||
}),
|
||
opacity: 0.2,
|
||
zIndex: 5,
|
||
});
|
||
}
|
||
|
||
async function refreshRain() {
|
||
const url = await fetchRadarUrl();
|
||
if (!url) return;
|
||
|
||
if (overlayLayers['rain']) {
|
||
map.removeLayer(overlayLayers['rain']);
|
||
delete overlayLayers['rain'];
|
||
}
|
||
|
||
if (!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');
|
||
|
||
if (overlayLayers['rain']) {
|
||
map.removeLayer(overlayLayers['rain']);
|
||
delete overlayLayers['rain'];
|
||
}
|
||
} else {
|
||
activeOverlays.value.add('rain');
|
||
refreshRain();
|
||
}
|
||
}
|
||
|
||
saveLayers();
|
||
}
|
||
|
||
function toggleMenu(id: string) {
|
||
openMenu.value = openMenu.value === id ? null : id;
|
||
}
|
||
|
||
function buildBaseLayer(dark: boolean) {
|
||
const layer = new VectorTileLayer({declutter: true});
|
||
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json');
|
||
return layer;
|
||
}
|
||
|
||
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
||
const center = fromLonLat([lon, lat]);
|
||
const source = new VectorSource();
|
||
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)';
|
||
|
||
for (const nm of [50, 100, 150, 200]) {
|
||
const ring = new Feature(new CircleGeom(center, nm * NM));
|
||
|
||
ring.setStyle(new Style({
|
||
stroke: new Stroke({
|
||
color: ringStroke,
|
||
width: 1,
|
||
lineDash: [6, 4],
|
||
}),
|
||
fill: new Fill({color: 'transparent'}),
|
||
}));
|
||
|
||
source.addFeature(ring);
|
||
}
|
||
|
||
const dot = new Feature(new Point(center));
|
||
|
||
dot.setStyle(new Style({
|
||
image: new CircleStyle({
|
||
radius: 7,
|
||
fill: new Fill({color: dark ? '#ffffff' : '#000000'}),
|
||
stroke: new Stroke({
|
||
color: dark ? '#000000' : '#ffffff',
|
||
width: 2,
|
||
}),
|
||
}),
|
||
}));
|
||
|
||
source.addFeature(dot);
|
||
|
||
return new VectorLayer({source, zIndex: 20});
|
||
}
|
||
|
||
function restoreOverlayState() {
|
||
if (activeOverlays.value.has('aircraft')) {
|
||
airTraffic.show();
|
||
range.show();
|
||
} else {
|
||
airTraffic.hide();
|
||
range.hide();
|
||
}
|
||
|
||
if (activeOverlays.value.has('marine')) aisLayer.show();
|
||
else aisLayer.hide();
|
||
|
||
if (activeOverlays.value.has('satellites')) sats.show();
|
||
else sats.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) {
|
||
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: string) {
|
||
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;
|
||
|
||
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),
|
||
);
|
||
}
|
||
|
||
function clearAutoTarget() {
|
||
closeAutoPopup();
|
||
autoCategory.value = null;
|
||
autoTarget.value = null;
|
||
autoTargetKey = null;
|
||
}
|
||
|
||
function stopAutoMode() {
|
||
if (autoInterval) {
|
||
clearInterval(autoInterval);
|
||
autoInterval = undefined as any;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
onMounted(async () => {
|
||
activeOverlays.value = loadLayers();
|
||
|
||
const savedMapState = loadMapState();
|
||
const position = await api.position();
|
||
|
||
current.value = {
|
||
latitude: position?.latitude || 0,
|
||
longitude: position?.longitude || 0,
|
||
};
|
||
|
||
stationLayer = buildStationLayer(
|
||
current.value.latitude,
|
||
current.value.longitude,
|
||
props.dark,
|
||
);
|
||
|
||
const initialLatitude = savedMapState?.latitude ?? current.value.latitude;
|
||
const initialLongitude = savedMapState?.longitude ?? current.value.longitude;
|
||
const initialZoom = savedMapState?.zoom ?? 8;
|
||
|
||
map = new Map({
|
||
target: mapEl.value!,
|
||
layers: [
|
||
buildBaseLayer(props.dark),
|
||
stationLayer,
|
||
],
|
||
interactions: defaultInteractions({
|
||
altShiftDragRotate: false,
|
||
pinchRotate: false,
|
||
}),
|
||
view: new View({
|
||
center: fromLonLat([initialLongitude, initialLatitude]),
|
||
zoom: initialZoom,
|
||
maxZoom: 13,
|
||
}),
|
||
controls: [],
|
||
});
|
||
|
||
map.on('moveend', saveMapState);
|
||
|
||
aisLayer = new AISLayer(map);
|
||
airTraffic = new AirTrafficLayer(map);
|
||
sats = new SatsLayer(map);
|
||
sondes = new SondesLayer(map);
|
||
aurora = new Aurora(map);
|
||
range = new RangeLayer(map);
|
||
|
||
restoreOverlayState();
|
||
|
||
radarInterval = setInterval(refreshRain, 5 * 60 * 1000);
|
||
|
||
if (loadAutoMode()) {
|
||
setTimeout(() => {
|
||
if (!autoMode.value) startAutoMode();
|
||
}, 1_000);
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
stopAutoMode();
|
||
clearInterval(radarInterval);
|
||
|
||
if (map) saveMapState();
|
||
|
||
airTraffic?.hide();
|
||
aisLayer?.hide();
|
||
sats?.hide();
|
||
sondes?.hide();
|
||
aurora?.hide();
|
||
range?.hide();
|
||
});
|
||
|
||
watch(() => props.dark, dark => {
|
||
if (!map) return;
|
||
|
||
map.getLayers().setAt(0, buildBaseLayer(dark));
|
||
map.removeLayer(stationLayer);
|
||
|
||
stationLayer = buildStationLayer(
|
||
current.value.latitude || 0,
|
||
current.value.longitude || 0,
|
||
dark,
|
||
);
|
||
|
||
map.addLayer(stationLayer);
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.map-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.map-el {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.wind-iframe {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
border: none;
|
||
z-index: 10;
|
||
}
|
||
|
||
.overlay-toggles {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
gap: 8px;
|
||
z-index: 100;
|
||
background: var(--surface);
|
||
border-radius: 99px;
|
||
padding: 6px 10px;
|
||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.overlay-group {
|
||
position: relative;
|
||
}
|
||
|
||
.overlay-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
padding: 6px 12px;
|
||
border-radius: 99px;
|
||
border: 1.5px solid var(--border);
|
||
background: transparent;
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
|
||
&.active {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
}
|
||
|
||
&:hover:not(.active) {
|
||
background: var(--hover);
|
||
}
|
||
}
|
||
|
||
.group-btn {
|
||
position: relative;
|
||
|
||
&.has-active {
|
||
border-color: var(--accent);
|
||
}
|
||
}
|
||
|
||
.group-indicator {
|
||
font-size: 9px;
|
||
opacity: 0.7;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.overlay-submenu {
|
||
position: absolute;
|
||
bottom: calc(100% + 8px);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 130px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 6px;
|
||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.submenu-btn {
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 8px 10px;
|
||
|
||
&.active {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.auto-btn {
|
||
&.active {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.auto-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.auto-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
animation: auto-pulse 1.5s infinite;
|
||
}
|
||
|
||
@keyframes auto-pulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.35;
|
||
}
|
||
}
|
||
|
||
.layers-menu {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
right: 10px;
|
||
z-index: 100;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
}
|
||
|
||
.layers-btn {
|
||
padding: 8px 14px;
|
||
border-radius: 99px;
|
||
border: 1.5px solid var(--border);
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
backdrop-filter: blur(8px);
|
||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.layers-dropdown {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
align-items: stretch;
|
||
background: var(--surface);
|
||
border-radius: 12px;
|
||
padding: 8px;
|
||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.mobile-group {
|
||
position: relative;
|
||
}
|
||
|
||
.mobile-submenu {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 4px;
|
||
padding-left: 8px;
|
||
}
|
||
|
||
.mobile-submenu .overlay-btn {
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.desktop {
|
||
display: flex;
|
||
}
|
||
|
||
.mobile {
|
||
display: none;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.desktop {
|
||
display: none;
|
||
}
|
||
|
||
.mobile {
|
||
display: flex;
|
||
}
|
||
|
||
.layers-menu {
|
||
bottom: 10px;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<template>
|
||
<div class="map-wrap">
|
||
<div ref="mapEl" class="map-el" />
|
||
|
||
<iframe v-if="showWind" class="wind-iframe" :src="windSrc" frameborder="0" allowfullscreen/>
|
||
|
||
<div class="overlay-toggles desktop">
|
||
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="overlay-group">
|
||
<button class="overlay-btn group-btn" :class="{'has-active': group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
|
||
{{ group.icon }} {{ group.label }}
|
||
<span class="group-indicator">
|
||
{{ openMenu === group.id ? '▲' : '▼' }}
|
||
</span>
|
||
</button>
|
||
|
||
<div v-if="openMenu === group.id" class="overlay-submenu">
|
||
<button v-for="item in group.items" :key="item.id" class="overlay-btn submenu-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
|
||
{{ item.icon }} {{ item.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
|
||
{{ autoMode ? '⏹' : '▶' }} Auto
|
||
</button>
|
||
</div>
|
||
|
||
<div class="layers-menu mobile">
|
||
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
||
⚙️ Layers
|
||
</button>
|
||
|
||
<div v-if="showOverlays" class="layers-dropdown">
|
||
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="mobile-group">
|
||
<button class="overlay-btn" :class="{active: group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
|
||
{{ group.icon }} {{ group.label }}
|
||
<span class="group-indicator">
|
||
{{ openMenu === group.id ? '▲' : '▼' }}
|
||
</span>
|
||
</button>
|
||
|
||
<div v-if="openMenu === group.id" class="mobile-submenu">
|
||
<button v-for="item in group.items" :key="item.id" class="overlay-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
|
||
{{ item.icon }} {{ item.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
|
||
{{ autoMode ? '⏹ Stop Auto' : '▶ Auto Mode' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|