feat(native): an iOS map tile store, so viewed maps keep working offline

WKWebView runs no service worker for a self-hosted server, so on iOS nothing
kept a map tile once it scrolled away. Binaries with the tileCache feature
answer a trek-tile:// scheme from a store on disk: a tile viewed once is
served from there for up to 30 days (Mapbox's limit for a device cache,
applied to every provider), then fetched anew; the store keeps under 250 MB,
dropping the least recently used tiles first, sends a unique app User-Agent,
and never prefetches. Mapbox's own resources (style, tiles, glyphs, sprites)
and Leaflet's raster tiles go through it, telemetry never does.

Offline, a Mapbox map in such an app now stays Mapbox where it was viewed,
and the pill offers the stored trip region instead. The offline settings
show what the store takes against its limit, with a way to empty it.
This commit is contained in:
Maurice
2026-11-06 13:17:25 +01:00
parent d859e23879
commit dec6babe1d
69 changed files with 652 additions and 15 deletions
@@ -4,6 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap, initialMapStyle } from '../Map/glSupport'
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
@@ -395,6 +396,9 @@ function JourneyMapGL(
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// In the iOS app, Mapbox's own resources go through the app's tile store, so
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
if (isMapLibre) mapOptions.aroundCenter = false
+2 -1
View File
@@ -68,6 +68,7 @@ import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { nativeRasterTemplate } from '../../native/tileCache'
import { useSettingsStore } from '../../store/settingsStore'
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
@@ -938,7 +939,7 @@ export const MapView = memo(function MapView({
) : (
<TileLayer
key="raster"
url={basemap.url}
url={nativeRasterTemplate(basemap.url)}
attribution={attributionForTile(basemap.url)}
maxZoom={19}
keepBuffer={8}
@@ -6,14 +6,14 @@
* markers and routes, and says so.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
import { useSettingsStore } from '../../store/settingsStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { MapViewAuto } from './MapViewAuto';
const env = vi.hoisted(() => ({ offline: false, dark: false }));
const env = vi.hoisted(() => ({ offline: false, dark: false, tileStore: false }));
const seen = vi.hoisted(() => ({
leaflet: null as null | { tileUrl?: string },
gl: null as null | { engine: string; glProvider?: string; styleOverride?: string },
@@ -40,6 +40,7 @@ vi.mock('../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
}));
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore }));
vi.mock('../../native/platform', async (importOriginal) => ({
...(await importOriginal<typeof import('../../native/platform')>()),
isNativeIos: () => false,
@@ -52,6 +53,7 @@ beforeEach(() => {
resetWebGLProbe();
env.offline = false;
env.dark = false;
env.tileStore = false;
seen.leaflet = null;
seen.gl = null;
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
@@ -120,4 +122,30 @@ describe('MapViewAuto offline', () => {
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
expect(pill()).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-006: in the iOS app with its tile store, Mapbox stays offline and the pill switches to the stored map', async () => {
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
env.offline = true;
env.tileStore = true;
render(<MapViewAuto places={[]} />);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'mapbox', styleOverride: undefined });
const toggle = screen.getByRole('button', { pressed: false });
fireEvent.click(toggle);
await screen.findByTestId('gl-map');
expect(seen.gl).toMatchObject({ engine: 'maplibre', styleOverride: OFM_POSITRON });
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-OFFLINE-007: with the tile store, a raster template stays offline in the app too', () => {
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
env.offline = true;
env.tileStore = true;
render(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
});
});
+42 -11
View File
@@ -1,4 +1,4 @@
import { Suspense } from 'react'
import { Suspense, useState } from 'react'
import { AlertTriangle, Clock, CloudOff, Info } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
@@ -13,6 +13,7 @@ import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useIsDark } from '../../hooks/useIsDark'
import { OFM_POSITRON } from '../../constants/mapDefaults'
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'
import { tileCacheActive } from '../../native/tileCache'
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
@@ -43,12 +44,26 @@ export function MapViewAuto(props: MapViewAutoProps) {
const { offline } = useNetworkMode()
const dark = useIsDark()
const offlineStyle = offlineStyleFor(dark)
// Offline on a basemap that has nothing stored: draw the stored one with MapLibre.
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle)
// In the iOS app with its tile store, a Mapbox map keeps what was viewed for 30
// days, so offline it stays Mapbox, and the pill offers the stored trip region
// instead for the places never looked at. Everywhere else a basemap with
// nothing stored is swapped for the stored one straight away.
const nativeStore = tileCacheActive()
const [preferStored, setPreferStored] = useState(false)
const mapboxKept = configuredGl === 'mapbox-gl' && nativeStore
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored)
const glProvider = glFallback ? 'maplibre-gl' : configuredGl
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON)
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore))
if (leafletFallback) mapProps.tileUrl = offlineStyle
const pill = (glFallback || leafletFallback) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
const pill = offline && mapboxKept
? (
<OfflineBasemapPill
label={preferStored ? t('map.offlineBasemap') : t('map.offlineShowStored')}
pressed={preferStored}
onToggle={() => setPreferStored(v => !v)}
/>
)
: (glFallback || leafletFallback) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
// maplibre-gl out of each other's downloads.
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
@@ -79,13 +94,29 @@ export function MapViewAuto(props: MapViewAutoProps) {
)
}
/** Says the map on screen is the stored offline basemap, not the user's own. */
function OfflineBasemapPill({ label }: { label: string }) {
/** A raster template cannot be served offline in the iOS app unless it has its tile store. */
function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined {
return nativeStore ? false : undefined
}
const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
/**
* Says the map on screen is the stored offline basemap, not the user's own. With
* onToggle it is a switch between the user's map (as far as it was viewed and
* stored) and the stored trip region.
*/
function OfflineBasemapPill({ label, pressed, onToggle }: { label: string; pressed?: boolean; onToggle?: () => void }) {
if (onToggle) {
return (
<button type="button" onClick={onToggle} aria-pressed={pressed} className={`${PILL} pointer-events-auto`}>
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
{label}
</button>
)
}
return (
<div
role="status"
className="pointer-events-none absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]"
>
<div role="status" className={`${PILL} pointer-events-none`}>
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
{label}
</div>
+4
View File
@@ -27,6 +27,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import { buildGlMap, initialMapStyle } from './glSupport'
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
@@ -1018,6 +1019,9 @@ export function MapViewGL({
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// In the iOS app, Mapbox's own resources go through the app's tile store, so
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
if (isMapLibre) mapOptions.aroundCenter = false