From a326e1c13b792c8f24ba94427c5541b465b89c0b Mon Sep 17 00:00:00 2001 From: Arturo Manzoli Date: Fri, 22 May 2026 12:25:31 -0300 Subject: [PATCH 1/2] base-station: add draggable map marker and context menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cockpit had no way to anchor the operator's physical base station on the map, so any downstream feature that needed a station position — range arcs, offscreen indicators, signal hints — had nothing to hang off of. Add the foundation pieces: - a persistent `useBaseStation` composable holding position and enabled state plus set-position/remove actions, - a draggable Leaflet marker rendered through a new `useBaseStationOverlay` composable, - a right-click `BaseStationContextPopup` for status and quick actions, - "Set / Move base station here" entries in the mission-planning and Map widget context menus. --- src/components/BaseStationContextPopup.vue | 158 ++++++++++++++++++ .../mission-planning/ContextMenu.vue | 65 +++++++ src/components/widgets/Map.vue | 94 ++++++++--- .../baseStation/baseStationOverlay.css | 39 +++++ src/composables/baseStation/useBaseStation.ts | 97 +++++++++++ .../baseStation/useBaseStationOverlay.ts | 102 +++++++++++ src/libs/baseStation/menu.ts | 13 ++ src/types/baseStation.ts | 17 ++ src/views/MissionPlanningView.vue | 17 ++ 9 files changed, 580 insertions(+), 22 deletions(-) create mode 100644 src/components/BaseStationContextPopup.vue create mode 100644 src/composables/baseStation/baseStationOverlay.css create mode 100644 src/composables/baseStation/useBaseStation.ts create mode 100644 src/composables/baseStation/useBaseStationOverlay.ts create mode 100644 src/libs/baseStation/menu.ts create mode 100644 src/types/baseStation.ts diff --git a/src/components/BaseStationContextPopup.vue b/src/components/BaseStationContextPopup.vue new file mode 100644 index 0000000000..6db0f62ef2 --- /dev/null +++ b/src/components/BaseStationContextPopup.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/components/mission-planning/ContextMenu.vue b/src/components/mission-planning/ContextMenu.vue index 19b2f69f0d..edc9fc677f 100644 --- a/src/components/mission-planning/ContextMenu.vue +++ b/src/components/mission-planning/ContextMenu.vue @@ -206,6 +206,44 @@ Set home waypoint + + + {{ baseStationPlaceMenuLabel(baseStationStore.config.enabled) }} + + + (null) /* eslint-disable jsdoc/require-jsdoc */ @@ -334,6 +381,9 @@ const emit = defineEmits<{ (event: 'setHomePosition'): void (event: 'clearVehiclePathHistory'): void (event: 'openMapOverlays'): void + (event: 'placeBaseStation'): void + (event: 'configureBaseStation'): void + (event: 'removeBaseStation'): void }>() const menuType = computed(() => props.menuType) @@ -454,6 +504,21 @@ const handleClearVehiclePathHistory = (): void => { emit('close') } +const handlePlaceBaseStation = (): void => { + emit('placeBaseStation') + emit('close') +} + +const handleConfigureBaseStation = (): void => { + emit('configureBaseStation') + emit('close') +} + +const handleRemoveBaseStation = (): void => { + emit('removeBaseStation') + emit('close') +} + const onRegenerateSurveyWaypoints = (newAngle: number): void => { emit('regenerateSurveyWaypoints', newAngle) } diff --git a/src/components/widgets/Map.vue b/src/components/widgets/Map.vue index 2403327824..13e35ed0ab 100644 --- a/src/components/widgets/Map.vue +++ b/src/components/widgets/Map.vue @@ -175,6 +175,7 @@ + vehiclePosition.value) targetFollower.setTrackableTarget(WhoToFollow.HOME, () => home.value) +useBaseStationOverlay(map, mapReady) + // Calculate live vehicle position const vehiclePosition = computed(() => vehicleStore.coordinates.latitude @@ -1437,33 +1451,20 @@ const globalOriginLatitude = ref(0) const globalOriginLongitude = ref(0) const globalOriginMarker = shallowRef() -const menuItems = reactive([ - { - item: 'Set home waypoint', - action: () => onMenuOptionSelect('set-home-waypoint'), - icon: 'mdi-home-map-marker', - }, - { - item: 'Set Global Origin', - action: () => onMenuOptionSelect('set-global-origin'), - icon: 'mdi-crosshairs-question', - }, - { - item: 'Place Point of Interest', - action: () => onMenuOptionSelect('place-poi'), - icon: 'mdi-map-marker-plus', - }, +const staticTopMenuItems = [ + { item: 'Set home waypoint', action: () => onMenuOptionSelect('set-home-waypoint'), icon: 'mdi-home-map-marker' }, + { item: 'Set Global Origin', action: () => onMenuOptionSelect('set-global-origin'), icon: 'mdi-crosshairs-question' }, + { item: 'Place Point of Interest', action: () => onMenuOptionSelect('place-poi'), icon: 'mdi-map-marker-plus' }, { item: 'Add overlay (GeoTIFF)', action: () => onMenuOptionSelect('add-overlay'), icon: 'mdi-image-plus', _isOverlay: true, }, - { - item: 'Copy coordinates', - action: () => onMenuOptionSelect('copy-coordinates'), - icon: 'mdi-content-copy', - }, + { item: 'Copy coordinates', action: () => onMenuOptionSelect('copy-coordinates'), icon: 'mdi-content-copy' }, +] + +const staticBottomMenuItems = [ { item: 'GoTo', action: () => onMenuOptionSelect('goto'), icon: 'mdi-crosshairs-gps' }, { item: 'Set default map position', @@ -1475,7 +1476,40 @@ const menuItems = reactive([ action: () => onMenuOptionSelect('clear-vehicle-path-history'), icon: 'mdi-gesture', }, -]) +] + +const baseStationMenuEntries = computed(() => { + const entries = [ + { + item: baseStationPlaceMenuLabel(baseStationStore.config.enabled), + action: () => onMenuOptionSelect('place-base-station'), + icon: baseStationMenuIcon, + }, + ] + if (baseStationStore.config.enabled) { + entries.push( + { + item: configureBaseStationMenuLabel, + action: () => onMenuOptionSelect('configure-base-station'), + icon: configureBaseStationMenuIcon, + }, + { + item: removeBaseStationMenuLabel, + action: () => onMenuOptionSelect('remove-base-station'), + icon: baseStationMenuIcon, + } + ) + } + return entries +}) + +const menuItems = reactive([...staticTopMenuItems, ...baseStationMenuEntries.value, ...staticBottomMenuItems]) + +// The base-station entries change label/visibility with the store; rebuild the fixed segments +// around them so the reactive array handed to the context menu keeps its identity. +watch(baseStationMenuEntries, (entries) => { + menuItems.splice(0, menuItems.length, ...staticTopMenuItems, ...entries, ...staticBottomMenuItems) +}) const updateSkipToWpMenu = (): void => { const want = contextMenuSelectedWpIndex.value !== null @@ -1681,6 +1715,22 @@ const onMenuOptionSelect = async (option: string): Promise => { openSnackbar({ message: 'Vehicle path history cleared', variant: 'success' }) break + case 'place-base-station': + if (clickedLocation.value) { + baseStationStore.setPosition(clickedLocation.value) + baseStationStore.configPanelOpen = true + logUserAction('Placed the base station via the map context menu') + } + break + + case 'configure-base-station': + baseStationStore.configPanelOpen = true + break + + case 'remove-base-station': + confirmRemoveBaseStation(showDialog, closeDialog) + break + default: console.warn('Unknown menu option selected:', option) } diff --git a/src/composables/baseStation/baseStationOverlay.css b/src/composables/baseStation/baseStationOverlay.css new file mode 100644 index 0000000000..c33747e22b --- /dev/null +++ b/src/composables/baseStation/baseStationOverlay.css @@ -0,0 +1,39 @@ +/* Base-station overlay styles must be global because Leaflet's `divIcon` renders the markup + outside the consuming component's scoped boundary. They live with the overlay composable so + any view that mounts it gets the styles, instead of relying on an unrelated component. */ +.base-station-marker-icon { + background: none; + border: none; +} + +.base-station-marker-container { + position: relative; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; +} + +.base-station-marker-background { + position: absolute; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #005fad; + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + z-index: 1; +} + +.base-station-marker-label { + position: absolute; + bottom: -12px; + background-color: rgba(0, 0, 0, 0.7); + color: white; + font-size: 10px; + padding: 1px 4px; + border-radius: 2px; + white-space: nowrap; +} diff --git a/src/composables/baseStation/useBaseStation.ts b/src/composables/baseStation/useBaseStation.ts new file mode 100644 index 0000000000..67ab826ae7 --- /dev/null +++ b/src/composables/baseStation/useBaseStation.ts @@ -0,0 +1,97 @@ +import { reactive, ref } from 'vue' + +import type { DialogOptions, DialogResult } from '@/composables/interactionDialog' +import { useBlueOsStorage } from '@/composables/settingsSyncer' +import { type BaseStationConfig, DEFAULT_BASE_STATION_CONFIG } from '@/types/baseStation' +import type { DialogActions } from '@/types/general' +import type { WaypointCoordinates } from '@/types/mission' + +// eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/explicit-function-return-type -- type inferred for the reactive() output to keep per-state-field typing local to this file +function initialize() { + const config = useBlueOsStorage('cockpit-base-station-config', DEFAULT_BASE_STATION_CONFIG) + + // Merge defaults so newly-added fields are populated for existing users. + config.value = { ...DEFAULT_BASE_STATION_CONFIG, ...config.value } + + const configPanelOpen = ref(false) + + const contextPopupOpen = ref(false) + const contextPopupPosition = ref({ x: 0, y: 0 }) + + const openContextPopup = (x: number, y: number): void => { + contextPopupPosition.value = { x, y } + contextPopupOpen.value = true + } + + const closeContextPopup = (): void => { + contextPopupOpen.value = false + } + + const setPosition = (position: WaypointCoordinates): void => { + config.value.position = [Number(position[0].toFixed(8)), Number(position[1].toFixed(8))] + config.value.enabled = true + } + + const remove = (): void => { + config.value = { ...DEFAULT_BASE_STATION_CONFIG } + configPanelOpen.value = false + contextPopupOpen.value = false + } + + return reactive({ + config, + configPanelOpen, + contextPopupOpen, + contextPopupPosition, + openContextPopup, + closeContextPopup, + setPosition, + remove, + }) +} + +let api: ReturnType | null = null + +/** + * Singleton-style composable holding the base-station configuration, transient UI state + * (config panel / context popup / coverage controls), and the actions that mutate them. + * State is shared across all callers; the first call lazily initializes it so dependent + * stores (Pinia, BlueOS settings) are guaranteed to be ready. + * @returns {ReturnType} Reactive base-station state and actions. + */ +export const useBaseStation = (): ReturnType => { + if (!api) api = initialize() + return api +} + +/** + * Shows the shared confirmation dialog for removing the base station and clears it once confirmed. + * Centralizes the prompt so every entry point (context popup, config panel, map context menu) + * asks before the destructive, undo-less removal. + * @param {(options: DialogOptions) => Promise} showDialog - Opens the caller's interaction dialog. + * @param {() => void} closeDialog - Closes the caller's interaction dialog. + * @returns {void} + */ +export const confirmRemoveBaseStation = ( + showDialog: (options: DialogOptions) => Promise, + closeDialog: () => void +): void => { + showDialog({ + variant: 'text-only', + message: 'Remove the base station? This will clear its position and configuration.', + persistent: false, + maxWidth: '480px', + actions: [ + { text: 'Cancel', color: 'white', action: closeDialog }, + { + text: 'Remove', + color: 'white', + action: () => { + logUserAction('Removed the base station') + useBaseStation().remove() + closeDialog() + }, + }, + ] as DialogActions[], + }) +} diff --git a/src/composables/baseStation/useBaseStationOverlay.ts b/src/composables/baseStation/useBaseStationOverlay.ts new file mode 100644 index 0000000000..6332fa47a4 --- /dev/null +++ b/src/composables/baseStation/useBaseStationOverlay.ts @@ -0,0 +1,102 @@ +import './baseStationOverlay.css' + +import L from 'leaflet' +import { type Ref, type ShallowRef, onBeforeUnmount, shallowRef, watch } from 'vue' + +import { useBaseStation } from '@/composables/baseStation/useBaseStation' +import type { BaseStationConfig } from '@/types/baseStation' + +/* eslint-disable jsdoc/require-jsdoc -- internal helper return shape, name is self-describing. */ +type BaseStationOverlayApi = { openConfigPanel: () => void } +/* eslint-enable jsdoc/require-jsdoc */ + +const baseStationMarkerHtml = (label: string): string => ` +
+
+ +
${label}
+
+` + +/** + * Renders the base-station marker on a Leaflet map and keeps it in sync with the + * {@link useBaseStation} state. Mounting and unmounting are handled automatically. + * @param {ShallowRef} map Reactive reference to the Leaflet map instance. + * @param {Ref} mapReady Reactive flag that becomes true once the map is initialized. + * @returns {BaseStationOverlayApi} Helpers to drive the overlay from the host view. + */ +export const useBaseStationOverlay = ( + map: ShallowRef, + mapReady: Ref +): BaseStationOverlayApi => { + const store = useBaseStation() + + const marker = shallowRef() + + const openConfigPanel = (): void => { + store.configPanelOpen = true + } + + const removeLayer = (layer: L.Layer | undefined): void => { + if (layer && map.value) map.value.removeLayer(layer) + } + + const buildMarkerIcon = (): L.DivIcon => + L.divIcon({ + className: 'base-station-marker-icon', + html: baseStationMarkerHtml('Base'), + iconSize: [24, 24], + iconAnchor: [12, 12], + }) + + const ensureMarker = (config: BaseStationConfig): void => { + if (!map.value || !config.position) return + if (marker.value) { + marker.value.setLatLng(config.position) + return + } + const m = L.marker(config.position, { + icon: buildMarkerIcon(), + draggable: true, + zIndexOffset: 600, + // The marker owns its own right-click popup; don't propagate to the map context menu. + bubblingMouseEvents: false, + }) + m.on('drag', (event: L.LeafletEvent) => { + const target = event.target as L.Marker + const { lat, lng } = target.getLatLng() + store.setPosition([lat, lng]) + }) + m.on('contextmenu', (event: L.LeafletMouseEvent) => { + L.DomEvent.stopPropagation(event) + event.originalEvent.stopPropagation() + event.originalEvent.preventDefault() + store.openContextPopup(event.originalEvent.clientX, event.originalEvent.clientY) + }) + m.addTo(map.value) + marker.value = m + } + + const refreshAll = (): void => { + if (!mapReady.value || !(map.value instanceof L.Map)) return + const config = store.config + + if (!config.enabled || !config.position) { + removeLayer(marker.value) + marker.value = undefined + return + } + + ensureMarker(config) + } + + watch([map, mapReady], refreshAll, { immediate: true }) + watch(() => store.config, refreshAll, { deep: true }) + + onBeforeUnmount(() => { + removeLayer(marker.value) + marker.value = undefined + }) + + return { openConfigPanel } +} diff --git a/src/libs/baseStation/menu.ts b/src/libs/baseStation/menu.ts new file mode 100644 index 0000000000..eb0e4b013f --- /dev/null +++ b/src/libs/baseStation/menu.ts @@ -0,0 +1,13 @@ +export const baseStationMenuIcon = 'mdi-radio-tower' +export const configureBaseStationMenuIcon = 'mdi-cog' +export const configureBaseStationMenuLabel = 'Configure base station' +export const removeBaseStationMenuLabel = 'Remove base station' + +/** + * Label for the "place base station" context-menu entry, shared by the Map widget and the + * mission-planning context menus so both surfaces stay in sync. + * @param {boolean} enabled Whether a base station is already placed. + * @returns {string} Menu entry label. + */ +export const baseStationPlaceMenuLabel = (enabled: boolean): string => + enabled ? 'Move base station here' : 'Set base station here' diff --git a/src/types/baseStation.ts b/src/types/baseStation.ts new file mode 100644 index 0000000000..c52293fcfc --- /dev/null +++ b/src/types/baseStation.ts @@ -0,0 +1,17 @@ +import type { WaypointCoordinates } from '@/types/mission' + +export type BaseStationConfig = { + /** + * Whether the base station is placed on the map. False until the user sets a position. + */ + enabled: boolean + /** + * Geographical position of the base station as [latitude, longitude]. + */ + position: WaypointCoordinates | null +} + +export const DEFAULT_BASE_STATION_CONFIG: BaseStationConfig = { + enabled: false, + position: null, +} diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index 6807019623..56f0c2bf74 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -581,6 +581,9 @@ @add-waypoint-at-cursor="addWaypointFromContextMenu" @clear-vehicle-path-history="clearVehiclePathHistory" @open-map-overlays="overlaysDialogOpen = true" + @place-base-station="placeBaseStationFromContextMenu" + @configure-base-station="baseStationStore.configPanelOpen = true" + @remove-base-station="confirmRemoveBaseStation(showDialog, closeDialog)" /> @@ -614,6 +617,7 @@ + mapOverlays.zoomToOverlay(missionStore.mapOverlayFocusRequest.id) ) +useBaseStationOverlay(planningMap, mapReady) + const mapCenter = ref(missionStore.userLastMapCenter ?? missionStore.defaultMapCenter) const zoom = ref(missionStore.userLastMapZoom ?? missionStore.defaultMapZoom) const followerTarget = ref(undefined) @@ -2106,6 +2116,13 @@ const setHomePositionFromContextMenu = async (): Promise => { await setHomePosition() } +const placeBaseStationFromContextMenu = (): void => { + if (!currentCursorGeoCoordinates.value) return + baseStationStore.setPosition(currentCursorGeoCoordinates.value) + baseStationStore.configPanelOpen = true + logUserAction('Placed the base station via the mission-planning context menu') +} + const setHomePosition = async (): Promise => { if (!currentCursorGeoCoordinates.value) return const newHome: [number, number] = [currentCursorGeoCoordinates.value[0], currentCursorGeoCoordinates.value[1]] From 48ec70d851ce0192e1568c82f39ce7a189032eca Mon Sep 17 00:00:00 2001 From: Arturo Manzoli Date: Fri, 22 May 2026 12:31:17 -0300 Subject: [PATCH 2/2] base-station: add config panel for tether and radio links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare position pin can't drive a meaningful coverage visualization — operators using a tether or a radio link have to express the link's characteristics (antenna type, gain, beamwidth, range, height, mast multiplier) before any range overlay can be drawn. - Add `BaseStationConfigPanel.vue` with tether-length and radio-link controls. - Track the position by GPS, from browser geolocation or from a USB/serial GNSS receiver configured under Settings > Sources. - Extend `useBaseStationOverlay` with omni/sector coverage polygons, gradient steps, and a draggable bearing handle plus aiming arc. - Persist the new fields through `useBaseStation` and the `BaseStationConfig` type. --- src/components/BaseStationConfigPanel.vue | 831 ++++++++++++++++++ src/components/widgets/Map.vue | 2 + .../baseStation/baseStationOverlay.css | 15 + src/composables/baseStation/useBaseStation.ts | 128 ++- .../baseStation/useBaseStationOverlay.ts | 225 ++++- src/libs/baseStation/coverage.ts | 168 ++++ src/types/baseStation.ts | 148 ++++ src/views/MissionPlanningView.vue | 4 +- 8 files changed, 1513 insertions(+), 8 deletions(-) create mode 100644 src/components/BaseStationConfigPanel.vue create mode 100644 src/libs/baseStation/coverage.ts diff --git a/src/components/BaseStationConfigPanel.vue b/src/components/BaseStationConfigPanel.vue new file mode 100644 index 0000000000..d9a4427fb8 --- /dev/null +++ b/src/components/BaseStationConfigPanel.vue @@ -0,0 +1,831 @@ + + + + + diff --git a/src/components/widgets/Map.vue b/src/components/widgets/Map.vue index 13e35ed0ab..7d2a9d0033 100644 --- a/src/components/widgets/Map.vue +++ b/src/components/widgets/Map.vue @@ -175,6 +175,7 @@ + ('cockpit-base-station-config', DEFAULT_BASE_STATION_CONFIG) // Merge defaults so newly-added fields are populated for existing users. - config.value = { ...DEFAULT_BASE_STATION_CONFIG, ...config.value } + config.value = { + ...DEFAULT_BASE_STATION_CONFIG, + ...config.value, + antenna: { ...DEFAULT_BASE_STATION_CONFIG.antenna, ...(config.value.antenna ?? {}) }, + } const configPanelOpen = ref(false) + const interfaceStore = useAppInterfaceStore() + watch(configPanelOpen, (isOpen) => { + interfaceStore.configPanelVisible = isOpen + if (isOpen) logUserAction('Opened the base station configuration panel') + }) + const contextPopupOpen = ref(false) const contextPopupPosition = ref({ x: 0, y: 0 }) @@ -27,6 +53,14 @@ function initialize() { contextPopupOpen.value = false } + const showCoverage = computed( + () => + config.value.enabled && + config.value.position !== null && + (config.value.commsType === BaseStationCommsType.RadioLink || + config.value.commsType === BaseStationCommsType.Tethered) + ) + const setPosition = (position: WaypointCoordinates): void => { config.value.position = [Number(position[0].toFixed(8)), Number(position[1].toFixed(8))] config.value.enabled = true @@ -38,14 +72,100 @@ function initialize() { contextPopupOpen.value = false } + const resetAntennaToDefaults = (): void => { + const factory = ANTENNA_FACTORY_DEFAULTS[config.value.antenna.type] + config.value.antenna = { ...factory, bearing: config.value.antenna.bearing } + } + + const setAntennaType = (type: AntennaType): void => { + const factory = ANTENNA_FACTORY_DEFAULTS[type] + config.value.antenna = { ...factory, bearing: config.value.antenna.bearing } + } + + const setBearing = (bearing: number): void => { + config.value.antenna.bearing = normalizeBearing(bearing) + } + + const gnss = useGnss() + + // Serial GNSS receivers only work on Standalone, so Lite is left with the browser's Geolocation API. + const gpsSourceOptions = computed(() => [ + { id: BROWSER_GEOLOCATION_SOURCE_ID, label: 'Browser geolocation' }, + ...(gnss.isSupported ? gnss.devices.value.map((device) => ({ id: device.id, label: device.name })) : []), + ]) + + // Sources that are gone (device removed, or a device id synced in from a Standalone install) fall back + // to the browser, so tracking never waits on a source that cannot report a position. + const gpsSource = computed(() => + gpsSourceOptions.value.some((source) => source.id === config.value.gpsSourceId) + ? config.value.gpsSourceId + : BROWSER_GEOLOCATION_SOURCE_ID + ) + + const isTracking = computed(() => config.value.trackByGps && config.value.enabled) + + let geoWatchId: number | null = null + const stopGeoWatch = (): void => { + if (geoWatchId !== null && navigator?.geolocation) { + navigator.geolocation.clearWatch(geoWatchId) + geoWatchId = null + } + } + const startGeoWatch = (): void => { + if (geoWatchId !== null || !navigator?.geolocation) return + geoWatchId = navigator.geolocation.watchPosition( + (position) => setPosition([position.coords.latitude, position.coords.longitude]), + (error) => { + openSnackbar({ + variant: 'error', + message: `Base station GPS tracking failed: ${error.message}. Disabling.`, + duration: 4000, + }) + config.value.trackByGps = false + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 1000 } + ) + } + + watch( + () => isTracking.value && gpsSource.value === BROWSER_GEOLOCATION_SOURCE_ID, + (usingBrowserGeolocation) => (usingBrowserGeolocation ? startGeoWatch() : stopGeoWatch()), + { immediate: true } + ) + + const trackedGnssDeviceId = computed(() => + isTracking.value && gpsSource.value !== BROWSER_GEOLOCATION_SOURCE_ID ? gpsSource.value : null + ) + + const applyGnssPosition = useThrottleFn(setPosition, gnssPositionSampleRateMs, true, true) + + watch( + () => (trackedGnssDeviceId.value === null ? undefined : gnss.latestFixes[trackedGnssDeviceId.value]), + (fix) => { + if (fix?.latitude == null || fix.longitude == null || !fix.hasValidFix) return + applyGnssPosition([fix.latitude, fix.longitude]) + }, + { immediate: true } + ) + + // This singleton never unmounts, so the watch above can't release the geolocation watch on a + // full app teardown; clear it on window unload to avoid leaking it across reloads. + if (typeof window !== 'undefined') window.addEventListener('beforeunload', stopGeoWatch) + return reactive({ config, configPanelOpen, contextPopupOpen, contextPopupPosition, + showCoverage, + gpsSource, + gpsSourceOptions, + setPosition, + setBearing, + setAntennaType, + resetAntennaToDefaults, openContextPopup, closeContextPopup, - setPosition, remove, }) } diff --git a/src/composables/baseStation/useBaseStationOverlay.ts b/src/composables/baseStation/useBaseStationOverlay.ts index 6332fa47a4..d5ab0f7525 100644 --- a/src/composables/baseStation/useBaseStationOverlay.ts +++ b/src/composables/baseStation/useBaseStationOverlay.ts @@ -4,7 +4,20 @@ import L from 'leaflet' import { type Ref, type ShallowRef, onBeforeUnmount, shallowRef, watch } from 'vue' import { useBaseStation } from '@/composables/baseStation/useBaseStation' -import type { BaseStationConfig } from '@/types/baseStation' +import { + aimingArcLatLngs, + bearingBetween, + bearingHandlePosition, + effectiveAntennaRangeMeters, + sectorPolygonLatLngs, +} from '@/libs/baseStation/coverage' +import { type BaseStationConfig, AntennaType, BaseStationCommsType } from '@/types/baseStation' + +// Concentric coverage rings with decreasing radius. Stacking them at the same per-layer opacity +// produces a smooth radial fade that mimics the pattern published in BR's directional antenna +// guide while keeping the brightest band where the signal is strongest. +const COVERAGE_GRADIENT_STEPS = 12 +const COVERAGE_STEP_OPACITY = 0.045 /* eslint-disable jsdoc/require-jsdoc -- internal helper return shape, name is self-describing. */ type BaseStationOverlayApi = { openConfigPanel: () => void } @@ -19,8 +32,9 @@ const baseStationMarkerHtml = (label: string): string => ` ` /** - * Renders the base-station marker on a Leaflet map and keeps it in sync with the - * {@link useBaseStation} state. Mounting and unmounting are handled automatically. + * Renders the base-station marker, antenna coverage and tether circle on a Leaflet map and + * keeps them in sync with the {@link useBaseStation} state. Mounting and unmounting are + * handled automatically. * @param {ShallowRef} map Reactive reference to the Leaflet map instance. * @param {Ref} mapReady Reactive flag that becomes true once the map is initialized. * @returns {BaseStationOverlayApi} Helpers to drive the overlay from the host view. @@ -32,6 +46,13 @@ export const useBaseStationOverlay = ( const store = useBaseStation() const marker = shallowRef() + const coverageLayer = shallowRef() + const coverageSteps = shallowRef<(L.Circle | L.Polygon)[]>([]) + const coverageAntennaType = shallowRef() + const tetherLayer = shallowRef() + const bearingHandle = shallowRef() + const bearingLine = shallowRef() + const aimingArc = shallowRef() const openConfigPanel = (): void => { store.configPanelOpen = true @@ -49,10 +70,19 @@ export const useBaseStationOverlay = ( iconAnchor: [12, 12], }) + const buildBearingHandleIcon = (): L.DivIcon => + L.divIcon({ + className: 'base-station-bearing-handle', + html: '
', + iconSize: [18, 18], + iconAnchor: [9, 9], + }) + const ensureMarker = (config: BaseStationConfig): void => { if (!map.value || !config.position) return if (marker.value) { marker.value.setLatLng(config.position) + applyMarkerColor(config.coverageColor) return } const m = L.marker(config.position, { @@ -75,6 +105,172 @@ export const useBaseStationOverlay = ( }) m.addTo(map.value) marker.value = m + applyMarkerColor(config.coverageColor) + } + + // Marker stays fully visible regardless of the opacity slider — only the coverage and + // bearing/arc dotted lines fade with `coverageOpacity`. The trailing `cc` keeps the + // marker's original 80% fill so the icon underneath stays legible against the map. + const applyMarkerColor = (color: string): void => { + const el = marker.value?.getElement() + if (!el) return + const bg = el.querySelector('.base-station-marker-background') as HTMLElement | null + if (bg) bg.style.backgroundColor = `${color.slice(0, 7)}cc` + } + + const updateCoverage = (config: BaseStationConfig): void => { + if (!map.value || !store.showCoverage || !config.position || config.commsType !== BaseStationCommsType.RadioLink) { + removeLayer(coverageLayer.value) + coverageLayer.value = undefined + coverageSteps.value = [] + coverageAntennaType.value = undefined + return + } + + const position = config.position + const isOmni = config.antenna.type === AntennaType.Omni + const rangeMeters = effectiveAntennaRangeMeters(config) + const stepStyle = { + color: config.coverageColor, + weight: 0, + fillColor: config.coverageColor, + fillOpacity: COVERAGE_STEP_OPACITY * config.coverageOpacity, + interactive: false, + } + const stepRadius = (step: number): number => (rangeMeters * step) / COVERAGE_GRADIENT_STEPS + + // Recreating every gradient layer on each config change thrashes Leaflet during a bearing + // drag, so reuse the existing step layers in place and only rebuild when the shape changes. + const canUpdateInPlace = + coverageLayer.value !== undefined && + coverageAntennaType.value === config.antenna.type && + coverageSteps.value.length === COVERAGE_GRADIENT_STEPS + + if (canUpdateInPlace) { + coverageSteps.value.forEach((layer, index) => { + const radius = stepRadius(index + 1) + if (isOmni) { + const circle = layer as L.Circle + circle.setLatLng(position) + circle.setRadius(radius) + circle.setStyle(stepStyle) + } else { + const polygon = layer as L.Polygon + polygon.setLatLngs(sectorPolygonLatLngs(position, radius, config.antenna.bearing, config.antenna.beamwidth)) + polygon.setStyle(stepStyle) + } + }) + return + } + + removeLayer(coverageLayer.value) + const group = L.layerGroup() + const steps: (L.Circle | L.Polygon)[] = [] + for (let step = 1; step <= COVERAGE_GRADIENT_STEPS; step++) { + const radius = stepRadius(step) + const layer = isOmni + ? L.circle(position, { ...stepStyle, radius }) + : L.polygon(sectorPolygonLatLngs(position, radius, config.antenna.bearing, config.antenna.beamwidth), stepStyle) + layer.addTo(group) + steps.push(layer) + } + group.addTo(map.value) + coverageLayer.value = group + coverageSteps.value = steps + coverageAntennaType.value = config.antenna.type + } + + const updateTether = (config: BaseStationConfig): void => { + removeLayer(tetherLayer.value) + tetherLayer.value = undefined + + if (!map.value || !store.showCoverage || !config.position) return + if (config.commsType !== BaseStationCommsType.Tethered) return + + tetherLayer.value = L.circle(config.position, { + radius: config.tetherLengthMeters, + color: config.coverageColor, + weight: 1, + opacity: config.coverageOpacity, + fillColor: config.coverageColor, + fillOpacity: 0.1 * config.coverageOpacity, + dashArray: '4 4', + interactive: false, + }).addTo(map.value) + } + + const updateBearingHandle = (config: BaseStationConfig): void => { + const shouldShow = + map.value !== undefined && + config.position !== null && + config.commsType === BaseStationCommsType.RadioLink && + config.antenna.type !== AntennaType.Omni + + if (!shouldShow) { + removeLayer(bearingHandle.value) + removeLayer(bearingLine.value) + removeLayer(aimingArc.value) + bearingHandle.value = undefined + bearingLine.value = undefined + aimingArc.value = undefined + return + } + + const rangeMeters = effectiveAntennaRangeMeters(config) + const handleLatLng = bearingHandlePosition(config.position!, rangeMeters, config.antenna.bearing) + const lineLatLngs = [config.position!, handleLatLng] as L.LatLngExpression[] + const arcLatLngs = aimingArcLatLngs(config.position!, rangeMeters, config.antenna.bearing) + const lineOpacity = 0.3 * config.coverageOpacity + const arcOpacity = 0.25 * config.coverageOpacity + + if (bearingLine.value) { + bearingLine.value.setLatLngs(lineLatLngs) + bearingLine.value.setStyle({ color: config.coverageColor, opacity: lineOpacity }) + } else { + bearingLine.value = L.polyline(lineLatLngs, { + color: config.coverageColor, + weight: 1, + dashArray: '6 4', + opacity: lineOpacity, + interactive: false, + }).addTo(map.value!) + } + + if (aimingArc.value) { + aimingArc.value.setLatLngs(arcLatLngs) + aimingArc.value.setStyle({ color: config.coverageColor, opacity: arcOpacity }) + } else { + aimingArc.value = L.polyline(arcLatLngs, { + color: config.coverageColor, + weight: 1, + dashArray: '6 4', + opacity: arcOpacity, + interactive: false, + }).addTo(map.value!) + } + + // Update in place; recreating during drag would destroy the handle Leaflet is tracking + // and stop the rotation after a single drag step. + if (bearingHandle.value) { + bearingHandle.value.setLatLng(handleLatLng) + return + } + + const handle = L.marker(handleLatLng, { + icon: buildBearingHandleIcon(), + draggable: true, + zIndexOffset: 700, + bubblingMouseEvents: true, + }) + handle.on('drag', (event: L.LeafletEvent) => { + const center = store.config.position + if (!center) return + const target = event.target as L.Marker + const { lat, lng } = target.getLatLng() + store.setBearing(bearingBetween(center, [lat, lng])) + }) + handle.addTo(map.value!) + bearingHandle.value = handle } const refreshAll = (): void => { @@ -83,11 +279,24 @@ export const useBaseStationOverlay = ( if (!config.enabled || !config.position) { removeLayer(marker.value) + removeLayer(coverageLayer.value) + removeLayer(tetherLayer.value) + removeLayer(bearingHandle.value) + removeLayer(bearingLine.value) + removeLayer(aimingArc.value) marker.value = undefined + coverageLayer.value = undefined + tetherLayer.value = undefined + bearingHandle.value = undefined + bearingLine.value = undefined + aimingArc.value = undefined return } ensureMarker(config) + updateCoverage(config) + updateTether(config) + updateBearingHandle(config) } watch([map, mapReady], refreshAll, { immediate: true }) @@ -95,7 +304,17 @@ export const useBaseStationOverlay = ( onBeforeUnmount(() => { removeLayer(marker.value) + removeLayer(coverageLayer.value) + removeLayer(tetherLayer.value) + removeLayer(bearingHandle.value) + removeLayer(bearingLine.value) + removeLayer(aimingArc.value) marker.value = undefined + coverageLayer.value = undefined + tetherLayer.value = undefined + bearingHandle.value = undefined + bearingLine.value = undefined + aimingArc.value = undefined }) return { openConfigPanel } diff --git a/src/libs/baseStation/coverage.ts b/src/libs/baseStation/coverage.ts new file mode 100644 index 0000000000..952fac9a90 --- /dev/null +++ b/src/libs/baseStation/coverage.ts @@ -0,0 +1,168 @@ +import * as turf from '@turf/turf' + +import { + type BaseStationConfig, + BaseStationCommsType, + BLUEBOAT_ANTENNA_MAST_RANGE_MULTIPLIER, + DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS, +} from '@/types/baseStation' +import type { WaypointCoordinates } from '@/types/mission' + +/** + * Number of segments used to approximate a coverage arc. + */ +export const SECTOR_ARC_STEPS = 64 + +const MIN_BASE_STATION_ANTENNA_HEIGHT_METERS = 0.5 +const MAX_BASE_STATION_ANTENNA_HEIGHT_METERS = 50 + +/** + * Range multiplier from base-station antenna height. Over flat water/terrain, radio horizon distance + * scales as √h (d_km ≈ 4.12·√h with standard 4/3 Earth-radius refraction), so practical range grows + * with the square root of height relative to {@link DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS}. + * @param {number} heightMeters Antenna height above ground in meters. + * @returns {number} Multiplier applied to the entered range for map coverage. + */ +export const baseStationAntennaHeightRangeMultiplier = (heightMeters: number): number => { + const clamped = Math.min( + MAX_BASE_STATION_ANTENNA_HEIGHT_METERS, + Math.max(MIN_BASE_STATION_ANTENNA_HEIGHT_METERS, heightMeters) + ) + return Math.sqrt(clamped / DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS) +} + +/** + * Practical antenna range (m) used for map coverage, including height and vehicle mast adjustments. + * @param {BaseStationConfig} config Current base-station configuration. + * @returns {number} Range in meters for overlay geometry. + */ +export const effectiveAntennaRangeMeters = (config: BaseStationConfig): number => { + if (config.commsType !== BaseStationCommsType.RadioLink) return config.antenna.range + + let range = config.antenna.range * baseStationAntennaHeightRangeMultiplier(config.baseStationAntennaHeightMeters) + if (config.vehicleHasBlueBoatAntennaMast) range *= BLUEBOAT_ANTENNA_MAST_RANGE_MULTIPLIER + + return Math.max(1, Math.round(range)) +} + +/** + * Rescale antenna range after a gain change. Friis: a single-end gain delta scales LOS range by + * 10^(ΔG_dB/20). + * @param {number} currentRange Current range in meters. + * @param {number} oldGain Previous antenna gain in dBi. + * @param {number} newGain New antenna gain in dBi. + * @returns {number} Rescaled range in meters (at least 1). + */ +export const rangeAfterGainChange = (currentRange: number, oldGain: number, newGain: number): number => { + const ratio = Math.pow(10, (newGain - oldGain) / 20) + return Math.max(1, Math.round(currentRange * ratio)) +} + +/** + * Rescale antenna range after a transmit-power change. Friis: range ∝ √P_t, so the range scales by + * √(P_new/P_old). + * @param {number} currentRange Current range in meters. + * @param {number} oldPowerMw Previous transmit power in milliwatts. + * @param {number} newPowerMw New transmit power in milliwatts. + * @returns {number} Rescaled range in meters (at least 1). + */ +export const rangeAfterTxPowerChange = (currentRange: number, oldPowerMw: number, newPowerMw: number): number => { + const ratio = Math.sqrt(newPowerMw / oldPowerMw) + return Math.max(1, Math.round(currentRange * ratio)) +} + +/** + * Normalize a bearing to the [0, 360) range. + * @param {number} bearing Bearing in degrees. + * @returns {number} Equivalent bearing within [0, 360). + */ +export const normalizeBearing = (bearing: number): number => ((bearing % 360) + 360) % 360 + +/** + * Great-circle bearing from one coordinate to another. + * @param {WaypointCoordinates} from Origin coordinate as [latitude, longitude]. + * @param {WaypointCoordinates} to Target coordinate as [latitude, longitude]. + * @returns {number} Bearing in degrees, where 0 = north and angles grow clockwise. + */ +export const bearingBetween = (from: WaypointCoordinates, to: WaypointCoordinates): number => + turf.bearing(turf.point([from[1], from[0]]), turf.point([to[1], to[0]])) + +/** + * Centroid (mean position) of a set of coordinates. + * @param {WaypointCoordinates[]} points Coordinates as [latitude, longitude]. + * @returns {WaypointCoordinates | null} Mean coordinate, or null when the list is empty. + */ +export const centroidOf = (points: WaypointCoordinates[]): WaypointCoordinates | null => { + if (points.length === 0) return null + const [sumLat, sumLng] = points.reduce<[number, number]>( + ([lat, lng], [pLat, pLng]) => [lat + pLat, lng + pLng], + [0, 0] + ) + return [sumLat / points.length, sumLng / points.length] +} + +/** + * Closed polygon ring outlining a directional (sector) coverage area, anchored at the center. + * @param {WaypointCoordinates} center Sector apex as [latitude, longitude]. + * @param {number} rangeMeters Sector radius in meters. + * @param {number} bearingDeg Boresight bearing in degrees. + * @param {number} beamwidthDeg Horizontal beamwidth in degrees. + * @returns {WaypointCoordinates[]} Closed polygon ring as [latitude, longitude] points. + */ +export const sectorPolygonLatLngs = ( + center: WaypointCoordinates, + rangeMeters: number, + bearingDeg: number, + beamwidthDeg: number +): WaypointCoordinates[] => { + const halfBeam = beamwidthDeg / 2 + const arc = turf.lineArc( + turf.point([center[1], center[0]]), + rangeMeters / 1000, + bearingDeg - halfBeam, + bearingDeg + halfBeam, + { + steps: SECTOR_ARC_STEPS, + } + ) + const arcPoints = arc.geometry.coordinates.map(([lng, lat]) => [lat, lng] as WaypointCoordinates) + return [center, ...arcPoints, center] +} + +/** + * Position of the draggable bearing handle at the antenna's max range along the boresight. + * @param {WaypointCoordinates} center Antenna position as [latitude, longitude]. + * @param {number} rangeMeters Range in meters. + * @param {number} bearingDeg Boresight bearing in degrees. + * @returns {WaypointCoordinates} Handle position as [latitude, longitude]. + */ +export const bearingHandlePosition = ( + center: WaypointCoordinates, + rangeMeters: number, + bearingDeg: number +): WaypointCoordinates => { + const dest = turf.destination(turf.point([center[1], center[0]]), rangeMeters / 1000, bearingDeg, { + units: 'kilometers', + }) + const [lng, lat] = dest.geometry.coordinates + return [lat, lng] +} + +/** + * 180° front-facing arc at the antenna's max range, previewing where the signal lands as the + * operator rotates the antenna. + * @param {WaypointCoordinates} center Antenna position as [latitude, longitude]. + * @param {number} rangeMeters Range in meters. + * @param {number} bearingDeg Boresight bearing in degrees. + * @returns {WaypointCoordinates[]} Arc points as [latitude, longitude]. + */ +export const aimingArcLatLngs = ( + center: WaypointCoordinates, + rangeMeters: number, + bearingDeg: number +): WaypointCoordinates[] => { + const arc = turf.lineArc(turf.point([center[1], center[0]]), rangeMeters / 1000, bearingDeg - 90, bearingDeg + 90, { + steps: SECTOR_ARC_STEPS, + }) + return arc.geometry.coordinates.map(([lng, lat]) => [lat, lng] as WaypointCoordinates) +} diff --git a/src/types/baseStation.ts b/src/types/baseStation.ts index c52293fcfc..c6b7c91e8f 100644 --- a/src/types/baseStation.ts +++ b/src/types/baseStation.ts @@ -1,5 +1,56 @@ import type { WaypointCoordinates } from '@/types/mission' +/** + * Communication link type between the topside (base station) and the vehicle. + */ +export enum BaseStationCommsType { + Tethered = 'Tethered', + MobileData = 'Mobile data (4G/5G)', + RadioLink = 'Radio link', +} + +/** + * Radio base station product family, used to seed antenna defaults. + */ +export enum RadioBaseStationKind { + BlueRobotics = 'Blue Robotics BaseStation', + Custom = 'Custom', +} + +/** + * Antenna form factor. Determines the shape of the coverage drawn on the map (full circle + * for omni, sector/cone for directional ones) and seeds gain/beamwidth/range defaults. + */ +export enum AntennaType { + Omni = 'Omnidirectional', + Panel = 'Panel', + Yagi = 'Yagi', +} + +export type AntennaSpec = { + /** + * Antenna form factor. + */ + type: AntennaType + /** + * Antenna gain in dBi. + */ + gain: number + /** + * Horizontal beamwidth in degrees. Use 360 for omnidirectional antennas. + */ + beamwidth: number + /** + * Practical communication range in meters used to draw the coverage on the map. + */ + range: number + /** + * Bearing/azimuth of the antenna boresight in degrees, where 0 = north and angles grow clockwise. + * Ignored for omnidirectional antennas. + */ + bearing: number +} + export type BaseStationConfig = { /** * Whether the base station is placed on the map. False until the user sets a position. @@ -9,9 +60,106 @@ export type BaseStationConfig = { * Geographical position of the base station as [latitude, longitude]. */ position: WaypointCoordinates | null + /** + * Whether to keep the base-station position synced with the GPS given by {@link gpsSourceId}. + */ + trackByGps: boolean + /** + * Positioning source used while {@link trackByGps} is on: either {@link BROWSER_GEOLOCATION_SOURCE_ID} + * or the id of a GNSS device configured under Settings > Sources. + */ + gpsSourceId: string + /** + * Communication link type between the topside and the vehicle. + */ + commsType: BaseStationCommsType + /** + * Radio base station product family. Only used when {@link commsType} is RadioLink. + */ + radioBaseStationKind: RadioBaseStationKind + /** + * Antenna parameters used to draw coverage. Only used when {@link commsType} is RadioLink. + */ + antenna: AntennaSpec + /** + * Height of the base-station antenna above ground in meters. Map coverage scales with √height using + * the radio-horizon range model. + */ + baseStationAntennaHeightMeters: number + /** + * Whether the vehicle mounts the BlueBoat Antenna and Accessory Mast (vehicle-side extender). + * When true, map coverage uses {@link BLUEBOAT_ANTENNA_MAST_RANGE_MULTIPLIER}× the entered range. + */ + vehicleHasBlueBoatAntennaMast: boolean + /** + * Tether length in meters used to draw coverage. Only used when {@link commsType} is Tethered. + */ + tetherLengthMeters: number + /** + * Transmitter power in milliwatts. Drives a Friis-based range scaling + * (range ∝ √P_t) when the operator picks a Custom radio. + */ + txPowerMilliwatts: number + /** + * Hex color used to render the projected antenna signal coverage on the map. + */ + coverageColor: string + /** + * Multiplier (0..1) applied to the coverage fill opacity. 1.0 keeps the default look, 0 makes it + * invisible. + */ + coverageOpacity: number } +/** + * {@link BaseStationConfig.gpsSourceId} value standing for the browser's Geolocation API. The colon keeps it + * distinct from any GNSS device id, as those are machinized to `[a-z0-9-]`. + */ +export const BROWSER_GEOLOCATION_SOURCE_ID = 'browser:geolocation' + +/** + * Factory antenna specs derived from the BlueBoat BaseStation and Directional Antenna Kit guides. + * Range values are the practical (rest-of-world) ranges from BR's tested data; gain values are + * the rest-of-world recommended values that account for connector loss. + * + * Sources: bluerobotics.com/store/boat/blueboat-components-spares/basestation and + * bluerobotics.com/learn/directional-antenna-guide. + */ +export const ANTENNA_FACTORY_DEFAULTS: Record> = { + [AntennaType.Omni]: { type: AntennaType.Omni, gain: 7, beamwidth: 360, range: 250 }, + [AntennaType.Panel]: { type: AntennaType.Panel, gain: 12, beamwidth: 40, range: 500 }, + [AntennaType.Yagi]: { type: AntennaType.Yagi, gain: 16, beamwidth: 25, range: 800 }, +} + +/** + * BlueRobotics BaseStation TX power (Microhard pMDDL/pDDL 900 MHz, 1 W max). + */ +export const BLUE_ROBOTICS_TX_POWER_MW = 1000 + +/** + * Communication range multiplier with the BlueBoat Antenna and Accessory Mast on the vehicle. + * @see https://bluerobotics.com/store/boat/blueboat-accessories/blueboat-antenna-and-accessory-mast/ + */ +export const BLUEBOAT_ANTENNA_MAST_RANGE_MULTIPLIER = 1.75 + +/** + * Reference base-station antenna height (m). Factory range values assume roughly this height on a + * typical tripod or short mast. + */ +export const DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS = 1 + export const DEFAULT_BASE_STATION_CONFIG: BaseStationConfig = { enabled: false, position: null, + trackByGps: false, + gpsSourceId: BROWSER_GEOLOCATION_SOURCE_ID, + commsType: BaseStationCommsType.RadioLink, + radioBaseStationKind: RadioBaseStationKind.BlueRobotics, + antenna: { ...ANTENNA_FACTORY_DEFAULTS[AntennaType.Omni], bearing: 0 }, + baseStationAntennaHeightMeters: DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS, + vehicleHasBlueBoatAntennaMast: false, + tetherLengthMeters: 150, + txPowerMilliwatts: BLUE_ROBOTICS_TX_POWER_MW, + coverageColor: '#3B82F6', + coverageOpacity: 1, } diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index 56f0c2bf74..00e2eaa060 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -128,7 +128,7 @@ />
@@ -617,6 +617,7 @@ +