diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 90c2ed0ff..ed6da85bd 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -5,6 +5,7 @@ import ErrorBoundary from '../shared/ErrorBoundary' import type { JourneyMapGLHandle } from './JourneyMapGL' import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy' +import { useGlRenderer } from '../Map/glSupport' import type { JourneyTrack } from '@trek/shared' import type { MapPhoto } from './journeyPhotoLayer' import type { JourneyMapLayerVisibility } from './journeyMapLayers' @@ -56,10 +57,11 @@ function JourneyMapAuto({ ref, ...props }: Props) { const leafletRef = useRef(null) const glRef = useRef(null) - // Fall back to Leaflet when the user selected Mapbox GL but hasn't - // supplied a token yet. MapLibre/OpenFreeMap is tokenless. - const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token) - const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl' + // Fall back to Leaflet when the user selected Mapbox GL but hasn't supplied a + // token yet (MapLibre/OpenFreeMap is tokenless), or when the browser has no WebGL2. + const glRenderer = useGlRenderer(provider, token) + const useGL = glRenderer !== null + const glProvider = glRenderer ?? 'mapbox-gl' useImperativeHandle(ref, () => ({ highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id), diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index f42d83066..a8de124ad 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -3,6 +3,7 @@ import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders' +import { buildGlMap } from '../Map/glSupport' import type { JourneyTrack } from '@trek/shared' import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup' import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer' @@ -360,11 +361,13 @@ function JourneyMapGL( antialias: mapboxQuality, } if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' - // MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545) + // MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545) // — see MapViewGL. Keep the plain dx-based rotate everywhere. if (isMapLibre) mapOptions.aroundCenter = false - const map = new gl.Map(mapOptions as any) + // Null when the GPU refused WebGL2: JourneyMapAuto has switched to Leaflet by then. + const map = buildGlMap(() => new gl.Map(mapOptions as any)) + if (!map) return mapRef.current = map marksFramedRef.current = false entryPointsRef.current = [...items.map(i => [i.lng, i.lat] as [number, number]), ...stableTrail.map(p => [p.lng, p.lat] as [number, number])] diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index eeb9d0bae..28d670417 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { MapView } from './MapView' import ErrorBoundary from '../shared/ErrorBoundary' import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy' +import { useGlRenderer } from './glSupport' import { useRoadtripHazards } from './useRoadtripHazards' import { useTranslation } from '../../i18n/TranslationContext' import type { MapViewAutoProps, MapViewProps } from './mapViewContract' @@ -28,11 +29,10 @@ export function MapViewAuto(props: MapViewAutoProps) { const mapProps = { ...props, hazards: hazards.feed?.hazards } as MapViewProps const provider = useSettingsStore(s => s.settings.map_provider) const token = useSettingsStore(s => s.settings.mapbox_access_token) - // Fall back to Leaflet when Mapbox is selected but no token is set, - // so trip planner never shows an empty map due to a missing token. - const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' - : provider === 'mapbox-gl' && token ? 'mapbox-gl' - : null + // Fall back to Leaflet when Mapbox is selected but no token is set, or when the + // browser has no WebGL2 (both GL engines need it), so the trip planner never + // shows an empty map or one that fails to start. + const glProvider = useGlRenderer(provider, token) // One chunk per engine: picking the binding here is what keeps mapbox-gl and // maplibre-gl out of each other's downloads. const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox diff --git a/client/src/components/Map/MapViewAuto.webgl.test.tsx b/client/src/components/Map/MapViewAuto.webgl.test.tsx new file mode 100644 index 000000000..d6caffe1a --- /dev/null +++ b/client/src/components/Map/MapViewAuto.webgl.test.tsx @@ -0,0 +1,88 @@ +/** + * The trip map without WebGL2 (FE-COMP-MAPAUTO-WEBGL-*). + * + * A browser that cannot give a GL engine its WebGL2 context sees the Leaflet map + * with the user's tiles, never a map that fails to start: up front when the probe + * says no, and the moment the GL map's own constructor is refused when it said yes. + */ +import { useEffect } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useSettingsStore } from '../../store/settingsStore'; +import { resetWebGLProbe } from '../../utils/webgl'; +import { buildGlMap } from './glSupport'; +import { MapViewAuto } from './MapViewAuto'; + +const gl = vi.hoisted(() => ({ refuse: false })); + +vi.mock('./MapView', () => ({ + MapView: () =>
, +})); + +/** Stands in for MapViewGL: builds its map the way the real one does, through buildGlMap. */ +function FakeGlMap() { + useEffect(() => { + buildGlMap(() => { + if (!gl.refuse) return {}; + const err = new Error('WebGL2 is required to display this map.'); + err.name = 'GPUInitializationError'; + throw err; + }); + }, []); + return
; +} + +vi.mock('./glLazy', () => ({ MapViewGLMaplibre: FakeGlMap, MapViewGLMapbox: FakeGlMap })); +vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) })); + +const realGetContext = HTMLCanvasElement.prototype.getContext; + +function browserWithWebGL2(available: boolean) { + const ctx = { getExtension: () => ({ loseContext: () => {} }) }; + HTMLCanvasElement.prototype.getContext = vi.fn((type: string) => + available && type === 'webgl2' ? ctx : null + ) as unknown as typeof realGetContext; +} + +beforeEach(() => { + resetAllStores(); + resetWebGLProbe(); + gl.refuse = false; + seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', mapbox_access_token: '' } }); +}); + +afterEach(() => { + HTMLCanvasElement.prototype.getContext = realGetContext; + resetWebGLProbe(); +}); + +describe('MapViewAuto without WebGL2', () => { + it('FE-COMP-MAPAUTO-WEBGL-001: draws the MapLibre map where WebGL2 is there', async () => { + browserWithWebGL2(true); + + render(); + + expect(await screen.findByTestId('gl-map')).toBeInTheDocument(); + expect(screen.queryByTestId('leaflet-map')).not.toBeInTheDocument(); + }); + + it('FE-COMP-MAPAUTO-WEBGL-002: draws the Leaflet map when the browser has no WebGL2', () => { + browserWithWebGL2(false); + + render(); + + expect(screen.getByTestId('leaflet-map')).toBeInTheDocument(); + expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument(); + }); + + it('FE-COMP-MAPAUTO-WEBGL-003: falls back to Leaflet when the GPU refuses the context the probe promised', async () => { + browserWithWebGL2(true); + gl.refuse = true; + + render(); + + expect(await screen.findByTestId('leaflet-map')).toBeInTheDocument(); + expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 5ecdee54d..1acc0675e 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -24,6 +24,7 @@ import { useTransportRoutes } from '../../hooks/useTransportRoutes' import { visibleRouteReservations } from '../../utils/reservationRoutes' import { safeHexColor } from '../../utils/safeColor' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' +import { buildGlMap } from './glSupport' import LocationButton from './LocationButton' import { useIsPhone } from '../../mobile/useIsPhone' import { useGeolocation } from '../../hooks/useGeolocation' @@ -1104,13 +1105,12 @@ export function MapViewGL({ antialias: mapboxQuality, } if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' - // MapLibre 5's mouse-rotate inverts its sign at a mid-screen line it gets by - // re-projecting the map center — a line that drifts with the bearing, so a - // right-button drag near mid-screen ping-pongs instead of rotating (#1545). - // aroundCenter: false restores the plain dx-based rotate mapbox-gl uses. + // MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate. if (isMapLibre) mapOptions.aroundCenter = false - const map = new gl.Map(mapOptions as any) + // Null when the GPU refused WebGL2: MapViewAuto has switched to Leaflet by then. + const map = buildGlMap(() => new gl.Map(mapOptions as any)) + if (!map) return mapRef.current = map popupRef.current = new gl.Popup({ closeButton: false, diff --git a/client/src/components/Map/VectorBasemap.tsx b/client/src/components/Map/VectorBasemap.tsx index 235612aa3..e6607c1f8 100644 --- a/client/src/components/Map/VectorBasemap.tsx +++ b/client/src/components/Map/VectorBasemap.tsx @@ -7,7 +7,8 @@ import { RASTER_FALLBACK_TILE_URL, attributionForTile, } from '../../constants/mapDefaults' -import { hasWebGL } from '../../utils/webgl' +import { hasWebGL, reportWebGLFailure } from '../../utils/webgl' +import { isGpuInitFailure } from './glSupport' import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload' /** The Leaflet layer maplibre-gl-leaflet hands back. */ @@ -96,6 +97,8 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea layer.addTo(map) } catch (err) { console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err) + // The GPU, not a context budget: the GL maps elsewhere in the app switch to Leaflet too. + if (isGpuInitFailure(err)) reportWebGLFailure() detachBasemapLayer(layer) return false } diff --git a/client/src/components/Map/glSupport.test.ts b/client/src/components/Map/glSupport.test.ts new file mode 100644 index 000000000..3061768b9 --- /dev/null +++ b/client/src/components/Map/glSupport.test.ts @@ -0,0 +1,134 @@ +/** + * Whether a map may be drawn with a GL engine (FE-MAP-GLSUP-*). + * + * MapLibre 6 and Mapbox GL 3 throw out of their Map constructor without WebGL2. + * What is under test is the one decision every GL map goes through: the user's + * provider when it can run here, Leaflet otherwise, and Leaflet from the moment a + * GPU that claimed WebGL2 refuses the context after all. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hasWebGL, resetWebGLProbe } from '../../utils/webgl'; +import { buildGlMap, isGpuInitFailure, pickGlRenderer, useGlRenderer, useWebGL2 } from './glSupport'; + +const realGetContext = HTMLCanvasElement.prototype.getContext; + +/** A browser whose canvas hands out a webgl2 context, or none. */ +function browserWithWebGL2(available: boolean) { + const ctx = { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) }; + const getContext = vi.fn((type: string) => (available && type === 'webgl2' ? ctx : null)); + HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext; + return getContext; +} + +function gpuError(): Error { + const err = new Error('WebGL2 is required to display this map.'); + err.name = 'GPUInitializationError'; + return err; +} + +beforeEach(() => { + resetWebGLProbe(); +}); + +afterEach(() => { + HTMLCanvasElement.prototype.getContext = realGetContext; + resetWebGLProbe(); +}); + +describe('pickGlRenderer', () => { + it('FE-MAP-GLSUP-001: keeps the GL provider the user picked when WebGL2 is there', () => { + expect(pickGlRenderer('maplibre-gl', '', () => true)).toBe('maplibre-gl'); + expect(pickGlRenderer('mapbox-gl', 'pk.token', () => true)).toBe('mapbox-gl'); + }); + + it('FE-MAP-GLSUP-002: draws with Leaflet when the browser has no WebGL2', () => { + expect(pickGlRenderer('maplibre-gl', '', () => false)).toBeNull(); + expect(pickGlRenderer('mapbox-gl', 'pk.token', () => false)).toBeNull(); + }); + + it('FE-MAP-GLSUP-003: Mapbox without a token stays on Leaflet, MapLibre needs none', () => { + expect(pickGlRenderer('mapbox-gl', '', () => true)).toBeNull(); + expect(pickGlRenderer('maplibre-gl', null, () => true)).toBe('maplibre-gl'); + }); + + it('FE-MAP-GLSUP-004: a Leaflet user never pays for the probe', () => { + const webgl2 = vi.fn(() => true); + + expect(pickGlRenderer('leaflet', 'pk.token', webgl2)).toBeNull(); + expect(pickGlRenderer(undefined, undefined, webgl2)).toBeNull(); + expect(webgl2).not.toHaveBeenCalled(); + }); +}); + +describe('isGpuInitFailure', () => { + it('FE-MAP-GLSUP-005: knows both engines’ way of saying the GPU refused', () => { + expect(isGpuInitFailure(gpuError())).toBe(true); + expect(isGpuInitFailure(new Error('Failed to initialize WebGL.'))).toBe(true); + }); + + it('FE-MAP-GLSUP-006: anything else is not a GPU failure', () => { + expect(isGpuInitFailure(new TypeError('container is null'))).toBe(false); + expect(isGpuInitFailure('Failed to initialize WebGL')).toBe(false); + expect(isGpuInitFailure(null)).toBe(false); + }); +}); + +describe('buildGlMap', () => { + it('FE-MAP-GLSUP-007: hands back the map the engine built', () => { + const map = { remove: vi.fn() }; + + expect(buildGlMap(() => map)).toBe(map); + }); + + it('FE-MAP-GLSUP-008: a GPU that claimed WebGL2 and refused it gives null and turns the answer to no', () => { + browserWithWebGL2(true); + expect(hasWebGL()).toBe(true); + + expect( + buildGlMap(() => { + throw gpuError(); + }) + ).toBeNull(); + expect(hasWebGL()).toBe(false); + }); + + it('FE-MAP-GLSUP-009: any other error is a bug and is thrown on', () => { + browserWithWebGL2(true); + + expect(() => + buildGlMap(() => { + throw new TypeError('style is undefined'); + }) + ).toThrow('style is undefined'); + expect(hasWebGL()).toBe(true); + }); +}); + +describe('useGlRenderer', () => { + it('FE-MAP-GLSUP-010: asks the canvas for webgl2 only', () => { + const getContext = browserWithWebGL2(false); + + const { result } = renderHook(() => useGlRenderer('maplibre-gl', '')); + + expect(result.current).toBeNull(); + expect(getContext.mock.calls.map((call) => call[0])).toEqual(['webgl2']); + }); + + it('FE-MAP-GLSUP-011: switches every mounted map to Leaflet once a GL map fails to build', () => { + browserWithWebGL2(true); + const trip = renderHook(() => useGlRenderer('maplibre-gl', '')); + const terrain = renderHook(() => useWebGL2()); + expect(trip.result.current).toBe('maplibre-gl'); + expect(terrain.result.current).toBe(true); + + act(() => { + buildGlMap(() => { + throw gpuError(); + }); + }); + + expect(trip.result.current).toBeNull(); + expect(terrain.result.current).toBe(false); + }); +}); diff --git a/client/src/components/Map/glSupport.ts b/client/src/components/Map/glSupport.ts new file mode 100644 index 000000000..9e3630bda --- /dev/null +++ b/client/src/components/Map/glSupport.ts @@ -0,0 +1,74 @@ +import { useSyncExternalStore } from 'react'; +import { hasWebGL, reportWebGLFailure, subscribeWebGL, webGLFailures } from '../../utils/webgl'; +import type { GlMapProvider } from './glProviders'; + +/* + * Whether a map may be drawn with a GL engine at all, for every place that would + * build one: the trip map, the journey maps, the settings preview and the 3D tour. + * + * MapLibre 6 and Mapbox GL 3 both need WebGL2 and throw out of their Map + * constructor without it. A browser that cannot give it gets the Leaflet map with + * the user's tiles instead, never a map that fails to start. Like the GL + * renderers it guards, this module stays free of both SDKs (`check:gl-split`). + */ + +/** + * The GL engine to draw with, or null for Leaflet: the user's provider, as long + * as it can run here. Mapbox also needs a token, MapLibre does not. + */ +export function pickGlRenderer( + provider: string | null | undefined, + mapboxToken: string | null | undefined, + webgl2: () => boolean = hasWebGL +): GlMapProvider | null { + const wanted: GlMapProvider | null = + provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && mapboxToken ? 'mapbox-gl' : null; + // Asked only for a GL provider: a Leaflet user never pays for the probe's context. + return wanted && webgl2() ? wanted : null; +} + +/** Re-renders the caller when a GL map found out that WebGL2 does not work after all. */ +function useWebGLFailures(): number { + return useSyncExternalStore(subscribeWebGL, webGLFailures, webGLFailures); +} + +/** pickGlRenderer for a component, switching to Leaflet the moment a GL map fails to build. */ +export function useGlRenderer( + provider: string | null | undefined, + mapboxToken: string | null | undefined +): GlMapProvider | null { + useWebGLFailures(); + return pickGlRenderer(provider, mapboxToken); +} + +/** Whether 3D (the GL terrain map) can run here, kept current the same way. */ +export function useWebGL2(): boolean { + useWebGLFailures(); + return hasWebGL(); +} + +/** + * The GPU refusing a context, as either engine reports it from its Map + * constructor. Matched on the name and message because the class itself lives in + * the maplibre chunk, and Mapbox throws a plain Error. + */ +export function isGpuInitFailure(err: unknown): boolean { + if (!(err instanceof Error)) return false; + return err.name === 'GPUInitializationError' || /failed to initialize webgl/i.test(err.message); +} + +/** + * Builds a GL map, the second line of defence behind the probe: a GPU that + * claims WebGL2 and still fails is reported, so every map switches to Leaflet, + * and the caller gets null instead of a throw. Anything else is a bug and is + * thrown on. + */ +export function buildGlMap(construct: () => T): T | null { + try { + return construct(); + } catch (err) { + if (!isGpuInitFailure(err)) throw err; + reportWebGLFailure(); + return null; + } +} diff --git a/client/src/components/PDF/tripMapImage.test.ts b/client/src/components/PDF/tripMapImage.test.ts index 203192bb7..210b6bd8e 100644 --- a/client/src/components/PDF/tripMapImage.test.ts +++ b/client/src/components/PDF/tripMapImage.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { TripOverviewDay } from '../Map/tripRouteGeometry' -vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() })) +vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn(), reportWebGLFailure: vi.fn() })) const { hasWebGL } = await import('../../utils/webgl') const { renderTripMapImage } = await import('./tripMapImage') diff --git a/client/src/components/PDF/tripMapImage.ts b/client/src/components/PDF/tripMapImage.ts index 8c0100408..d82727ad0 100644 --- a/client/src/components/PDF/tripMapImage.ts +++ b/client/src/components/PDF/tripMapImage.ts @@ -1,6 +1,7 @@ import { buildRouteOverlay, wrapSvg } from './tripMapSvg' import { hasWebGL } from '../../utils/webgl' import { normalizeStyleForProvider } from '../Map/glProviders' +import { buildGlMap } from '../Map/glSupport' import type { TripOverviewDay } from '../Map/tripRouteGeometry' import { importChunk } from '../../utils/chunkReload' @@ -59,7 +60,7 @@ export async function renderTripMapImage( const bounds = new gl.LngLatBounds() for (const [lat, lng] of points) bounds.extend([lng, lat]) - const instance = new gl.Map({ + const instance = buildGlMap(() => new gl.Map({ container, style: normalizeStyleForProvider('maplibre-gl', opts.style), bounds, @@ -70,7 +71,9 @@ export async function renderTripMapImage( // MapLibre 5 moved the WebGL flags off the top level into this bag. canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true }, fadeDuration: 0, - }) + })) + // A GPU that offered WebGL2 and then refused it: reported, so the app's maps follow. + if (!instance) return null map = instance const ready = await new Promise(resolve => { diff --git a/client/src/components/Settings/MapSettingsTab.test.tsx b/client/src/components/Settings/MapSettingsTab.test.tsx index e366cb18f..31c8fb055 100644 --- a/client/src/components/Settings/MapSettingsTab.test.tsx +++ b/client/src/components/Settings/MapSettingsTab.test.tsx @@ -33,6 +33,12 @@ vi.mock('./MapboxPreview', () => ({ ), })); +// jsdom has no WebGL2; these cases are about a browser that has it. +vi.mock('../../utils/webgl', async (importOriginal) => ({ + ...(await importOriginal()), + hasWebGL: () => true, +})); + beforeEach(() => { resetAllStores(); vi.clearAllMocks(); diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index 742080283..ad688a66b 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -132,7 +132,7 @@ export default function MapSettingsTab(): React.ReactElement { const { managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken, cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d, - mapboxQuality, setMapboxQuality, previewPlaces, save: saveMapSettings, + mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings, } = useMapSettingsForm() // One chunk per engine — see components/Map/glLazy.tsx. const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox @@ -318,7 +318,7 @@ export default function MapSettingsTab(): React.ReactElement { )}
- {provider !== 'leaflet' ? ( + {glPreview ? ( /* A net of its own: the preview is the one place a user flips providers live, so it is the likeliest chunk to fail — and a broken preview must not take the rest of the settings tab with it. */ @@ -363,6 +363,7 @@ export default function MapSettingsTab(): React.ReactElement { }) )}
+ {webgl2Missing && {t('map.webgl2.fallback')}}