feat: offline map download via MapLibre OfflineManager

New src/services/offline.ts:
- downloadRegion(): createPack with bounds, zoom 6-16, progress callback
- listRegions() / deleteRegion(): pack management
- expandBounds(): adds 5km buffer around the visible area
- formatBytes(): human-readable size string

RecordingScreen:
- MapRef attached to Map component to read getBounds()
- '↓ Offline' overlay button (Liberty style + idle state only)
- Modal: name input → download → progress bar with % and MB counter
- Raster styles show no download button (not supported by OfflineManager)

Settings → App → Offline maps:
- Lists all downloaded regions with size and tile count
- Delete with confirm alert
- Placeholder text when no regions exist
This commit is contained in:
Davide Scaini
2026-06-04 00:57:23 +02:00
parent 8cc2b07b1f
commit 0d03f34371
3 changed files with 244 additions and 9 deletions
+100
View File
@@ -0,0 +1,100 @@
import { OfflineManager, type OfflinePackStatus } from '@maplibre/maplibre-react-native';
type LngLatBounds = [number, number, number, number]; // [west, south, east, north]
// Vector style URL — only vector styles support offline packs
export const OFFLINE_STYLE_URL = 'https://tiles.openfreemap.org/styles/liberty';
// Zoom levels: 6 (region overview) → 16 (street detail, good for cycling)
const MIN_ZOOM = 6;
const MAX_ZOOM = 16;
export interface OfflineRegion {
id: string;
name: string;
bounds: LngLatBounds;
createdAt: string;
completedTiles: number;
totalSizeBytes: number;
state: 'active' | 'inactive' | 'complete';
percentage: number;
}
// ── Download ──────────────────────────────────────────────────────────────────
export async function downloadRegion(
name: string,
bounds: LngLatBounds,
onProgress: (pct: number, tilesDown: number, sizeBytes: number) => void,
onError: (msg: string) => void,
): Promise<string> {
OfflineManager.setProgressEventThrottle(500);
const pack = await OfflineManager.createPack(
{
mapStyle: OFFLINE_STYLE_URL,
bounds,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
metadata: { name, createdAt: new Date().toISOString() },
},
(_pack, status: OfflinePackStatus) => {
onProgress(status.percentage, status.completedTileCount, status.completedTileSize);
},
(_pack, error) => {
onError(error.message);
},
);
return pack.id;
}
// ── List ──────────────────────────────────────────────────────────────────────
export async function listRegions(): Promise<OfflineRegion[]> {
const packs = await OfflineManager.getPacks();
const regions: OfflineRegion[] = [];
for (const pack of packs) {
const status = await pack.status();
const meta = pack.metadata as { name?: string; createdAt?: string };
regions.push({
id: pack.id,
name: meta.name ?? 'Unnamed region',
bounds: pack.bounds,
createdAt: meta.createdAt ?? '',
completedTiles: status.completedTileCount,
totalSizeBytes: status.completedTileSize,
state: status.state,
percentage: status.percentage,
});
}
return regions;
}
// ── Delete ────────────────────────────────────────────────────────────────────
export async function deleteRegion(id: string): Promise<void> {
await OfflineManager.deletePack(id);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Expand a [w,s,e,n] bounds by `km` kilometres in every direction. */
export function expandBounds(bounds: LngLatBounds, km: number): LngLatBounds {
const latDelta = km / 111;
const lonDelta = km / (111 * Math.cos((bounds[1] + bounds[3]) / 2 * Math.PI / 180));
return [
bounds[0] - lonDelta,
bounds[1] - latDelta,
bounds[2] + lonDelta,
bounds[3] + latDelta,
];
}