fix(maps): default the Leaflet basemap to OpenFreeMap

CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile
on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves
a self-hosted instance needing an email exchange with CARTO before its maps
look right, and every default in the client still pointed at CARTO. Default
to OpenFreeMap Positron instead: no key, no account, no request limit,
commercial use allowed, attribution required.

OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre
style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL
canvas into Leaflet's own tile pane, which leaves everything above it alone -
markers, GeoJSON, clusters, plugin layers, the custom panes, and the click
handling, since Leaflet's CSS already puts pointer-events:none on that layer.
maplibre-gl still arrives only through a dynamic import, so no chunk that
draws a map grows by a megabyte.

Switched: the trip planner, the MapView fallback every preview goes through,
the journey map, collections, the atlas, the shared trip page and the offline
tile prefetch. OpenFreeMap Positron and Bright join the tile presets in
settings, the admin defaults and both mobile screens.

A CARTO template that is already saved keeps working when a key backs it -
that stays a supported choice. Without a key it is not drawn at all:
resolveTileUrl falls back to the default rather than to a watermark, so an
instance that upgrades with one in its settings sees the new basemap
immediately and needs no migration. The stored value is left as it is, so the
Map settings tab still shows what was chosen and the warning underneath it
explains why - one sentence appended, in all 23 locales.

OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles
is wrong and a licence problem. attributionForTile() derives it from the URL
rather than from a flag at each call site, and the shared trip map, which
carried no attribution at all, now gets one.

Offline maps keep working and get cheaper. A vector style is four things
instead of one, so sync/glPrefetcher.ts walks the style document, its sprite,
the Latin glyph ranges of each font stack it uses, the TileJSON - the planet
path is versioned, so guessing it answers 200 with an empty body - and then
the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is
2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177
requests and 32.1 MB and stays sharp above that, because MapLibre scales the
z14 tile rather than asking for another. The Workbox rule for
tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache,
so browsing tops a downloaded region up instead of filling a second copy
beside it - a versioned tile path can never be stale. Clearing offline maps
clears it too.

The atlas loses its second raster layer. It existed to warm the HTTP cache of
neighbouring zoom levels, which a vector basemap does not need; a second one
would only cost a second WebGL context. OpenFreeMap has no label-free style,
so its labels are switched off on the live map instead - they are all symbol
layers, while borders and coastlines are line and fill - re-armed on
style.load so a theme switch keeps them off.

Theme changes call setStyle in place rather than rebuilding the layer: a swap
per toggle would leak a WebGL context, and browsers cap those in the low
teens.
This commit is contained in:
Maurice
2026-08-29 00:02:12 +02:00
committed by Maurice
parent 1d12535b1b
commit b401da2264
53 changed files with 1342 additions and 168 deletions
+38 -13
View File
@@ -19,7 +19,9 @@ import { escapeHtml } from '@trek/shared'
import type { Day, Reservation, RouteVia } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { CARTO_LIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
import { resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher } from './MapLayerSwitcher'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
@@ -517,7 +519,7 @@ export const MapView = memo(function MapView({
zoom = DEFAULT_MAP_ZOOM,
// Callers hand down a URL that already carries the CARTO key; this is only
// the shape a caller without one gets.
tileUrl = CARTO_LIGHT,
tileUrl = OFM_POSITRON,
fitKey = 0,
dayOrderMap = {},
leftWidth = 0,
@@ -537,6 +539,10 @@ export const MapView = memo(function MapView({
tripId,
routeVias = [],
}: any) {
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
@@ -828,17 +834,36 @@ export const MapView = memo(function MapView({
zoomControl={false}
className="w-full h-full bg-[#e5e7eb]"
>
{/* key remounts the layer on switch, else attribution/maxZoom stick at mount-time values. */}
<TileLayer
key={isSatellite ? 'satellite' : 'default'}
url={isSatellite ? SATELLITE_TILE_URL : tileUrl}
attribution={isSatellite ? SATELLITE_TILE_ATTRIBUTION : '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}
maxZoom={isSatellite ? SATELLITE_TILE_MAXZOOM : 19}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
{/* The basemap is a vector style by default and a raster template when the
user brought their own, so the two are drawn by different things. The
satellite toggle is always raster.
key remounts the raster layer on switch, else attribution/maxZoom stick
at mount-time values. */}
{isSatellite ? (
<TileLayer
key="satellite"
url={SATELLITE_TILE_URL}
attribution={SATELLITE_TILE_ATTRIBUTION}
maxZoom={SATELLITE_TILE_MAXZOOM}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
) : basemap.kind === 'vector' ? (
<VectorBasemap style={basemap.style} />
) : (
<TileLayer
key="raster"
url={basemap.url}
attribution={attributionForTile(basemap.url)}
maxZoom={19}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
)}
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
@@ -0,0 +1,203 @@
/**
* VectorBasemap unit tests.
*
* The component and its imperative twin both hang a MapLibre style into Leaflet's
* tile pane. Nothing here renders WebGL — maplibre-gl and the bridge are mocked;
* what is under test is the wiring: which style goes in, what happens on a style
* change, and what is left behind on teardown.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import type * as L from 'leaflet'
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
import { VectorBasemap, attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from './VectorBasemap'
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION } from '../../constants/mapDefaults'
const addAttribution = vi.fn()
/** Only the two things the component touches; casting keeps the mock honest about that. */
const asMap = (m: object) => m as unknown as L.Map
const leafletMap = asMap({ attributionControl: { addAttribution } })
/** A map built without an attribution control, like the atlas. */
const bareMap = asMap({})
vi.mock('react-leaflet', () => ({ useMap: () => leafletMap }))
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
const maplibreGL = vi.fn(() => {
// One GL map per layer, kept stable: a fresh object per call would hand the
// assertions a different spy than the code just used.
const gl = {
setStyle: vi.fn(),
on: vi.fn(),
isStyleLoaded: vi.fn(() => false),
getStyle: vi.fn(() => ({
layers: [
{ id: 'water', type: 'fill' },
{ id: 'boundary', type: 'line' },
{ id: 'place-city', type: 'symbol' },
{ id: 'country-label', type: 'symbol' },
],
})),
setLayoutProperty: vi.fn(),
}
const layer: Record<string, unknown> = { remove: vi.fn(), getMaplibreMap: vi.fn(() => gl) }
layer.addTo = vi.fn(() => layer)
return layer
})
return { maplibreGL, default: maplibreGL }
})
const maplibreGL = vi.mocked(bridge as unknown as ReturnType<typeof vi.fn>)
type Spy = ReturnType<typeof vi.fn>
/** The GL map behind a mocked layer, typed as spies so assertions can reach them. */
interface MockGl {
setStyle: Spy
on: Spy
isStyleLoaded: Spy
getStyle: Spy
setLayoutProperty: Spy
}
/** The layer the last maplibreGL() call produced, and its GL map. */
function lastLayer(): { layer: GlLeafletLayer; gl: MockGl } {
const results = maplibreGL.mock.results
const raw = results[results.length - 1]!.value as { getMaplibreMap: () => MockGl }
return { layer: raw as unknown as GlLeafletLayer, gl: raw.getMaplibreMap() }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('VectorBasemap', () => {
it('FE-COMP-VECBM-001: hangs the style into the map, non-interactive', async () => {
render(<VectorBasemap style={OFM_POSITRON} />)
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
expect(maplibreGL.mock.calls[0]![0]).toMatchObject({
style: OFM_POSITRON,
// Leaflet keeps handling the gestures; a second interactive map underneath
// would fight it for the wheel and the drag.
interactive: false,
attributionControl: false,
})
expect(lastLayer().layer.addTo).toHaveBeenCalledWith(leafletMap)
})
it('FE-COMP-VECBM-002: credits OpenFreeMap, not just OpenStreetMap', async () => {
// The data is OSM, but the rendering and the hosting are not, and the licence
// asks for both.
render(<VectorBasemap style={OFM_POSITRON} />)
await waitFor(() => expect(addAttribution).toHaveBeenCalledWith(OFM_ATTRIBUTION))
})
it('FE-COMP-VECBM-003: a theme switch restyles in place instead of rebuilding', async () => {
const { rerender } = render(<VectorBasemap style={OFM_POSITRON} />)
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
const { gl } = lastLayer()
rerender(<VectorBasemap style={OFM_DARK} />)
// Swapping the layer would leak a WebGL context per toggle, and browsers cap
// those in the low teens: a dozen theme switches and the map goes blank.
expect(gl.setStyle).toHaveBeenCalledWith(OFM_DARK)
expect(maplibreGL).toHaveBeenCalledTimes(1)
})
it('FE-COMP-VECBM-004: takes the layer off the map on unmount', async () => {
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
const { layer } = lastLayer()
unmount()
expect(layer.remove).toHaveBeenCalled()
})
})
describe('attachVectorBasemap', () => {
it('FE-COMP-VECBM-005: hands the caller the layer it attached', async () => {
const ref: { current: GlLeafletLayer | null } = { current: null }
const map = { id: 'imperative' }
await attachVectorBasemap(asMap(map), OFM_DARK, ref, () => false)
expect(maplibreGL.mock.calls[0]![0]).toMatchObject({ style: OFM_DARK, interactive: false })
expect(ref.current).toBe(lastLayer().layer)
expect(lastLayer().layer.addTo).toHaveBeenCalledWith(map)
})
it('FE-COMP-VECBM-006: a map torn down mid-load gets no layer', async () => {
// maplibre-gl only ever arrives through a dynamic import, so there is always a
// gap between "the map wants a basemap" and "the basemap is here". Leaving the
// layer attached to a dead map keeps its WebGL context alive for good.
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(bareMap, OFM_DARK, ref, () => true)
expect(maplibreGL).not.toHaveBeenCalled()
expect(ref.current).toBeNull()
})
it('FE-COMP-VECBM-007: credits OpenFreeMap on a map that has an attribution control', async () => {
// The raster layer it replaces carried the credit, so dropping it silently
// would leave the journey map crediting nobody.
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(leafletMap, OFM_POSITRON, ref, () => false)
expect(addAttribution).toHaveBeenCalledWith(OFM_ATTRIBUTION)
})
it('FE-COMP-VECBM-008: stays quiet on a map built without one', async () => {
// The atlas turns the control off on purpose; asking for it there would throw.
const ref: { current: GlLeafletLayer | null } = { current: null }
await expect(attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)).resolves.toBeUndefined()
expect(addAttribution).not.toHaveBeenCalled()
})
it('FE-COMP-VECBM-009: hides the labels when asked to', async () => {
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false, { hideLabels: true })
const { gl } = lastLayer()
expect(gl.on).toHaveBeenCalledWith('style.load', expect.any(Function))
})
})
describe('hideLabelLayers', () => {
it('FE-COMP-VECBM-010: hides every symbol layer and nothing else', async () => {
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)
const { layer, gl } = lastLayer()
gl.isStyleLoaded.mockReturnValue(true)
hideLabelLayers(layer)
// The atlas draws country names into its own fills, so basemap labels repeat
// them. Borders and coastlines are line/fill layers and have to survive.
expect(gl.setLayoutProperty).toHaveBeenCalledWith('place-city', 'visibility', 'none')
expect(gl.setLayoutProperty).toHaveBeenCalledWith('country-label', 'visibility', 'none')
expect(gl.setLayoutProperty).toHaveBeenCalledTimes(2)
})
it('FE-COMP-VECBM-011: re-hides them after every restyle', async () => {
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)
const { layer, gl } = lastLayer()
hideLabelLayers(layer)
expect(gl.setLayoutProperty).not.toHaveBeenCalled() // style not loaded yet
// setStyle discards the layout properties along with the old style, so the
// theme toggle would bring the labels back without this.
const onStyleLoad = gl.on.mock.calls.find((c: unknown[]) => c[0] === 'style.load')![1] as () => void
onStyleLoad()
expect(gl.setLayoutProperty).toHaveBeenCalledTimes(2)
})
it('FE-COMP-VECBM-012: does nothing when the GL map is not up yet', () => {
const layer = { getMaplibreMap: () => undefined } as unknown as GlLeafletLayer
expect(() => hideLabelLayers(layer)).not.toThrow()
})
})
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef } from 'react'
import { useMap } from 'react-leaflet'
import type * as L from 'leaflet'
import type { MaplibreGL } from '@maplibre/maplibre-gl-leaflet'
import { attributionForTile } from '../../constants/mapDefaults'
/** The Leaflet layer maplibre-gl-leaflet hands back. */
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
/**
* Load the layer factory.
*
* Both imports are dynamic, the same rule the GL renderers follow: a static one
* would pull about a megabyte into every chunk that draws a map, including the
* public share page. The engine module comes along for maplibre-gl's stylesheet,
* which the GL canvas needs to lay itself out.
*
* The layer comes from the bridge's own export rather than from the `L.maplibreGL`
* it also installs: that side effect is guarded by `Object.isExtensible(L)` and
* would fail silently — every Leaflet map blank — if a bundler ever handed it a
* sealed namespace object.
*/
async function loadLayerFactory() {
const [, bridge] = await Promise.all([
import('./engines/maplibre'),
import('@maplibre/maplibre-gl-leaflet'),
])
return bridge.maplibreGL
}
/** Credit the basemap on a map that has an attribution control; a no-op on one that does not. */
function creditBasemap(map: L.Map, style: string): void {
map.attributionControl?.addAttribution(attributionForTile(style))
}
/**
* A MapLibre vector style as the basemap of a Leaflet map.
*
* OpenFreeMap only serves vector tiles, and CARTO's raster tiles now carry a
* watermark unless the operator has requested a key by mail, so the basemap of
* every Leaflet map here is a style document rather than a tile template.
* maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers
* and the panes they live in are untouched, and Leaflet's CSS puts
* `pointer-events: none` on the layer's canvas, so clicks fall through to them.
*/
export function VectorBasemap({ style }: { style: string }): null {
const map = useMap()
const layerRef = useRef<GlLeafletLayer | null>(null)
const styleRef = useRef(style)
styleRef.current = style
useEffect(() => {
let cancelled = false
void (async () => {
const maplibreGL = await loadLayerFactory()
if (cancelled) return
// The style is read through a ref, never from the dependency list: a style
// 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,
interactive: false,
attributionControl: false,
})
layerRef.current = layer
layer.addTo(map)
creditBasemap(map, styleRef.current)
})()
return () => {
cancelled = true
layerRef.current?.remove()
layerRef.current = null
}
}, [map])
// Restyle in place. Swapping the layer would drop a WebGL context per theme
// toggle, and browsers cap those in the low teens.
useEffect(() => {
layerRef.current?.getMaplibreMap()?.setStyle(style)
}, [style])
return null
}
export default VectorBasemap
/**
* The same layer for the maps that build Leaflet imperatively rather than
* through react-leaflet: the journey map and the atlas.
*
* Loading is async because maplibre-gl only ever arrives through a dynamic
* import, so the caller passes a `cancelled` probe: a map torn down mid-load
* must not get a layer attached to it afterwards.
*/
export async function attachVectorBasemap(
map: L.Map,
style: string,
ref: { current: GlLeafletLayer | null },
cancelled: () => boolean,
opts: { hideLabels?: boolean } = {},
): Promise<void> {
const maplibreGL = await loadLayerFactory()
if (cancelled()) return
const layer = maplibreGL({ style, interactive: false, attributionControl: false })
ref.current = layer
layer.addTo(map)
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
// one of its own.
creditBasemap(map, style)
if (opts.hideLabels) hideLabelLayers(layer)
}
/**
* Drop every label from a vector style.
*
* The atlas draws country names into its own fills, so a basemap that repeats
* them underneath is noise. OpenFreeMap has no label-free style, but its labels
* are all `symbol` layers, while borders and coastlines are `line` and `fill`,
* so hiding that one type leaves the geography intact.
*
* Bound to `style.load` rather than run once: the event fires again after every
* setStyle, so a theme switch keeps the labels off without a second call site.
*/
export function hideLabelLayers(layer: GlLeafletLayer): void {
const gl = layer.getMaplibreMap()
if (!gl) return
const apply = () => {
for (const l of gl.getStyle()?.layers ?? []) {
if (l.type === 'symbol') gl.setLayoutProperty(l.id, 'visibility', 'none')
}
}
gl.on('style.load', apply)
// The first style may already be in when we get here.
if (gl.isStyleLoaded()) apply()
}