mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 09:08:30 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e119a2c936 | |||
| 45ae8b5af2 | |||
| e28bd2fc99 | |||
| 1696162d22 | |||
| f375a2f3cc |
@@ -22,7 +22,6 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
|
||||
+3
-5
@@ -8,7 +8,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
@@ -41,9 +41,7 @@
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
@@ -94,7 +92,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"scripts/check-gl-split.mjs",
|
||||
"scripts/check-page-pattern.mjs",
|
||||
"scripts/generate-icons.mjs",
|
||||
"scripts/patch-maplibre.mjs",
|
||||
"src/App.test.tsx",
|
||||
"src/App.tsx",
|
||||
"src/App.versionHandover.test.tsx",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packagePath = require.resolve('maplibre-gl/package.json')
|
||||
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
|
||||
|
||||
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
|
||||
|
||||
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
|
||||
const loops = [
|
||||
['of elem.attributes)', 'of Array.from(elem.attributes))'],
|
||||
['of e.attributes)', 'of Array.from(e.attributes))'],
|
||||
]
|
||||
|
||||
for (const filename of [
|
||||
'src/util/dom.ts',
|
||||
'dist/maplibre-gl.js',
|
||||
'dist/maplibre-gl-dev.js',
|
||||
'dist/maplibre-gl-csp.js',
|
||||
'dist/maplibre-gl-csp-dev.js',
|
||||
]) {
|
||||
const target = join(dirname(packagePath), filename)
|
||||
const code = readFileSync(target, 'utf8')
|
||||
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
|
||||
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
|
||||
const [before, after] = loop
|
||||
if (code.split(before).length === 1 && code.split(after).length === 2) continue
|
||||
if (code.split(before).length !== 2 || code.includes(after)) {
|
||||
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
|
||||
}
|
||||
writeFileSync(target, code.replace(before, after))
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
|
||||
@@ -140,7 +140,17 @@ export function ChatBubble({
|
||||
>
|
||||
{reply.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{/* Wrapped and cut after two lines: a quote kept on one line set the bubble's width and pushed it out of the chat. */}
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.8,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{reply.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('the Collab pieces', () => {
|
||||
expect(quoted.parentElement?.getAttribute('style')).toContain('background: var(--bg-card)');
|
||||
expect(screen.getByText('Anna')).toBeInTheDocument();
|
||||
expect(screen.getByText('Train?')).toBeInTheDocument();
|
||||
// A long quote wraps instead of setting the bubble's width (it pushed the bubble out of the chat).
|
||||
expect(screen.getByText('Train?').getAttribute('style')).toContain('white-space: normal');
|
||||
});
|
||||
|
||||
it('FE-COLLAB-PARTS-002: a face is the picture, else the first letter; day, reactions and time draw their children', () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -107,7 +107,8 @@ const glModule = vi.hoisted(() => () => ({
|
||||
}))
|
||||
|
||||
vi.mock('mapbox-gl', glModule)
|
||||
vi.mock('maplibre-gl', glModule)
|
||||
// maplibre-gl 6 has no default export: the same fakes, as named exports.
|
||||
vi.mock('maplibre-gl', () => glModule().default)
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||
|
||||
@@ -121,7 +122,7 @@ vi.mock('../Map/mapboxSetup', () => ({
|
||||
|
||||
import React from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { render, screen, act } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
@@ -164,22 +164,21 @@ vi.mock('mapbox-gl', () => ({
|
||||
}));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
|
||||
// maplibre-gl 6 has no default export, so the fakes are named exports.
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
},
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||
@@ -156,22 +156,21 @@ vi.mock('mapbox-gl', () => ({
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
|
||||
// maplibre-gl 6 has no default export, so the fakes are named exports.
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts)
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create()
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup
|
||||
}),
|
||||
},
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts)
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create()
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup
|
||||
}),
|
||||
}))
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||
|
||||
@@ -352,7 +351,7 @@ describe('MapViewGL', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const maplibregl = (await import('maplibre-gl')).default
|
||||
const maplibregl = await import('maplibre-gl')
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
@@ -373,7 +372,7 @@ describe('MapViewGL', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-014: MapLibre maps disable the around-center mouse rotate (#1545)', async () => {
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const maplibregl = (await import('maplibre-gl')).default
|
||||
const maplibregl = await import('maplibre-gl')
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
|
||||
@@ -11,9 +10,22 @@ import { registerRtlTextPlugin } from '../rtlText'
|
||||
* Popup, LngLatBounds — which is why the components take the engine as a prop and
|
||||
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
|
||||
* on the provider (projection, accessToken, aroundCenter).
|
||||
*
|
||||
* MapLibre 6 ships as ES modules only and has no default export any more, so the
|
||||
* namespace is what goes out as this module's default. It is a frozen module
|
||||
* namespace: nothing may assign to it, which is why the accessToken writes stay
|
||||
* behind their mapbox-only guard.
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
|
||||
|
||||
// MapLibre 6 finds its worker next to its own module, which inside a bundle is a
|
||||
// chunk in /assets/ with no worker beside it. Vite builds the worker into its own
|
||||
// file and hands back the URL; set before any Map is constructed, the
|
||||
// maplibre-gl-leaflet bridge included, since it only ever reaches the engine
|
||||
// together with this module.
|
||||
maplibregl.setWorkerUrl(workerUrl)
|
||||
|
||||
// No RTL text plugin here, unlike mapbox. MapLibre 6.9 shapes Arabic and orders
|
||||
// bidirectional text itself; the old plugin would replace that implementation, and
|
||||
// a v6 worker can only load the plugin's UMD build through eval.
|
||||
|
||||
export default maplibregl
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* The two lines this fix actually adds. Everything else is a helper: the guard in
|
||||
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting either registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green.
|
||||
* What each engine module does on import. Everything else is a helper: the guard
|
||||
* in rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting the mapbox registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green, and so would
|
||||
* a maplibre engine that forgot its worker URL.
|
||||
*
|
||||
* The engines are mocked because importing them for real builds a worker pool
|
||||
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
|
||||
*/
|
||||
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
|
||||
const { mapboxSet, maplibreSet, maplibreWorker } = vi.hoisted(() => ({
|
||||
mapboxSet: vi.fn(),
|
||||
maplibreSet: vi.fn(),
|
||||
maplibreWorker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ setRTLTextPlugin: maplibreSet, setWorkerUrl: maplibreWorker }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url', () => ({ default: '/assets/maplibre-worker.js' }));
|
||||
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mapboxSet.mockReset();
|
||||
maplibreSet.mockReset();
|
||||
maplibreWorker.mockReset();
|
||||
});
|
||||
|
||||
describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
@@ -37,13 +41,12 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers no plugin', async () => {
|
||||
await import('./maplibre');
|
||||
|
||||
expect(maplibreSet).toHaveBeenCalledTimes(1);
|
||||
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
|
||||
// the call stays with each engine instead of going through one wrapper.
|
||||
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
|
||||
// MapLibre 6.9 shapes right-to-left text itself. The plugin would replace
|
||||
// that, and a v6 worker could only run its UMD build through eval.
|
||||
expect(maplibreSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
|
||||
@@ -54,13 +57,13 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
expect(engine.default).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
|
||||
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
|
||||
|
||||
it('FE-MAP-RTL-004: importing the maplibre engine points it at the bundled worker', async () => {
|
||||
const engine = await import('./maplibre');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
// Give the rejection a tick to go unhandled if nothing caught it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// MapLibre 6 looks for its worker next to its own module, which in a bundle
|
||||
// is a chunk with no worker beside it. Without this no map draws a tile.
|
||||
expect(maplibreWorker).toHaveBeenCalledTimes(1);
|
||||
expect(maplibreWorker).toHaveBeenCalledWith('/assets/maplibre-worker.js');
|
||||
expect(engine.default.setWorkerUrl).toBe(maplibreWorker);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,18 @@
|
||||
* this because their labels are drawn server-side, which is why it only surfaced
|
||||
* once the default basemap became a vector style.
|
||||
*
|
||||
* Both engines expose the fix as an optional plugin and neither loads it by
|
||||
* default. Registering is global to the engine, so it happens once per engine
|
||||
* module rather than at each map's construction site.
|
||||
* Mapbox GL exposes the fix as an optional plugin and does not load it by
|
||||
* default. Registering is global to the engine, so it happens once in the engine
|
||||
* module rather than at each map's construction site. MapLibre needed the same
|
||||
* plugin up to version 5; since 6.9 it shapes and reorders the text itself, and
|
||||
* its engine module registers nothing.
|
||||
*
|
||||
* The call itself stays with the caller: this is one of the places the two engines
|
||||
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
|
||||
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
|
||||
* module passes its own invocation rather than a shared wrapper guessing an arity.
|
||||
* The call itself stays with the caller: Mapbox GL 3 takes
|
||||
* `(url, callback, deferred)` and returns nothing, while MapLibre took
|
||||
* `(url, lazy)` and returned a promise, so the guard below accepts either rather
|
||||
* than guessing an arity.
|
||||
*
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it made MapLibre
|
||||
* build its worker pool, which is an eager side effect for a module that is
|
||||
* imported for its default export, and throws outright under jsdom. Registering
|
||||
* twice is reported — by a throw or a rejected promise depending on the engine —
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -49,18 +50,20 @@ export async function renderTripMapImage(
|
||||
try {
|
||||
const gl = (await importChunk(() => import('../Map/engines/maplibre'))).default
|
||||
|
||||
container = document.createElement('div')
|
||||
// A const of its own: the map is built inside a closure, where the let's narrowing is gone.
|
||||
const box = document.createElement('div')
|
||||
container = box
|
||||
// Off-screen rather than hidden: MapLibre needs a laid-out box with real
|
||||
// dimensions, and `display:none` gives it a zero-sized canvas.
|
||||
container.style.cssText =
|
||||
box.style.cssText =
|
||||
`position:fixed;left:-10000px;top:0;width:${width}px;height:${height}px;pointer-events:none;`
|
||||
document.body.appendChild(container)
|
||||
document.body.appendChild(box)
|
||||
|
||||
const bounds = new gl.LngLatBounds()
|
||||
for (const [lat, lng] of points) bounds.extend([lng, lat])
|
||||
|
||||
const instance = new gl.Map({
|
||||
container,
|
||||
const instance = buildGlMap(() => new gl.Map({
|
||||
container: box,
|
||||
style: normalizeStyleForProvider('maplibre-gl', opts.style),
|
||||
bounds,
|
||||
fitBoundsOptions: { padding: 42, animate: false },
|
||||
@@ -70,7 +73,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"
|
||||
|
||||
@@ -32,7 +32,7 @@ const mapboxModule = vi.hoisted(() => ({ accessToken: '', Map: mapboxCtor }));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: mapboxModule }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl', () => ({ default: { Map: maplibreCtor } }));
|
||||
vi.mock('maplibre-gl', () => ({ Map: maplibreCtor }));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
|
||||
vi.mock('../Map/mapboxSetup', () => ({
|
||||
@@ -45,7 +45,7 @@ vi.mock('../Map/mapboxSetup', () => ({
|
||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup';
|
||||
import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import GlMapPreviewWithEngine from './MapboxPreview';
|
||||
|
||||
// The engine is a prop now, not a module import — that is what keeps mapbox-gl and
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -331,7 +331,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
{hasReply && (
|
||||
<div className="mb-1 rounded-[10px] bg-[color:var(--m-card)] px-[10px] py-[5px]">
|
||||
<div className="font-geist text-[0.625rem] font-bold opacity-70">{msg.reply_username}</div>
|
||||
<div className="truncate text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
|
||||
<div className="line-clamp-2 text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="whitespace-pre-wrap break-words">{msg.text}</span>
|
||||
|
||||
@@ -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,44 @@ describe('TourPlannerMapStage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('3D without WebGL2', () => {
|
||||
// The stage translates with the t it is handed, and stageProps hands it one that returns the key.
|
||||
const NO_3D = 'map.webgl2.terrain';
|
||||
|
||||
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}
|
||||
|
||||
@@ -42,15 +42,15 @@ describe('hasWebGL', () => {
|
||||
expect(hasWebGL()).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-WEBGL-003: falls back to webgl1 when only webgl2 is refused', () => {
|
||||
// Exactly the order maplibre-gl asks in. A stricter probe would black out
|
||||
// maps that would in fact have drawn.
|
||||
it('FE-UTIL-WEBGL-003: says no when only webgl1 is on offer', () => {
|
||||
// MapLibre 6 asks for webgl2 and nothing else. A probe that settled for
|
||||
// webgl1 would wave the map through to a GPUInitializationError.
|
||||
const { ctx } = fakeContext()
|
||||
const getContext = vi.fn((type: string) => (type === 'webgl2' ? null : ctx))
|
||||
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext
|
||||
|
||||
expect(hasWebGL()).toBe(true)
|
||||
expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2', 'webgl'])
|
||||
expect(hasWebGL()).toBe(false)
|
||||
expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2'])
|
||||
})
|
||||
|
||||
it('FE-UTIL-WEBGL-004: gives the probe context straight back', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Whether this browser will hand MapLibre a WebGL 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
|
||||
}
|
||||
@@ -45,12 +78,13 @@ function probe(): boolean {
|
||||
canvas.width = 1
|
||||
canvas.height = 1
|
||||
|
||||
// Exactly the order maplibre-gl asks in (Map._setupPainter): webgl2 first,
|
||||
// webgl1 second. A stricter probe would black out maps that would in fact
|
||||
// have drawn; a looser one would let the throw through anyway.
|
||||
const gl = (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) as WebGLRenderingContext | null
|
||||
// Exactly what maplibre-gl asks for (Map._setupPainter): WebGL2 and nothing
|
||||
// else, since MapLibre 6 dropped WebGL1. A browser that only offers WebGL1
|
||||
// passes a looser probe and then throws GPUInitializationError out of the Map
|
||||
// constructor, which is the crash this probe exists to prevent.
|
||||
const gl = canvas.getContext('webgl2')
|
||||
if (!gl) {
|
||||
console.warn('[basemap] this browser refuses a WebGL context, vector basemaps are unavailable')
|
||||
console.warn('[basemap] this browser refuses a WebGL2 context, vector basemaps are unavailable')
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import { AttributionControl, type Map } from 'maplibre-gl'
|
||||
|
||||
/**
|
||||
* GHSA-jrc7-96c5-q579: MapLibre's attribution sanitizer let a second dangerous
|
||||
* attribute through after removing the first, so a tile source's attribution
|
||||
* could run script. TREK carried a patch for this on 5.x; 6.x replaced the
|
||||
* sanitizer with an allow list, and these cases hold the upgrade to it.
|
||||
*/
|
||||
function attribution(html: string, fromSource = false) {
|
||||
const control = new maplibregl.AttributionControl({
|
||||
const control = new AttributionControl({
|
||||
compact: false,
|
||||
customAttribution: fromSource ? undefined : html,
|
||||
})
|
||||
@@ -16,17 +22,17 @@ function attribution(html: string, fromSource = false) {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
getCanvasContainer: () => document.createElement('div'),
|
||||
} as unknown as maplibregl.Map
|
||||
} as unknown as Map
|
||||
return control.onAdd(map)
|
||||
}
|
||||
|
||||
describe('MapLibre attribution sanitizer backport', () => {
|
||||
it.each([false, true])('removes adjacent event handlers (source: %s)', fromSource => {
|
||||
describe('MapLibre attribution sanitizer', () => {
|
||||
it.each([false, true])('leaves no event handler behind (source: %s)', fromSource => {
|
||||
const container = attribution('<details open onload="1" ontoggle="alert(1)">Credit</details>', fromSource)
|
||||
const details = container.querySelector('.maplibregl-ctrl-attrib-inner details')!
|
||||
expect(details.hasAttribute('onload')).toBe(false)
|
||||
expect(details.hasAttribute('ontoggle')).toBe(false)
|
||||
expect(details.textContent).toBe('Credit')
|
||||
const inner = container.querySelector('.maplibregl-ctrl-attrib-inner')!
|
||||
// <details> is not on the allow list, so it goes with its subtree.
|
||||
expect(inner.querySelector('details')).toBeNull()
|
||||
expect(inner.querySelectorAll('[onload], [ontoggle]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes consecutive dangerous URLs and handlers in nested markup', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Generated
+42
-71
@@ -30,7 +30,6 @@
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.4",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
@@ -80,7 +79,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
@@ -4971,44 +4970,35 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.3.0.tgz",
|
||||
"integrity": "sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
|
||||
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
|
||||
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.3.0.tgz",
|
||||
"integrity": "sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-leaflet": {
|
||||
@@ -5023,12 +5013,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
|
||||
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
|
||||
"version": "26.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
|
||||
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
@@ -5041,16 +5031,10 @@
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
|
||||
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
|
||||
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
@@ -5067,18 +5051,6 @@
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz",
|
||||
"integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/tsdoc": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz",
|
||||
@@ -13603,9 +13575,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
|
||||
"integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
@@ -17490,27 +17462,26 @@
|
||||
]
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.24.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
|
||||
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.15.0.tgz",
|
||||
"integrity": "sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.1.0",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/geojson-vt": "^6.1.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
|
||||
"@maplibre/mlt": "^1.1.8",
|
||||
"@maplibre/vt-pbf": "^4.3.0",
|
||||
"@mapbox/tiny-sdf": "^2.3.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.3.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
||||
"@maplibre/mlt": "^1.3.0",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.0.2",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.4",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
@@ -19398,9 +19369,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
|
||||
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user