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
+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 }
}