Files
weather-station/client/src/components/MapView.vue
2026-09-13 09:29:19 -04:00

1006 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>