fix(maps): draw with Leaflet wherever WebGL2 is missing

MapLibre 6 and Mapbox GL 3 both throw out of their Map constructor without
WebGL2. Every place that builds a GL map now asks one helper first,
components/Map/glSupport.ts, and a browser without WebGL2 gets the Leaflet
map with the user's tiles instead of a map that fails to start:

- the trip map (MapViewAuto) and the journey maps (JourneyMapAuto) pick
  their renderer through useGlRenderer, which folds in the Mapbox token
  rule they each had;
- the settings preview, desktop and phone, shows the Leaflet preview
  through useMapSettingsForm and says why under it;
- the tour planner keeps the 2D map when 3D is asked for and says that
  3D needs WebGL2;
- the PDF route map and the Leaflet vector basemap already fell back and
  now report a GPU failure too.

Behind the probe, buildGlMap wraps each constructor (MapViewGL,
JourneyMapGL, the settings preview, the terrain map, the PDF map). A GPU
that claims WebGL2 and still refuses the context is reported through
utils/webgl, which turns the answer to no and tells every mounted map,
so they switch to Leaflet without a reload; any other error is still
thrown. Two new strings, map.webgl2.fallback and map.webgl2.terrain, in
all 27 locales.
This commit is contained in:
Maurice
2026-10-24 23:53:43 +02:00
parent 1696162d22
commit e28bd2fc99
47 changed files with 543 additions and 32 deletions
@@ -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<JourneyMapHandle>(null)
const glRef = useRef<JourneyMapGLHandle>(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),
@@ -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])]
+5 -5
View File
@@ -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
@@ -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: () => <div data-testid="leaflet-map" />,
}));
/** 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 <div data-testid="gl-map" />;
}
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(<MapViewAuto places={[]} />);
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(<MapViewAuto places={[]} />);
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(<MapViewAuto places={[]} />);
expect(await screen.findByTestId('leaflet-map')).toBeInTheDocument();
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
});
});
+5 -5
View File
@@ -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,
+4 -1
View File
@@ -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
}
+134
View File
@@ -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);
});
});
+74
View File
@@ -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<T>(construct: () => T): T | null {
try {
return construct();
} catch (err) {
if (!isGpuInitFailure(err)) throw err;
reportWebGLFailure();
return null;
}
}
@@ -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')
+5 -2
View File
@@ -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<boolean>(resolve => {
@@ -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<typeof import('../../utils/webgl')>()),
hasWebGL: () => true,
}));
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
@@ -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 {
)}
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
{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 {
})
)}
</div>
{webgl2Missing && <SettingsHint>{t('map.webgl2.fallback')}</SettingsHint>}
<div className="flex justify-end">
<button type="button"
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import { fs } from '../shared/DialogShell'
interface Props {
@@ -46,11 +47,13 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
antialias: quality,
}
if (!isMapLibre) mapOptions.projection = quality ? '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: the settings form has switched the preview to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
map.on('load', () => {
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { Place } from '../../types';
import { normalizeStyleForProvider } from '../Map/glProviders';
import { useWebGL2 } from '../Map/glSupport';
import { useToast } from '../shared/Toast';
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
@@ -51,6 +52,11 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
}, [settings]);
const previewPlaces = useMemo((): Place[] => [previewPlace()], []);
// Both GL engines need WebGL2. Without it the preview shows the Leaflet map the
// trip planner falls back to, and both shells say why.
const webgl2 = useWebGL2();
const glPreview = provider !== 'leaflet' && webgl2;
const webgl2Missing = provider !== 'leaflet' && !webgl2;
const save = async (): Promise<void> => {
setSaving(true);
@@ -105,6 +111,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
mapboxQuality,
setMapboxQuality,
previewPlaces,
glPreview,
webgl2Missing,
save,
};
}
@@ -1,6 +1,7 @@
import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl';
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
import { buildGlMap } from '../../Map/glSupport';
import { isTourLayerHit } from '../../Map/mapRasterOverlay';
import {
addTerrainLayers,
@@ -110,8 +111,11 @@ export function useTerrainMap({ scene, baseLayer, data, exaggeration }: Options)
let map: MapLibreMap | null = null;
void import('../../Map/engines/maplibre').then(({ default: engine }) => {
if (cancelled) return;
map = createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle });
setGl({ map, engine });
// Null when the GPU refused WebGL2. The report switches the planner back to the 2D map.
map = buildGlMap(() =>
createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle })
);
if (map) setGl({ map, engine });
});
return () => {
cancelled = true;
@@ -22,7 +22,7 @@ export default function MSettingsMap() {
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, save,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
const [presetOpen, setPresetOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
@@ -170,7 +170,7 @@ export default function MSettingsMap() {
)}
<div className="relative mt-3 h-[200px] w-full overflow-hidden rounded-xl">
{provider !== 'leaflet' ? (
{glPreview ? (
/* See MapSettingsTab: the preview gets its own net. */
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-m-card" />}>
<Suspense fallback={<div className="h-full w-full bg-m-card animate-pulse" />}>
@@ -208,6 +208,7 @@ export default function MSettingsMap() {
})
)}
</div>
{webgl2Missing && <MSetHint>{t('map.webgl2.fallback')}</MSetHint>}
<MSetButton className="mt-3" onClick={save} disabled={saving}>
<Save size={14} />
@@ -5,11 +5,13 @@ import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/
import { idleLegVariants, idleRoundHikes } from '../../../tests/helpers/tourPlannerStub';
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
import type { MapViewProps } from '../../components/Map/mapViewContract';
import { ToastContainer } from '../../components/shared/Toast';
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTourMapViewportStore } from '../../store/tourMapViewportStore';
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
type Handler = (event?: unknown) => void;
@@ -75,6 +77,12 @@ const stage = vi.hoisted(() => {
});
vi.mock('../../components/Map/engines/maplibre', () => ({ default: stage.engine }));
// jsdom has no WebGL2: the stage is told it is there, except where a case takes it away.
const webgl = vi.hoisted(() => ({ available: true }));
vi.mock('../../utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/webgl')>()),
hasWebGL: () => webgl.available,
}));
vi.mock('../../components/Map/MapViewAuto', () => ({
MapViewAuto: (props: MapViewProps) => {
stage.flatMap.props = props;
@@ -185,6 +193,8 @@ async function openTerrain() {
}
beforeEach(() => {
webgl.available = true;
resetWebGLProbe();
window.sessionStorage.clear();
useTourPlaceToolStore.setState(initialTourPlaceToolState());
stage.listeners.clear();
@@ -928,3 +938,43 @@ describe('TourPlannerMapStage', () => {
});
});
});
describe('3D without WebGL2', () => {
const NO_3D = '3D needs WebGL2, which this browser does not offer. The map stays in 2D.';
function renderWithToasts(tourPlanner: Planner) {
return render(
<>
<ToastContainer />
<TourPlannerMapStage {...stageProps(tourPlanner, true)} />
</>
);
}
it('FE-PAGE-TOURSTAGE-WEBGL-001: the 3D switch keeps the 2D map and says why', async () => {
webgl.available = false;
renderWithToasts(planner('edit-saved'));
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'flat map' })).toBeInTheDocument();
expect(stage.engine.Map).not.toHaveBeenCalled();
});
it('FE-PAGE-TOURSTAGE-WEBGL-002: a GPU that refuses the terrain context brings the 2D map back', async () => {
stage.engine.Map.mockImplementationOnce(function () {
webgl.available = false;
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
throw err;
});
renderWithToasts(planner('edit-saved'));
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'flat map' })).toBeInTheDocument();
expect(stage.engine.Map).toHaveBeenCalledTimes(1);
});
});
@@ -5,11 +5,13 @@ import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import { useWebGL2 } from '../../components/Map/glSupport';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
import LocationSelect from '../../components/Planner/LocationSelect';
import { useToast } from '../../components/shared/Toast';
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
@@ -25,6 +27,7 @@ import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddle
import { useSettingsStore } from '../../store/settingsStore';
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
import { publishTourMapViewport } from '../../store/tourMapViewportStore';
import { subscribeWebGL } from '../../utils/webgl';
import { useFerrataAdopt } from './useFerrataAdopt';
import { useOneCornerCard } from './useOneCornerCard';
import { useTourMapPoints } from './useTourMapPoints';
@@ -112,7 +115,22 @@ export function TourPlannerMapStage({
paddleChecklist,
assistantRoutes,
}: Readonly<TourPlannerMapStageProps>): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
const [terrainAsked, setTerrain3d] = useState(false);
// The terrain is a MapLibre map, so it needs WebGL2. Without it, or once its GPU
// refused the context, the stage stays on the 2D map and says why.
const webgl2 = useWebGL2();
const terrain3d = terrainAsked && webgl2;
const toast = useToast();
const openTerrain = useCallback(() => {
if (webgl2) setTerrain3d(true);
else toast.info(t('map.webgl2.terrain'));
}, [webgl2, toast, t]);
// A GPU that offered WebGL2 and then refused the terrain's context: the 2D map is
// back by itself, and this says why.
useEffect(() => {
if (!terrainAsked) return;
return subscribeWebGL(() => toast.info(t('map.webgl2.terrain')));
}, [terrainAsked, toast, t]);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE);
const [terrainLegend, setTerrainLegend] = useState<TerrainLegendLayout | null>(null);
@@ -287,7 +305,7 @@ export function TourPlannerMapStage({
viewBaseLayer={baseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
tourLayers={tourLayers}
onToggleTerrain3d={() => setTerrain3d(true)}
onToggleTerrain3d={openTerrain}
ferrataLayer={ferrataLayer}
onViewportChange={viewportChanged}
placeTool={placeTool}
+35 -2
View File
@@ -1,5 +1,6 @@
/**
* Whether this browser will hand MapLibre a WebGL2 context.
* Whether this browser will hand a GL map a WebGL2 context. MapLibre 6 and
* Mapbox GL 3 both ask for webgl2 and nothing else.
*
* The default basemap has been a vector style since 4.1.0, and maplibre-gl builds
* its GL context inside the Map constructor: no context, no map, and the throw
@@ -28,7 +29,39 @@ export function hasWebGL(): boolean {
return probed
}
/** Test seam. Nothing in the app clears this: the answer cannot change mid-session. */
const listeners = new Set<() => void>()
let failures = 0
/**
* A GL map that failed to build although the probe said yes: a GPU that offers
* WebGL2 and then cannot create the context, or a context budget that ran out.
* From here on the answer is no, and every map that asked is told, so it can
* redraw itself without WebGL instead of waiting for the next page load.
*/
export function reportWebGLFailure(): void {
if (probed === false) return
probed = false
failures++
for (const listener of listeners) listener()
}
/** For useSyncExternalStore: tells a map when reportWebGLFailure changed the answer. */
export function subscribeWebGL(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** For useSyncExternalStore: changes each time reportWebGLFailure turned the answer to no. */
export function webGLFailures(): number {
return failures
}
/**
* Test seam. The app never clears this, it only turns it to no through
* reportWebGLFailure: a browser does not gain WebGL2 mid-session.
*/
export function resetWebGLProbe(): void {
probed = null
}
@@ -29,6 +29,11 @@ vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({
/>
),
}));
// jsdom has no WebGL2; these cases are about a browser that has it.
vi.mock('../../../../src/utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/utils/webgl')>()),
hasWebGL: () => true,
}));
const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
+3
View File
@@ -47,5 +47,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted.few': 'تعذّر حساب {count} مراحل من هذا اليوم',
'map.overview.dayUnrouted.many': 'تعذّر حساب {count} مرحلة من هذا اليوم',
'map.baseLayer.topo': 'طبوغرافية',
'map.webgl2.fallback':
'لا يوفّر هذا المتصفح WebGL2 الذي تحتاجه خرائط GL، لذا تُعرض الخرائط باستخدام Leaflet بدلًا منها.',
'map.webgl2.terrain': 'يحتاج العرض ثلاثي الأبعاد إلى WebGL2، وهذا المتصفح لا يوفّره. تبقى الخريطة ثنائية الأبعاد.',
};
export default map;
+3
View File
@@ -45,6 +45,9 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'Bu günün {count} marşrut hissəsini hesablamaq mümkün olmadı',
'map.overview.dayUnrouted.one': 'Bu günün {count} marşrut hissəsini hesablamaq mümkün olmadı',
'map.baseLayer.topo': 'Topoqrafik',
'map.webgl2.fallback':
'Bu brauzer GL xəritələrinin ehtiyac duyduğu WebGL2-ni təqdim etmir, ona görə xəritələr Leaflet ilə göstərilir.',
'map.webgl2.terrain': '3D üçün WebGL2 lazımdır, bu brauzer isə onu təqdim etmir. Xəritə 2D olaraq qalır.',
};
export default map;
+3
View File
@@ -42,5 +42,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'Não foi possível calcular a rota de {count} trechos deste dia',
'map.overview.dayUnrouted.one': 'Não foi possível calcular a rota de {count} trecho deste dia',
'map.baseLayer.topo': 'Topográfico',
'map.webgl2.fallback':
'Este navegador não oferece WebGL2, necessário para os mapas GL, por isso os mapas são exibidos com o Leaflet.',
'map.webgl2.terrain': 'O 3D precisa de WebGL2, que este navegador não oferece. O mapa continua em 2D.',
};
export default map;
+3
View File
@@ -40,5 +40,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'No s’han pogut calcular {count} trams d’aquest dia',
'map.overview.dayUnrouted.one': 'No s’ha pogut calcular {count} tram d’aquest dia',
'map.baseLayer.topo': 'Topogràfic',
'map.webgl2.fallback':
'Aquest navegador no ofereix WebGL2, que necessiten els mapes GL, així que els mapes es mostren amb Leaflet.',
'map.webgl2.terrain': 'El 3D necessita WebGL2, que aquest navegador no ofereix. El mapa es queda en 2D.',
};
export default map;
+3
View File
@@ -41,5 +41,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted.one': '{count} úsek tohoto dne se nepodařilo spočítat',
'map.overview.dayUnrouted.few': '{count} úseky tohoto dne se nepodařilo spočítat',
'map.baseLayer.topo': 'Topografická',
'map.webgl2.fallback':
'Tento prohlížeč nenabízí WebGL2, které mapy GL potřebují, proto se mapy zobrazují pomocí Leafletu.',
'map.webgl2.terrain': '3D potřebuje WebGL2, které tento prohlížeč nenabízí. Mapa zůstává ve 2D.',
};
export default map;
+3
View File
@@ -42,5 +42,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': '{count} Etappen dieses Tages konnten nicht berechnet werden',
'map.overview.dayUnrouted.one': '{count} Etappe dieses Tages konnte nicht berechnet werden',
'map.baseLayer.topo': 'Topografisch',
'map.webgl2.fallback':
'Dieser Browser bietet kein WebGL2, das GL-Karten brauchen. Karten werden deshalb mit Leaflet angezeigt.',
'map.webgl2.terrain': '3D braucht WebGL2, das dieser Browser nicht bietet. Die Karte bleibt in 2D.',
};
export default map;
+3
View File
@@ -40,5 +40,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': '{count} legs of this day could not be routed',
'map.overview.dayUnrouted.one': '{count} leg of this day could not be routed',
'map.baseLayer.topo': 'Topo',
'map.webgl2.fallback':
'This browser does not offer WebGL2, which GL maps need, so maps are shown with Leaflet instead.',
'map.webgl2.terrain': '3D needs WebGL2, which this browser does not offer. The map stays in 2D.',
};
export default map;
+3
View File
@@ -39,5 +39,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'No se pudieron calcular {count} tramos de este día',
'map.overview.dayUnrouted.one': 'No se pudo calcular {count} tramo de este día',
'map.baseLayer.topo': 'Topográfico',
'map.webgl2.fallback':
'Este navegador no ofrece WebGL2, que necesitan los mapas GL, así que los mapas se muestran con Leaflet.',
'map.webgl2.terrain': 'El 3D necesita WebGL2, que este navegador no ofrece. El mapa se queda en 2D.',
};
export default map;
+2
View File
@@ -39,5 +39,7 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'Selle päeva {count} lõigu marsruuti ei saanud arvutada',
'map.overview.dayUnrouted.one': 'Selle päeva {count} lõigu marsruuti ei saanud arvutada',
'map.baseLayer.topo': 'Topograafiline',
'map.webgl2.fallback': 'See brauser ei paku WebGL2-te, mida GL-kaardid vajavad, seega kuvatakse kaardid Leafletiga.',
'map.webgl2.terrain': '3D vajab WebGL2-te, mida see brauser ei paku. Kaart jääb 2D-vaatesse.',
};
export default map;
+3
View File
@@ -41,5 +41,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': '{count} étapes de cette journée n’ont pas pu être calculées',
'map.overview.dayUnrouted.one': '{count} étape de cette journée n’a pas pu être calculée',
'map.baseLayer.topo': 'Topographique',
'map.webgl2.fallback':
'Ce navigateur ne propose pas WebGL2, nécessaire aux cartes GL : les cartes s’affichent donc avec Leaflet.',
'map.webgl2.terrain': 'La 3D nécessite WebGL2, que ce navigateur ne propose pas. La carte reste en 2D.',
};
export default map;
+4
View File
@@ -40,5 +40,9 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': '{count} σκέλη αυτής της ημέρας δεν μπόρεσαν να υπολογιστούν',
'map.overview.dayUnrouted.one': '{count} σκέλος αυτής της ημέρας δεν μπόρεσε να υπολογιστεί',
'map.baseLayer.topo': 'Τοπογραφικός',
'map.webgl2.fallback':
'Αυτό το πρόγραμμα περιήγησης δεν προσφέρει WebGL2, που χρειάζονται οι χάρτες GL, οπότε οι χάρτες εμφανίζονται με το Leaflet.',
'map.webgl2.terrain':
'Το 3D χρειάζεται WebGL2, που αυτό το πρόγραμμα περιήγησης δεν προσφέρει. Ο χάρτης μένει σε 2D.',
};
export default map;
+3
View File
@@ -39,5 +39,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'Ennek a napnak {count} szakaszát nem sikerült kiszámítani',
'map.overview.dayUnrouted.one': 'Ennek a napnak {count} szakaszát nem sikerült kiszámítani',
'map.baseLayer.topo': 'Topográfiai',
'map.webgl2.fallback':
'Ez a böngésző nem kínál WebGL2-t, amelyre a GL-térképeknek szükségük van, ezért a térképek Leaflettel jelennek meg.',
'map.webgl2.terrain': 'A 3D-hez WebGL2 kell, amelyet ez a böngésző nem kínál. A térkép 2D-ben marad.',
};
export default map;
+3
View File
@@ -38,5 +38,8 @@ const map: TranslationStrings = {
'map.overview.unrouted': 'Rute untuk {count} ruas tidak dapat dihitung, jadi jaraknya belum lengkap.',
'map.overview.dayUnrouted': 'Rute untuk {count} ruas pada hari ini tidak dapat dihitung',
'map.baseLayer.topo': 'Topografi',
'map.webgl2.fallback':
'Browser ini tidak menyediakan WebGL2 yang dibutuhkan peta GL, jadi peta ditampilkan dengan Leaflet.',
'map.webgl2.terrain': '3D membutuhkan WebGL2, yang tidak disediakan browser ini. Peta tetap dalam 2D.',
};
export default map;
+3
View File
@@ -42,5 +42,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'Per {count} tratte di questo giorno non è stato possibile calcolare il percorso',
'map.overview.dayUnrouted.one': 'Per {count} tratta di questo giorno non è stato possibile calcolare il percorso',
'map.baseLayer.topo': 'Topografica',
'map.webgl2.fallback':
'Questo browser non offre WebGL2, necessario alle mappe GL, quindi le mappe vengono mostrate con Leaflet.',
'map.webgl2.terrain': 'Il 3D richiede WebGL2, che questo browser non offre. La mappa resta in 2D.',
};
export default map;
+3
View File
@@ -38,5 +38,8 @@ const map: TranslationStrings = {
'map.overview.unrouted': '{count}区間のルートを計算できなかったため、距離は不完全です。',
'map.overview.dayUnrouted': 'この日の{count}区間のルートを計算できませんでした',
'map.baseLayer.topo': '地形図',
'map.webgl2.fallback':
'このブラウザーは GL マップに必要な WebGL2 に対応していないため、地図は Leaflet で表示されます。',
'map.webgl2.terrain': '3D には WebGL2 が必要ですが、このブラウザーは対応していません。地図は 2D のままです。',
};
export default map;
+2
View File
@@ -38,5 +38,7 @@ const map: TranslationStrings = {
'map.overview.unrouted': '{count}개 구간의 경로를 계산할 수 없어 거리가 불완전합니다.',
'map.overview.dayUnrouted': '이 날의 {count}개 구간 경로를 계산할 수 없습니다',
'map.baseLayer.topo': '지형도',
'map.webgl2.fallback': '이 브라우저는 GL 지도에 필요한 WebGL2를 지원하지 않아 지도를 Leaflet으로 표시합니다.',
'map.webgl2.terrain': '3D에는 WebGL2가 필요하지만 이 브라우저는 지원하지 않습니다. 지도는 2D로 유지됩니다.',
};
export default map;
+3
View File
@@ -41,5 +41,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': '{count} etappes van deze dag konden niet worden berekend',
'map.overview.dayUnrouted.one': '{count} etappe van deze dag kon niet worden berekend',
'map.baseLayer.topo': 'Topografisch',
'map.webgl2.fallback':
'Deze browser biedt geen WebGL2, dat GL-kaarten nodig hebben, dus kaarten worden met Leaflet getoond.',
'map.webgl2.terrain': '3D heeft WebGL2 nodig, dat deze browser niet biedt. De kaart blijft in 2D.',
};
export default map;
+3
View File
@@ -44,5 +44,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted.few': 'Nie udało się wyznaczyć {count} odcinków tego dnia',
'map.overview.dayUnrouted.many': 'Nie udało się wyznaczyć {count} odcinków tego dnia',
'map.baseLayer.topo': 'Topograficzna',
'map.webgl2.fallback':
'Ta przeglądarka nie obsługuje WebGL2, którego potrzebują mapy GL, więc mapy są wyświetlane za pomocą Leaflet.',
'map.webgl2.terrain': 'Tryb 3D wymaga WebGL2, którego ta przeglądarka nie obsługuje. Mapa pozostaje w 2D.',
};
export default map;
+3
View File
@@ -45,5 +45,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted.few': 'Не удалось построить маршрут для {count} участков этого дня',
'map.overview.dayUnrouted.many': 'Не удалось построить маршрут для {count} участков этого дня',
'map.baseLayer.topo': 'Топо',
'map.webgl2.fallback':
'Этот браузер не поддерживает WebGL2, нужный картам GL, поэтому карты показываются через Leaflet.',
'map.webgl2.terrain': 'Для 3D нужен WebGL2, а этот браузер его не поддерживает. Карта остаётся в 2D.',
};
export default map;
+3
View File
@@ -41,5 +41,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted.one': 'Trasu pre {count} úsek tohto dňa sa nepodarilo vypočítať',
'map.overview.dayUnrouted.few': 'Trasu pre {count} úseky tohto dňa sa nepodarilo vypočítať',
'map.baseLayer.topo': 'Topografická',
'map.webgl2.fallback':
'Tento prehliadač neponúka WebGL2, ktoré mapy GL potrebujú, preto sa mapy zobrazujú pomocou Leafletu.',
'map.webgl2.terrain': '3D potrebuje WebGL2, ktoré tento prehliadač neponúka. Mapa zostáva v 2D.',
};
export default map;
+3
View File
@@ -39,5 +39,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': '{count} delsträckor för den här dagen kunde inte beräknas',
'map.overview.dayUnrouted.one': '{count} delsträcka för den här dagen kunde inte beräknas',
'map.baseLayer.topo': 'Topografisk',
'map.webgl2.fallback':
'Den här webbläsaren erbjuder inte WebGL2, som GL-kartor behöver, så kartorna visas med Leaflet i stället.',
'map.webgl2.terrain': '3D kräver WebGL2, som den här webbläsaren inte erbjuder. Kartan stannar i 2D.',
};
export default map;
+2
View File
@@ -37,5 +37,7 @@ const map: TranslationStrings = {
'map.overview.unrouted': 'มี {count} ช่วงที่คำนวณเส้นทางไม่ได้ ระยะทางจึงไม่ครบถ้วน',
'map.overview.dayUnrouted': 'มี {count} ช่วงของวันนี้ที่คำนวณเส้นทางไม่ได้',
'map.baseLayer.topo': 'ภูมิประเทศ',
'map.webgl2.fallback': 'เบราว์เซอร์นี้ไม่รองรับ WebGL2 ที่แผนที่ GL ต้องใช้ จึงแสดงแผนที่ด้วย Leaflet แทน',
'map.webgl2.terrain': 'โหมด 3D ต้องใช้ WebGL2 ซึ่งเบราว์เซอร์นี้ไม่รองรับ แผนที่จึงยังคงเป็น 2D',
};
export default map;
+3
View File
@@ -39,5 +39,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'Bu günün {count} etabı hesaplanamadı',
'map.overview.dayUnrouted.one': 'Bu günün {count} etabı hesaplanamadı',
'map.baseLayer.topo': 'Topoğrafik',
'map.webgl2.fallback':
"Bu tarayıcı GL haritalarının ihtiyaç duyduğu WebGL2'yi sunmuyor, bu yüzden haritalar Leaflet ile gösteriliyor.",
'map.webgl2.terrain': '3D için WebGL2 gerekir, bu tarayıcı ise onu sunmuyor. Harita 2D olarak kalır.',
};
export default map;
+2
View File
@@ -44,5 +44,7 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted.few': 'Не вдалося прокласти {count} відрізки цього дня',
'map.overview.dayUnrouted.many': 'Не вдалося прокласти {count} відрізків цього дня',
'map.baseLayer.topo': 'Топо',
'map.webgl2.fallback': 'Цей браузер не підтримує WebGL2, потрібний картам GL, тому карти показуються через Leaflet.',
'map.webgl2.terrain': 'Для 3D потрібен WebGL2, а цей браузер його не підтримує. Карта залишається у 2D.',
};
export default map;
+2
View File
@@ -40,5 +40,7 @@ const map: TranslationStrings = {
'map.overview.unrouted': 'Không thể tính đường đi cho {count} chặng, nên quãng đường chưa đầy đủ.',
'map.overview.dayUnrouted': 'Không thể tính đường đi cho {count} chặng của ngày này',
'map.baseLayer.topo': 'Địa hình',
'map.webgl2.fallback': 'Trình duyệt này không hỗ trợ WebGL2 mà bản đồ GL cần, nên bản đồ được hiển thị bằng Leaflet.',
'map.webgl2.terrain': 'Chế độ 3D cần WebGL2, nhưng trình duyệt này không hỗ trợ. Bản đồ vẫn ở dạng 2D.',
};
export default map;
+2
View File
@@ -36,5 +36,7 @@ const map: TranslationStrings = {
'map.overview.unrouted': '有 {count} 段無法計算路線,因此距離不完整。',
'map.overview.dayUnrouted': '本日有 {count} 段無法計算路線',
'map.baseLayer.topo': '地形圖',
'map.webgl2.fallback': '此瀏覽器不支援 GL 地圖所需的 WebGL2,因此地圖改用 Leaflet 顯示。',
'map.webgl2.terrain': '3D 需要 WebGL2,而此瀏覽器不支援。地圖維持 2D。',
};
export default map;
+2
View File
@@ -36,5 +36,7 @@ const map: TranslationStrings = {
'map.overview.unrouted': '有 {count} 段无法计算路线,因此距离不完整。',
'map.overview.dayUnrouted': '本日有 {count} 段无法计算路线',
'map.baseLayer.topo': '地形图',
'map.webgl2.fallback': '此浏览器不支持 GL 地图所需的 WebGL2,因此地图改用 Leaflet 显示。',
'map.webgl2.terrain': '3D 需要 WebGL2,而此浏览器不支持。地图保持 2D。',
};
export default map;