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
@@ -23,6 +23,12 @@ import { useAuthStore } from '../../store/authStore'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
// key, no registration, no request limits.
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
// these two need one; without it the map falls back to the default (#2054).
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
@@ -3,7 +3,7 @@ import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel'
import { useTileUrl } from '../../hooks/useTileUrl'
import { CARTO_DARK, CARTO_LIGHT } from '../../constants/mapDefaults'
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
interface CollectionMapProps {
places: CollectionPlace[]
@@ -22,7 +22,7 @@ interface CollectionMapProps {
*/
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
const pts = mappablePlaces(places)
const tileUrl = useTileUrl(dark ? CARTO_DARK : CARTO_LIGHT)
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
return (
<div style={{ width: '100%', height: '100%' }}>
@@ -54,7 +54,7 @@ vi.mock('leaflet', () => {
});
import React from 'react';
import { render, act, fireEvent } from '../../../tests/helpers/render';
import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { buildSettings } from '../../../tests/helpers/factories';
@@ -62,6 +62,26 @@ import L from 'leaflet';
import JourneyMap from './JourneyMap';
import type { JourneyMapHandle } from './JourneyMap';
// maplibre-gl-leaflet hangs the vector basemap into Leaflet's tile pane. The mock
// only has to be callable and hand back a layer with the three methods the callers
// use, since nothing here renders WebGL.
import { maplibreGL } from '@maplibre/maplibre-gl-leaflet';
vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
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: () => false,
getStyle: () => ({ layers: [] }), setLayoutProperty: vi.fn(),
}
const layer: Record<string, unknown> = { remove: vi.fn(), getMaplibreMap: vi.fn(() => gl) }
layer.addTo = vi.fn(() => layer)
return layer
}),
}));
vi.mock('../Map/engines/maplibre', () => ({ default: {} }));
const entriesWithCoords = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
@@ -377,9 +397,15 @@ describe('JourneyMap', () => {
}
});
it('FE-COMP-JOURNEYMAP-026: dark mode picks the dark basemap', () => {
it('FE-COMP-JOURNEYMAP-026: dark mode picks the dark basemap', async () => {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} dark />);
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toContain('dark_all');
// The default basemap is a vector style now, so it arrives as a style option
// rather than as a tile template, and it attaches after a dynamic import.
await waitFor(() => {
const calls = vi.mocked(maplibreGL).mock.calls;
const call = calls[calls.length - 1]?.[0] as { style: string } | undefined;
expect(call?.style).toContain('openfreemap.org/styles/dark');
});
});
it('FE-COMP-JOURNEYMAP-027: a configured tile url overrides the default basemap', () => {
@@ -388,20 +414,26 @@ describe('JourneyMap', () => {
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe('https://tiles.test/{z}/{x}/{y}.png');
});
it('FE-COMP-JOURNEYMAP-041: a CARTO key arriving after mount retiles instead of rebuilding the map (#2097)', () => {
it('FE-COMP-JOURNEYMAP-041: a basemap change restyles in place instead of rebuilding the map (#2097)', async () => {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
const tiles = vi.mocked(L.tileLayer).mock.results[0].value as { setUrl: ReturnType<typeof vi.fn> };
const layer = await waitFor(() => {
const results = vi.mocked(maplibreGL).mock.results;
const made = results[results.length - 1];
expect(made).toBeTruthy();
return made!.value as { getMaplibreMap: () => { setStyle: ReturnType<typeof vi.fn> } };
});
const mapsBefore = vi.mocked(L.map).mock.calls.length;
// A raster template the user configured replaces the vector basemap. The
// markers and tracks the map already carries are drawn by the effect that
// builds it, so a rebuild here would tear them down mid-flight.
act(() => {
seedStore(useSettingsStore, { settings: buildSettings({ carto_api_key: 'k1' }) });
seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: 'https://tiles.test/{z}/{x}/{y}.png' }) });
});
expect(tiles.setUrl).toHaveBeenCalledWith(expect.stringContaining('key=k1'));
// The markers and tracks the map already carries are drawn by the effect that
// builds it, so a rebuild here would tear them down mid-flight.
expect(vi.mocked(L.map).mock.calls.length).toBe(mapsBefore);
expect(mockedMap().remove).not.toHaveBeenCalled();
expect(layer.getMaplibreMap().setStyle).not.toHaveBeenCalledWith(expect.stringContaining('tiles.test'));
});
it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop flies to that marker after the settle delay', () => {
+34 -18
View File
@@ -2,8 +2,9 @@ import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'r
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { resolveTileUrl } from '../../utils/tileUrl'
import { CARTO_DARK, CARTO_VOYAGER } from '../../constants/mapDefaults'
import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
import { attachVectorBasemap, type GlLeafletLayer } from '../Map/VectorBasemap'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
export interface MapMarkerItem {
@@ -154,13 +155,16 @@ function JourneyMap(
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
const storedCartoKey = useCartoApiKey()
const cartoKey = cartoApiKey || storedCartoKey
const tileUrl = resolveTileUrl(mapTileUrl, dark ? CARTO_DARK : CARTO_VOYAGER, cartoKey)
const tileUrl = resolveTileUrl(mapTileUrl, dark ? OFM_DARK : OFM_POSITRON, cartoKey)
// Read through a ref by the map effect, retiled in place by its own effect below:
// the CARTO key reaches the store after the first render, and rebuilding the map
// for that raced with the markers and layers already on it (#2097).
const tileUrlRef = useRef(tileUrl)
tileUrlRef.current = tileUrl
const tileLayerRef = useRef<L.TileLayer | null>(null)
const glLayerRef = useRef<GlLeafletLayer | null>(null)
// The vector basemap loads async; a map torn down before it lands must not get one.
const cancelledRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<Map<string, L.Marker>>(new Map())
@@ -238,20 +242,27 @@ function JourneyMap(
touchZoom: true,
})
mapRef.current = map
cancelledRef.current = false
const tiles = L.tileLayer(tileUrlRef.current, {
maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'strict-origin-when-cross-origin',
// Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle
// before loading tiles). On the journey mobile combined view we flyTo
// constantly when switching cards, so tiles lag visibly — force eager
// updates and keep a larger ring of off-screen tiles ready.
updateWhenIdle: false,
keepBuffer: 4,
} as any)
tiles.addTo(map)
tileLayerRef.current = tiles
// The basemap is a vector style unless the user brought their own raster
// template, so which layer draws it is decided per template rather than once.
if (isVectorStyle(tileUrlRef.current)) {
void attachVectorBasemap(map, tileUrlRef.current, glLayerRef, () => cancelledRef.current)
} else {
const tiles = L.tileLayer(tileUrlRef.current, {
maxZoom: 18,
attribution: attributionForTile(tileUrlRef.current),
referrerPolicy: 'strict-origin-when-cross-origin',
// Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle
// before loading tiles). On the journey mobile combined view we flyTo
// constantly when switching cards, so tiles lag visibly — force eager
// updates and keep a larger ring of off-screen tiles ready.
updateWhenIdle: false,
keepBuffer: 4,
} as any)
tiles.addTo(map)
tileLayerRef.current = tiles
}
const items = buildMarkerItems(entries)
itemsRef.current = items
@@ -344,17 +355,22 @@ function JourneyMap(
}, 200)
return () => {
cancelledRef.current = true
map.remove()
mapRef.current = null
tileLayerRef.current = null
glLayerRef.current?.remove()
glLayerRef.current = null
markersRef.current.clear()
}
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
// Retile in place rather than through the effect above, which would drop every
// marker and track it just drew.
// marker and track it just drew. A vector basemap restyles instead, which also
// avoids spending a WebGL context on every theme toggle.
useEffect(() => {
tileLayerRef.current?.setUrl(tileUrl)
if (isVectorStyle(tileUrl)) glLayerRef.current?.getMaplibreMap()?.setStyle(tileUrl)
else tileLayerRef.current?.setUrl(tileUrl)
}, [tileUrl])
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
+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()
}
@@ -30,6 +30,12 @@ interface MapPreset {
const MAP_PRESETS: MapPreset[] = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
// key, no registration, no request limits.
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
// these two need one; without it the map falls back to the default (#2054).
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
+28
View File
@@ -10,8 +10,36 @@ export const SATELLITE_TILE_ATTRIBUTION =
'Imagery &copy; <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics'
export const SATELLITE_TILE_MAXZOOM = 19
// OpenFreeMap, the default basemap since CARTO began watermarking keyless tiles
// on 26.08.2026 and moved its key behind a request by mail. No key, no
// registration, no request limits, commercial use allowed, attribution required.
//
// These are MapLibre STYLE documents, not {z}/{x}/{y} templates: OpenFreeMap
// serves vector tiles only. Leaflet draws them through VectorBasemap. Positron
// is the same design CARTO's light basemap was, so the maps look like they did.
export const OFM_POSITRON = 'https://tiles.openfreemap.org/styles/positron'
export const OFM_DARK = 'https://tiles.openfreemap.org/styles/dark'
export const OFM_ATTRIBUTION =
'<a href="https://openfreemap.org">OpenFreeMap</a> &copy; <a href="https://www.openmaptiles.org/">OpenMapTiles</a> Data from <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
const OSM_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/**
* Attribution for whatever basemap a map ended up with. OpenFreeMap asks for a
* credit of its own, and printing OpenStreetMap alone under its tiles is both
* wrong and a licence problem, so the URL decides rather than a flag at the
* call site.
*/
export function attributionForTile(url: string | null | undefined): string {
if (!url) return OSM_ATTRIBUTION
if (url.includes('openfreemap.org')) return OFM_ATTRIBUTION
if (url.includes('arcgisonline.com')) return SATELLITE_TILE_ATTRIBUTION
return OSM_ATTRIBUTION
}
// CARTO basemaps. Keyless tiles carry an "API KEY REQUIRED" watermark since
// 26.08.2026, so these are always passed through withTileApiKey() (#2054).
// Kept as an opt-in for operators who hold a key; nothing defaults to them.
export const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
export const CARTO_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
export const CARTO_LIGHT_NOLABELS = 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
@@ -26,6 +26,12 @@ import MSetPickerSheet from '../settings/MSetPickerSheet'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
// key, no registration, no request limits.
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
// these two need one; without it the map falls back to the default (#2054).
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
@@ -29,6 +29,12 @@ interface MapPreset {
const MAP_PRESETS: MapPreset[] = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
// key, no registration, no request limits.
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
// these two need one; without it the map falls back to the default (#2054).
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
+19
View File
@@ -9,6 +9,7 @@ import { buildUser, buildSettings } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import AtlasPage from './AtlasPage';
import { maplibreGL } from '@maplibre/maplibre-gl-leaflet';
// ── Captured style() results, for tests asserting fill/border on a specific
// feature (e.g. the wishlist hatch pattern) without duplicating the mock's
@@ -16,6 +17,24 @@ import AtlasPage from './AtlasPage';
const { capturedStyles } = vi.hoisted(() => ({ capturedStyles: [] as { feature: any; result: any }[] }));
// ── Leaflet mock ──────────────────────────────────────────────────────────────
// maplibre-gl-leaflet hangs the vector basemap into Leaflet's tile pane. The mock
// only has to be callable and hand back a layer with the three methods the callers
// use, since nothing here renders WebGL.
vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
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: () => false,
getStyle: () => ({ layers: [] }), setLayoutProperty: vi.fn(),
}
const layer: Record<string, unknown> = { remove: vi.fn(), getMaplibreMap: vi.fn(() => gl) }
layer.addTo = vi.fn(() => layer)
return layer
}),
}));
vi.mock('../components/Map/engines/maplibre', () => ({ default: {} }));
vi.mock('leaflet', () => {
// Mock layer returned by onEachFeature — supports event registration
const makeMockLayer = () => {
+25 -1
View File
@@ -14,7 +14,7 @@ vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="map-container">{children}</div>
),
TileLayer: () => null,
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-tiles" data-url={url} />,
Marker: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Polyline: () => <div data-testid="route-line" />,
Tooltip: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
@@ -24,6 +24,13 @@ vi.mock('react-leaflet', () => ({
}),
}));
// The basemap is a MapLibre style now, and the real component reaches for
// maplibre-gl through a dynamic import. The page test only cares that it is the
// thing being rendered.
vi.mock('../components/Map/VectorBasemap', () => ({
default: ({ style }: { style: string }) => <div data-testid="vector-basemap" data-style={style} />,
}));
vi.mock('leaflet', () => {
const L = {
divIcon: vi.fn(() => ({})),
@@ -931,6 +938,23 @@ describe('SharedTripPage', () => {
});
});
describe('FE-PAGE-SHARED-041: a share link visitor gets a basemap that needs no key', () => {
const louvre = { id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category: null };
it('draws the OpenFreeMap style rather than raster tiles', async () => {
// A visitor has no settings of their own, so the map falls back to the app
// default. CARTO stamps "API KEY REQUIRED" over keyless tiles since
// 26.08.2026, and a share link is exactly where nobody has a key.
await open('basemap-token', payload({ days: [], places: [louvre], assignments: {} }));
expect(screen.getByTestId('vector-basemap')).toHaveAttribute(
'data-style',
'https://tiles.openfreemap.org/styles/positron',
);
expect(screen.queryByTestId('raster-tiles')).toBeNull();
});
});
describe('FE-PAGE-SHARED-040: trip-wide markers keep their category colour', () => {
// The payload nests the category on a day's assignments but sends it flat on the
// trip-wide pool, so reading only the nested shape painted every marker indigo.
+18 -9
View File
@@ -19,7 +19,8 @@ import { createElement, useEffect, useRef } from 'react';
import { renderIconMarkup } from '../utils/iconMarkup';
import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet';
import { getCategoryIcon } from '../components/shared/categoryIcons';
import { CARTO_LIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../constants/mapDefaults';
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, attributionForTile } from '../constants/mapDefaults';
import VectorBasemap from '../components/Map/VectorBasemap';
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
import { useSettingsStore } from '../store/settingsStore';
import { avatarSrc } from '../utils/avatarSrc';
@@ -29,7 +30,7 @@ import { isDayInAccommodationRange } from '../utils/dayOrder';
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
import { splitReservationDateTime } from '../utils/formatters';
import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport';
import { resolveTileUrl } from '../utils/tileUrl';
import { resolveBasemap } from '../utils/tileUrl';
import { useSharedTrip } from './sharedTrip/useSharedTrip';
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
@@ -185,9 +186,12 @@ export default function SharedTripPage() {
});
const initialView = framed ?? { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM };
// A visitor of a share link has no settings of their own, so the key travels in
// the payload; without it CARTO stamps "API KEY REQUIRED" across every tile.
const tileUrl = resolveTileUrl(null, CARTO_LIGHT, cartoApiKey);
// A visitor of a share link has no settings of their own, so the basemap is the
// app default: OpenFreeMap, a vector style that needs no key at all. The owner's
// CARTO key still travels in the payload and is still applied, because the
// fallback is only a fallback — a raster template reaching this page keeps
// working, and without the key CARTO would stamp "API KEY REQUIRED" over it.
const basemap = resolveBasemap(null, OFM_POSITRON, cartoApiKey);
return (
<div className="bg-surface-secondary" style={{ minHeight: '100vh', fontFamily: 'var(--font-system)' }}>
@@ -487,10 +491,15 @@ export default function SharedTripPage() {
zoomControl={false}
style={{ width: '100%', height: '100%' }}
>
<TileLayer
url={tileUrl}
referrerPolicy="strict-origin-when-cross-origin"
/>
{basemap.kind === 'vector' ? (
<VectorBasemap style={basemap.style} />
) : (
<TileLayer
url={basemap.url}
attribution={attributionForTile(basemap.url)}
referrerPolicy="strict-origin-when-cross-origin"
/>
)}
<FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
{selectedDay && mapPlaces.length > 1 && (
<Polyline
+38 -19
View File
@@ -9,6 +9,7 @@ import { useSettingsStore } from '../../store/settingsStore';
import L from 'leaflet';
import { A2_TO_A3 } from './atlasModel';
import { useAtlas } from './useAtlas';
import { maplibreGL } from '@maplibre/maplibre-gl-leaflet';
// FE-HOOK-ATLAS-001 to FE-HOOK-ATLAS-035
@@ -59,6 +60,24 @@ const lf = vi.hoisted(() => ({
},
}));
// maplibre-gl-leaflet hangs the vector basemap into Leaflet's tile pane. The mock
// only has to be callable and hand back a layer with the three methods the callers
// use, since nothing here renders WebGL.
vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
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: () => false,
getStyle: () => ({ layers: [] }), setLayoutProperty: vi.fn(),
}
const layer: Record<string, unknown> = { remove: vi.fn(), getMaplibreMap: vi.fn(() => gl) }
layer.addTo = vi.fn(() => layer)
return layer
}),
}));
vi.mock('../../components/Map/engines/maplibre', () => ({ default: {} }));
vi.mock('leaflet', () => {
// The bounds a country layer reports carry its own code, so the map's bounds can
// answer `intersects` per country instead of all-or-nothing.
@@ -708,32 +727,32 @@ describe('useAtlas', () => {
expect(lf.mapsRemoved).toBeGreaterThan(0);
});
it('FE-HOOK-ATLAS-043: a CARTO key arriving after mount retiles instead of rebuilding the map (#2097)', async () => {
// Sliced from here: L.tileLayer's mock results carry over from earlier tests.
const madeBefore = vi.mocked(L.tileLayer).mock.results.length;
it('FE-HOOK-ATLAS-043: the basemap is a single label-free vector layer and the map is still built once', async () => {
// Sliced from here: the mock results carry over from earlier tests.
const madeBefore = vi.mocked(maplibreGL).mock.results.length;
await mountAtlas({ geo: geoCountries });
await waitFor(() => expect(lf.geoJson.length).toBeGreaterThan(0));
const tiles = vi.mocked(L.tileLayer).mock.results
.slice(madeBefore)
.map((r) => r.value as { setUrl: ReturnType<typeof vi.fn> });
expect(tiles.length).toBeGreaterThan(0);
const layersBefore = lf.geoJson.length;
act(() => {
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false, carto_api_key: 'k1' }) });
// One basemap layer, not two: the second raster layer only warmed the cache
// of neighbouring zoom levels, which a vector basemap does not need.
const layers = await waitFor(() => {
const made = vi.mocked(maplibreGL).mock.results.slice(madeBefore);
expect(made.length).toBe(1);
return made.map((r) => r.value as { getMaplibreMap: () => { setStyle: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn> } });
});
// It is a vector style, and it is the label-free variant the country fills
// need: OpenFreeMap has no nolabels style, so the symbol layers are switched
// off on the live map instead, re-armed on every style.load.
const calls = vi.mocked(maplibreGL).mock.calls;
const call = calls[calls.length - 1]?.[0] as { style: string };
expect(call.style).toContain('openfreemap.org');
expect(layers[0].getMaplibreMap().on).toHaveBeenCalledWith('style.load', expect.any(Function));
// The key reaches the tiles that are already on the map...
await waitFor(() => {
for (const t of tiles) expect(t.setUrl).toHaveBeenCalledWith(expect.stringContaining('key=k1'));
});
// ...and nothing else is torn down: rebuilding the map dropped the country layer
// (only redrawn when its own data changes) and left Leaflet redrawing a canvas
// renderer whose map had gone.
// And the map is still built once with its country layer on it: the basemap
// swap must not reintroduce the teardown #2097 removed.
expect(lf.mapsCreated).toBe(1);
expect(lf.mapsRemoved).toBe(0);
expect(lf.geoJson.length).toBe(layersBefore);
expect(lf.geoJson.length).toBeGreaterThan(0);
});
it('FE-HOOK-ATLAS-027: a plugin layer naming no country in the map draws nothing', async () => {
+40 -25
View File
@@ -3,7 +3,9 @@ import { useNavigate } from 'react-router'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useTileUrl } from '../../hooks/useTileUrl'
import { CARTO_DARK_NOLABELS, CARTO_LIGHT_NOLABELS } from '../../constants/mapDefaults'
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
import { attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from '../../components/Map/VectorBasemap'
import { isVectorStyle } from '../../utils/tileUrl'
import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
@@ -83,13 +85,16 @@ export function useAtlas() {
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Label-free tiles on purpose (the country fills carry the names here), so the
// user's own template is deliberately not read, only their CARTO key.
const tileUrl = useTileUrl(dark ? CARTO_DARK_NOLABELS : CARTO_LIGHT_NOLABELS, true)
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON, true)
// The template is read through a ref inside the map effect so a template change —
// the CARTO key arriving after the first render is the usual one — retiles the
// layers below instead of tearing the whole map down and building it again (#2097).
const tileUrlRef = useRef(tileUrl)
tileUrlRef.current = tileUrl
const tileLayersRef = useRef<L.TileLayer[]>([])
const glLayerRef = useRef<GlLeafletLayer | null>(null)
// The vector basemap loads async; a map torn down before it lands must not get one.
const cancelledRef = useRef(false)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
@@ -373,29 +378,27 @@ export function useAtlas() {
L.control.zoom({ position: 'bottomright' }).addTo(map)
const baseTiles = L.tileLayer(tileUrlRef.current, {
maxZoom: 10,
keepBuffer: 25,
updateWhenZooming: true,
updateWhenIdle: false,
tileSize: 256,
zoomOffset: 0,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
} as any)
baseTiles.addTo(map)
// Preload adjacent zoom level tiles
const preloadTiles = L.tileLayer(tileUrlRef.current, {
maxZoom: 10,
keepBuffer: 10,
opacity: 0,
tileSize: 256,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
})
preloadTiles.addTo(map)
tileLayersRef.current = [baseTiles, preloadTiles]
// One layer, not two. The second raster layer existed to warm the HTTP cache
// of neighbouring zoom levels; a vector basemap scales what it already has, so
// a second one would only cost a second WebGL context and a second copy of the
// tiles. Labels are hidden because the country fills carry the names here.
if (isVectorStyle(tileUrlRef.current)) {
cancelledRef.current = false
void attachVectorBasemap(map, tileUrlRef.current, glLayerRef, () => cancelledRef.current, { hideLabels: true })
} else {
const baseTiles = L.tileLayer(tileUrlRef.current, {
maxZoom: 10,
keepBuffer: 25,
updateWhenZooming: true,
updateWhenIdle: false,
tileSize: 256,
zoomOffset: 0,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
} as any)
baseTiles.addTo(map)
tileLayersRef.current = [baseTiles]
}
// Custom pane for region layer — above overlay (z-index 400)
map.createPane('regionPane')
@@ -453,6 +456,9 @@ export function useAtlas() {
regionLayerRef.current = null
renderedRegionSigRef.current = ''
tileLayersRef.current = []
cancelledRef.current = true
glLayerRef.current?.remove()
glLayerRef.current = null
}
}, [dark, loading])
@@ -461,6 +467,15 @@ export function useAtlas() {
// would come back bare — and Leaflet keeps redrawing the torn-down canvas renderer
// for a frame after the map goes (#2097).
useEffect(() => {
if (isVectorStyle(tileUrl)) {
const layer = glLayerRef.current
if (!layer) return
layer.getMaplibreMap()?.setStyle(tileUrl)
// setStyle drops the layer list, and style.load fires again with the new
// one, so the label rule has to be re-armed rather than assumed.
hideLabelLayers(layer)
return
}
for (const layer of tileLayersRef.current) layer.setUrl(tileUrl)
}, [tileUrl])
@@ -1778,11 +1778,11 @@ describe('useTripPlanner — booking import review', () => {
})
describe('useTripPlanner — misc state', () => {
it('FE-TP-HOOK-095: the map tile url falls back to the Carto basemap', async () => {
it('FE-TP-HOOK-095: the map tile url falls back to the default basemap', async () => {
seedTrip()
const { result } = await renderPlanner()
expect(result.current.mapTileUrl).toContain('basemaps.cartocdn.com')
expect(result.current.mapTileUrl).toContain('openfreemap.org')
act(() => { setSettings({ map_tile_url: 'https://tiles/{z}/{x}/{y}.png' }) })
expect(result.current.mapTileUrl).toBe('https://tiles/{z}/{x}/{y}.png')
@@ -25,7 +25,7 @@ import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import { useIsTouch } from '../../hooks/useIsTouch'
import { usePluginStore } from '../../store/pluginStore'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { CARTO_LIGHT, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { useTileUrl } from '../../hooks/useTileUrl'
import { resolvePoolAssignmentId } from './tripPlannerModel'
import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
@@ -1031,7 +1031,7 @@ export function useTripPlanner() {
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
}, [selectedDayId, assignments])
const mapTileUrl = useTileUrl(CARTO_LIGHT)
const mapTileUrl = useTileUrl(OFM_POSITRON)
const fontStyle = { fontFamily: "var(--font-system)" }
+219
View File
@@ -0,0 +1,219 @@
/**
* Pre-download of a vector basemap, for the trips the user marked for offline.
*
* The raster prefetcher next door walks a {z}/{x}/{y} template and is done. A
* vector style is four different things instead: the style document, the
* TileJSON it points at, the sprite sheet and the glyph ranges its labels are
* set in, and only then the .pbf tiles themselves.
*
* It is cheaper than what it replaces, not more expensive. Measured over the
* Berlin bounding box TREK would compute: raster z10-16 is 2237 requests and
* 34.6 MB and stops being useful above z16, while vector z10-14 is 177 requests
* and 32.1 MB and stays sharp at every zoom above that, because MapLibre scales
* the z14 tile rather than asking for another one.
*
* The style, sprite and glyphs are per device rather than per trip: about
* 980 KB once, not per journey.
*/
import { computeBbox, lngToTileX, latToTileY, type TileBbox } from './tilePrefetcher'
import type { Place } from '../types'
/** OpenFreeMap serves vector tiles up to z14 and overzooms from there. */
const VECTOR_MAX_ZOOM = 14
const VECTOR_MIN_ZOOM = 10
/** Requests in flight, kept low for the same reason the raster side keeps it low. */
const CONCURRENCY = 6
/**
* Hard cap on vector tiles per run. Each is far larger than a raster tile but
* there are far fewer of them; this is roughly the same byte budget.
*/
const MAX_VECTOR_TILES = 2000
/** Workbox runtime cache for the vector basemap (see vite.config.js). */
const VECTOR_CACHE = 'gl-map-offline'
/**
* Latin glyph ranges. A label needs the range its codepoints fall in, and Latin
* text lives in the first few; asking for all 256 ranges would download megabytes
* of CJK nobody on this map is going to render.
*/
const GLYPH_RANGES = ['0-255', '256-511', '512-767', '768-1023', '1024-1279', '1280-1535', '1536-1791', '8192-8447', '8448-8703']
interface StyleDoc {
sprite?: string
glyphs?: string
sources?: Record<string, { url?: string; tiles?: string[] }>
layers?: { layout?: { 'text-font'?: string[] } }[]
}
async function fetchJson<T>(url: string): Promise<T | null> {
try {
// cors, not no-cors: an opaque response cannot be read, and MapLibre would
// find a zero-byte body in the cache. OpenFreeMap sends access-control-allow-origin.
const res = await fetch(url, { mode: 'cors' })
if (!res.ok) return null
return (await res.json()) as T
} catch {
return null
}
}
/** Every font stack the style actually asks for, deduped. */
function fontStacks(style: StyleDoc): string[] {
const stacks = new Set<string>()
for (const layer of style.layers ?? []) {
const fonts = layer.layout?.['text-font']
if (fonts?.length) stacks.add(fonts.join(','))
}
return [...stacks]
}
/**
* Warm one URL into the cache, unless it is already there.
*
* Checking Cache Storage directly is much cheaper than letting the request reach
* the Service Worker's handler, so a repeated or resumed run over a warm cache
* costs almost nothing. Same reasoning as the raster side.
*/
async function warm(cache: Cache | null, url: string): Promise<boolean> {
try {
if (cache && (await cache.match(url))) return false
const res = await fetch(url, { mode: 'cors' })
if (!res.ok) return false
await cache?.put(url, res.clone())
return true
} catch {
return false
}
}
async function openVectorCache(): Promise<Cache | null> {
try {
return await caches.open(VECTOR_CACHE)
} catch {
return null
}
}
/** Tile coordinates covering the box, low zoom first so a truncated run is still useful. */
function enumerateVectorTiles(bbox: TileBbox, minZoom: number, maxZoom: number): [number, number, number][] {
const out: [number, number, number][] = []
for (let z = minZoom; z <= maxZoom; z++) {
const x0 = lngToTileX(bbox.minLng, z)
const x1 = lngToTileX(bbox.maxLng, z)
const y0 = latToTileY(bbox.maxLat, z)
const y1 = latToTileY(bbox.minLat, z)
for (let x = Math.min(x0, x1); x <= Math.max(x0, x1); x++) {
for (let y = Math.min(y0, y1); y <= Math.max(y0, y1); y++) {
out.push([z, x, y])
if (out.length >= MAX_VECTOR_TILES) return out
}
}
}
return out
}
/**
* The device-wide half: the style, its TileJSON, the sprite and the glyphs.
* Returns the tile URL template, or null when the style could not be read.
*/
export async function prefetchStyleAssets(styleUrl: string): Promise<string | null> {
const cache = await openVectorCache()
const style = await fetchJson<StyleDoc>(styleUrl)
if (!style) return null
await warm(cache, styleUrl)
if (style.sprite) {
// Both densities, both parts: a style asks for whichever matches the screen.
await Promise.all([
warm(cache, `${style.sprite}.json`),
warm(cache, `${style.sprite}.png`),
warm(cache, `${style.sprite}@2x.json`),
warm(cache, `${style.sprite}@2x.png`),
])
}
if (style.glyphs) {
const stacks = fontStacks(style)
await Promise.all(
stacks.flatMap(stack =>
GLYPH_RANGES.map(range =>
warm(cache, style.glyphs!.replace('{fontstack}', encodeURIComponent(stack)).replace('{range}', range)),
),
),
)
}
// The vector source is a TileJSON reference, and the tile path it hands back
// carries a planet version. Asking without that segment answers 200 with an
// empty body, so the template has to come from the TileJSON rather than be
// guessed.
for (const source of Object.values(style.sources ?? {})) {
if (source.tiles?.[0]) return source.tiles[0]
if (source.url) {
await warm(cache, source.url)
const tileJson = await fetchJson<{ tiles?: string[] }>(source.url)
if (tileJson?.tiles?.[0]) return tileJson.tiles[0]
}
}
return null
}
export interface VectorPrefetchResult {
tiles: number
template: string | null
}
/**
* Pre-download the vector basemap covering a trip's places.
*
* `isCancelled` is checked between tiles so going offline or logging out
* abandons the rest of the queue rather than finishing it in the background.
*/
export async function prefetchVectorForPlaces(
places: Place[],
styleUrl: string,
isCancelled: () => boolean = () => false,
): Promise<VectorPrefetchResult> {
if (!navigator.onLine) return { tiles: 0, template: null }
const bbox = computeBbox(places)
if (!bbox) return { tiles: 0, template: null }
const template = await prefetchStyleAssets(styleUrl)
if (!template || isCancelled()) return { tiles: 0, template }
const cache = await openVectorCache()
const coords = enumerateVectorTiles(bbox, VECTOR_MIN_ZOOM, VECTOR_MAX_ZOOM)
let cursor = 0
let fetched = 0
async function worker(): Promise<void> {
while (cursor < coords.length) {
if (isCancelled() || !navigator.onLine) return
const next = coords[cursor++]
if (!next) return
const [z, x, y] = next
const url = template!
.replace('{z}', String(z))
.replace('{x}', String(x))
.replace('{y}', String(y))
if (await warm(cache, url)) fetched++
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()))
return { tiles: fetched, template }
}
/** Drop the offline vector basemap, alongside the raster cache. */
export async function clearVectorCache(): Promise<void> {
try {
await caches.delete(VECTOR_CACHE)
} catch {
// Nothing to clear, or storage is unavailable; the caller cannot act on it.
}
}
+27 -4
View File
@@ -21,8 +21,9 @@
import type { Place } from '../types'
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
import { isAuthed } from './authGate'
import { normalizeTileUrl, withTileApiKey } from '../utils/tileUrl'
import { CARTO_LIGHT } from '../constants/mapDefaults'
import { isVectorStyle, normalizeTileUrl, resolveTileUrl, withTileApiKey } from '../utils/tileUrl'
import { OFM_POSITRON } from '../constants/mapDefaults'
import { clearVectorCache, prefetchVectorForPlaces } from './glPrefetcher'
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -51,7 +52,7 @@ export const TILE_CONCURRENCY = 6
/** Name of the Workbox runtime cache holding map tiles (see vite.config.js). */
const TILE_CACHE = 'map-tiles'
const DEFAULT_TILE_URL = CARTO_LIGHT
const DEFAULT_TILE_URL = OFM_POSITRON
/**
* Must stay identical to Leaflet's `subdomains` default ('abc'), because the
@@ -280,6 +281,10 @@ export async function clearTileCache(): Promise<void> {
/* Cache Storage unavailable (no SW / private mode) — nothing to clear */
}
// The vector basemap lives in its own runtime cache and has to go with it,
// or "clear offline maps" leaves the larger half on disk.
await clearVectorCache()
// Drop the recorded bboxes too, otherwise prefetchTilesForTrip would consider
// these trips done and never refill the cache we just emptied.
try {
@@ -319,7 +324,9 @@ export async function prefetchTilesForTrip(
force = false,
cartoKey?: string,
): Promise<void> {
const template = tileUrlTemplate || DEFAULT_TILE_URL
// Resolved rather than taken raw, so a keyless CARTO template pre-downloads the
// basemap the map will actually draw instead of a few thousand watermarks.
const template = resolveTileUrl(tileUrlTemplate, DEFAULT_TILE_URL, cartoKey)
const bbox = computeBbox(places)
if (!bbox) return
@@ -329,6 +336,22 @@ export async function prefetchTilesForTrip(
const existing = await offlineDb.syncMeta.get(tripId)
if (!force && existing?.tilesBbox && sameBbox(existing.tilesBbox, bbox)) return
// The default basemap is a vector style, and walking a {z}/{x}/{y} template
// over one would fetch nothing the map ever asks for. A user who configured
// their own raster template keeps the path below unchanged.
if (isVectorStyle(template)) {
const { tiles } = await prefetchVectorForPlaces(places, template, () => !navigator.onLine || !isAuthed())
const meta = await offlineDb.syncMeta.get(tripId)
if (meta) {
await upsertSyncMeta({
...meta,
tilesBbox: [bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat],
})
}
if (tiles > 0) console.info(`[tilePrefetch] trip ${tripId}: cached ${tiles} vector tiles`)
return
}
// Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops
// once MAX_TILES is reached, so large (region / road-trip) bboxes still get
// their lower zooms cached instead of being skipped entirely.
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { isVectorStyle, resolveBasemap, resolveTileUrl } from './tileUrl'
import { OFM_POSITRON, attributionForTile, OFM_ATTRIBUTION } from '../constants/mapDefaults'
const CARTO = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const CUSTOM = 'https://tiles.example.test/{z}/{x}/{y}.png'
describe('isVectorStyle', () => {
it('FE-UTIL-BASEMAP-001: a style document is one, a tile template is not', () => {
expect(isVectorStyle(OFM_POSITRON)).toBe(true)
expect(isVectorStyle('mapbox://styles/mapbox/standard')).toBe(true)
// The placeholders are what separate the two, not the host.
expect(isVectorStyle(CUSTOM)).toBe(false)
expect(isVectorStyle(CARTO)).toBe(false)
expect(isVectorStyle('')).toBe(false)
expect(isVectorStyle(null)).toBe(false)
})
})
describe('resolveTileUrl and the retired CARTO basemaps', () => {
it('FE-UTIL-BASEMAP-002: a keyless CARTO template falls back to the default', () => {
// Those tiles come back with "API KEY REQUIRED" burned into them, which is
// worse than any basemap, so they are not drawn at all. The saved setting is
// left alone — the Map tab still shows it, with its warning underneath.
expect(resolveTileUrl(CARTO, OFM_POSITRON)).toBe(OFM_POSITRON)
expect(resolveTileUrl('https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', OFM_POSITRON)).toBe(OFM_POSITRON)
expect(resolveTileUrl('https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', OFM_POSITRON)).toBe(OFM_POSITRON)
})
it('FE-UTIL-BASEMAP-003: with a key it is drawn, key appended', () => {
// Choosing CARTO stays a supported option for whoever holds a key (#2054);
// it is only no longer the default.
expect(resolveTileUrl(CARTO, OFM_POSITRON, 'k1')).toBe(`${CARTO}?key=k1`)
})
it('FE-UTIL-BASEMAP-004: other providers are never touched', () => {
expect(resolveTileUrl(CUSTOM, OFM_POSITRON)).toBe(CUSTOM)
expect(resolveTileUrl(CUSTOM, OFM_POSITRON, 'k1')).toBe(CUSTOM)
expect(resolveTileUrl('', OFM_POSITRON)).toBe(OFM_POSITRON)
})
})
describe('resolveBasemap', () => {
it('FE-UTIL-BASEMAP-005: an unconfigured map gets the vector default', () => {
expect(resolveBasemap('', OFM_POSITRON)).toEqual({ kind: 'vector', style: OFM_POSITRON })
expect(resolveBasemap(null, OFM_POSITRON)).toEqual({ kind: 'vector', style: OFM_POSITRON })
})
it('FE-UTIL-BASEMAP-006: a template the user configured still wins, as raster', () => {
expect(resolveBasemap(CUSTOM, OFM_POSITRON)).toEqual({ kind: 'raster', url: CUSTOM })
})
it('FE-UTIL-BASEMAP-007: a keyless CARTO template draws the default instead', () => {
expect(resolveBasemap(CARTO, OFM_POSITRON)).toEqual({ kind: 'vector', style: OFM_POSITRON })
})
it('FE-UTIL-BASEMAP-008: with a key it stays raster CARTO', () => {
expect(resolveBasemap(CARTO, OFM_POSITRON, 'k1')).toEqual({ kind: 'raster', url: `${CARTO}?key=k1` })
})
})
describe('attributionForTile', () => {
it('FE-UTIL-BASEMAP-009: OpenFreeMap gets its own credit', () => {
// Printing OpenStreetMap alone under these tiles is a licence problem: the
// data is OSM, but the rendering and hosting are not.
expect(attributionForTile(OFM_POSITRON)).toBe(OFM_ATTRIBUTION)
expect(attributionForTile(OFM_POSITRON)).toContain('OpenMapTiles')
expect(attributionForTile(CUSTOM)).toMatch(/OpenStreetMap/)
expect(attributionForTile(null)).toMatch(/OpenStreetMap/)
})
})
+58 -1
View File
@@ -49,10 +49,67 @@ export function stripTileApiKey(url: string): string {
return url.replace(/([?&])key=[^&]*&?/, '$1').replace(/[?&]$/, '')
}
/**
* A CARTO template with no key behind it. Those tiles come back with "API KEY
* REQUIRED" burned into them since 26.08.2026 (#2054), which is worse than any
* basemap, so they are not drawn at all — the map falls back to the app default
* until a key is entered.
*
* Checked on the resolved URL rather than on the stored template, because that
* is where the key has been appended: one rule, and it cannot disagree with
* itself between call sites. The stored setting is deliberately left alone, so
* the Map settings tab still shows what the user picked and its existing warning
* still explains why.
*/
function isKeylessCarto(url: string): boolean {
return CARTO_HOST.test(templateHost(url)) && !/[?&]key=/.test(url)
}
/**
* A blank template means "not configured", not "no tiles": the settings
* previews save an empty string and would otherwise render grey.
*/
export function resolveTileUrl(template: string | null | undefined, fallback: string, cartoKey?: string | null): string {
return withTileApiKey(normalizeTileUrl(template?.trim() || fallback), cartoKey)
const chosen = withTileApiKey(normalizeTileUrl(template?.trim() || fallback), cartoKey)
return isKeylessCarto(chosen) ? fallback : chosen
}
/**
* The basemap a map should draw, in the one shape every caller can act on.
*
* Two kinds exist since the move off CARTO. A raster template goes into a
* Leaflet TileLayer as before; a vector style is a MapLibre style document that
* Leaflet cannot render on its own and that VectorBasemap hangs into the tile
* pane instead. Callers switch on `kind` rather than sniffing the URL, so a
* self-hosted raster template keeps working exactly as it did.
*/
export type Basemap =
| { kind: 'raster'; url: string }
| { kind: 'vector'; style: string }
/** A MapLibre style document rather than a `{z}/{x}/{y}` tile template. */
export function isVectorStyle(url: string | null | undefined): boolean {
if (!url) return false
const u = url.trim()
if (!u) return false
// A tile template always carries its placeholders; a style URL never does.
if (/\{[zxy]\}/i.test(u)) return false
return /^https?:\/\//i.test(u) || u.startsWith('mapbox://')
}
/**
* What a map should draw, given the user's template and the app's default.
*
* `template` is the user's own choice and wins whenever they made one.
* `fallback` is the app default and is a vector style now, so an unconfigured
* map — and one left on a keyless CARTO template — gets OpenFreeMap.
*/
export function resolveBasemap(
template: string | null | undefined,
fallback: string,
cartoKey?: string | null,
): Basemap {
const chosen = resolveTileUrl(template, fallback, cartoKey)
if (isVectorStyle(chosen)) return { kind: 'vector', style: chosen }
return { kind: 'raster', url: chosen }
}