mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(native): offline vector maps in the iOS app
In a browser the service worker answers MapLibre from the gl-map-offline cache that glPrefetcher fills for trips marked for offline. The iOS app has no service worker, so nothing read that cache there and the map went blank without network. A trek-offline:// protocol takes the service worker's place. Inside the iOS app the MapLibre style URL is rewritten to it, the handler rewrites every https URL inside the style and its TileJSON the same way, and then serves documents network first and tiles, glyphs and sprites cache first. Outside the iOS app nothing changes. The default OpenFreeMap basemap is covered; Mapbox GL has no protocol hook and stays online-only in the app, as does a custom raster template, whose opaque responses only a service worker can store.
This commit is contained in:
@@ -5,6 +5,7 @@ import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings,
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { offlineStyle } from '../../native/offlineTiles'
|
||||
|
||||
export interface JourneyMapGLHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
@@ -290,7 +291,7 @@ function JourneyMapGL(
|
||||
|
||||
const mapOptions: Record<string, unknown> = {
|
||||
container: containerRef.current,
|
||||
style: glStyle,
|
||||
style: isMapLibre ? offlineStyle(glStyle) : glStyle,
|
||||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||
zoom: hasPoints ? 2 : 1,
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
|
||||
@@ -43,6 +43,7 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { offlineStyle } from '../../native/offlineTiles'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -1219,7 +1220,7 @@ export function MapViewGL({
|
||||
|
||||
const mapOptions: Record<string, unknown> = {
|
||||
container: containerRef.current,
|
||||
style: glStyle,
|
||||
style: isMapLibre ? offlineStyle(glStyle) : glStyle,
|
||||
center: [initial.center[1], initial.center[0]],
|
||||
zoom: initial.zoom,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||
import { offlineStyle } from '../../native/offlineTiles'
|
||||
|
||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
|
||||
@@ -142,7 +143,7 @@ export function detachBasemapLayer(layer: BasemapLayer | null | undefined): void
|
||||
*/
|
||||
export function restyleBasemap(layer: BasemapLayer | null | undefined, style: string): void {
|
||||
if (!layer || !isGlLayer(layer)) return
|
||||
layer.getMaplibreMap()?.setStyle(style)
|
||||
layer.getMaplibreMap()?.setStyle(offlineStyle(style))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,7 +189,7 @@ export function VectorBasemap({ style }: { style: string }) {
|
||||
// change must retile in place rather than tear the layer down, the same
|
||||
// reason the raster maps keep their template out of the build effect (#2097).
|
||||
const layer = maplibreGL({
|
||||
style: styleRef.current,
|
||||
style: offlineStyle(styleRef.current),
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
@@ -264,7 +265,7 @@ export async function attachVectorBasemap(
|
||||
return
|
||||
}
|
||||
|
||||
const layer = maplibreGL({ style, interactive: false, attributionControl: false })
|
||||
const layer = maplibreGL({ style: offlineStyle(style), interactive: false, attributionControl: false })
|
||||
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
|
||||
// one of its own; attachGlLayer adds it once the layer is actually on the map.
|
||||
if (!attachGlLayer(map, layer, style)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
import { installOfflineProtocol } from '../../../native/offlineTiles'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
|
||||
@@ -15,5 +16,7 @@ import { registerRtlTextPlugin } from '../rtlText'
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
|
||||
// The iOS app has no service worker to serve pre-downloaded tiles; see native/offlineTiles.ts.
|
||||
installOfflineProtocol(maplibregl)
|
||||
|
||||
export default maplibregl
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
fromOfflineUrl, installOfflineProtocol, offlineProtocol, offlineStyle, rewriteDocument, toOfflineUrl,
|
||||
} from './offlineTiles'
|
||||
import { isNativeIos } from './platform'
|
||||
|
||||
vi.mock('./platform', () => ({ isNativeIos: vi.fn() }))
|
||||
|
||||
const cached = new Map<string, Response>()
|
||||
const cache = { match: vi.fn(async (url: string) => cached.get(url)?.clone()) }
|
||||
|
||||
beforeEach(() => {
|
||||
cached.clear()
|
||||
vi.mocked(isNativeIos).mockReturnValue(true)
|
||||
vi.stubGlobal('caches', { open: vi.fn(async () => cache) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function params(url: string, type: 'json' | 'arrayBuffer' | 'image' | 'string') {
|
||||
return { url, type }
|
||||
}
|
||||
|
||||
describe('URL rewriting', () => {
|
||||
it('moves https URLs onto the protocol and back, and leaves everything else', () => {
|
||||
expect(toOfflineUrl('https://tiles.openfreemap.org/styles/positron')).toBe('trek-offline://tiles.openfreemap.org/styles/positron')
|
||||
expect(fromOfflineUrl('trek-offline://tiles.openfreemap.org/styles/positron')).toBe('https://tiles.openfreemap.org/styles/positron')
|
||||
expect(toOfflineUrl('mapbox://styles/mapbox/streets-v12')).toBe('mapbox://styles/mapbox/streets-v12')
|
||||
expect(fromOfflineUrl('https://x.test/a')).toBe('https://x.test/a')
|
||||
})
|
||||
|
||||
it('rewrites the style only inside the iOS app with Cache Storage', () => {
|
||||
const style = 'https://tiles.openfreemap.org/styles/liberty?key=abc'
|
||||
expect(offlineStyle(style)).toBe('trek-offline://tiles.openfreemap.org/styles/liberty?key=abc')
|
||||
expect(offlineStyle({ version: 8 })).toEqual({ version: 8 })
|
||||
|
||||
vi.mocked(isNativeIos).mockReturnValue(false)
|
||||
expect(offlineStyle(style)).toBe(style)
|
||||
|
||||
vi.mocked(isNativeIos).mockReturnValue(true)
|
||||
vi.stubGlobal('caches', undefined)
|
||||
expect(offlineStyle(style)).toBe(style)
|
||||
})
|
||||
|
||||
it('points sources, tiles, glyphs and sprites of a style at the protocol', () => {
|
||||
expect(rewriteDocument({
|
||||
version: 8,
|
||||
glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: [{ id: 'default', url: 'https://tiles.openfreemap.org/sprites/ofm' }, 'odd'],
|
||||
sources: {
|
||||
openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' },
|
||||
inline: { type: 'raster', tiles: ['https://a.test/{z}/{x}/{y}.png', 7] },
|
||||
broken: null,
|
||||
},
|
||||
layers: [],
|
||||
})).toEqual({
|
||||
version: 8,
|
||||
glyphs: 'trek-offline://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: [{ id: 'default', url: 'trek-offline://tiles.openfreemap.org/sprites/ofm' }, 'odd'],
|
||||
sources: {
|
||||
openmaptiles: { type: 'vector', url: 'trek-offline://tiles.openfreemap.org/planet' },
|
||||
inline: { type: 'raster', tiles: ['trek-offline://a.test/{z}/{x}/{y}.png', 7] },
|
||||
broken: null,
|
||||
},
|
||||
layers: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('handles a TileJSON, a single sprite URL and things that are not documents', () => {
|
||||
expect(rewriteDocument({ tiles: ['https://t.test/20250101/{z}/{x}/{y}.pbf'] })).toEqual({ tiles: ['trek-offline://t.test/20250101/{z}/{x}/{y}.pbf'] })
|
||||
expect(rewriteDocument({ sprite: 'https://s.test/sprite' })).toEqual({ sprite: 'trek-offline://s.test/sprite' })
|
||||
expect(rewriteDocument('x')).toBe('x')
|
||||
expect(rewriteDocument([1])).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('offlineProtocol', () => {
|
||||
it('answers a document from the network and rewrites it', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ tiles: ['https://t.test/{z}/{x}/{y}.pbf'] })))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const result = await offlineProtocol(params('trek-offline://t.test/planet', 'json'), new AbortController())
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://t.test/planet', expect.objectContaining({ mode: 'cors' }))
|
||||
expect(result.data).toEqual({ tiles: ['trek-offline://t.test/{z}/{x}/{y}.pbf'] })
|
||||
})
|
||||
|
||||
it('falls back to the cached document when the network is gone', async () => {
|
||||
cached.set('https://t.test/style', new Response(JSON.stringify({ version: 8 })))
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline') }))
|
||||
const result = await offlineProtocol(params('trek-offline://t.test/style', 'json'), new AbortController())
|
||||
expect(result.data).toEqual({ version: 8 })
|
||||
})
|
||||
|
||||
it('serves tiles from the cache without asking the network', async () => {
|
||||
cached.set('https://t.test/1/2/3.pbf', new Response(new Uint8Array([1, 2, 3])))
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const result = await offlineProtocol(params('trek-offline://t.test/1/2/3.pbf', 'arrayBuffer'), new AbortController())
|
||||
expect(new Uint8Array(result.data as ArrayBuffer)).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches an uncached tile, and returns text for string requests', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('hello')))
|
||||
const tile = await offlineProtocol(params('trek-offline://t.test/9/9/9.png', 'image'), new AbortController())
|
||||
expect(new TextDecoder().decode(tile.data as ArrayBuffer)).toBe('hello')
|
||||
const text = await offlineProtocol(params('trek-offline://t.test/a.txt', 'string'), new AbortController())
|
||||
expect(text.data).toBe('hello')
|
||||
})
|
||||
|
||||
it('fails loudly on an uncached tile offline or an HTTP error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline') }))
|
||||
await expect(offlineProtocol(params('trek-offline://t.test/0/0/0.pbf', 'arrayBuffer'), new AbortController())).rejects.toThrow('offline')
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 404 })))
|
||||
await expect(offlineProtocol(params('trek-offline://t.test/0/0/0.pbf', 'arrayBuffer'), new AbortController())).rejects.toThrow('HTTP 404')
|
||||
await expect(offlineProtocol(params('trek-offline://t.test/style', 'json'), new AbortController())).rejects.toThrow('Not available offline')
|
||||
})
|
||||
|
||||
it('passes an abort straight through for documents', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new DOMException('aborted', 'AbortError') }))
|
||||
await expect(offlineProtocol(params('trek-offline://t.test/style', 'json'), controller)).rejects.toThrow('aborted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('installOfflineProtocol', () => {
|
||||
it('registers only inside the iOS app', () => {
|
||||
const addProtocol = vi.fn()
|
||||
installOfflineProtocol({ addProtocol })
|
||||
expect(addProtocol).toHaveBeenCalledWith('trek-offline', offlineProtocol)
|
||||
|
||||
addProtocol.mockClear()
|
||||
vi.mocked(isNativeIos).mockReturnValue(false)
|
||||
installOfflineProtocol({ addProtocol })
|
||||
expect(addProtocol).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { AddProtocolAction, GetResourceResponse, RequestParameters } from 'maplibre-gl'
|
||||
import { VECTOR_CACHE } from '../sync/glPrefetcher'
|
||||
import { isNativeIos } from './platform'
|
||||
|
||||
/**
|
||||
* Offline vector maps for the iOS app.
|
||||
*
|
||||
* In a browser the service worker answers MapLibre's requests from the
|
||||
* 'gl-map-offline' cache that glPrefetcher fills for trips marked for offline.
|
||||
* The iOS app has no service worker, so nothing reads that cache there. This
|
||||
* protocol takes the service worker's place: the style URL is rewritten to
|
||||
* trek-offline://, every https URL inside the style and its TileJSON gets the
|
||||
* same scheme, and the handler serves them from the cache or the network.
|
||||
*
|
||||
* Mapbox GL has no protocol hook, so a Mapbox basemap stays online-only in
|
||||
* the app.
|
||||
*/
|
||||
|
||||
export const OFFLINE_SCHEME = 'trek-offline'
|
||||
const PREFIX = `${OFFLINE_SCHEME}://`
|
||||
|
||||
export function offlineTilesActive(): boolean {
|
||||
return isNativeIos() && typeof caches !== 'undefined'
|
||||
}
|
||||
|
||||
export function toOfflineUrl(url: string): string {
|
||||
return url.startsWith('https://') ? PREFIX + url.slice('https://'.length) : url
|
||||
}
|
||||
|
||||
export function fromOfflineUrl(url: string): string {
|
||||
return url.startsWith(PREFIX) ? 'https://' + url.slice(PREFIX.length) : url
|
||||
}
|
||||
|
||||
/** The style URL to hand MapLibre: rewritten inside the iOS app, untouched anywhere else. */
|
||||
export function offlineStyle<T>(style: T): T {
|
||||
return offlineTilesActive() && typeof style === 'string' ? (toOfflineUrl(style) as T) : style
|
||||
}
|
||||
|
||||
type Doc = Record<string, unknown>
|
||||
|
||||
const isDoc = (value: unknown): value is Doc => typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
const rewriteList = (value: unknown): unknown => (Array.isArray(value) ? value.map((v) => (typeof v === 'string' ? toOfflineUrl(v) : v)) : value)
|
||||
|
||||
/**
|
||||
* Points the URLs inside a style document or a TileJSON at the protocol, so
|
||||
* that MapLibre asks this handler for the sprite, the glyphs, the TileJSON and
|
||||
* the tiles as well, not only for the style.
|
||||
*/
|
||||
export function rewriteDocument(doc: unknown): unknown {
|
||||
if (!isDoc(doc)) return doc
|
||||
const out: Doc = { ...doc }
|
||||
if ('tiles' in out) out.tiles = rewriteList(out.tiles)
|
||||
if (typeof out.glyphs === 'string') out.glyphs = toOfflineUrl(out.glyphs)
|
||||
if (typeof out.sprite === 'string') out.sprite = toOfflineUrl(out.sprite)
|
||||
else if (Array.isArray(out.sprite)) {
|
||||
out.sprite = out.sprite.map((entry) => (isDoc(entry) && typeof entry.url === 'string' ? { ...entry, url: toOfflineUrl(entry.url) } : entry))
|
||||
}
|
||||
if (isDoc(out.sources)) {
|
||||
out.sources = Object.fromEntries(Object.entries(out.sources).map(([id, source]) => {
|
||||
if (!isDoc(source)) return [id, source]
|
||||
const rewritten: Doc = { ...source }
|
||||
if (typeof rewritten.url === 'string') rewritten.url = toOfflineUrl(rewritten.url)
|
||||
if ('tiles' in rewritten) rewritten.tiles = rewriteList(rewritten.tiles)
|
||||
return [id, rewritten]
|
||||
}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents are asked of the network first so a changed style shows up, and
|
||||
* fall back to the cache offline. Tiles, glyphs and sprites never change under
|
||||
* their URL, so the cache goes first and saves the round trip.
|
||||
*/
|
||||
async function load(url: string, networkFirst: boolean, signal: AbortSignal): Promise<Response> {
|
||||
const cache = await caches.open(VECTOR_CACHE)
|
||||
if (!networkFirst) {
|
||||
const cached = await cache.match(url)
|
||||
if (cached) return cached
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { mode: 'cors', signal })
|
||||
if (response.ok) return response
|
||||
if (!networkFirst) return response
|
||||
} catch (err) {
|
||||
if (signal.aborted || !networkFirst) throw err
|
||||
}
|
||||
const cached = await cache.match(url)
|
||||
if (cached) return cached
|
||||
throw new Error(`Not available offline: ${url}`)
|
||||
}
|
||||
|
||||
export const offlineProtocol: AddProtocolAction = async (params: RequestParameters, abortController: AbortController): Promise<GetResourceResponse<unknown>> => {
|
||||
const url = fromOfflineUrl(params.url)
|
||||
const response = await load(url, params.type === 'json', abortController.signal)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
|
||||
if (params.type === 'json') return { data: rewriteDocument(await response.json()) }
|
||||
if (params.type === 'string') return { data: await response.text() }
|
||||
return { data: await response.arrayBuffer() }
|
||||
}
|
||||
|
||||
export function installOfflineProtocol(maplibre: { addProtocol: (name: string, action: AddProtocolAction) => void }): void {
|
||||
if (offlineTilesActive()) maplibre.addProtocol(OFFLINE_SCHEME, offlineProtocol)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const CONCURRENCY = 6
|
||||
const MAX_VECTOR_TILES = 2000
|
||||
|
||||
/** Workbox runtime cache for the vector basemap (see vite.config.js). */
|
||||
const VECTOR_CACHE = 'gl-map-offline'
|
||||
export const VECTOR_CACHE = 'gl-map-offline'
|
||||
|
||||
/**
|
||||
* Latin glyph ranges. A label needs the range its codepoints fall in, and Latin
|
||||
|
||||
Reference in New Issue
Block a user