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:
@@ -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,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user