mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
fix(maps): default the Leaflet basemap to OpenFreeMap
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves a self-hosted instance needing an email exchange with CARTO before its maps look right, and every default in the client still pointed at CARTO. Default to OpenFreeMap Positron instead: no key, no account, no request limit, commercial use allowed, attribution required. OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which leaves everything above it alone - markers, GeoJSON, clusters, plugin layers, the custom panes, and the click handling, since Leaflet's CSS already puts pointer-events:none on that layer. maplibre-gl still arrives only through a dynamic import, so no chunk that draws a map grows by a megabyte. Switched: the trip planner, the MapView fallback every preview goes through, the journey map, collections, the atlas, the shared trip page and the offline tile prefetch. OpenFreeMap Positron and Bright join the tile presets in settings, the admin defaults and both mobile screens. A CARTO template that is already saved keeps working when a key backs it - that stays a supported choice. Without a key it is not drawn at all: resolveTileUrl falls back to the default rather than to a watermark, so an instance that upgrades with one in its settings sees the new basemap immediately and needs no migration. The stored value is left as it is, so the Map settings tab still shows what was chosen and the warning underneath it explains why - one sentence appended, in all 23 locales. OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles is wrong and a licence problem. attributionForTile() derives it from the URL rather than from a flag at each call site, and the shared trip map, which carried no attribution at all, now gets one. Offline maps keep working and get cheaper. A vector style is four things instead of one, so sync/glPrefetcher.ts walks the style document, its sprite, the Latin glyph ranges of each font stack it uses, the TileJSON - the planet path is versioned, so guessing it answers 200 with an empty body - and then the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is 2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177 requests and 32.1 MB and stays sharp above that, because MapLibre scales the z14 tile rather than asking for another. The Workbox rule for tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache, so browsing tops a downloaded region up instead of filling a second copy beside it - a versioned tile path can never be stale. Clearing offline maps clears it too. The atlas loses its second raster layer. It existed to warm the HTTP cache of neighbouring zoom levels, which a vector basemap does not need; a second one would only cost a second WebGL context. OpenFreeMap has no label-free style, so its labels are switched off on the live map instead - they are all symbol layers, while borders and coastlines are line and fill - re-armed on style.load so a theme switch keeps them off. Theme changes call setStyle in place rather than rebuilding the layer: a swap per toggle would leak a WebGL context, and browsers cap those in the low teens.
This commit is contained in:
@@ -19,7 +19,9 @@ import { escapeHtml } from '@trek/shared'
|
||||
import type { Day, Reservation, RouteVia } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { CARTO_LIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapLayerSwitcher } from './MapLayerSwitcher'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
@@ -517,7 +519,7 @@ export const MapView = memo(function MapView({
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
// Callers hand down a URL that already carries the CARTO key; this is only
|
||||
// the shape a caller without one gets.
|
||||
tileUrl = CARTO_LIGHT,
|
||||
tileUrl = OFM_POSITRON,
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
leftWidth = 0,
|
||||
@@ -537,6 +539,10 @@ export const MapView = memo(function MapView({
|
||||
tripId,
|
||||
routeVias = [],
|
||||
}: any) {
|
||||
// The caller hands over whatever the user configured; what kind of basemap
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
// the default is a vector style.
|
||||
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
|
||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
@@ -828,17 +834,36 @@ export const MapView = memo(function MapView({
|
||||
zoomControl={false}
|
||||
className="w-full h-full bg-[#e5e7eb]"
|
||||
>
|
||||
{/* key remounts the layer on switch, else attribution/maxZoom stick at mount-time values. */}
|
||||
<TileLayer
|
||||
key={isSatellite ? 'satellite' : 'default'}
|
||||
url={isSatellite ? SATELLITE_TILE_URL : tileUrl}
|
||||
attribution={isSatellite ? SATELLITE_TILE_ATTRIBUTION : '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}
|
||||
maxZoom={isSatellite ? SATELLITE_TILE_MAXZOOM : 19}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
{/* The basemap is a vector style by default and a raster template when the
|
||||
user brought their own, so the two are drawn by different things. The
|
||||
satellite toggle is always raster.
|
||||
key remounts the raster layer on switch, else attribution/maxZoom stick
|
||||
at mount-time values. */}
|
||||
{isSatellite ? (
|
||||
<TileLayer
|
||||
key="satellite"
|
||||
url={SATELLITE_TILE_URL}
|
||||
attribution={SATELLITE_TILE_ATTRIBUTION}
|
||||
maxZoom={SATELLITE_TILE_MAXZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
) : basemap.kind === 'vector' ? (
|
||||
<VectorBasemap style={basemap.style} />
|
||||
) : (
|
||||
<TileLayer
|
||||
key="raster"
|
||||
url={basemap.url}
|
||||
attribution={attributionForTile(basemap.url)}
|
||||
maxZoom={19}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* VectorBasemap unit tests.
|
||||
*
|
||||
* The component and its imperative twin both hang a MapLibre style into Leaflet's
|
||||
* tile pane. Nothing here renders WebGL — maplibre-gl and the bridge are mocked;
|
||||
* what is under test is the wiring: which style goes in, what happens on a style
|
||||
* change, and what is left behind on teardown.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import type * as L from 'leaflet'
|
||||
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { VectorBasemap, attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from './VectorBasemap'
|
||||
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION } from '../../constants/mapDefaults'
|
||||
|
||||
const addAttribution = vi.fn()
|
||||
/** Only the two things the component touches; casting keeps the mock honest about that. */
|
||||
const asMap = (m: object) => m as unknown as L.Map
|
||||
const leafletMap = asMap({ attributionControl: { addAttribution } })
|
||||
/** A map built without an attribution control, like the atlas. */
|
||||
const bareMap = asMap({})
|
||||
|
||||
vi.mock('react-leaflet', () => ({ useMap: () => leafletMap }))
|
||||
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
|
||||
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
||||
const maplibreGL = vi.fn(() => {
|
||||
// One GL map per layer, kept stable: a fresh object per call would hand the
|
||||
// assertions a different spy than the code just used.
|
||||
const gl = {
|
||||
setStyle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
isStyleLoaded: vi.fn(() => false),
|
||||
getStyle: vi.fn(() => ({
|
||||
layers: [
|
||||
{ id: 'water', type: 'fill' },
|
||||
{ id: 'boundary', type: 'line' },
|
||||
{ id: 'place-city', type: 'symbol' },
|
||||
{ id: 'country-label', type: 'symbol' },
|
||||
],
|
||||
})),
|
||||
setLayoutProperty: vi.fn(),
|
||||
}
|
||||
const layer: Record<string, unknown> = { remove: vi.fn(), getMaplibreMap: vi.fn(() => gl) }
|
||||
layer.addTo = vi.fn(() => layer)
|
||||
return layer
|
||||
})
|
||||
return { maplibreGL, default: maplibreGL }
|
||||
})
|
||||
|
||||
const maplibreGL = vi.mocked(bridge as unknown as ReturnType<typeof vi.fn>)
|
||||
|
||||
type Spy = ReturnType<typeof vi.fn>
|
||||
|
||||
/** The GL map behind a mocked layer, typed as spies so assertions can reach them. */
|
||||
interface MockGl {
|
||||
setStyle: Spy
|
||||
on: Spy
|
||||
isStyleLoaded: Spy
|
||||
getStyle: Spy
|
||||
setLayoutProperty: Spy
|
||||
}
|
||||
|
||||
/** The layer the last maplibreGL() call produced, and its GL map. */
|
||||
function lastLayer(): { layer: GlLeafletLayer; gl: MockGl } {
|
||||
const results = maplibreGL.mock.results
|
||||
const raw = results[results.length - 1]!.value as { getMaplibreMap: () => MockGl }
|
||||
return { layer: raw as unknown as GlLeafletLayer, gl: raw.getMaplibreMap() }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('VectorBasemap', () => {
|
||||
it('FE-COMP-VECBM-001: hangs the style into the map, non-interactive', async () => {
|
||||
render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
|
||||
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
||||
expect(maplibreGL.mock.calls[0]![0]).toMatchObject({
|
||||
style: OFM_POSITRON,
|
||||
// Leaflet keeps handling the gestures; a second interactive map underneath
|
||||
// would fight it for the wheel and the drag.
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
expect(lastLayer().layer.addTo).toHaveBeenCalledWith(leafletMap)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-002: credits OpenFreeMap, not just OpenStreetMap', async () => {
|
||||
// The data is OSM, but the rendering and the hosting are not, and the licence
|
||||
// asks for both.
|
||||
render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
await waitFor(() => expect(addAttribution).toHaveBeenCalledWith(OFM_ATTRIBUTION))
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-003: a theme switch restyles in place instead of rebuilding', async () => {
|
||||
const { rerender } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
||||
const { gl } = lastLayer()
|
||||
|
||||
rerender(<VectorBasemap style={OFM_DARK} />)
|
||||
|
||||
// Swapping the layer would leak a WebGL context per toggle, and browsers cap
|
||||
// those in the low teens: a dozen theme switches and the map goes blank.
|
||||
expect(gl.setStyle).toHaveBeenCalledWith(OFM_DARK)
|
||||
expect(maplibreGL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-004: takes the layer off the map on unmount', async () => {
|
||||
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
||||
const { layer } = lastLayer()
|
||||
|
||||
unmount()
|
||||
expect(layer.remove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachVectorBasemap', () => {
|
||||
it('FE-COMP-VECBM-005: hands the caller the layer it attached', async () => {
|
||||
const ref: { current: GlLeafletLayer | null } = { current: null }
|
||||
const map = { id: 'imperative' }
|
||||
|
||||
await attachVectorBasemap(asMap(map), OFM_DARK, ref, () => false)
|
||||
|
||||
expect(maplibreGL.mock.calls[0]![0]).toMatchObject({ style: OFM_DARK, interactive: false })
|
||||
expect(ref.current).toBe(lastLayer().layer)
|
||||
expect(lastLayer().layer.addTo).toHaveBeenCalledWith(map)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-006: a map torn down mid-load gets no layer', async () => {
|
||||
// maplibre-gl only ever arrives through a dynamic import, so there is always a
|
||||
// gap between "the map wants a basemap" and "the basemap is here". Leaving the
|
||||
// layer attached to a dead map keeps its WebGL context alive for good.
|
||||
const ref: { current: GlLeafletLayer | null } = { current: null }
|
||||
|
||||
await attachVectorBasemap(bareMap, OFM_DARK, ref, () => true)
|
||||
|
||||
expect(maplibreGL).not.toHaveBeenCalled()
|
||||
expect(ref.current).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-007: credits OpenFreeMap on a map that has an attribution control', async () => {
|
||||
// The raster layer it replaces carried the credit, so dropping it silently
|
||||
// would leave the journey map crediting nobody.
|
||||
const ref: { current: GlLeafletLayer | null } = { current: null }
|
||||
await attachVectorBasemap(leafletMap, OFM_POSITRON, ref, () => false)
|
||||
expect(addAttribution).toHaveBeenCalledWith(OFM_ATTRIBUTION)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-008: stays quiet on a map built without one', async () => {
|
||||
// The atlas turns the control off on purpose; asking for it there would throw.
|
||||
const ref: { current: GlLeafletLayer | null } = { current: null }
|
||||
await expect(attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)).resolves.toBeUndefined()
|
||||
expect(addAttribution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-009: hides the labels when asked to', async () => {
|
||||
const ref: { current: GlLeafletLayer | null } = { current: null }
|
||||
|
||||
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false, { hideLabels: true })
|
||||
|
||||
const { gl } = lastLayer()
|
||||
expect(gl.on).toHaveBeenCalledWith('style.load', expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
describe('hideLabelLayers', () => {
|
||||
it('FE-COMP-VECBM-010: hides every symbol layer and nothing else', async () => {
|
||||
const ref: { current: GlLeafletLayer | null } = { current: null }
|
||||
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)
|
||||
const { layer, gl } = lastLayer()
|
||||
gl.isStyleLoaded.mockReturnValue(true)
|
||||
|
||||
hideLabelLayers(layer)
|
||||
|
||||
// The atlas draws country names into its own fills, so basemap labels repeat
|
||||
// them. Borders and coastlines are line/fill layers and have to survive.
|
||||
expect(gl.setLayoutProperty).toHaveBeenCalledWith('place-city', 'visibility', 'none')
|
||||
expect(gl.setLayoutProperty).toHaveBeenCalledWith('country-label', 'visibility', 'none')
|
||||
expect(gl.setLayoutProperty).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-011: re-hides them after every restyle', async () => {
|
||||
const ref: { current: GlLeafletLayer | null } = { current: null }
|
||||
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)
|
||||
const { layer, gl } = lastLayer()
|
||||
|
||||
hideLabelLayers(layer)
|
||||
expect(gl.setLayoutProperty).not.toHaveBeenCalled() // style not loaded yet
|
||||
|
||||
// setStyle discards the layout properties along with the old style, so the
|
||||
// theme toggle would bring the labels back without this.
|
||||
const onStyleLoad = gl.on.mock.calls.find((c: unknown[]) => c[0] === 'style.load')![1] as () => void
|
||||
onStyleLoad()
|
||||
expect(gl.setLayoutProperty).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-012: does nothing when the GL map is not up yet', () => {
|
||||
const layer = { getMaplibreMap: () => undefined } as unknown as GlLeafletLayer
|
||||
expect(() => hideLabelLayers(layer)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import type * as L from 'leaflet'
|
||||
import type { MaplibreGL } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { attributionForTile } from '../../constants/mapDefaults'
|
||||
|
||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
|
||||
|
||||
/**
|
||||
* Load the layer factory.
|
||||
*
|
||||
* Both imports are dynamic, the same rule the GL renderers follow: a static one
|
||||
* would pull about a megabyte into every chunk that draws a map, including the
|
||||
* public share page. The engine module comes along for maplibre-gl's stylesheet,
|
||||
* which the GL canvas needs to lay itself out.
|
||||
*
|
||||
* The layer comes from the bridge's own export rather than from the `L.maplibreGL`
|
||||
* it also installs: that side effect is guarded by `Object.isExtensible(L)` and
|
||||
* would fail silently — every Leaflet map blank — if a bundler ever handed it a
|
||||
* sealed namespace object.
|
||||
*/
|
||||
async function loadLayerFactory() {
|
||||
const [, bridge] = await Promise.all([
|
||||
import('./engines/maplibre'),
|
||||
import('@maplibre/maplibre-gl-leaflet'),
|
||||
])
|
||||
return bridge.maplibreGL
|
||||
}
|
||||
|
||||
/** Credit the basemap on a map that has an attribution control; a no-op on one that does not. */
|
||||
function creditBasemap(map: L.Map, style: string): void {
|
||||
map.attributionControl?.addAttribution(attributionForTile(style))
|
||||
}
|
||||
|
||||
/**
|
||||
* A MapLibre vector style as the basemap of a Leaflet map.
|
||||
*
|
||||
* OpenFreeMap only serves vector tiles, and CARTO's raster tiles now carry a
|
||||
* watermark unless the operator has requested a key by mail, so the basemap of
|
||||
* every Leaflet map here is a style document rather than a tile template.
|
||||
* maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which
|
||||
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers
|
||||
* and the panes they live in are untouched, and Leaflet's CSS puts
|
||||
* `pointer-events: none` on the layer's canvas, so clicks fall through to them.
|
||||
*/
|
||||
export function VectorBasemap({ style }: { style: string }): null {
|
||||
const map = useMap()
|
||||
const layerRef = useRef<GlLeafletLayer | null>(null)
|
||||
const styleRef = useRef(style)
|
||||
styleRef.current = style
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const maplibreGL = await loadLayerFactory()
|
||||
if (cancelled) return
|
||||
|
||||
// The style is read through a ref, never from the dependency list: a style
|
||||
// change must retile in place rather than tear the layer down, the same
|
||||
// reason the raster maps keep their template out of the build effect (#2097).
|
||||
const layer = maplibreGL({
|
||||
style: styleRef.current,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
layerRef.current = layer
|
||||
layer.addTo(map)
|
||||
creditBasemap(map, styleRef.current)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
layerRef.current?.remove()
|
||||
layerRef.current = null
|
||||
}
|
||||
}, [map])
|
||||
|
||||
// Restyle in place. Swapping the layer would drop a WebGL context per theme
|
||||
// toggle, and browsers cap those in the low teens.
|
||||
useEffect(() => {
|
||||
layerRef.current?.getMaplibreMap()?.setStyle(style)
|
||||
}, [style])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default VectorBasemap
|
||||
|
||||
/**
|
||||
* The same layer for the maps that build Leaflet imperatively rather than
|
||||
* through react-leaflet: the journey map and the atlas.
|
||||
*
|
||||
* Loading is async because maplibre-gl only ever arrives through a dynamic
|
||||
* import, so the caller passes a `cancelled` probe: a map torn down mid-load
|
||||
* must not get a layer attached to it afterwards.
|
||||
*/
|
||||
export async function attachVectorBasemap(
|
||||
map: L.Map,
|
||||
style: string,
|
||||
ref: { current: GlLeafletLayer | null },
|
||||
cancelled: () => boolean,
|
||||
opts: { hideLabels?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const maplibreGL = await loadLayerFactory()
|
||||
if (cancelled()) return
|
||||
|
||||
const layer = maplibreGL({ style, interactive: false, attributionControl: false })
|
||||
ref.current = layer
|
||||
layer.addTo(map)
|
||||
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
|
||||
// one of its own.
|
||||
creditBasemap(map, style)
|
||||
if (opts.hideLabels) hideLabelLayers(layer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every label from a vector style.
|
||||
*
|
||||
* The atlas draws country names into its own fills, so a basemap that repeats
|
||||
* them underneath is noise. OpenFreeMap has no label-free style, but its labels
|
||||
* are all `symbol` layers, while borders and coastlines are `line` and `fill`,
|
||||
* so hiding that one type leaves the geography intact.
|
||||
*
|
||||
* Bound to `style.load` rather than run once: the event fires again after every
|
||||
* setStyle, so a theme switch keeps the labels off without a second call site.
|
||||
*/
|
||||
export function hideLabelLayers(layer: GlLeafletLayer): void {
|
||||
const gl = layer.getMaplibreMap()
|
||||
if (!gl) return
|
||||
const apply = () => {
|
||||
for (const l of gl.getStyle()?.layers ?? []) {
|
||||
if (l.type === 'symbol') gl.setLayoutProperty(l.id, 'visibility', 'none')
|
||||
}
|
||||
}
|
||||
gl.on('style.load', apply)
|
||||
// The first style may already be in when we get here.
|
||||
if (gl.isStyleLoaded()) apply()
|
||||
}
|
||||
Reference in New Issue
Block a user