diff --git a/src/components/widgets/Map.vue b/src/components/widgets/Map.vue
index bd40719cbd..f58e2e8a6d 100644
--- a/src/components/widgets/Map.vue
+++ b/src/components/widgets/Map.vue
@@ -218,8 +218,10 @@
>
Saving offline map content
- ({{ savingLayerName }}):
- {{ tilesTotal ? Math.round((tilesSaved / tilesTotal) * 100) : 0 }}%
+ ({{ savingLayerName }}): {{ savePercentage }}%
+
+ (~{{ estimatedDownloadedMB }} / {{ estimatedTotalMB }} MB)
+
@@ -228,7 +230,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 +256,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 +266,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 +284,15 @@ const props = defineProps<{ widget: Widget }>()
const widget = toRefs(props).widget
const interfaceStore = useAppInterfaceStore()
const { showDialog, closeDialog } = useInteractionDialog()
+const {
+ isSavingOfflineTiles,
+ savingLayerName,
+ estimatedTotalMB,
+ estimatedDownloadedMB,
+ savePercentage,
+ downloadOfflineMapTiles,
+ attachOfflineProgress,
+} = useOfflineTiles({ showDialog, closeDialog, openSnackbar })
// Instantiate the necessary stores
const vehicleStore = useMainVehicleStore()
const missionStore = useMissionStore()
@@ -302,10 +313,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 +824,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..63319abfc9
--- /dev/null
+++ b/src/composables/useOfflineTiles.ts
@@ -0,0 +1,255 @@
+import L from 'leaflet'
+import { type SaveStatus, type TileInfo, downloadTile, 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
+}
+
+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
+ * 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
+ */
+// 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 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
+ return Math.round((tilesSaved.value / tilesTotal.value) * 100)
+ })
+
+ const confirmDownloadDialog =
+ (layerLabel: string) =>
+ 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 ${tileCount} ${layerLabel} tiles${sizeInfo} 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 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
+ * @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
+ avgTileSize.value = 0
+ }
+ })
+ }
+
+ return {
+ isSavingOfflineTiles,
+ tilesSaved,
+ tilesTotal,
+ savingLayerName,
+ avgTileSize,
+ estimatedTotalMB,
+ estimatedDownloadedMB,
+ savePercentage,
+ downloadOfflineMapTiles,
+ attachOfflineProgress,
+ }
+}
diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue
index 3ad440618c..0ccb780779 100644
--- a/src/views/MissionPlanningView.vue
+++ b/src/views/MissionPlanningView.vue
@@ -582,7 +582,13 @@
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 }}%
+
+ (~{{ estimatedDownloadedMB }} / {{ estimatedTotalMB }} MB)
+
+
@@ -594,7 +600,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 +624,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 +636,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 +665,15 @@ const { height: windowHeight } = useWindowSize()
const { showDialog, closeDialog } = useInteractionDialog()
const { openSnackbar } = useSnackbar()
+const {
+ isSavingOfflineTiles,
+ savingLayerName,
+ estimatedTotalMB,
+ estimatedDownloadedMB,
+ savePercentage,
+ downloadOfflineMapTiles,
+ attachOfflineProgress,
+} = useOfflineTiles({ showDialog, closeDialog, openSnackbar })
const clearMissionOnVehicle = (): void => {
vehicleStore.clearMissions()
@@ -881,10 +897,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 +3573,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,