Files
TREK/client/src/components/Settings/MapSettingsTab.tsx
T
Maurice e28bd2fc99 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.
2026-10-24 22:41:56 +00:00

382 lines
16 KiB
TypeScript

import React, { useState, useEffect, useRef, useId, Suspense, type ReactNode } from 'react'
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT } from '../shared/dialogParts'
import { POPOVER } from '../Packing/packingPopoverStyles'
import { MapView } from '../Map/MapView'
// The preview loads on demand, and paired with a single engine — a Leaflet-only
// install pays for neither, and a GL install pays for one instead of both.
import ErrorBoundary from '../shared/ErrorBoundary'
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider, getStylePresets, type GlMapProvider } from '../Map/glProviders'
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
import { useMapSettingsForm } from './useMapSettingsForm'
/** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */
const CODE_INPUT = `${INPUT} font-geist`
/** A style's tag (3D, Satellite, Terrain…): a small quiet chip, so a scan of the list finds the kind of map. */
function TagChip({ tag }: { tag: string }) {
return (
<span className="flex-none rounded-[5px] bg-surface-tertiary px-1.5 py-[3px] font-geist font-bold uppercase leading-none tracking-[.06em] text-content-muted" style={fs(9)}>
{tag}
</span>
)
}
function StyleDropdown({ value, provider, onChange }: { value: string; provider: GlMapProvider; onChange: (v: string) => void }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const presets = getStylePresets(provider)
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])
const selected = presets.find(p => p.url === value)
const placeholder = provider === 'maplibre-gl'
? t('settings.mapOpenFreeMapStylePlaceholder')
: t('settings.mapStylePlaceholder')
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(v => !v)}
aria-expanded={open}
className="flex w-full items-center justify-between gap-2 rounded-[10px] border border-edge bg-surface-input px-3 py-2 text-start hover:border-content-faint focus:outline-none focus:ring-2 focus:ring-[color:var(--text-primary)]"
style={fs(13, 'body')}
>
<span className="flex min-w-0 items-center gap-2">
<span className={`truncate ${selected ? 'font-medium text-content' : 'text-content-faint'}`}>
{selected ? selected.name : placeholder}
</span>
{selected && (
<span className="flex flex-none items-center gap-1">
{(selected.tags || []).map(t => <TagChip key={t} tag={t} />)}
</span>
)}
</span>
<ChevronDown size={14} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-20 max-h-64 overflow-y-auto" style={POPOVER}>
{presets.map(preset => {
const isActive = preset.url === value
return (
<button
key={preset.url}
type="button"
onClick={() => { onChange(preset.url); setOpen(false) }}
className={`flex w-full items-center justify-between gap-2 rounded-[9px] px-2.5 py-2 text-start hover:bg-surface-tertiary ${isActive ? 'bg-surface-tertiary' : ''}`}
style={fs(12.5, 'body')}
>
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="font-medium text-content">{preset.name}</span>
{(preset.tags || []).map(t => <TagChip key={t} tag={t} />)}
</span>
{isActive && <Check size={13} className="flex-none text-content-muted" />}
</button>
)
})}
</div>
)}
</div>
)
}
/** One engine to pick: an icon tile, its name and what it is, outlined while chosen. */
function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: {
active: boolean
onClick: () => void
icon: LucideIcon
name: ReactNode
subtitle: string
badge?: ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex min-w-0 items-start gap-3 rounded-[12px] border bg-surface-card p-3 text-start transition-colors ${active ? 'border-[color:var(--text-primary)] shadow-sm' : 'border-edge hover:border-content-faint'}`}
>
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] ${active ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-secondary'}`}>
<Icon size={15} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="font-semibold text-content" style={fs(13, 'body')}>{name}</span>
{badge}
</span>
<span className="mt-0.5 hidden leading-snug text-content-faint sm:block" style={fs(11.5)}>{subtitle}</span>
</span>
</button>
)
}
export default function MapSettingsTab(): React.ReactElement {
const { t } = useTranslation()
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
} = useMapSettingsForm()
// One chunk per engine — see components/Map/glLazy.tsx.
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
// Ties each eyebrow label to its field, so a click on the label focuses it.
const fieldId = useId()
// 3D is available on every style now — pure satellite uses the
// mapbox-streets-v8 tileset as a fallback building source.
const supports3d = true
const link = 'font-medium text-content-secondary underline decoration-edge underline-offset-2 hover:text-content'
return (
<Section title={t('settings.map')} icon={Map}>
{/* Provider picker — big tiles so the choice is obvious */}
<SettingRows>
<SettingRow label={t('settings.mapProvider')} hint={t('settings.mapProviderHint')} stacked>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<ProviderTile
active={provider === 'leaflet'}
onClick={() => changeProvider('leaflet')}
icon={Layers}
name="Leaflet"
subtitle={t('settings.mapLeafletSubtitle')}
/>
<ProviderTile
active={provider === 'mapbox-gl'}
onClick={() => changeProvider('mapbox-gl')}
icon={Box}
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
subtitle={t('settings.mapMapboxSubtitle')}
// Only on ≥sm; on a narrow window there's no room next to the title.
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
/>
<ProviderTile
active={provider === 'maplibre-gl'}
onClick={() => changeProvider('maplibre-gl')}
icon={Globe2}
name={<><span className="sm:hidden">MapLibre</span><span className="hidden sm:inline">MapLibre GL</span></>}
subtitle={t('settings.mapMapLibreSubtitle')}
/>
</div>
</SettingRow>
</SettingRows>
{/* Leaflet settings */}
{provider === 'leaflet' && (
<EditorField label={t('settings.mapTemplate')} htmlFor={`${fieldId}-tile`} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
/>
<input
id={`${fieldId}-tile`}
type="text"
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
className={CODE_INPUT}
/>
</div>
</EditorField>
)}
{/* Same deal as the Mapbox token: a managed install brings its own key. */}
{provider === 'leaflet' && !managed && (
<EditorField
label={t('settings.mapCartoKey')}
htmlFor={`${fieldId}-carto`}
hint={
<>
{t('settings.mapCartoKeyHint')}{' '}
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className={link}>
{t('settings.mapCartoKeyLink')}
</a>
{cartoNeedsKey && (
<span className="mt-1 block font-medium text-warning">{t('settings.mapCartoKeyMissing')}</span>
)}
</>
}
>
<input
id={`${fieldId}-carto`}
type="text"
value={cartoKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
spellCheck={false}
autoComplete="off"
className={CODE_INPUT}
/>
</EditorField>
)}
{/* GL settings */}
{provider !== 'leaflet' && (
<>
{/* The token comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{provider === 'mapbox-gl' && !managed && (
<EditorField
label={t('settings.mapMapboxToken')}
htmlFor={`${fieldId}-token`}
hint={
<>
{t('settings.mapMapboxTokenHint')}{' '}
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className={link}>
{t('settings.mapMapboxTokenLink')}
</a>
</>
}
>
<input
id={`${fieldId}-token`}
type="text"
value={mapboxToken}
onChange={(e) => setMapboxToken(e.target.value)}
placeholder="pk.eyJ1Ijoi..."
className={CODE_INPUT}
/>
</EditorField>
)}
<EditorField
label={t('settings.mapStyle')}
htmlFor={`${fieldId}-style`}
hint={provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
>
<div className="flex flex-col gap-2">
<StyleDropdown value={mapboxStyle} provider={provider} onChange={setMapboxStyle} />
<input
id={`${fieldId}-style`}
type="text"
value={mapboxStyle}
onChange={(e) => setMapboxStyle(e.target.value)}
placeholder={defaultStyleForProvider(provider)}
className={CODE_INPUT}
/>
</div>
</EditorField>
{provider === 'mapbox-gl' && (
<>
<SettingRows>
<SettingRow
label={t('settings.map3dBuildings')}
hint={t('settings.map3dHint')}
dimmed={!supports3d}
control={
<ToggleSwitch
on={mapbox3d && supports3d}
onToggle={() => { if (supports3d) setMapbox3d(!mapbox3d) }}
label={t('settings.map3dBuildings')}
/>
}
/>
<SettingRow
label={
<span className="inline-flex flex-wrap items-center gap-2">
<span>{t('settings.mapHighQuality')}</span>
<StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill>
</span>
}
hint={
<>
{t('settings.mapHighQualityHint')}{' '}
<span className="text-warning">{t('settings.mapHighQualityWarning')}</span>
</>
}
control={<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} label={t('settings.mapHighQuality')} />}
/>
</SettingRows>
<div className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<SettingsHint>
<strong className="font-semibold text-content-secondary">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
</SettingsHint>
</div>
</>
)}
</>
)}
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
{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. */
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-surface-secondary" />}>
<Suspense fallback={<div className="h-full w-full animate-pulse bg-surface-secondary" />}>
<GlMapPreview
provider={provider}
token={mapboxToken}
style={mapboxStyle}
lat={PREVIEW_CENTER[0]}
lng={PREVIEW_CENTER[1]}
// Zoom in close so the style's character (3D buildings,
// satellite texture, label density) is immediately visible.
zoom={PREVIEW_ZOOM}
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
quality={provider === 'mapbox-gl' && mapboxQuality}
/>
</Suspense>
</ErrorBoundary>
) : (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
React.createElement(MapView as any, {
places: previewPlaces,
dayPlaces: [],
route: null,
routeSegments: null,
selectedPlaceId: null,
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
// With the key on it, or the preview resolves the template as a
// keyless CARTO one and quietly shows the app default instead of
// the basemap being configured. The fields hold what the user is
// editing rather than what useTileUrl already resolved, so the key
// has to be put back on here.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
rightWidth: 0,
hasInspector: false,
})
)}
</div>
{webgl2Missing && <SettingsHint>{t('map.webgl2.fallback')}</SettingsHint>}
<div className="flex justify-end">
<button type="button"
onClick={saveMapSettings}
disabled={saving}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{saving ? <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" /> : <Save size={14} strokeWidth={2} />}
{t('settings.saveMap')}
</button>
</div>
</Section>
)
}