diff --git a/src/components/widgets/Map.vue b/src/components/widgets/Map.vue index 08ca6288a5..d5ce391dd3 100644 --- a/src/components/widgets/Map.vue +++ b/src/components/widgets/Map.vue @@ -256,6 +256,7 @@ import PoiMapArrows from '@/components/poi/PoiMapArrows.vue' import { useInteractionDialog } from '@/composables/interactionDialog' import { provideMapContext } from '@/composables/map/useMapContext' import { openSnackbar } from '@/composables/snackbar' +import { useMapOverlays } from '@/composables/useMapOverlays' import { MavCmd, MavType } from '@/libs/connection/m2r/messages/mavlink2rest-enum' import { datalogger, DatalogVariable } from '@/libs/sensors-logging' import { degrees } from '@/libs/utils' @@ -558,18 +559,23 @@ const mapBase = ref() const isMouseOver = useElementHover(mapBase) const zoomControl = L.control.zoom({ position: 'bottomright' }) -const layerControl = L.control.layers(baseMaps, overlays) +const layerControl = ref() const gridLayer = shallowRef(undefined) watch(showButtons, () => { if (map.value === undefined) return if (showButtons.value) { map.value.addControl(zoomControl) - map.value.addControl(layerControl) + if (!layerControl.value) { + layerControl.value = L.control.layers(baseMaps, overlays) + } + map.value.addControl(layerControl.value) createScaleControl() } else { map.value.removeControl(zoomControl) - map.value.removeControl(layerControl) + if (layerControl.value) { + map.value.removeControl(layerControl.value) + } removeScaleControl() } }) @@ -665,6 +671,8 @@ const removeScaleControl = (): void => { } } +const { setupMapOverlays } = useMapOverlays(map, layerControl) + onMounted(async () => { reachedWaypoints.value = {} missionItemsInVehicle.value = [] @@ -677,7 +685,7 @@ onMounted(async () => { // Bind leaflet instance to map element map.value = L.map(mapId.value, { - layers: [initialBaseLayer, seamarks, marineProfile], + layers: [initialBaseLayer], attributionControl: false, }).setView(mapCenter.value as LatLngTuple, zoom.value) as Map @@ -697,6 +705,14 @@ onMounted(async () => { // Remove default zoom control map.value.removeControl(map.value.zoomControl) + // Setup overlays with custom panes and fetch logic + setupMapOverlays(map.value) + + // Add default overlays with proper pane (pane needs to exist before addTo for z-index to apply) + seamarks.options.pane = 'seamarks' + seamarks.addTo(map.value) + marineProfile.addTo(map.value) + map.value.on('click', (event: LeafletMouseEvent) => { clickedLocation.value = [event.latlng.lat, event.latlng.lng] }) diff --git a/src/composables/useMapOverlays.ts b/src/composables/useMapOverlays.ts new file mode 100644 index 0000000000..5d78976409 --- /dev/null +++ b/src/composables/useMapOverlays.ts @@ -0,0 +1,130 @@ +import L from 'leaflet' +import { type Ref, ref } from 'vue' + +/* eslint-disable jsdoc/require-jsdoc */ +interface Overlay { + id: string + title: string + description: string + url: string +} + +/** + * Composable that manages dynamic, bounds-driven map tile overlays fetched from a remote server. + * Registers map event handlers to refetch overlays on pan/zoom and keeps the provided + * layer control in sync with the overlays currently in view. + * @param {Ref} map Reactive reference to the Leaflet map instance. + * @param {Ref} layerControl Reactive reference to the Leaflet layer control used to toggle overlays. + * @returns {{ + * setupMapOverlays: (leafletMap: L.Map) => void, + * mapBounds: Ref, + * dynamicOverlays: Ref>, + * overlaysInView: Ref, + * }} An object exposing the setup function and reactive overlay state. + */ +export function useMapOverlays( + map: Ref, + layerControl: Ref +): { + setupMapOverlays: (leafletMap: L.Map) => void + mapBounds: Ref + dynamicOverlays: Ref> + overlaysInView: Ref +} { + const mapBounds = ref() + const dynamicOverlays = ref>({}) + const overlaysInView = ref([]) + const lastFetchTime = ref(0) + + const updateOverlays = (): void => { + if (!map.value || !layerControl.value) return + + // Keep track of current overlay IDs + const currentOverlayIds = new Set(Object.keys(dynamicOverlays.value)) + const newOverlayIds = new Set(overlaysInView.value.map((o) => o.id.toString())) + + // Remove overlays that are no longer in view + currentOverlayIds.forEach((id) => { + if (!newOverlayIds.has(id)) { + const layer = dynamicOverlays.value[id] + map.value?.removeLayer(layer) + layerControl.value?.removeLayer(layer) + delete dynamicOverlays.value[id] + } + }) + + // Add new overlays + overlaysInView.value.forEach((overlay) => { + const id = overlay.id.toString() + // Skip if we already have this overlay + if (currentOverlayIds.has(id)) return + + const tileLayer = L.tileLayer(overlay.url, { + maxZoom: 22, + minZoom: 1, + tileSize: 256, + attribution: `© ${overlay.title}`, + }) + + // Add to map first (will be underneath existing layers) + tileLayer.addTo(map.value!) + // Then add to control + layerControl.value?.addOverlay(tileLayer, overlay.title) + dynamicOverlays.value[id] = tileLayer + }) + } + + const fetchAndUpdateOverlays = async (): Promise => { + if (!mapBounds.value) return + + const now = Date.now() + const timeSinceLastFetch = now - lastFetchTime.value + + if (timeSinceLastFetch < 1000) return + + const bounds = mapBounds.value + const url = `https://logtools.cloud/api/images/bounds/?min_lat=${bounds.getSouth()}&max_lat=${bounds.getNorth()}&min_lon=${bounds.getWest()}&max_lon=${bounds.getEast()}` + + try { + lastFetchTime.value = now + const response = await fetch(url) + const data = await response.json() + overlaysInView.value = data.map((overlay: any) => ({ + id: overlay.id, + title: overlay.title, + description: overlay.description, + url: `https://logtools.cloud/api/images/${overlay.id}/tiles/{z}/{x}/{y}.png`, + })) + updateOverlays() + } catch (error) { + console.error('Failed to fetch overlays:', error) + } + } + + const setupMapOverlays = (leafletMap: L.Map): void => { + // Create a custom pane for seamarks with highest z-index + leafletMap.createPane('seamarks') + const seamarksPane = leafletMap.getPane('seamarks') + if (seamarksPane) { + seamarksPane.style.zIndex = '650' // Above other overlays (default overlay pane is 400) + } + + // Set up map event handlers for overlay updates + leafletMap.on('moveend', () => { + mapBounds.value = leafletMap.getBounds() + fetchAndUpdateOverlays() + }) + + leafletMap.on('zoomend', () => { + mapBounds.value = leafletMap.getBounds() + fetchAndUpdateOverlays() + }) + } + + return { + setupMapOverlays, + mapBounds, + dynamicOverlays, + overlaysInView, + } +} diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index a5513e7b56..1d48e0df0f 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -627,6 +627,7 @@ import SideConfigPanel from '@/components/SideConfigPanel.vue' import { useInteractionDialog } from '@/composables/interactionDialog' import { provideMapContext } from '@/composables/map/useMapContext' import { useSnackbar } from '@/composables/snackbar' +import { useMapOverlays } from '@/composables/useMapOverlays' import { clearAllSurveyAreas, removeSurveyAreaSquareMeters, @@ -3579,6 +3580,9 @@ const onMapClick = (e: L.LeafletMouseEvent): void => { } } +const layerControl = ref() +const { setupMapOverlays } = useMapOverlays(planningMap, layerControl) + const confirmDownloadDialog = (layerLabel: string) => (status: SaveStatus, ok: () => void): void => { @@ -3715,17 +3719,20 @@ onMounted(async () => { missionStore.userLastMapTileProvider = event.name as MapTileProvider }) + // Initialize layer control and setup external overlay fetching + layerControl.value = L.control.layers(baseMaps) + planningMap.value.addControl(layerControl.value) + setupMapOverlays(planningMap.value) + planningMap.value.on('moveend', () => { - if (planningMap.value === undefined) return - let { lat, lng } = planningMap.value.getCenter() - if (lat && lng) { - mapCenter.value = [lat, lng] - } + if (!planningMap.value) return + const center = planningMap.value.getCenter() + mapCenter.value = [center.lat, center.lng] }) planningMap.value.on('zoomstart', clearLiveMeasure) planningMap.value.on('zoomend', () => { - if (planningMap.value === undefined) return - zoom.value = planningMap.value?.getZoom() ?? mapCenter.value + if (!planningMap.value) return + zoom.value = planningMap.value.getZoom() }) const saveCtlEsri = downloadOfflineMapTiles(esri, 'Esri', 19) @@ -3780,9 +3787,6 @@ onMounted(async () => { onMapClick(e) }) - const layerControl = L.control.layers(baseMaps) - planningMap.value.addControl(layerControl) - // Initialize scale control (always show) createScaleControl()