From b401da226400dc2801632a2ff9961e26a98f110b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 29 Aug 2026 00:02:12 +0200 Subject: [PATCH] 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. --- client/package.json | 1 + .../Admin/DefaultUserSettingsTab.tsx | 6 + .../components/Collections/CollectionMap.tsx | 4 +- .../components/Journey/JourneyMap.test.tsx | 50 +++- client/src/components/Journey/JourneyMap.tsx | 52 +++-- client/src/components/Map/MapView.tsx | 51 ++-- .../src/components/Map/VectorBasemap.test.tsx | 203 ++++++++++++++++ client/src/components/Map/VectorBasemap.tsx | 139 +++++++++++ .../components/Settings/MapSettingsTab.tsx | 6 + client/src/constants/mapDefaults.ts | 28 +++ .../admin/MAdminDefaultUserSettings.tsx | 6 + .../mobile/screens/settings/MSettingsMap.tsx | 6 + client/src/pages/AtlasPage.test.tsx | 19 ++ client/src/pages/SharedTripPage.test.tsx | 26 ++- client/src/pages/SharedTripPage.tsx | 27 ++- client/src/pages/atlas/useAtlas.test.tsx | 57 +++-- client/src/pages/atlas/useAtlas.ts | 65 ++++-- .../pages/tripPlanner/useTripPlanner.test.tsx | 4 +- .../src/pages/tripPlanner/useTripPlanner.ts | 4 +- client/src/sync/glPrefetcher.ts | 219 ++++++++++++++++++ client/src/sync/tilePrefetcher.ts | 31 ++- client/src/utils/basemap.test.ts | 71 ++++++ client/src/utils/tileUrl.ts | 59 ++++- client/tests/unit/sync/glPrefetcher.test.ts | 196 ++++++++++++++++ client/vite.config.js | 20 +- package-lock.json | 82 +++++-- shared/src/i18n/ar/settings.ts | 2 +- shared/src/i18n/br/settings.ts | 2 +- shared/src/i18n/ca/settings.ts | 2 +- shared/src/i18n/cs/settings.ts | 2 +- shared/src/i18n/de/settings.ts | 2 +- shared/src/i18n/en/settings.ts | 2 +- shared/src/i18n/es/settings.ts | 2 +- shared/src/i18n/fr/settings.ts | 2 +- shared/src/i18n/gr/settings.ts | 2 +- shared/src/i18n/hu/settings.ts | 2 +- shared/src/i18n/id/settings.ts | 2 +- shared/src/i18n/it/settings.ts | 2 +- shared/src/i18n/ja/settings.ts | 2 +- shared/src/i18n/ko/settings.ts | 2 +- shared/src/i18n/nl/settings.ts | 2 +- shared/src/i18n/pl/settings.ts | 2 +- shared/src/i18n/ru/settings.ts | 2 +- shared/src/i18n/sv/settings.ts | 2 +- shared/src/i18n/tr/settings.ts | 2 +- shared/src/i18n/uk/settings.ts | 2 +- shared/src/i18n/vi/settings.ts | 2 +- shared/src/i18n/zh-TW/settings.ts | 2 +- shared/src/i18n/zh/settings.ts | 2 +- wiki/Contributing.md | 2 +- wiki/Map-Settings.md | 22 +- wiki/Offline-Mode-and-PWA.md | 6 +- wiki/User-Settings.md | 2 +- 53 files changed, 1342 insertions(+), 168 deletions(-) create mode 100644 client/src/components/Map/VectorBasemap.test.tsx create mode 100644 client/src/components/Map/VectorBasemap.tsx create mode 100644 client/src/sync/glPrefetcher.ts create mode 100644 client/src/utils/basemap.test.ts create mode 100644 client/tests/unit/sync/glPrefetcher.test.ts 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>(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: '© OpenStreetMap', - 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 diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index cbabfd6a9..fe9737e3a 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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) => ( - {/* key remounts the layer on switch, else attribution/maxZoom stick at mount-time values. */} - OpenStreetMap'} - 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 ? ( + + ) : basemap.kind === 'vector' ? ( + + ) : ( + + )} 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} /> diff --git a/client/src/components/Map/VectorBasemap.test.tsx b/client/src/components/Map/VectorBasemap.test.tsx new file mode 100644 index 000000000..8cc0a1e6d --- /dev/null +++ b/client/src/components/Map/VectorBasemap.test.tsx @@ -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 = { 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) + +type Spy = ReturnType + +/** 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() + + 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() + 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() + await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1)) + const { gl } = lastLayer() + + rerender() + + // 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() + 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() + }) +}) diff --git a/client/src/components/Map/VectorBasemap.tsx b/client/src/components/Map/VectorBasemap.tsx new file mode 100644 index 000000000..ba6512ba0 --- /dev/null +++ b/client/src/components/Map/VectorBasemap.tsx @@ -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 + +/** + * 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(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 { + 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() +} diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index e22135f3d..4476508fa 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -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' }, diff --git a/client/src/constants/mapDefaults.ts b/client/src/constants/mapDefaults.ts index 627dad517..fb0a64ac7 100644 --- a/client/src/constants/mapDefaults.ts +++ b/client/src/constants/mapDefaults.ts @@ -10,8 +10,36 @@ export const SATELLITE_TILE_ATTRIBUTION = 'Imagery © Esri, 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 = + 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap' + +const OSM_ATTRIBUTION = '© OpenStreetMap' + +/** + * 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' diff --git a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx index dec74e984..7b089d00a 100644 --- a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx +++ b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx @@ -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' }, diff --git a/client/src/mobile/screens/settings/MSettingsMap.tsx b/client/src/mobile/screens/settings/MSettingsMap.tsx index 8b9e5312a..a15de571c 100644 --- a/client/src/mobile/screens/settings/MSettingsMap.tsx +++ b/client/src/mobile/screens/settings/MSettingsMap.tsx @@ -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' }, diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx index 9c96e73be..a65fdfb56 100644 --- a/client/src/pages/AtlasPage.test.tsx +++ b/client/src/pages/AtlasPage.test.tsx @@ -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 = { 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 = () => { diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 7e710eaa6..a1ed42467 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -14,7 +14,7 @@ vi.mock('react-leaflet', () => ({ MapContainer: ({ children }: { children: React.ReactNode }) => (
{children}
), - TileLayer: () => null, + TileLayer: ({ url }: { url: string }) =>
, Marker: ({ children }: { children?: React.ReactNode }) =>
{children}
, Polyline: () =>
, Tooltip: ({ children }: { children?: React.ReactNode }) =>
{children}
, @@ -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 }) =>
, +})); + 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. diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index d58e0001b..88fc4b5ab 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -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 (
@@ -487,10 +491,15 @@ export default function SharedTripPage() { zoomControl={false} style={{ width: '100%', height: '100%' }} > - + {basemap.kind === 'vector' ? ( + + ) : ( + + )} {selectedDay && mapPlaces.length > 1 && ( ({ }, })); +// 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 = { 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 }); - 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; on: ReturnType } }); }); + // 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 () => { diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 34d1521ad..264578364 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -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([]) + 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 mapRef = useRef(null) const mapInstance = useRef(null) const geoLayerRef = useRef(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]) diff --git a/client/src/pages/tripPlanner/useTripPlanner.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.test.tsx index 9bb117221..5921cd455 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.test.tsx @@ -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') diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 40163eb91..a7f1aa39f 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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)" } diff --git a/client/src/sync/glPrefetcher.ts b/client/src/sync/glPrefetcher.ts new file mode 100644 index 000000000..c8a7e1716 --- /dev/null +++ b/client/src/sync/glPrefetcher.ts @@ -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 + layers?: { layout?: { 'text-font'?: string[] } }[] +} + +async function fetchJson(url: string): Promise { + 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() + 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 { + 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 { + 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 { + const cache = await openVectorCache() + const style = await fetchJson(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 { + 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 { + 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 { + try { + await caches.delete(VECTOR_CACHE) + } catch { + // Nothing to clear, or storage is unavailable; the caller cannot act on it. + } +} diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index 4e5ecb84e..735f7b1e7 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -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 { /* 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 { - 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. diff --git a/client/src/utils/basemap.test.ts b/client/src/utils/basemap.test.ts new file mode 100644 index 000000000..ee57591c1 --- /dev/null +++ b/client/src/utils/basemap.test.ts @@ -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/) + }) +}) diff --git a/client/src/utils/tileUrl.ts b/client/src/utils/tileUrl.ts index 108f39e19..766091651 100644 --- a/client/src/utils/tileUrl.ts +++ b/client/src/utils/tileUrl.ts @@ -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 } } diff --git a/client/tests/unit/sync/glPrefetcher.test.ts b/client/tests/unit/sync/glPrefetcher.test.ts new file mode 100644 index 000000000..59eeca12d --- /dev/null +++ b/client/tests/unit/sync/glPrefetcher.test.ts @@ -0,0 +1,196 @@ +/** + * glPrefetcher unit tests. + * + * Covers: style/sprite/glyph warming, the TileJSON indirection, the cors + * requirement, the offline and cancellation guards, cache reuse, and clearing. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import 'fake-indexeddb/auto'; +import { + prefetchStyleAssets, + prefetchVectorForPlaces, + clearVectorCache, +} from '../../../src/sync/glPrefetcher'; +import { buildPlace } from '../../helpers/factories'; + +const STYLE_URL = 'https://tiles.openfreemap.org/styles/positron'; +const TILE_TEMPLATE = 'https://tiles.openfreemap.org/planet/20260101_001001055/{z}/{x}/{y}.pbf'; + +const STYLE_DOC = { + sprite: 'https://tiles.openfreemap.org/sprites/ofm_f384/ofm', + glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf', + sources: { openmaptiles: { url: 'https://tiles.openfreemap.org/planet' } }, + layers: [ + { layout: { 'text-font': ['Noto Sans Regular'] } }, + { layout: { 'text-font': ['Noto Sans Regular'] } }, // same stack, must dedupe + { layout: { 'text-font': ['Noto Sans Italic'] } }, + { layout: {} }, + {}, + ], +}; + +/** Cache Storage stand-in: jsdom has none, and we want to inspect what landed. */ +class FakeCache { + store = new Map(); + match(url: string) { + return Promise.resolve(this.store.get(url)); + } + put(url: string, res: unknown) { + this.store.set(url, res); + return Promise.resolve(); + } +} + +let cache: FakeCache; +let deleted: string[]; +let requested: { url: string; mode?: string }[]; + +/** Answers the style URL and the TileJSON as JSON, everything else as a body-less 200. */ +function respond(url: string) { + if (url === STYLE_URL) return { ok: true, json: () => Promise.resolve(STYLE_DOC), clone: () => ({}) }; + if (url === 'https://tiles.openfreemap.org/planet') { + return { ok: true, json: () => Promise.resolve({ tiles: [TILE_TEMPLATE] }), clone: () => ({}) }; + } + return { ok: true, json: () => Promise.resolve({}), clone: () => ({}) }; +} + +beforeEach(() => { + cache = new FakeCache(); + deleted = []; + requested = []; + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(cache), + delete: vi.fn((name: string) => { + deleted.push(name); + return Promise.resolve(true); + }), + }); + vi.stubGlobal( + 'fetch', + vi.fn((url: string, init?: { mode?: string }) => { + requested.push({ url, mode: init?.mode }); + return Promise.resolve(respond(url)); + }), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('prefetchStyleAssets', () => { + it('FE-SYNC-GLPF-001: takes the tile template from the TileJSON rather than guessing it', async () => { + // OpenFreeMap's tile path carries a planet version. Guessing /planet/{z}/{x}/{y}.pbf + // answers 200 with an empty body, which caches as a map with no data on it. + const template = await prefetchStyleAssets(STYLE_URL); + expect(template).toBe(TILE_TEMPLATE); + expect(cache.store.has('https://tiles.openfreemap.org/planet')).toBe(true); + }); + + it('FE-SYNC-GLPF-002: warms both sprite densities and both parts', async () => { + await prefetchStyleAssets(STYLE_URL); + const urls = [...cache.store.keys()]; + expect(urls).toEqual( + expect.arrayContaining([ + `${STYLE_DOC.sprite}.json`, + `${STYLE_DOC.sprite}.png`, + `${STYLE_DOC.sprite}@2x.json`, + `${STYLE_DOC.sprite}@2x.png`, + ]), + ); + }); + + it('FE-SYNC-GLPF-003: warms the Latin ranges for each distinct font stack, deduped', async () => { + await prefetchStyleAssets(STYLE_URL); + const glyphs = [...cache.store.keys()].filter(u => u.includes('/fonts/')); + // Two stacks x nine Latin ranges. Asking for all 256 ranges would pull down + // megabytes of CJK that nothing on this map renders. + expect(glyphs).toHaveLength(18); + expect(glyphs.some(u => u.includes('Noto%20Sans%20Regular') && u.endsWith('/0-255.pbf'))).toBe(true); + expect(glyphs.some(u => u.includes('Noto%20Sans%20Italic'))).toBe(true); + expect(glyphs.some(u => u.includes('20480-20735'))).toBe(false); + }); + + it('FE-SYNC-GLPF-004: asks in cors mode, never no-cors', async () => { + // An opaque response cannot be read, so MapLibre would find a zero-byte body + // in the cache and draw nothing. OpenFreeMap sends the CORS header. + await prefetchStyleAssets(STYLE_URL); + expect(requested.length).toBeGreaterThan(0); + expect(requested.every(r => r.mode === 'cors')).toBe(true); + }); + + it('FE-SYNC-GLPF-005: gives up quietly when the style cannot be read', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBeNull(); + }); + + it('FE-SYNC-GLPF-006: uses a source that already carries its tiles inline', async () => { + const inline = { sources: { s: { tiles: ['https://example.test/{z}/{x}/{y}.pbf'] } } }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(inline), clone: () => ({}) }), + ); + await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBe('https://example.test/{z}/{x}/{y}.pbf'); + }); +}); + +describe('prefetchVectorForPlaces', () => { + const places = [buildPlace({ lat: 52.52, lng: 13.405 })]; + + it('FE-SYNC-GLPF-007: downloads the tiles covering the trip, z10 to z14', async () => { + const result = await prefetchVectorForPlaces(places, STYLE_URL); + expect(result.template).toBe(TILE_TEMPLATE); + expect(result.tiles).toBeGreaterThan(0); + + const zooms = new Set( + [...cache.store.keys()] + .filter(u => u.endsWith('.pbf') && u.includes('/planet/')) + .map(u => Number(u.split('/planet/')[1]!.split('/')[1])), + ); + // z14 is the last one OpenFreeMap serves; MapLibre scales it beyond that + // rather than asking for another, which is why this stops where raster could not. + expect([...zooms].sort((a, b) => a - b)).toEqual([10, 11, 12, 13, 14]); + }); + + it('FE-SYNC-GLPF-008: does nothing offline', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }); + await expect(prefetchVectorForPlaces(places, STYLE_URL)).resolves.toEqual({ tiles: 0, template: null }); + expect(requested).toHaveLength(0); + }); + + it('FE-SYNC-GLPF-009: does nothing when no place has coordinates', async () => { + const result = await prefetchVectorForPlaces([buildPlace({ lat: null, lng: null })], STYLE_URL); + expect(result).toEqual({ tiles: 0, template: null }); + }); + + it('FE-SYNC-GLPF-010: abandons the queue when cancelled', async () => { + const result = await prefetchVectorForPlaces(places, STYLE_URL, () => true); + // The style assets are already paid for at that point; the tiles are not. + expect(result.tiles).toBe(0); + expect([...cache.store.keys()].some(u => u.endsWith('.pbf') && u.includes('/planet/'))).toBe(false); + }); + + it('FE-SYNC-GLPF-011: a second run over a warm cache re-downloads nothing', async () => { + await prefetchVectorForPlaces(places, STYLE_URL); + const first = requested.length; + requested.length = 0; + + const again = await prefetchVectorForPlaces(places, STYLE_URL); + expect(again.tiles).toBe(0); + expect(requested.length).toBeLessThan(first); + }); +}); + +describe('clearVectorCache', () => { + it('FE-SYNC-GLPF-012: drops the offline vector basemap', async () => { + await clearVectorCache(); + expect(deleted).toEqual(['gl-map-offline']); + }); + + it('FE-SYNC-GLPF-013: stays quiet when storage is unavailable', async () => { + vi.stubGlobal('caches', { delete: vi.fn().mockRejectedValue(new Error('no storage')) }); + await expect(clearVectorCache()).resolves.toBeUndefined(); + }); +}); diff --git a/client/vite.config.js b/client/vite.config.js index 7acf3a4cd..155c52ec6 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -139,13 +139,23 @@ export default defineConfig(({ mode }) => ({ { // OpenFreeMap MapLibre glyphs, sprites and vector tiles (the style // itself is handled by the style rule above). - // Same best-effort offline model as Mapbox GL: viewed resources are - // reused from cache, but the vector tile pipeline is not prefetched. + // + // CacheFirst, and sharing its cache with the prefetcher: OpenFreeMap + // serves tiles under a versioned planet path, so a cached tile is + // never stale — a new planet is a new URL. StaleWhileRevalidate + // would revalidate every tile of a pre-downloaded region on the next + // online visit for nothing. + // + // cacheName matches sync/glPrefetcher.ts, which writes here directly: + // one cache means normal browsing tops the offline region up instead + // of filling a second copy beside it. maxEntries is above the + // prefetcher's own cap so a browsing session cannot evict a region + // the user asked to keep. urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i, - handler: 'StaleWhileRevalidate', + handler: 'CacheFirst', options: { - cacheName: 'openfreemap-tiles', - expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 }, + cacheName: 'gl-map-offline', + expiration: { maxEntries: 6000, maxAgeSeconds: 90 * 24 * 60 * 60 }, cacheableResponse: { statuses: [200] }, }, }, diff --git a/package-lock.json b/package-lock.json index 31d29f761..3f4c4d013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,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", @@ -4745,6 +4746,17 @@ "kdbush": "^4.0.2" } }, + "node_modules/@maplibre/maplibre-gl-leaflet": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-leaflet/-/maplibre-gl-leaflet-0.1.4.tgz", + "integrity": "sha512-k57wcndZIvq3m08g2je4pjUPGr43ffNW3j+gXbymt8Vi2l1lUERUi1UmdTZcTPN8LuDDYOMWRNBaSLwpAu02ew==", + "license": "ISC", + "peerDependencies": { + "@types/leaflet": "^1.9.0", + "leaflet": "^1.9.3", + "maplibre-gl": "^2.4.0 || ^3.3.1 || ^4.3.2 || ^5.0.0 || ^6.0.0" + } + }, "node_modules/@maplibre/maplibre-gl-style-spec": { "version": "24.10.0", "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", @@ -6731,7 +6743,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.62.0", @@ -6745,7 +6758,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.62.0", @@ -6759,7 +6773,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.62.0", @@ -6773,7 +6788,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.62.0", @@ -6787,7 +6803,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.62.0", @@ -6801,7 +6818,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.62.0", @@ -6815,7 +6833,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.62.0", @@ -6829,7 +6848,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.62.0", @@ -6843,7 +6863,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.62.0", @@ -6870,7 +6891,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.62.0", @@ -6884,7 +6906,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.62.0", @@ -6898,7 +6921,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.62.0", @@ -6912,7 +6936,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.62.0", @@ -6926,7 +6951,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.62.0", @@ -6940,7 +6966,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.62.0", @@ -6954,7 +6981,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.0", @@ -6968,7 +6996,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.62.0", @@ -6995,7 +7024,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.62.0", @@ -7009,7 +7039,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.62.0", @@ -7023,7 +7054,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.62.0", @@ -7037,7 +7069,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.62.0", @@ -7051,7 +7084,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.62.0", @@ -7065,7 +7099,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@scarf/scarf": { "version": "1.4.0", @@ -7868,7 +7903,6 @@ "version": "1.9.21", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", - "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 707e3e6c1..9fd9cc1d5 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -36,7 +36,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'تعرض خرائط CARTO الأساسية علامة مائية بدون مفتاح. مجاني وبدون حساب، من', 'settings.mapCartoKeyLink': 'مفتاح API لخرائط carto.com الأساسية', 'settings.mapCartoKeyMissing': - 'هذا القالب خريطة أساسية من CARTO. بدون مفتاح تطبع CARTO عبارة "API KEY REQUIRED" على كل بلاطة.', + 'هذا القالب خريطة أساسية من CARTO. بدون مفتاح تطبع CARTO عبارة "API KEY REQUIRED" على كل بلاطة. إلى أن تُدخل مفتاحًا، تعرض TREK الخريطة الأساسية الافتراضية بدلاً منها.', 'settings.mapStyle': 'نمط الخريطة', 'settings.mapStylePlaceholder': 'اختر نمط Mapbox', 'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 2e20f3abd..21da50045 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': "Os mapas base do CARTO exibem uma marca d'água sem chave. Gratuita e sem conta, em", 'settings.mapCartoKeyLink': 'chave de API de mapas base do carto.com', 'settings.mapCartoKeyMissing': - 'Este modelo é um mapa base do CARTO. Sem uma chave, o CARTO estampa "API KEY REQUIRED" em cada bloco.', + 'Este modelo é um mapa base do CARTO. Sem uma chave, o CARTO estampa "API KEY REQUIRED" em cada bloco. Até você inserir uma, o TREK mostra o mapa base padrão.', 'settings.mapStyle': 'Estilo do mapa', 'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox', 'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 9f3fe894e..07f2c6739 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -28,7 +28,7 @@ const settings: TranslationStrings = { "Els mapes base de CARTO mostren una marca d'aigua sense clau. Gratuïta i sense compte, des de", 'settings.mapCartoKeyLink': "clau d'API de mapes base de carto.com", 'settings.mapCartoKeyMissing': - 'Aquesta plantilla és un mapa base de CARTO. Sense clau, CARTO estampa "API KEY REQUIRED" a cada tessel·la.', + 'Aquesta plantilla és un mapa base de CARTO. Sense clau, CARTO estampa "API KEY REQUIRED" a cada tessel·la. Mentre no hi hagi clau, TREK mostra el mapa base per defecte.', 'settings.mapStyle': 'Estil de mapa', 'settings.mapStylePlaceholder': 'Selecciona un estil de Mapbox', 'settings.mapStyleHint': 'Predefinit o la teva pròpia URL mapbox://styles/USUARI/ID', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index c383b3b86..4ddd20976 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -37,7 +37,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'Podkladové mapy CARTO se bez klíče zobrazují s vodoznakem. Zdarma a bez účtu, na', 'settings.mapCartoKeyLink': 'carto.com API klíč pro podkladové mapy', 'settings.mapCartoKeyMissing': - 'Tato šablona je podkladová mapa CARTO. Bez klíče CARTO vypálí do každé dlaždice nápis "API KEY REQUIRED".', + 'Tato šablona je podkladová mapa CARTO. Bez klíče CARTO vypálí do každé dlaždice nápis "API KEY REQUIRED". Dokud klíč nezadáte, TREK zobrazuje výchozí podkladovou mapu.', 'settings.mapStyle': 'Styl mapy', 'settings.mapStylePlaceholder': 'Vyberte styl Mapbox', 'settings.mapStyleHint': 'Preset nebo vaše vlastní URL mapbox://styles/USER/ID', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 4b56f0383..92ed08456 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'CARTO-Basiskarten zeigen ohne Key ein Wasserzeichen. Kostenlos und ohne Konto, unter', 'settings.mapCartoKeyLink': 'carto.com Basemap-API-Key', 'settings.mapCartoKeyMissing': - 'Diese Vorlage ist eine CARTO-Basiskarte. Ohne Key brennt CARTO "API KEY REQUIRED" in jede Kachel.', + 'Diese Vorlage ist eine CARTO-Basiskarte. Ohne Key brennt CARTO "API KEY REQUIRED" in jede Kachel. Bis ein Key eingetragen ist, zeigt TREK die Standard-Basiskarte.', 'settings.mapStyle': 'Kartenstil', 'settings.mapStylePlaceholder': 'Mapbox-Stil wählen', 'settings.mapStyleHint': 'Preset oder eigene mapbox://styles/USER/ID URL', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 95bc51df4..b61998a80 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -46,7 +46,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'CARTO basemaps show a watermark without a key. Free, no account needed, from', 'settings.mapCartoKeyLink': 'carto.com basemap API key', 'settings.mapCartoKeyMissing': - 'This template is a CARTO basemap. Without a key CARTO stamps "API KEY REQUIRED" onto every tile.', + 'This template is a CARTO basemap. Without a key CARTO stamps "API KEY REQUIRED" onto every tile. Until you enter one, TREK shows the default basemap instead.', 'settings.mapStyle': 'Map Style', 'settings.mapStylePlaceholder': 'Select a Mapbox style', 'settings.mapStyleHint': 'Preset or your own mapbox://styles/USER/ID URL', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 457893af3..75e98434e 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -37,7 +37,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'Los mapas base de CARTO muestran una marca de agua sin clave. Gratuita y sin cuenta, en', 'settings.mapCartoKeyLink': 'clave de API de mapas base de carto.com', 'settings.mapCartoKeyMissing': - 'Esta plantilla es un mapa base de CARTO. Sin clave, CARTO estampa "API KEY REQUIRED" en cada tesela.', + 'Esta plantilla es un mapa base de CARTO. Sin clave, CARTO estampa "API KEY REQUIRED" en cada tesela. Hasta que introduzcas una, TREK muestra el mapa base predeterminado.', 'settings.mapStyle': 'Estilo de mapa', 'settings.mapStylePlaceholder': 'Seleccionar un estilo de Mapbox', 'settings.mapStyleHint': 'Preset o tu propia URL mapbox://styles/USER/ID', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index c31dda7cc..127f24de1 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'Les fonds de carte CARTO affichent un filigrane sans clé. Gratuite et sans compte, depuis', 'settings.mapCartoKeyLink': "clé d'API de fonds de carte carto.com", 'settings.mapCartoKeyMissing': - 'Ce modèle est un fond de carte CARTO. Sans clé, CARTO appose "API KEY REQUIRED" sur chaque tuile.', + 'Ce modèle est un fond de carte CARTO. Sans clé, CARTO appose "API KEY REQUIRED" sur chaque tuile. Tant que la clé manque, TREK affiche le fond de carte par défaut.', 'settings.mapStyle': 'Style de carte', 'settings.mapStylePlaceholder': 'Sélectionner un style Mapbox', 'settings.mapStyleHint': 'Preset ou votre propre URL mapbox://styles/USER/ID', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index aed5bbb5a..9c5d0c509 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -40,7 +40,7 @@ const settings: TranslationStrings = { 'Οι βασικοί χάρτες CARTO εμφανίζουν υδατογράφημα χωρίς κλειδί. Δωρεάν, χωρίς λογαριασμό, από', 'settings.mapCartoKeyLink': 'κλειδί API βασικών χαρτών carto.com', 'settings.mapCartoKeyMissing': - 'Αυτό το πρότυπο είναι βασικός χάρτης CARTO. Χωρίς κλειδί, η CARTO τυπώνει "API KEY REQUIRED" σε κάθε πλακίδιο.', + 'Αυτό το πρότυπο είναι βασικός χάρτης CARTO. Χωρίς κλειδί, η CARTO τυπώνει "API KEY REQUIRED" σε κάθε πλακίδιο. Μέχρι να εισαγάγετε κλειδί, το TREK εμφανίζει τον προεπιλεγμένο βασικό χάρτη.', 'settings.mapStyle': 'Στυλ Χάρτη', 'settings.mapStylePlaceholder': 'Επιλέξτε ένα στυλ Mapbox', 'settings.mapStyleHint': 'Προκαθορισμένο ή δικό σας mapbox://styles/USER/ID URL', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index c25efa6e8..c3aef7af2 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'A CARTO alaptérképek kulcs nélkül vízjelet jelenítenek meg. Ingyenes, fiók nélkül, innen:', 'settings.mapCartoKeyLink': 'carto.com alaptérkép API-kulcs', 'settings.mapCartoKeyMissing': - 'Ez a sablon CARTO alaptérkép. Kulcs nélkül a CARTO minden csempére ráírja: "API KEY REQUIRED".', + 'Ez a sablon CARTO alaptérkép. Kulcs nélkül a CARTO minden csempére ráírja: "API KEY REQUIRED". Amíg nincs kulcs megadva, a TREK az alapértelmezett alaptérképet mutatja.', 'settings.mapStyle': 'Térkép stílus', 'settings.mapStylePlaceholder': 'Válassz Mapbox stílust', 'settings.mapStyleHint': 'Preset vagy saját mapbox://styles/USER/ID URL', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 0defc1f36..525ba6bdc 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -37,7 +37,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'Peta dasar CARTO menampilkan tanda air tanpa kunci. Gratis, tanpa akun, dari', 'settings.mapCartoKeyLink': 'kunci API peta dasar carto.com', 'settings.mapCartoKeyMissing': - 'Templat ini adalah peta dasar CARTO. Tanpa kunci, CARTO mencetak "API KEY REQUIRED" di setiap ubin.', + 'Templat ini adalah peta dasar CARTO. Tanpa kunci, CARTO mencetak "API KEY REQUIRED" di setiap ubin. Sampai kunci dimasukkan, TREK menampilkan peta dasar bawaan.', 'settings.mapStyle': 'Gaya peta', 'settings.mapStylePlaceholder': 'Pilih gaya Mapbox', 'settings.mapStyleHint': 'Preset atau URL mapbox://styles/USER/ID milikmu', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index d59c6ece8..d2168937a 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -37,7 +37,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'Le mappe base CARTO mostrano una filigrana senza chiave. Gratuita e senza account, da', 'settings.mapCartoKeyLink': 'chiave API mappe base di carto.com', 'settings.mapCartoKeyMissing': - 'Questo modello è una mappa base CARTO. Senza chiave, CARTO stampa "API KEY REQUIRED" su ogni tassello.', + 'Questo modello è una mappa base CARTO. Senza chiave, CARTO stampa "API KEY REQUIRED" su ogni tassello. Finché non inserisci una chiave, TREK mostra la mappa base predefinita.', 'settings.mapStyle': 'Stile mappa', 'settings.mapStylePlaceholder': 'Seleziona uno stile Mapbox', 'settings.mapStyleHint': 'Preset o il tuo URL mapbox://styles/USER/ID', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index cb85e6208..f96bff798 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -37,7 +37,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'キーがないと CARTO のベースマップに透かしが入ります。無料でアカウントも不要、取得先:', 'settings.mapCartoKeyLink': 'carto.com ベースマップ API キー', 'settings.mapCartoKeyMissing': - 'このテンプレートは CARTO のベースマップです。キーがないと CARTO はすべてのタイルに "API KEY REQUIRED" を焼き込みます。', + 'このテンプレートは CARTO のベースマップです。キーがないと CARTO はすべてのタイルに "API KEY REQUIRED" を焼き込みます。 キーを入力するまで、TREK は既定のベースマップを表示します。', 'settings.mapStyle': '地図スタイル', 'settings.mapStylePlaceholder': 'Mapboxスタイルを選択', 'settings.mapStyleHint': 'プリセットまたは mapbox://styles/USER/ID のURL', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 1a1d518bd..e24839746 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'CARTO 배경 지도는 키가 없으면 워터마크가 표시됩니다. 무료이며 계정도 필요 없습니다. 출처', 'settings.mapCartoKeyLink': 'carto.com 배경 지도 API 키', 'settings.mapCartoKeyMissing': - '이 템플릿은 CARTO 배경 지도입니다. 키가 없으면 CARTO가 모든 타일에 "API KEY REQUIRED"를 새깁니다.', + '이 템플릿은 CARTO 배경 지도입니다. 키가 없으면 CARTO가 모든 타일에 "API KEY REQUIRED"를 새깁니다. 키를 입력하기 전까지 TREK은 기본 배경 지도를 표시합니다.', 'settings.mapStyle': '지도 스타일', 'settings.mapStylePlaceholder': 'Mapbox 스타일 선택', 'settings.mapStyleHint': '프리셋 또는 mapbox://styles/USER/ID URL 직접 입력', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 25fd4fd93..ac3bc928e 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'CARTO-basiskaarten tonen zonder sleutel een watermerk. Gratis en zonder account, via', 'settings.mapCartoKeyLink': 'carto.com basemap API-sleutel', 'settings.mapCartoKeyMissing': - 'Deze sjabloon is een CARTO-basiskaart. Zonder sleutel drukt CARTO "API KEY REQUIRED" op elke tegel.', + 'Deze sjabloon is een CARTO-basiskaart. Zonder sleutel drukt CARTO "API KEY REQUIRED" op elke tegel. Zolang er geen sleutel is, toont TREK de standaardbasiskaart.', 'settings.mapStyle': 'Kaartstijl', 'settings.mapStylePlaceholder': 'Kies een Mapbox-stijl', 'settings.mapStyleHint': 'Preset of eigen mapbox://styles/USER/ID URL', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index f09206db1..09596a9e1 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -37,7 +37,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'Mapy podkładowe CARTO bez klucza pokazują znak wodny. Bezpłatny, bez konta, z', 'settings.mapCartoKeyLink': 'klucz API map podkładowych carto.com', 'settings.mapCartoKeyMissing': - 'Ten szablon to mapa podkładowa CARTO. Bez klucza CARTO nanosi "API KEY REQUIRED" na każdy kafelek.', + 'Ten szablon to mapa podkładowa CARTO. Bez klucza CARTO nanosi "API KEY REQUIRED" na każdy kafelek. Dopóki nie podasz klucza, TREK pokazuje domyślną mapę podkładową.', 'settings.mapStyle': 'Styl mapy', 'settings.mapStylePlaceholder': 'Wybierz styl Mapbox', 'settings.mapStyleHint': 'Preset lub własny URL mapbox://styles/USER/ID', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 69ebad980..f15d261fd 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'Без ключа на базовых картах CARTO появляется водяной знак. Бесплатно и без учётной записи, на', 'settings.mapCartoKeyLink': 'ключ API базовых карт carto.com', 'settings.mapCartoKeyMissing': - 'Этот шаблон является базовой картой CARTO. Без ключа CARTO наносит "API KEY REQUIRED" на каждый тайл.', + 'Этот шаблон является базовой картой CARTO. Без ключа CARTO наносит "API KEY REQUIRED" на каждый тайл. Пока ключ не указан, TREK показывает базовую карту по умолчанию.', 'settings.mapStyle': 'Стиль карты', 'settings.mapStylePlaceholder': 'Выберите стиль Mapbox', 'settings.mapStyleHint': 'Preset или собственный URL mapbox://styles/USER/ID', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 80e0fdc95..4c3ad2164 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -40,7 +40,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'CARTO-bakgrundskartor visar en vattenstämpel utan nyckel. Gratis och utan konto, från', 'settings.mapCartoKeyLink': 'API-nyckel för bakgrundskartor på carto.com', 'settings.mapCartoKeyMissing': - 'Den här mallen är en CARTO-bakgrundskarta. Utan nyckel stämplar CARTO "API KEY REQUIRED" på varje ruta.', + 'Den här mallen är en CARTO-bakgrundskarta. Utan nyckel stämplar CARTO "API KEY REQUIRED" på varje ruta. Tills en nyckel anges visar TREK standardbakgrundskartan.', 'settings.mapStyle': 'Kartstil', 'settings.mapStylePlaceholder': 'Välj en Mapbox-stil', 'settings.mapStyleHint': 'Förinställda eller egna mapbox://styles/USER/ID länk', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index e68cb8a36..b6b45f7f5 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'CARTO temel haritaları anahtar olmadan filigran gösterir. Ücretsiz, hesap gerekmez, kaynağı:', 'settings.mapCartoKeyLink': 'carto.com temel harita API anahtarı', 'settings.mapCartoKeyMissing': - 'Bu şablon bir CARTO temel haritası. Anahtar olmadan CARTO her karoya "API KEY REQUIRED" damgası basar.', + 'Bu şablon bir CARTO temel haritası. Anahtar olmadan CARTO her karoya "API KEY REQUIRED" damgası basar. Anahtar girilene kadar TREK varsayılan temel haritayı gösterir.', 'settings.mapStyle': 'Harita Stili', 'settings.mapStylePlaceholder': 'Bir Mapbox stili seçin', 'settings.mapStyleHint': 'Ön ayar veya kendi mapbox://styles/KULLANICI/ID adresiniz', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index cc245bb67..322b93300 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -39,7 +39,7 @@ const settings: TranslationStrings = { 'Без ключа базові карти CARTO показують водяний знак. Безкоштовно та без облікового запису, на', 'settings.mapCartoKeyLink': 'ключ API базових карт carto.com', 'settings.mapCartoKeyMissing': - 'Цей шаблон є базовою картою CARTO. Без ключа CARTO наносить "API KEY REQUIRED" на кожен тайл.', + 'Цей шаблон є базовою картою CARTO. Без ключа CARTO наносить "API KEY REQUIRED" на кожен тайл. Доки ключ не вказано, TREK показує базову карту за замовчуванням.', 'settings.mapStyle': 'Стиль карти', 'settings.mapStylePlaceholder': 'Виберіть стиль Mapbox', 'settings.mapStyleHint': 'Preset або власний URL mapbox://styles/USER/ID', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 948c48a43..43e7b2278 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -38,7 +38,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKeyHint': 'Bản đồ nền CARTO hiển thị hình mờ nếu không có khóa. Miễn phí, không cần tài khoản, từ', 'settings.mapCartoKeyLink': 'khóa API bản đồ nền carto.com', 'settings.mapCartoKeyMissing': - 'Mẫu này là bản đồ nền CARTO. Không có khóa, CARTO in "API KEY REQUIRED" lên mọi ô bản đồ.', + 'Mẫu này là bản đồ nền CARTO. Không có khóa, CARTO in "API KEY REQUIRED" lên mọi ô bản đồ. Cho đến khi bạn nhập khóa, TREK hiển thị bản đồ nền mặc định.', 'settings.mapStyle': 'Kiểu bản đồ', 'settings.mapStylePlaceholder': 'Chọn kiểu Mapbox', 'settings.mapStyleHint': 'Đặt trước hoặc của riêng bạn mapbox://styles/USER/ID URL', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 9d62e68ec..54b3a002e 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -36,7 +36,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKey': 'CARTO API 金鑰', 'settings.mapCartoKeyHint': '沒有金鑰時 CARTO 底圖會顯示浮水印。免費且無需帳戶,來自', 'settings.mapCartoKeyLink': 'carto.com 底圖 API 金鑰', - 'settings.mapCartoKeyMissing': '此範本是 CARTO 底圖。沒有金鑰時,CARTO 會在每個圖磚上印上 "API KEY REQUIRED"。', + 'settings.mapCartoKeyMissing': '此範本是 CARTO 底圖。沒有金鑰時,CARTO 會在每個圖磚上印上 "API KEY REQUIRED"。 在輸入金鑰之前,TREK 會顯示預設底圖。', 'settings.mapStyle': '地圖樣式', 'settings.mapStylePlaceholder': '選擇 Mapbox 樣式', 'settings.mapStyleHint': '預設或您自己的 mapbox://styles/USER/ID URL', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index acc9f3dba..a2ef5f312 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -36,7 +36,7 @@ const settings: TranslationStrings = { 'settings.mapCartoKey': 'CARTO API 密钥', 'settings.mapCartoKeyHint': '没有密钥时 CARTO 底图会显示水印。免费且无需账户,来自', 'settings.mapCartoKeyLink': 'carto.com 底图 API 密钥', - 'settings.mapCartoKeyMissing': '此模板是 CARTO 底图。没有密钥时,CARTO 会在每个瓦片上打上 "API KEY REQUIRED"。', + 'settings.mapCartoKeyMissing': '此模板是 CARTO 底图。没有密钥时,CARTO 会在每个瓦片上打上 "API KEY REQUIRED"。 在输入密钥之前,TREK 会显示默认底图。', 'settings.mapStyle': '地图样式', 'settings.mapStylePlaceholder': '选择 Mapbox 样式', 'settings.mapStyleHint': '预设或您自己的 mapbox://styles/USER/ID URL', diff --git a/wiki/Contributing.md b/wiki/Contributing.md index f4a0db201..e739c5b33 100644 --- a/wiki/Contributing.md +++ b/wiki/Contributing.md @@ -59,7 +59,7 @@ See the [[Development Environment|Development-environment]] page for the full se | Real-time | WebSocket (ws) | | Database | SQLite with WAL mode | | Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC | -| Maps | Leaflet + react-leaflet (default, CartoDB tiles), MapLibre GL, Mapbox GL, OSRM, Nominatim | +| Maps | Leaflet + react-leaflet (default, OpenFreeMap vector basemap via maplibre-gl-leaflet), MapLibre GL, Mapbox GL, OSRM, Nominatim | | i18n | 23 languages, EN canonical (locale directories live in shared/src/i18n/) | Every translation key must exist in all 23 locales — the `i18n Key Parity` CI job fails on drift, so run `npm run i18n:parity:strict --workspace=shared` before pushing. Two directory names differ from the language they hold: Brazilian Portuguese is `br`, Greek is `gr`. diff --git a/wiki/Map-Settings.md b/wiki/Map-Settings.md index 91df1f237..2e4b745b7 100644 --- a/wiki/Map-Settings.md +++ b/wiki/Map-Settings.md @@ -32,18 +32,25 @@ When Leaflet is selected, pick a preset or enter a custom tile URL. |------|-----| | OpenStreetMap | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | | OpenStreetMap DE | `https://tile.openstreetmap.de/{z}/{x}/{y}.png` | -| CartoDB Light | `https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png` | -| CartoDB Dark | `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | +| OpenFreeMap Positron | `https://tiles.openfreemap.org/styles/positron` | +| OpenFreeMap Bright | `https://tiles.openfreemap.org/styles/bright` | +| CartoDB Light (needs a key) | `https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png` | +| CartoDB Dark (needs a key) | `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | | Stadia Smooth | `https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png` | -You can also type any XYZ tile URL directly into the text field. +**OpenFreeMap Positron is the default** and is what every map falls back to when the field is empty. It needs no key, +no account and has no request limit, and it is a MapLibre *style* rather than an XYZ template — TREK renders it with +MapLibre inside the Leaflet map, so markers, routes and clusters behave exactly as before. + +You can also type any XYZ tile URL, or the URL of any MapLibre style document, directly into the text field. > **Admin:** The admin can set a default map tile URL for all new users via the **User Defaults** tab in the Admin Panel. See [Admin-Panel-Overview](Admin-Panel-Overview). ## CARTO API key Since 26 August 2026 CARTO stamps an **API KEY REQUIRED** watermark onto every basemap tile fetched without a key, so -both CartoDB presets need one. The key is free and no CARTO account is required: request it at +both CartoDB presets need one. This is why CARTO is no longer the default; you only need this section if you +deliberately want the CartoDB look back. The key is free and no CARTO account is required: request it at [carto.com/basemaps/apikey](https://carto.com/basemaps/apikey/) with an email address, the domain you run TREK on and a one-line description of your project. It arrives by mail, there is no approval queue. The free allowance is 5 million tile requests per calendar month. @@ -56,8 +63,11 @@ never stored inside the tile URL itself, so switching keys later does not break > who has not entered one of their own, which clears the watermark for the whole instance at once. It is stored > encrypted at rest. -Other providers are unaffected. If you would rather not register at all, switch the template to OpenStreetMap, which -needs no key. Note that the OSM tile servers do not permit bulk pre-downloading, so offline maps stay a CARTO feature. +Other providers are unaffected, and a keyless CARTO template is treated as "not configured" so the map falls back to +OpenFreeMap instead of drawing watermarked tiles. Save a key and your template is kept as you entered it. + +Offline pre-download works on OpenFreeMap and on CARTO. It does not work on the OpenStreetMap presets, whose tile +servers do not permit bulk downloading. ## Mapbox GL — access token and style diff --git a/wiki/Offline-Mode-and-PWA.md b/wiki/Offline-Mode-and-PWA.md index 4c4a7b060..03b9c5640 100644 --- a/wiki/Offline-Mode-and-PWA.md +++ b/wiki/Offline-Mode-and-PWA.md @@ -28,10 +28,10 @@ TREK uses Workbox service-worker caching plus an IndexedDB database (Dexie) for | Content | Cache name | Strategy | Duration | Max entries | |---------|------------|----------|----------|-------------| -| CartoDB / OpenStreetMap map tiles | `map-tiles` | CacheFirst | 30 days | 12 288 | +| Raster map tiles (OpenStreetMap, CartoDB, custom XYZ) | `map-tiles` | CacheFirst | 30 days | 12 288 | | Mapbox GL and OpenFreeMap style documents | `gl-map-styles` | NetworkFirst (5 s timeout) | 30 days | 20 | | Mapbox GL glyphs, sprites and vector tiles | `mapbox-tiles` | StaleWhileRevalidate | 30 days | 3 000 | -| OpenFreeMap MapLibre glyphs, sprites and vector tiles | `openfreemap-tiles` | StaleWhileRevalidate | 30 days | 3 000 | +| OpenFreeMap glyphs, sprites and vector tiles | `gl-map-offline` | CacheFirst | 90 days | 6 000 | | Cover images and avatars (`/uploads/covers`, `/uploads/avatars`) | `user-uploads` | CacheFirst | 7 days | 300 | | App shell and every page of the app (HTML / JS / CSS) | precache | Precached | Until next deploy | — | @@ -87,7 +87,7 @@ The stats panel shows cached trips, pending changes, conflicts and failed change - Creating a trip requires connectivity. Trip creation is not queued, so a new trip cannot be started while offline. - Photo uploads require connectivity. Photo and video attachments are not pre-cached; every other file attachment is pre-cached automatically during sync. - Real-time collaboration features require an active WebSocket connection. -- Mapbox GL / vector tiles are not pre-downloaded; raster (Leaflet) tiles are. With map-tile storage off, individually viewed tiles may still be cached opportunistically by the service worker. +- The Leaflet basemap is pre-downloaded, whether it is an OpenFreeMap vector style (style, sprite, glyphs and the vector tiles for the trip's area) or a raster template. Mapbox GL tiles are not pre-downloaded. With map-tile storage off, individually viewed tiles may still be cached opportunistically by the service worker. ## See also diff --git a/wiki/User-Settings.md b/wiki/User-Settings.md index 989a25461..9efdc8ee4 100644 --- a/wiki/User-Settings.md +++ b/wiki/User-Settings.md @@ -62,7 +62,7 @@ The Map tab requires an explicit **Save** action after making changes. **Provider** — choose between: -- **Leaflet** — Classic 2D raster tiles. You can pick from built-in presets (OpenStreetMap, OpenStreetMap DE, CartoDB Light/Dark, Stadia Smooth) or enter a custom tile URL template. +- **Leaflet** — The default. Pick from built-in presets (OpenStreetMap, OpenStreetMap DE, OpenFreeMap Positron/Bright, CartoDB Light/Dark, Stadia Smooth) or enter a custom tile URL template. OpenFreeMap Positron is what an unconfigured map draws; the CartoDB presets need an API key. - **Mapbox GL** (Experimental) — Vector tiles with 3D buildings and terrain. Requires a public Mapbox access token (`pk.*`). Supports built-in style presets (Mapbox Standard, Standard Satellite, Streets, Outdoors, Light, Dark, Satellite, Satellite Streets, Navigation Day, Navigation Night) or a custom `mapbox://styles/USER/ID` URL. Additional options: - **3D Buildings & Terrain** — Pitch and real 3D building extrusions (works on every style including satellite). - **High Quality Mode** (Experimental) — Antialiasing + globe projection for sharper edges. May impact performance on lower-end devices.