diff --git a/client/package.json b/client/package.json
index d78fcbe9f..edf11fccb 100644
--- a/client/package.json
+++ b/client/package.json
@@ -37,6 +37,7 @@
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
+ "@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
"axios": "^1.6.7",
diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx
index e9b098d52..9b705726b 100644
--- a/client/src/components/Admin/DefaultUserSettingsTab.tsx
+++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx
@@ -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' },
diff --git a/client/src/components/Collections/CollectionMap.tsx b/client/src/components/Collections/CollectionMap.tsx
index d9b9db11e..cd7bb03b5 100644
--- a/client/src/components/Collections/CollectionMap.tsx
+++ b/client/src/components/Collections/CollectionMap.tsx
@@ -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 (
diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx
index 6e704a377..518e53908 100644
--- a/client/src/components/Journey/JourneyMap.test.tsx
+++ b/client/src/components/Journey/JourneyMap.test.tsx
@@ -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
= { 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();
- 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();
- const tiles = vi.mocked(L.tileLayer).mock.results[0].value as { setUrl: ReturnType };
+ 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 } };
+ });
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', () => {
diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx
index 5539e98f1..be294c9e0 100644
--- a/client/src/components/Journey/JourneyMap.tsx
+++ b/client/src/components/Journey/JourneyMap.tsx
@@ -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(null)
+ const glLayerRef = useRef(null)
+ // The vector basemap loads async; a map torn down before it lands must not get one.
+ const cancelledRef = useRef(false)
const containerRef = useRef(null)
const mapRef = useRef(null)
const markersRef = useRef