From 3bde045b51b9e259645b305293d7328d46dbb0a8 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Tue, 14 Apr 2026 22:28:19 -0300 Subject: [PATCH 1/2] map: Extract offline tile logic into useOfflineTiles composable Move shared offline tile download dialogs, controls, and progress tracking from Map.vue and MissionPlanningView.vue into a reusable composable, eliminating ~80 lines of duplication per file. --- src/components/widgets/Map.vue | 93 +------------- src/composables/useOfflineTiles.ts | 200 +++++++++++++++++++++++++++++ src/views/MissionPlanningView.vue | 96 ++------------ 3 files changed, 214 insertions(+), 175 deletions(-) create mode 100644 src/composables/useOfflineTiles.ts diff --git a/src/components/widgets/Map.vue b/src/components/widgets/Map.vue index bd40719cbd..5bbe6665b2 100644 --- a/src/components/widgets/Map.vue +++ b/src/components/widgets/Map.vue @@ -218,8 +218,7 @@ >

Saving offline map content - ({{ savingLayerName }}):  - {{ tilesTotal ? Math.round((tilesSaved / tilesTotal) * 100) : 0 }}% + ({{ savingLayerName }}):  {{ savePercentage }}%

@@ -228,7 +227,7 @@ import { useDebounceFn, useElementHover } from '@vueuse/core' import { formatDistanceToNow } from 'date-fns' import L, { type LatLngTuple, LayersControlEvent, LeafletMouseEvent, Map } from 'leaflet' -import { SaveStatus, savetiles, tileLayerOffline } from 'leaflet.offline' +import { tileLayerOffline } from 'leaflet.offline' import { computed, nextTick, @@ -254,6 +253,7 @@ import PoiMapArrows from '@/components/poi/PoiMapArrows.vue' import { useInteractionDialog } from '@/composables/interactionDialog' import { setMapLayer } from '@/composables/map/useMapLayer' import { openSnackbar } from '@/composables/snackbar' +import { useOfflineTiles } from '@/composables/useOfflineTiles' import { MavCmd, MavType } from '@/libs/connection/m2r/messages/mavlink2rest-enum' import { datalogger, DatalogVariable } from '@/libs/sensors-logging' import { degrees } from '@/libs/utils' @@ -263,7 +263,6 @@ import { useAppInterfaceStore } from '@/stores/appInterface' import { useMainVehicleStore } from '@/stores/mainVehicle' import { useMissionStore } from '@/stores/mission' import { useWidgetManagerStore } from '@/stores/widgetManager' -import { DialogActions } from '@/types/general' import type { IconDimensions, MapTileProvider, @@ -282,6 +281,8 @@ const props = defineProps<{ widget: Widget }>() const widget = toRefs(props).widget const interfaceStore = useAppInterfaceStore() const { showDialog, closeDialog } = useInteractionDialog() +const { isSavingOfflineTiles, savingLayerName, savePercentage, downloadOfflineMapTiles, attachOfflineProgress } = + useOfflineTiles({ showDialog, closeDialog, openSnackbar }) // Instantiate the necessary stores const vehicleStore = useMainVehicleStore() const missionStore = useMissionStore() @@ -302,10 +303,6 @@ const contextMenuRef = ref() const isDragging = ref(false) const isPinching = ref(false) const isMissionChecklistOpen = ref(false) -const isSavingOfflineTiles = ref(false) -const tilesSaved = ref(0) -const tilesTotal = ref(0) -const savingLayerName = ref('') let esriSaveBtn: HTMLAnchorElement | undefined let osmSaveBtn: HTMLAnchorElement | undefined let seamarksSaveBtn: HTMLAnchorElement | undefined @@ -817,86 +814,6 @@ onMounted(async () => { }) }) -const confirmDownloadDialog = - (layerLabel: string) => - (status: SaveStatus, ok: () => void): void => { - showDialog({ - variant: 'info', - message: `Save ${status._tilesforSave.length} ${layerLabel} tiles for offline use?`, - persistent: false, - maxWidth: '450px', - actions: [ - { text: 'Cancel', color: 'white', action: closeDialog }, - { - text: 'Save tiles', - color: 'white', - action: () => { - ok() - closeDialog() - }, - }, - ] as DialogActions[], - }) - } - -const deleteDownloadedTilesDialog = - (layerLabel: string) => - (_status: SaveStatus, ok: () => void): void => { - showDialog({ - variant: 'warning', - message: `Remove all saved ${layerLabel} tiles for this layer?`, - persistent: false, - maxWidth: '450px', - actions: [ - { text: 'Cancel', color: 'white', action: closeDialog }, - { - text: 'Remove tiles', - color: 'white', - action: () => { - ok() - closeDialog() - openSnackbar({ message: `${layerLabel} offline tiles removed`, variant: 'info', duration: 3000 }) - }, - }, - ] as DialogActions[], - }) - } - -const downloadOfflineMapTiles = (layer: any, layerLabel: string, maxZoom: number): L.Control => { - return savetiles(layer, { - saveWhatYouSee: true, - maxZoom, - alwaysDownload: false, - position: 'topright', - parallel: 20, - confirm: confirmDownloadDialog(layerLabel), - confirmRemoval: deleteDownloadedTilesDialog(layerLabel), - saveText: ``, - rmText: ``, - }) -} - -const attachOfflineProgress = (layer: any, layerName: string): void => { - layer.on('savestart', (e: any) => { - tilesSaved.value = 0 - tilesTotal.value = e?._tilesforSave?.length ?? 0 - savingLayerName.value = layerName - isSavingOfflineTiles.value = true - openSnackbar({ message: `Saving ${tilesTotal.value} ${layerName} tiles...`, variant: 'info', duration: 2000 }) - }) - - layer.on('loadtileend', () => { - tilesSaved.value += 1 - if (tilesTotal.value > 0 && tilesSaved.value >= tilesTotal.value) { - openSnackbar({ message: `${layerName} offline tiles saved!`, variant: 'success', duration: 3000 }) - isSavingOfflineTiles.value = false - savingLayerName.value = '' - tilesSaved.value = 0 - tilesTotal.value = 0 - } - }) -} - const handleContextMenu = { open: async (event: MouseEvent): Promise => { if (!map.value || isPinching.value || isDragging.value) return diff --git a/src/composables/useOfflineTiles.ts b/src/composables/useOfflineTiles.ts new file mode 100644 index 0000000000..ee3212288b --- /dev/null +++ b/src/composables/useOfflineTiles.ts @@ -0,0 +1,200 @@ +import L from 'leaflet' +import { type SaveStatus, savetiles } from 'leaflet.offline' +import { computed, ref } from 'vue' + +import { type DialogActions } from '@/types/general' + +/** + * + */ +interface DialogOptions { + /** + * + */ + variant: string + /** + * + */ + message: string + /** + * + */ + persistent?: boolean + /** + * + */ + maxWidth?: string | number + /** + * + */ + actions?: DialogActions[] +} + +/** + * + */ +interface DialogResult { + /** + * + */ + isConfirmed: boolean +} + +/** + * + */ +interface SnackbarOptions { + /** + * + */ + message: string + /** + * + */ + variant: 'info' | 'success' | 'warning' | 'error' + /** + * + */ + duration?: number +} + +/** + * + */ +interface OfflineTilesDeps { + /** + * + */ + showDialog: (options: DialogOptions) => Promise + /** + * + */ + closeDialog: () => void + /** + * + */ + openSnackbar: (options: SnackbarOptions) => void +} + +/** + * Composable that encapsulates offline tile download logic including + * confirmation dialogs and download progress tracking. + * @param {OfflineTilesDeps} deps - Dialog and snackbar functions from the consuming component + * @returns {object} Reactive state and helper functions for offline tile management + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function useOfflineTiles(deps: OfflineTilesDeps) { + const { showDialog, closeDialog, openSnackbar } = deps + + const isSavingOfflineTiles = ref(false) + const tilesSaved = ref(0) + const tilesTotal = ref(0) + const savingLayerName = ref('') + + const savePercentage = computed(() => { + if (tilesTotal.value <= 0) return 0 + return Math.round((tilesSaved.value / tilesTotal.value) * 100) + }) + + const confirmDownloadDialog = + (layerLabel: string) => + (status: SaveStatus, ok: () => void): void => { + showDialog({ + variant: 'info', + message: `Save ${status._tilesforSave.length} ${layerLabel} tiles for offline use?`, + persistent: false, + maxWidth: '450px', + actions: [ + { text: 'Cancel', color: 'white', action: closeDialog }, + { + text: 'Save tiles', + color: 'white', + action: () => { + ok() + closeDialog() + }, + }, + ] as DialogActions[], + }) + } + + const deleteDownloadedTilesDialog = + (layerLabel: string) => + (_status: SaveStatus, ok: () => void): void => { + showDialog({ + variant: 'warning', + message: `Remove all saved ${layerLabel} tiles for this layer?`, + persistent: false, + maxWidth: '450px', + actions: [ + { text: 'Cancel', color: 'white', action: closeDialog }, + { + text: 'Remove tiles', + color: 'white', + action: () => { + ok() + closeDialog() + openSnackbar({ message: `${layerLabel} offline tiles removed`, variant: 'info', duration: 3000 }) + }, + }, + ] as DialogActions[], + }) + } + + /** + * Creates a savetiles control for the given layer. + * @param {any} layer - The TileLayerOffline instance + * @param {string} layerLabel - Human-readable label for the layer (e.g. "Esri") + * @param {number} maxZoom - Maximum zoom level to save + * @returns {L.Control} The savetiles control instance + */ + const downloadOfflineMapTiles = (layer: any, layerLabel: string, maxZoom: number): L.Control => { + return savetiles(layer, { + saveWhatYouSee: true, + maxZoom, + alwaysDownload: false, + position: 'topright', + parallel: 20, + confirm: confirmDownloadDialog(layerLabel), + confirmRemoval: deleteDownloadedTilesDialog(layerLabel), + saveText: ``, + rmText: ``, + }) + } + + /** + * Attaches progress event listeners to a tile layer for tracking download state. + * @param {any} layer - The TileLayerOffline instance + * @param {string} layerName - Human-readable name for snackbar messages + */ + const attachOfflineProgress = (layer: any, layerName: string): void => { + layer.on('savestart', (e: any) => { + tilesSaved.value = 0 + tilesTotal.value = e?._tilesforSave?.length ?? 0 + savingLayerName.value = layerName + isSavingOfflineTiles.value = true + openSnackbar({ message: `Saving ${tilesTotal.value} ${layerName} tiles...`, variant: 'info', duration: 2000 }) + }) + + layer.on('loadtileend', () => { + tilesSaved.value += 1 + if (tilesTotal.value > 0 && tilesSaved.value >= tilesTotal.value) { + openSnackbar({ message: `${layerName} offline tiles saved!`, variant: 'success', duration: 3000 }) + isSavingOfflineTiles.value = false + savingLayerName.value = '' + tilesSaved.value = 0 + tilesTotal.value = 0 + } + }) + } + + return { + isSavingOfflineTiles, + tilesSaved, + tilesTotal, + savingLayerName, + savePercentage, + downloadOfflineMapTiles, + attachOfflineProgress, + } +} diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index 3ad440618c..28a389e97a 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -582,7 +582,10 @@ class="absolute top-14 left-2 flex justify-start items-center text-white text-md py-2 px-4 rounded-lg" :style="interfaceStore.globalGlassMenuStyles" > -

Saving offline map content: {{ tilesTotal ? Math.round((tilesSaved / tilesTotal) * 100) : 0 }}%

+

+ Saving offline map content + ({{ savingLayerName }}):  {{ savePercentage }}% +

@@ -594,7 +597,7 @@ import { formatDistanceToNow } from 'date-fns' import { format } from 'date-fns' import { saveAs } from 'file-saver' import L, { type LatLngTuple, LayersControlEvent, LeafletMouseEvent, Map, Marker, Polygon } from 'leaflet' -import { SaveStatus, savetiles, tileLayerOffline } from 'leaflet.offline' +import { tileLayerOffline } from 'leaflet.offline' import { v4 as uuid } from 'uuid' import { type InstanceType, computed, nextTick, onMounted, onUnmounted, ref, shallowRef, toRaw, watch } from 'vue' @@ -618,6 +621,7 @@ import { setSurveyAreaSquareMeters, useMissionEstimates, } from '@/composables/useMissionEstimates' +import { useOfflineTiles } from '@/composables/useOfflineTiles' import { MavType } from '@/libs/connection/m2r/messages/mavlink2rest-enum' import { MavCmd } from '@/libs/connection/m2r/messages/mavlink2rest-enum' import { centroidLatLng, polygonAreaSquareMeters } from '@/libs/mission/general-estimates' @@ -629,7 +633,7 @@ import { SubMenuComponentName, SubMenuName, useAppInterfaceStore } from '@/store import { useMainVehicleStore } from '@/stores/mainVehicle' import { useMissionStore } from '@/stores/mission' import { useWidgetManagerStore } from '@/stores/widgetManager' -import { DialogActions, Point2D } from '@/types/general' +import { Point2D } from '@/types/general' import { type CockpitMission, type Waypoint, @@ -658,6 +662,8 @@ const { height: windowHeight } = useWindowSize() const { showDialog, closeDialog } = useInteractionDialog() const { openSnackbar } = useSnackbar() +const { isSavingOfflineTiles, savingLayerName, savePercentage, downloadOfflineMapTiles, attachOfflineProgress } = + useOfflineTiles({ showDialog, closeDialog, openSnackbar }) const clearMissionOnVehicle = (): void => { vehicleStore.clearMissions() @@ -881,10 +887,6 @@ const loading = ref(false) const showMissionCreationTips = ref(missionStore.showMissionCreationTips) const countdownToHideTips = ref(undefined) const isSettingHomeWaypoint = ref(false) -const isSavingOfflineTiles = ref(false) -const tilesSaved = ref(0) -const tilesTotal = ref(0) -const savingLayerName = ref('') const downloadMenuOpen = ref(false) const gridLayer = shallowRef(undefined) let esriSaveBtn: HTMLAnchorElement | undefined @@ -3561,86 +3563,6 @@ const onMapClick = (e: L.LeafletMouseEvent): void => { } } -const confirmDownloadDialog = - (layerLabel: string) => - (status: SaveStatus, ok: () => void): void => { - showDialog({ - variant: 'info', - message: `Save ${status._tilesforSave.length} ${layerLabel} tiles for offline use?`, - persistent: false, - maxWidth: '450px', - actions: [ - { text: 'Cancel', color: 'white', action: closeDialog }, - { - text: 'Save tiles', - color: 'white', - action: () => { - ok() - closeDialog() - }, - }, - ] as DialogActions[], - }) - } - -const deleteDownloadedTilesDialog = - (layerLabel: string) => - (_status: SaveStatus, ok: () => void): void => { - showDialog({ - variant: 'warning', - message: `Remove all saved ${layerLabel} tiles for this layer?`, - persistent: false, - maxWidth: '450px', - actions: [ - { text: 'Cancel', color: 'white', action: closeDialog }, - { - text: 'Remove tiles', - color: 'white', - action: () => { - ok() - closeDialog() - openSnackbar({ message: `${layerLabel} offline tiles removed`, variant: 'info', duration: 3000 }) - }, - }, - ] as DialogActions[], - }) - } - -const downloadOfflineMapTiles = (layer: any, layerLabel: string, maxZoom: number): L.Control => { - return savetiles(layer, { - saveWhatYouSee: true, - maxZoom, - alwaysDownload: false, - position: 'topright', - parallel: 20, - confirm: confirmDownloadDialog(layerLabel), - confirmRemoval: deleteDownloadedTilesDialog(layerLabel), - saveText: ``, - rmText: ``, - }) -} - -const attachOfflineProgress = (layer: any, layerName: string): void => { - layer.on('savestart', (e: any) => { - tilesSaved.value = 0 - tilesTotal.value = e?._tilesforSave?.length ?? 0 - savingLayerName.value = layerName - isSavingOfflineTiles.value = true - openSnackbar({ message: `Saving ${tilesTotal.value} ${layerName} tiles...`, variant: 'info', duration: 2000 }) - }) - - layer.on('loadtileend', () => { - tilesSaved.value += 1 - if (tilesTotal.value > 0 && tilesSaved.value >= tilesTotal.value) { - openSnackbar({ message: `${layerName} offline tiles saved!`, variant: 'success', duration: 3000 }) - isSavingOfflineTiles.value = false - savingLayerName.value = '' - tilesSaved.value = 0 - tilesTotal.value = 0 - } - }) -} - onMounted(async () => { const osm = tileLayerOffline('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 23, From f2ef14681442033016a490212742c7422ee03edc Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Tue, 14 Apr 2026 22:30:14 -0300 Subject: [PATCH 2/2] map: Add estimated download size to offline tile dialogs Sample up to 3 tiles before confirming a download to estimate total size in MB. Display the estimate in both the confirmation dialog and the progress overlay during download. --- src/components/widgets/Map.vue | 14 ++++++- src/composables/useOfflineTiles.ts | 65 +++++++++++++++++++++++++++--- src/views/MissionPlanningView.vue | 14 ++++++- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/components/widgets/Map.vue b/src/components/widgets/Map.vue index 5bbe6665b2..f58e2e8a6d 100644 --- a/src/components/widgets/Map.vue +++ b/src/components/widgets/Map.vue @@ -219,6 +219,9 @@

Saving offline map content ({{ savingLayerName }}):  {{ savePercentage }}% + + (~{{ estimatedDownloadedMB }} / {{ estimatedTotalMB }} MB) +

@@ -281,8 +284,15 @@ const props = defineProps<{ widget: Widget }>() const widget = toRefs(props).widget const interfaceStore = useAppInterfaceStore() const { showDialog, closeDialog } = useInteractionDialog() -const { isSavingOfflineTiles, savingLayerName, savePercentage, downloadOfflineMapTiles, attachOfflineProgress } = - useOfflineTiles({ showDialog, closeDialog, openSnackbar }) +const { + isSavingOfflineTiles, + savingLayerName, + estimatedTotalMB, + estimatedDownloadedMB, + savePercentage, + downloadOfflineMapTiles, + attachOfflineProgress, +} = useOfflineTiles({ showDialog, closeDialog, openSnackbar }) // Instantiate the necessary stores const vehicleStore = useMainVehicleStore() const missionStore = useMissionStore() diff --git a/src/composables/useOfflineTiles.ts b/src/composables/useOfflineTiles.ts index ee3212288b..63319abfc9 100644 --- a/src/composables/useOfflineTiles.ts +++ b/src/composables/useOfflineTiles.ts @@ -1,5 +1,5 @@ import L from 'leaflet' -import { type SaveStatus, savetiles } from 'leaflet.offline' +import { type SaveStatus, type TileInfo, downloadTile, savetiles } from 'leaflet.offline' import { computed, ref } from 'vue' import { type DialogActions } from '@/types/general' @@ -76,9 +76,37 @@ interface OfflineTilesDeps { openSnackbar: (options: SnackbarOptions) => void } +const SAMPLE_COUNT = 3 + +/** + * Estimates the average byte size of tiles by downloading a small sample. + * @param {TileInfo[]} tiles - The array of tile info objects to sample from + * @param {number} count - How many tiles to sample + * @returns {Promise} Average tile size in bytes, or 0 on failure + */ +async function estimateAvgTileSize(tiles: TileInfo[], count: number = SAMPLE_COUNT): Promise { + const samples = tiles.slice(0, Math.min(count, tiles.length)) + if (samples.length === 0) return 0 + try { + const blobs = await Promise.all(samples.map((t) => downloadTile(t.url))) + return blobs.reduce((sum, b) => sum + b.size, 0) / blobs.length + } catch { + return 0 + } +} + +/** + * Formats a byte value into a human-readable MB string. + * @param {number} bytes - Size in bytes + * @returns {string} Formatted size string (e.g. "12.3 MB") + */ +function formatMB(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1) +} + /** * Composable that encapsulates offline tile download logic including - * confirmation dialogs and download progress tracking. + * size estimation, confirmation dialogs, and download progress tracking. * @param {OfflineTilesDeps} deps - Dialog and snackbar functions from the consuming component * @returns {object} Reactive state and helper functions for offline tile management */ @@ -90,6 +118,17 @@ export function useOfflineTiles(deps: OfflineTilesDeps) { const tilesSaved = ref(0) const tilesTotal = ref(0) const savingLayerName = ref('') + const avgTileSize = ref(0) + + const estimatedTotalMB = computed(() => { + if (avgTileSize.value <= 0 || tilesTotal.value <= 0) return '' + return formatMB(avgTileSize.value * tilesTotal.value) + }) + + const estimatedDownloadedMB = computed(() => { + if (avgTileSize.value <= 0) return '' + return formatMB(avgTileSize.value * tilesSaved.value) + }) const savePercentage = computed(() => { if (tilesTotal.value <= 0) return 0 @@ -98,10 +137,22 @@ export function useOfflineTiles(deps: OfflineTilesDeps) { const confirmDownloadDialog = (layerLabel: string) => - (status: SaveStatus, ok: () => void): void => { + async (status: SaveStatus, ok: () => void): Promise => { + const tileCount = status._tilesforSave.length + let sizeInfo = '' + try { + const avg = await estimateAvgTileSize(status._tilesforSave) + if (avg > 0) { + avgTileSize.value = avg + sizeInfo = ` (~${formatMB(avg * tileCount)} MB)` + } + } catch { + // Fall back to count-only display + } + showDialog({ variant: 'info', - message: `Save ${status._tilesforSave.length} ${layerLabel} tiles for offline use?`, + message: `Save ${tileCount} ${layerLabel} tiles${sizeInfo} for offline use?`, persistent: false, maxWidth: '450px', actions: [ @@ -142,7 +193,7 @@ export function useOfflineTiles(deps: OfflineTilesDeps) { } /** - * Creates a savetiles control for the given layer. + * Creates a savetiles control for the given layer with download size estimation. * @param {any} layer - The TileLayerOffline instance * @param {string} layerLabel - Human-readable label for the layer (e.g. "Esri") * @param {number} maxZoom - Maximum zoom level to save @@ -184,6 +235,7 @@ export function useOfflineTiles(deps: OfflineTilesDeps) { savingLayerName.value = '' tilesSaved.value = 0 tilesTotal.value = 0 + avgTileSize.value = 0 } }) } @@ -193,6 +245,9 @@ export function useOfflineTiles(deps: OfflineTilesDeps) { tilesSaved, tilesTotal, savingLayerName, + avgTileSize, + estimatedTotalMB, + estimatedDownloadedMB, savePercentage, downloadOfflineMapTiles, attachOfflineProgress, diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index 28a389e97a..0ccb780779 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -585,6 +585,9 @@

Saving offline map content ({{ savingLayerName }}):  {{ savePercentage }}% + + (~{{ estimatedDownloadedMB }} / {{ estimatedTotalMB }} MB) +

@@ -662,8 +665,15 @@ const { height: windowHeight } = useWindowSize() const { showDialog, closeDialog } = useInteractionDialog() const { openSnackbar } = useSnackbar() -const { isSavingOfflineTiles, savingLayerName, savePercentage, downloadOfflineMapTiles, attachOfflineProgress } = - useOfflineTiles({ showDialog, closeDialog, openSnackbar }) +const { + isSavingOfflineTiles, + savingLayerName, + estimatedTotalMB, + estimatedDownloadedMB, + savePercentage, + downloadOfflineMapTiles, + attachOfflineProgress, +} = useOfflineTiles({ showDialog, closeDialog, openSnackbar }) const clearMissionOnVehicle = (): void => { vehicleStore.clearMissions()