Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Below is a table summarizing the current status, but in general, you can expect
| **Video** | Needs to be downloaded and merged into a working video using the Desktop app | ✅ Final MP4 file saved directly to your folders |
| **Snapshots** | Needs to be downloaded | ✅ Saved directly to your folders |
| **Vehicle Discovery** | ❌ Not available | ✅ Auto-scan for vehicles in the network|
| **Mobile Coverage (OpenCellID)** | Requires a personal OpenCellID API key (browser CORS blocks the key-free endpoint) | ✅ Fetched through the built-in bridge (no browser CORS), but still needs a personal OpenCellID API key. Use the OpenStreetMap provider for key-free coverage |
| **Updates** | Manual updates required | ✅ Auto-updates / update notifications |
| **System Monitoring** | Memory usage only | ✅ CPU and Memory tracking |
| **Workspace Capture** | ❌ Not available | ✅ Full interface screenshots |
Expand Down
1,249 changes: 1,249 additions & 0 deletions src/components/BaseStationConfigPanel.vue

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions src/components/BaseStationContextPopup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<template>
<Teleport to="body">
<div
v-if="store.contextPopupOpen && config.position"
class="base-station-context-popup fixed z-[99999] flex flex-col rounded-md text-white"
:style="[
interfaceStore.globalGlassMenuStyles,
{ background: '#333333EE', border: '1px solid #FFFFFF44' },
{ top: `${position.y}px`, left: `${position.x}px`, width: `${POPUP_WIDTH}px` },
]"
@click.stop
@contextmenu.stop.prevent
>
<div class="flex justify-between items-center pt-1 pb-2 px-2">
<p class="text-[14px] truncate mr-2">Base station</p>
<div class="flex shrink-0 items-center gap-x-1">
<v-btn
v-tooltip="{ text: 'Remove base station', zIndex: TOOLTIP_Z_INDEX }"
icon="mdi-trash-can"
variant="text"
size="x-small"
color="white"
aria-label="Remove base station"
@click="onDelete"
/>
<v-btn
v-tooltip="{ text: 'Configure base station', zIndex: TOOLTIP_Z_INDEX }"
icon="mdi-cog"
variant="text"
size="x-small"
color="white"
aria-label="Configure base station"
@click="onConfigure"
/>
</div>
</div>

<v-divider />

<div
class="flex flex-col justify-center w-full items-center py-1 px-2 bg-[#EEEEEE] text-black rounded-bl-md rounded-br-md"
>
<div class="flex w-full items-center gap-x-2 text-[10px] py-[1px] mb-[2px]">
<v-icon
v-tooltip="{ text: 'Copy latitude to clipboard', zIndex: TOOLTIP_Z_INDEX }"
variant="text"
icon="mdi-content-copy"
size="x-small"
color="black"
class="text-[12px] cursor-pointer shrink-0"
@click="onCopyCoordinate(0)"
/>
<p class="flex-1">Lat.:</p>
<p>{{ config.position[0].toFixed(7) }}</p>
</div>
<v-divider class="border-black w-full" />
<div class="flex w-full items-center gap-x-2 text-[10px] py-[1px]">
<v-icon
v-tooltip="{ text: 'Copy longitude to clipboard', zIndex: TOOLTIP_Z_INDEX }"
variant="text"
icon="mdi-content-copy"
size="x-small"
color="black"
class="text-[12px] cursor-pointer shrink-0"
@click="onCopyCoordinate(1)"
/>
<p class="flex-1">Long.:</p>
<p>{{ config.position[1].toFixed(7) }}</p>
</div>
</div>
</div>
</Teleport>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue'

import { confirmRemoveBaseStation, useBaseStation } from '@/composables/baseStation/useBaseStation'
import { useInteractionDialog } from '@/composables/interactionDialog'
import { openSnackbar } from '@/composables/snackbar'
import { copyToClipboard } from '@/libs/utils'
import { useAppInterfaceStore } from '@/stores/appInterface'

const POPUP_WIDTH = 221
const POPUP_HEIGHT = 110
const TOOLTIP_Z_INDEX = 100000

// Popup state lives in the store, so the click/keydown listeners are registered once at the
// module level, regardless of how many widgets mount this component.
let activeInstanceCount = 0
let documentClickHandler: ((event: MouseEvent) => void) | null = null
let documentKeydownHandler: ((event: KeyboardEvent) => void) | null = null

const store = useBaseStation()
const interfaceStore = useAppInterfaceStore()
const { showDialog, closeDialog } = useInteractionDialog()

const config = computed(() => store.config)

// Clamp the popup inside the viewport so it never opens off-screen near the map edges.
const position = computed(() => {
const margin = 8
let x = store.contextPopupPosition.x
let y = store.contextPopupPosition.y
if (x + POPUP_WIDTH > window.innerWidth - margin) x = window.innerWidth - POPUP_WIDTH - margin
if (y + POPUP_HEIGHT > window.innerHeight - margin) y = window.innerHeight - POPUP_HEIGHT - margin
if (x < margin) x = margin
if (y < margin) y = margin
return { x, y }
})

const onDelete = (): void => {
store.closeContextPopup()
confirmRemoveBaseStation(showDialog, closeDialog)
}

const onConfigure = (): void => {
store.configPanelOpen = true
store.closeContextPopup()
}

const onCopyCoordinate = async (index: 0 | 1): Promise<void> => {
if (!config.value.position) return
const label = index === 0 ? 'Latitude' : 'Longitude'
try {
await copyToClipboard(`${config.value.position[index]}`)
openSnackbar({ message: `${label} copied to clipboard.`, variant: 'success' })
} catch (error) {
openSnackbar({ message: `Failed to copy ${label.toLowerCase()}: ${(error as Error).message}`, variant: 'error' })
}
}

onMounted(() => {
if (activeInstanceCount === 0) {
documentClickHandler = () => {
if (store.contextPopupOpen) store.closeContextPopup()
}
documentKeydownHandler = (event: KeyboardEvent) => {
if (event.key === 'Escape' && store.contextPopupOpen) store.closeContextPopup()
}
document.addEventListener('click', documentClickHandler)
window.addEventListener('keydown', documentKeydownHandler)
}
activeInstanceCount++
})

onBeforeUnmount(() => {
activeInstanceCount--
if (activeInstanceCount === 0) {
if (documentClickHandler) document.removeEventListener('click', documentClickHandler)
if (documentKeydownHandler) window.removeEventListener('keydown', documentKeydownHandler)
documentClickHandler = null
documentKeydownHandler = null
}
})
</script>
53 changes: 53 additions & 0 deletions src/components/mission-planning/ContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,39 @@
<span class="text-white text-sm ml-4">Set home waypoint</span>
</v-list-item>
<v-divider />
<v-list-item class="flex items-center gap-x-2 pb-2" @click="handlePlaceBaseStation">
<v-icon
variant="text"
icon="mdi-radio-tower"
rounded="full"
size="x-small"
color="white"
class="text-[16px]"
></v-icon>
<span class="text-white text-sm ml-4">{{
baseStationStore.config.enabled ? 'Move base station here' : 'Set base station here'
}}</span>
</v-list-item>
<template v-if="baseStationStore.config.enabled">
<v-divider />
<v-list-item class="flex items-center gap-x-2 pb-2" @click="handleConfigureBaseStation">
<v-icon variant="text" icon="mdi-cog" rounded="full" size="x-small" color="white" class="text-[16px]" />
<span class="text-white text-sm ml-4">Configure base station</span>
</v-list-item>
<v-divider />
<v-list-item class="flex items-center gap-x-2 pb-2" @click="handleRemoveBaseStation">
<v-icon
variant="text"
icon="mdi-radio-tower"
rounded="full"
size="x-small"
color="white"
class="text-[16px]"
/>
<span class="text-white text-sm ml-4">Remove base station</span>
</v-list-item>
</template>
<v-divider />
<v-list-item class="flex items-center gap-x-2 pb-2" @click="handleClearVehiclePathHistory">
<v-icon
variant="text"
Expand Down Expand Up @@ -261,12 +294,14 @@
import { computed, defineEmits, defineProps, nextTick, ref, watch } from 'vue'

import ScanDirectionDial from '@/components/mission-planning/ScanDirectionDial.vue'
import { useBaseStation } from '@/composables/baseStation/useBaseStation'
import { useAppInterfaceStore } from '@/stores/appInterface'
import { useMissionStore } from '@/stores/mission'
import { ContextMenuTypes, Survey, Waypoint } from '@/types/mission'

const missionStore = useMissionStore()
const interfaceStore = useAppInterfaceStore()
const baseStationStore = useBaseStation()
const menuEl = ref<HTMLElement | null>(null)

/* eslint-disable jsdoc/require-jsdoc */
Expand Down Expand Up @@ -301,6 +336,9 @@ const emit = defineEmits<{
(event: 'placePointOfInterest'): void
(event: 'setHomePosition'): void
(event: 'clearVehiclePathHistory'): void
(event: 'placeBaseStation'): void
(event: 'configureBaseStation'): void
(event: 'removeBaseStation'): void
}>()

const menuType = computed(() => props.menuType)
Expand Down Expand Up @@ -409,6 +447,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)
}
Expand Down
79 changes: 79 additions & 0 deletions src/components/widgets/Map.vue
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@
</p>

<PoiManager ref="poiManagerMapWidgetRef" />
<BaseStationConfigPanel />
<BaseStationContextPopup />
<MissionChecklist
:model-value="isMissionChecklistOpen"
@confirmed="executeMissionOnVehicle"
Expand Down Expand Up @@ -284,12 +286,16 @@ import copterMarkerImage from '@/assets/arducopter-top-view.avif'
import blueboatMarkerImage from '@/assets/blueboat-marker.avif'
import brov2MarkerImage from '@/assets/brov2-marker.avif'
import genericVehicleMarkerImage from '@/assets/generic-vehicle-marker.avif'
import BaseStationConfigPanel from '@/components/BaseStationConfigPanel.vue'
import BaseStationContextPopup from '@/components/BaseStationContextPopup.vue'
import ExpansiblePanel from '@/components/ExpansiblePanel.vue'
import GlobalOriginDialog from '@/components/GlobalOriginDialog.vue'
import MapNorthIndicator from '@/components/map/MapNorthIndicator.vue'
import MissionChecklist from '@/components/MissionChecklist.vue'
import PoiManager from '@/components/poi/PoiManager.vue'
import PoiMapArrows from '@/components/poi/PoiMapArrows.vue'
import { confirmRemoveBaseStation, useBaseStation } from '@/composables/baseStation/useBaseStation'
import { useBaseStationOverlay } from '@/composables/baseStation/useBaseStationOverlay'
import { useInteractionDialog } from '@/composables/interactionDialog'
import { provideMapContext } from '@/composables/map/useMapContext'
import { openSnackbar } from '@/composables/snackbar'
Expand Down Expand Up @@ -342,6 +348,7 @@ const {
const vehicleStore = useMainVehicleStore()
const missionStore = useMissionStore()
const widgetStore = useWidgetManagerStore()
const baseStationStore = useBaseStation()
const router = useRouter()

const mapContext = provideMapContext()
Expand Down Expand Up @@ -1183,6 +1190,8 @@ const targetFollower = new TargetFollower(
targetFollower.setTrackableTarget(WhoToFollow.VEHICLE, () => vehiclePosition.value)
targetFollower.setTrackableTarget(WhoToFollow.HOME, () => home.value)

useBaseStationOverlay(map, mapReady)

// Calculate live vehicle position
const vehiclePosition = computed(() =>
vehicleStore.coordinates.latitude
Expand Down Expand Up @@ -1464,6 +1473,22 @@ const globalOriginLatitude = ref(0)
const globalOriginLongitude = ref(0)
const globalOriginMarker = shallowRef<L.Marker>()

// Tag used to identify the base-station "place" entry so the watcher can rebind it
// on store changes without relying on label/icon string matching.
type BaseStationMenuTags = {
/* eslint-disable jsdoc/require-jsdoc */
_isBaseStationPlace?: boolean
_isBaseStationContext?: boolean
/* eslint-enable jsdoc/require-jsdoc */
}

const baseStationMenuItem = computed(() => ({
item: baseStationStore.config.enabled ? 'Move base station here' : 'Set base station here',
action: () => onMenuOptionSelect('place-base-station'),
icon: 'mdi-radio-tower',
_isBaseStationPlace: true,
}))

const menuItems = reactive([
{
item: 'Set home waypoint',
Expand All @@ -1480,6 +1505,7 @@ const menuItems = reactive([
action: () => onMenuOptionSelect('place-poi'),
icon: 'mdi-map-marker-plus',
},
baseStationMenuItem.value,
{ item: 'GoTo', action: () => onMenuOptionSelect('goto'), icon: 'mdi-crosshairs-gps' },
{
item: 'Set default map position',
Expand All @@ -1493,6 +1519,44 @@ const menuItems = reactive([
},
])

// The base-station entry's label depends on whether one already exists; rebind on store changes.
watch(baseStationMenuItem, (newItem) => {
const idx = menuItems.findIndex((i) => (i as BaseStationMenuTags)._isBaseStationPlace === true)
if (idx >= 0) menuItems[idx] = newItem
})

const baseStationContextItems = computed(() =>
baseStationStore.config.enabled
? [
{
item: 'Configure base station',
action: () => onMenuOptionSelect('configure-base-station'),
icon: 'mdi-cog',
_isBaseStationContext: true,
},
{
item: 'Remove base station',
action: () => onMenuOptionSelect('remove-base-station'),
icon: 'mdi-radio-tower',
_isBaseStationContext: true,
},
]
: []
)

watch(
baseStationContextItems,
(newItems) => {
for (let i = menuItems.length - 1; i >= 0; i--) {
if ((menuItems[i] as BaseStationMenuTags)._isBaseStationContext) {
menuItems.splice(i, 1)
}
}
newItems.forEach((i) => menuItems.push(i))
},
{ immediate: true }
)

const updateSkipToWpMenu = (): void => {
const want = contextMenuSelectedWpIndex.value !== null
const last = menuItems[menuItems.length - 1] as any
Expand Down Expand Up @@ -1681,6 +1745,21 @@ const onMenuOptionSelect = async (option: string): Promise<void> => {
openSnackbar({ message: 'Vehicle path history cleared', variant: 'success' })
break

case 'place-base-station':
if (clickedLocation.value) {
baseStationStore.setPosition(clickedLocation.value)
baseStationStore.configPanelOpen = true
}
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)
}
Expand Down
Loading