SWMAPS DocumentationAPI portal

Offline and mobile

Store and render a local pack

Wire a downloaded pack into a browser map with local styles, glyphs, sprites and file-backed PMTiles.

On this page

Pack layout and preparation #

Prerequisites: all manifest files downloaded and verified, a bundled application shell, MapLibre GL JS 6.15.0/pmtiles 4.5.0 (the repository’s verified web versions), and local resource access. Loading only base.pmtiles is insufficient for labels and symbols.

text
manifest.json
tiles/base.pmtiles
tiles/gebco.pmtiles       # if requested
tiles/gmrt.pmtiles        # if available/requested
tiles/contours.pmtiles    # if requested
styles/dark.json
styles/light.json
sprites/dark.json        # plus PNG, @2x and the selected other theme
fonts.tar
fonts/<fontstack>/<range>.pbf  # extracted from fonts.tar
places.json              # check manifest; older packs may lack it

Extract fonts.tar into the pack folder without flattening paths. For a filesystem workflow, use tar -xf fonts.tar -C PACK_FOLDER. A browser app must supply a TAR extractor; it is not exported by @sw-maps/core. Validate archive entry paths, reject absolute paths/.. and links, and write only normal files inside that pack. The builder produces a plain TAR of glyph files.

OPFS storage and activation #

javascript
const root = await navigator.storage.getDirectory();
const packs = await root.getDirectoryHandle('packs', { create: true });
const packDir = await packs.getDirectoryHandle(manifest.packId, { create: true });
const quota = await navigator.storage.estimate();
console.log('Available estimate', (quota.quota ?? 0) - (quota.usage ?? 0));
const persistent = await navigator.storage.persist();
console.log('Persistent storage granted', persistent);
// After verifying/extracting all resources, save manifest.json last.
// Store your active pack ID in IndexedDB only when the pack is complete.
// Removal after switching away from this pack:
// await packs.removeEntry(manifest.packId, { recursive: true });

Implement staging and a ready marker: never switch an active map to a partially downloaded directory. Budget space for the new pack, extracted fonts and the previous pack until activation succeeds. Quotas are estimates and persistence can be refused. Handle QuotaExceededError, cancelled downloads, unsupported OPFS, evicted data and app restarts. Keep the manifest and an inventory so users can inspect size, release and availability, retry or remove a pack.

Serve local non-tile resources in a PWA #

One concrete design uses a service worker route /offline-pack/<packId>/... for styles, sprites, extracted glyphs and places. The following handler is application code for your existing service worker; OPFS directories must match the storage example. Cache the application shell separately using your application’s offline strategy.

service worker
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  if (url.origin !== self.location.origin || !url.pathname.startsWith('/offline-pack/')) return;
  event.respondWith((async () => {
    try {
      const parts = url.pathname.slice('/offline-pack/'.length).split('/').map(decodeURIComponent);
      if (parts.some(p => !p || p === '.' || p === '..' || /[\\/]/.test(p)))
        return new Response('Invalid path', { status: 400 });
      let dir = await (await navigator.storage.getDirectory()).getDirectoryHandle('packs');
      for (const name of parts.slice(0, -1)) dir = await dir.getDirectoryHandle(name);
      const file = await (await dir.getFileHandle(parts.at(-1))).getFile();
      const ext = parts.at(-1).split('.').at(-1);
      const type = { json: 'application/json', png: 'image/png', pbf: 'application/x-protobuf' }[ext];
      return new Response(file, { headers: { 'Content-Type': type || 'application/octet-stream' } });
    } catch { return new Response('Local pack resource missing', { status: 404 }); }
  })());
});

Do not fall back to the maps server when a local resource is missing: surface the incomplete pack. This handler deliberately does not implement PMTiles Range serving; the next example reads archives directly from OPFS Files. Leave online /maps/ Range requests out of Cache Storage. Ensure your service worker controls the page before opening local resource URLs.

Render archives from Files #

javascript
import { maplibregl, packProtocol, resolveStyle } from '@sw-maps/core';
import { PMTiles, FileSource } from 'pmtiles';

// Prerequisites: manifest, packDir and a controlling service worker as above.
const base = location.origin + '/offline-pack/' + manifest.packId;
const protocol = packProtocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
for (const entry of manifest.files.filter(f => f.path.endsWith('.pmtiles'))) {
  const names = entry.path.split('/');
  let dir = packDir;
  for (const name of names.slice(0, -1)) dir = await dir.getDirectoryHandle(name);
  const file = await (await dir.getFileHandle(names.at(-1))).getFile();
  const source = new FileSource(file);
  const key = base + '/' + entry.path;
  protocol.add(new PMTiles({ getKey: () => key,
    getBytes: (...args) => source.getBytes(...args) }));
}
const response = await fetch(base + '/styles/dark.json');
if (!response.ok) throw new Error('Local style unavailable');
const map = new maplibregl.Map({ container: 'map',
  style: resolveStyle(await response.json(), base),
  bounds: [[manifest.bbox[0], manifest.bbox[1]], [manifest.bbox[2], manifest.bbox[3]]] });
map.on('error', event => {
  if (!String(event.error?.message).includes('Tile not found')) console.error(event.error);
});

Expected result: map rendering with no maps-host connection; tiles are read from Files, and other resources from your same-origin local route. Keep attribution enabled. Use a fresh MapLibre instance/protocol lifecycle for this offline workflow: do not call createMap first and then overwrite its global protocol while another online map is using it. Remove maps and release resources on view teardown.

Sparse zooms and disconnected verification #

The base archive has z15 only in shore boxes. packProtocol() sets errorOnMissingTile:true so MapLibre GL JS draws z14 parents elsewhere. Missing vector tiles emit “Tile not found” errors; expected sparse contour tiles can do so over land too. Do not suppress other errors, and do not treat every missing base tile as harmless if the entire pack is empty.

The repository’s offline pack test verifies light/dark rendering, local fonts/sprites, and z15 inside, across and outside a shore box with outbound networking blocked. Native fallback behavior remains unverified. Your application must verify its service worker/OPFS adapter on target browsers, including restart, airplane mode and storage eviction. The existing repository test uses a local HTTP resource server and FileSource; it does not certify every PWA storage adapter.