mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
add mapbox gl option, gps location, journey reorder + polish
- Mapbox GL provider alongside Leaflet for trip and journey maps (opt-in in settings with token, style presets incl. 3D on satellite, quality mode, experimental badge). - GPS "blue dot" with heading cone on mobile; three-state FAB (off / show / follow), geodesic accuracy circle, desktop-hidden since browser IP geo is too coarse for navigation. - Marker drift fix: outer wrap no longer carries inline position/transform, so mapbox's translate keeps the pin pinned at every zoom and pitch. - Journey map popup (mapbox-gl): Apple-Maps-style tooltip on marker highlight/click showing entry title + location / date subline. - Journey feed reorder: up/down controls to the left of each entry reorder sort_order within a day. Server endpoint, optimistic store update, rollback on failure. - Journey entry editor: desktop modal now centers over the feed column only, backdrop still blurs the whole page (map included). - Scroll-sync guard on journey: marker click locks the sync so smooth-scroll can't steer the highlight to a neighbouring entry mid-animation. - Misc: map top-padding aligned with hero, live/synced badges replaced by a compact back-button in the hero, skeleton entries no longer pollute the journey map, journey detail no longer shows map on mobile path when combined view is active.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||
import type { TrackingMode } from '../../hooks/useGeolocation'
|
||||
|
||||
interface Props {
|
||||
mode: TrackingMode
|
||||
error: string | null
|
||||
onClick: () => void
|
||||
// Offset from the bottom edge — callers push this up above the mobile
|
||||
// bottom nav. Defaults to 20px for desktop.
|
||||
bottomOffset?: number
|
||||
}
|
||||
|
||||
// Three-state FAB. Matches the Apple/Google Maps pattern:
|
||||
// off → outline locate icon
|
||||
// show → filled locate (blue dot is visible on the map)
|
||||
// follow → filled navigation arrow (map follows + rotates with heading)
|
||||
export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) {
|
||||
const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate
|
||||
const isActive = mode !== 'off'
|
||||
const title = error
|
||||
? error
|
||||
: mode === 'off'
|
||||
? 'Show my location'
|
||||
: mode === 'show'
|
||||
? 'Follow my location'
|
||||
: 'Stop following'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: bottomOffset,
|
||||
right: 12,
|
||||
zIndex: 1000,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={mode === 'follow' ? 2.5 : 2} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -278,93 +278,76 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
// Live-location rendering inside the Leaflet map. Subscribes via the
|
||||
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
|
||||
// identically. Heading is shown as a rotated conic SVG when available.
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||
|
||||
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
|
||||
const map = useMap()
|
||||
const [position, setPosition] = useState<[number, number] | null>(null)
|
||||
const [accuracy, setAccuracy] = useState(0)
|
||||
const [tracking, setTracking] = useState(false)
|
||||
const watchId = useRef<number | null>(null)
|
||||
|
||||
const startTracking = useCallback(() => {
|
||||
if (!('geolocation' in navigator)) return
|
||||
setTracking(true)
|
||||
watchId.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
|
||||
setPosition(latlng)
|
||||
setAccuracy(pos.coords.accuracy)
|
||||
},
|
||||
() => setTracking(false),
|
||||
{ enableHighAccuracy: true, maximumAge: 5000 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const stopTracking = useCallback(() => {
|
||||
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
|
||||
watchId.current = null
|
||||
setTracking(false)
|
||||
setPosition(null)
|
||||
}, [])
|
||||
|
||||
const toggleTracking = useCallback(() => {
|
||||
if (tracking) { stopTracking() } else { startTracking() }
|
||||
}, [tracking, startTracking, stopTracking])
|
||||
|
||||
// Center map on position when first acquired
|
||||
const centered = useRef(false)
|
||||
// When the user is in follow mode, keep the map centred on the dot.
|
||||
// setView (no animation) is what Google Maps does during navigation —
|
||||
// it feels responsive and avoids animation jitter at walking speed.
|
||||
useEffect(() => {
|
||||
if (position && !centered.current) {
|
||||
map.setView(position, 15)
|
||||
centered.current = true
|
||||
}
|
||||
}, [position, map])
|
||||
if (mode !== 'follow' || !position) return
|
||||
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 }) } catch { /* noop */ }
|
||||
}, [position, mode, map])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
|
||||
// Once, when the user first acquires a fix in "show" mode, pan to it so
|
||||
// they don't have to scroll the map. Subsequent fixes only move the dot.
|
||||
const centeredRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (mode === 'off') { centeredRef.current = false; return }
|
||||
if (!position || centeredRef.current) return
|
||||
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15)) } catch { /* noop */ }
|
||||
centeredRef.current = true
|
||||
}, [position, mode, map])
|
||||
|
||||
if (!position) return null
|
||||
|
||||
const headingIcon = position.heading === null || Number.isNaN(position.heading) ? null : L.divIcon({
|
||||
className: '',
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 30],
|
||||
html: `<div style="
|
||||
width:60px;height:60px;
|
||||
transform:rotate(${position.heading}deg);transition:transform 120ms ease-out;
|
||||
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
|
||||
border-radius:50%;
|
||||
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
pointer-events:none;
|
||||
"></div>`,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Location button */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
|
||||
}}>
|
||||
<button onClick={toggleTracking} style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Blue dot + accuracy circle */}
|
||||
{position && (
|
||||
<>
|
||||
{accuracy < 500 && (
|
||||
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
|
||||
)}
|
||||
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
|
||||
</>
|
||||
{position.accuracy < 500 && (
|
||||
<Circle
|
||||
center={[position.lat, position.lng]}
|
||||
radius={position.accuracy}
|
||||
pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.12, weight: 1, opacity: 0.35 }}
|
||||
interactive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pulse animation CSS */}
|
||||
{position && (
|
||||
<style>{`
|
||||
@keyframes location-pulse {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
{headingIcon && (
|
||||
<Marker
|
||||
position={[position.lat, position.lng]}
|
||||
icon={headingIcon}
|
||||
interactive={false}
|
||||
zIndexOffset={900}
|
||||
/>
|
||||
)}
|
||||
<CircleMarker
|
||||
center={[position.lat, position.lng]}
|
||||
radius={8}
|
||||
pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 3 }}
|
||||
interactive={false}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -561,8 +544,15 @@ export const MapView = memo(function MapView({
|
||||
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full relative">
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
@@ -586,7 +576,7 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<LocationTracker />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
@@ -631,6 +621,13 @@ export const MapView = memo(function MapView({
|
||||
onEndpointClick={onReservationClick}
|
||||
/>
|
||||
</MapContainer>
|
||||
{isMobile && <LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={locationButtonBottom as unknown as number}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
{TooltipOverlay && (
|
||||
<div data-testid="tooltip" style={{
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import { MapViewGL } from './MapViewGL'
|
||||
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MapViewAuto(props: any) {
|
||||
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.
|
||||
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
|
||||
return <MapView {...props} />
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
interface RouteSegment {
|
||||
mid: [number, number]
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
walkingText?: string
|
||||
drivingText?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
places: Place[]
|
||||
dayPlaces?: Place[]
|
||||
route?: [number, number][][] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
selectedPlaceId?: number | null
|
||||
onMarkerClick?: (id: number) => void
|
||||
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
fitKey?: number | null
|
||||
dayOrderMap?: Record<number, number[] | null>
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
hasInspector?: boolean
|
||||
hasDayDetail?: boolean
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
const size = selected ? 44 : 36
|
||||
const borderColor = selected ? '#111827' : 'white'
|
||||
const borderWidth = selected ? 3 : 2.5
|
||||
const shadow = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const bgColor = place.category_color || '#6b7280'
|
||||
|
||||
// The visual circle is `size` + 2*border on each side. To make the
|
||||
// mapbox `anchor: 'center'` land on the real visual middle of the marker
|
||||
// (rather than just the inner content box), the wrapper has to be the
|
||||
// full outer size. If we gave the wrapper only `size`, the border would
|
||||
// bleed outside it and the route lines would appear slightly off.
|
||||
const outer = size + borderWidth * 2
|
||||
|
||||
let badgeHtml = ''
|
||||
if (orderNumbers && orderNumbers.length > 0) {
|
||||
const label = orderNumbers.join(' · ')
|
||||
badgeHtml = `<span style="
|
||||
position:absolute;bottom:-2px;right:-2px;
|
||||
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
|
||||
padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
|
||||
background:rgba(255,255,255,0.94);
|
||||
border:1.5px solid rgba(0,0,0,0.15);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||
box-sizing:border-box;white-space:nowrap;
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
// Do NOT set `position: relative` here — mapbox-gl ships
|
||||
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
|
||||
// `position: relative` here overrides the class, turns every marker into
|
||||
// a static block element, and stacks them in document order inside the
|
||||
// canvas container. The result looks exactly like "markers drift as the
|
||||
// map zooms" because each marker's transform is then applied relative
|
||||
// to its stacked slot, not to the map viewport.
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
|
||||
|
||||
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'))
|
||||
if (hasPhoto) {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:hidden;background:${bgColor};
|
||||
box-sizing:content-box;
|
||||
">
|
||||
<img src="${photoUrl}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
`
|
||||
} else {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
background:${bgColor};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-sizing:content-box;
|
||||
">
|
||||
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
|
||||
</div>
|
||||
${badgeHtml}
|
||||
`
|
||||
}
|
||||
return wrap
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
onMapContextMenu = null,
|
||||
center = [48.8566, 2.3522],
|
||||
zoom = 10,
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
onClickRefs.current.map = onMapClick
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
|
||||
// Build/rebuild the map on style/token/3d change
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: containerRef.current,
|
||||
style: mapboxStyle,
|
||||
center: [center[1], center[0]],
|
||||
zoom,
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).__trek_map = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
// Terrain is only valuable on satellite styles — on clean vector
|
||||
// styles it makes route lines drift off the HTML markers because
|
||||
// the lines snap to DEM height while markers stay at sea level.
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(mapboxStyle)) {
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
addCustom3dBuildings(map, dark)
|
||||
}
|
||||
}
|
||||
|
||||
// Mapbox Standard ships its own DEM-based terrain that kicks in
|
||||
// below zoom 13.7. HTML markers project at sea level, so when the
|
||||
// terrain exaggeration ramps up at lower zooms the markers drift
|
||||
// away from the 3D buildings and route lines they belong to. The
|
||||
// non-satellite Standard style still looks great without terrain,
|
||||
// so flatten it out to keep markers pinned. (Satellite variants
|
||||
// are left alone — the DEM is what gives them their character.)
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: {
|
||||
'line-color': '#111827',
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.9,
|
||||
'line-dasharray': [2, 1.5],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
// gpx geometries source (place.route_geometry)
|
||||
if (!map.getSource('trip-gpx')) {
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-gpx-line',
|
||||
type: 'line',
|
||||
source: 'trip-gpx',
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
|
||||
'line-width': 3.5,
|
||||
'line-opacity': 0.75,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
map.on('click', (e) => {
|
||||
const t = e.originalEvent.target as HTMLElement
|
||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// In the mapbox-gl map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
const canvas = map.getCanvasContainer()
|
||||
const onAuxDown = (ev: MouseEvent) => {
|
||||
if (ev.button !== 1) return
|
||||
ev.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
||||
onClickRefs.current.context?.({
|
||||
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
||||
originalEvent: ev,
|
||||
})
|
||||
}
|
||||
// Also suppress the browser's native auxclick menu on middle-click.
|
||||
const onAuxClick = (ev: MouseEvent) => {
|
||||
if (ev.button === 1) ev.preventDefault()
|
||||
}
|
||||
canvas.addEventListener('mousedown', onAuxDown)
|
||||
canvas.addEventListener('auxclick', onAuxClick)
|
||||
|
||||
// Drop follow mode if the user pans the map manually — matches the
|
||||
// Apple Maps behaviour where the blue dot stays but the map no longer
|
||||
// chases it until the user taps the button again.
|
||||
map.on('dragstart', () => {
|
||||
setTrackingMode(prev => prev === 'follow' ? 'show' : prev)
|
||||
})
|
||||
|
||||
// Keep HTML markers glued to the terrain / 3D ground. Mapbox projects
|
||||
// HTML markers at altitude=0 (sea level) by default, so as soon as the
|
||||
// style has a terrain DEM (Standard, Standard Satellite, custom terrain)
|
||||
// the markers drift off the places when the camera pitches or zooms —
|
||||
// the buildings rise from DEM height, the marker stays at sea level,
|
||||
// and the pixel offset grows as the perspective changes.
|
||||
//
|
||||
// Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to
|
||||
// project the marker onto the same ground the route line sits on.
|
||||
// We re-apply this every render because DEM tiles stream in async.
|
||||
let lastAltUpdate = 0
|
||||
const syncMarkerAltitudes = () => {
|
||||
const now = performance.now()
|
||||
if (now - lastAltUpdate < 80) return // ~12Hz is plenty
|
||||
lastAltUpdate = now
|
||||
markersRef.current.forEach(marker => {
|
||||
const ll = marker.getLngLat()
|
||||
let alt = 0
|
||||
try {
|
||||
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
||||
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
||||
} catch { /* terrain not ready */ }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const curAlt = (ll as any).alt ?? 0
|
||||
if (Math.abs(curAlt - alt) > 0.25) {
|
||||
marker.setLngLat([ll.lng, ll.lat, alt])
|
||||
}
|
||||
})
|
||||
}
|
||||
map.on('render', syncMarkerAltitudes)
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onAuxDown)
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (locationMarkerRef.current) {
|
||||
locationMarkerRef.current.destroy()
|
||||
locationMarkerRef.current = null
|
||||
}
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
|
||||
|
||||
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
||||
// simultaneous thumb arrivals into one re-render.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) continue
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached?.thumbDataUrl) {
|
||||
setThumb(cacheKey, cached.thumbDataUrl)
|
||||
continue
|
||||
}
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reconcile markers with places + photos. Rebuilds the DOM node when any
|
||||
// visual input changes so photos, selection state and order badges stay
|
||||
// in sync.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const ids = new Set(places.map(p => p.id))
|
||||
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
if (!ids.has(id)) {
|
||||
marker.remove()
|
||||
markersRef.current.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
places.forEach(place => {
|
||||
if (!place.lat || !place.lng) return
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const selected = place.id === selectedPlaceId
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
// Recreate marker each time rather than patching internal state —
|
||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||
const existing = markersRef.current.get(place.id)
|
||||
if (existing) existing.remove()
|
||||
// Default (viewport-aligned) anchors keep the marker parallel to the
|
||||
// screen so its pixel centre lines up with the route line at any
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
// but it rotates the element by the pitch angle and visually offsets
|
||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([place.lng, place.lat])
|
||||
.addTo(map)
|
||||
markersRef.current.set(place.id, m)
|
||||
})
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||
|
||||
// Update route geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}))
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route])
|
||||
|
||||
// Update GPX geometries
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [{
|
||||
type: 'Feature' as const,
|
||||
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
}]
|
||||
} catch { return [] }
|
||||
})
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [places])
|
||||
|
||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||
const paddingOpts = useMemo(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
|
||||
const top = 60
|
||||
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Also fit when the places collection changes so the initial render
|
||||
// zooms to the trip instead of the default center.
|
||||
const placeBoundsKey = useMemo(
|
||||
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
|
||||
[places]
|
||||
)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||
const valid = target.filter(p => p.lat && p.lng)
|
||||
if (valid.length === 0) return
|
||||
const bounds = new mapboxgl.LngLatBounds()
|
||||
valid.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||
const run = () => {
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
if (map.loaded()) run()
|
||||
else map.once('load', run)
|
||||
}, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !selectedPlaceId) return
|
||||
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
|
||||
if (!target?.lat || !target?.lng) return
|
||||
try {
|
||||
map.flyTo({
|
||||
center: [target.lng, target.lat],
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// External center/zoom prop changes — jump without animation
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
try { map.jumpTo({ center: [center[1], center[0]], zoom }) } catch { /* noop */ }
|
||||
}, [center[0], center[1]]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Blue dot rendering + follow-mode camera. Attach the marker lazily the
|
||||
// first time a fix arrives so the layers sit on top of everything else
|
||||
// added so far, and destroy it when tracking is turned off.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
if (trackingMode === 'off') {
|
||||
if (locationMarkerRef.current) {
|
||||
locationMarkerRef.current.update(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!userPosition) return
|
||||
const apply = () => {
|
||||
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
|
||||
locationMarkerRef.current.update(userPosition)
|
||||
if (trackingMode === 'follow') {
|
||||
// easeTo is gentler than flyTo for continuous updates
|
||||
try {
|
||||
map.easeTo({
|
||||
center: [userPosition.lng, userPosition.lat],
|
||||
bearing: userPosition.heading ?? map.getBearing(),
|
||||
zoom: Math.max(map.getZoom(), 16),
|
||||
duration: 350,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
if (map.loaded()) apply()
|
||||
else map.once('load', apply)
|
||||
}, [userPosition, trackingMode])
|
||||
|
||||
if (!mapboxToken) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
||||
<div className="text-sm text-zinc-500">
|
||||
No Mapbox access token configured.<br />
|
||||
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
{isMobile && (
|
||||
<LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={buttonBottom as unknown as number}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import type { GeoPosition } from '../../hooks/useGeolocation'
|
||||
|
||||
// Build the DOM element that backs the mapbox Marker. We animate the
|
||||
// heading cone via a CSS rotation so the DOM stays stable across updates
|
||||
// and mapbox doesn't get confused about which element to position.
|
||||
function buildLocationEl(): { root: HTMLDivElement; cone: HTMLDivElement } {
|
||||
const root = document.createElement('div')
|
||||
root.style.cssText = 'width:28px;height:28px;position:relative;pointer-events:none;'
|
||||
// Accuracy pulse behind the dot
|
||||
const pulse = document.createElement('div')
|
||||
pulse.style.cssText = `
|
||||
position:absolute;inset:-14px;border-radius:50%;
|
||||
background:#3b82f6;opacity:0.25;
|
||||
animation:trek-location-pulse 2s ease-out infinite;
|
||||
`
|
||||
// Heading cone (conic gradient fan)
|
||||
const cone = document.createElement('div')
|
||||
cone.style.cssText = `
|
||||
position:absolute;left:50%;top:50%;width:60px;height:60px;
|
||||
transform:translate(-50%,-50%) rotate(0deg);
|
||||
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
|
||||
border-radius:50%;
|
||||
mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
transition:transform 0.12s ease-out;
|
||||
display:none;
|
||||
`
|
||||
// Blue dot
|
||||
const dot = document.createElement('div')
|
||||
dot.style.cssText = `
|
||||
position:absolute;left:50%;top:50%;
|
||||
transform:translate(-50%,-50%);
|
||||
width:18px;height:18px;border-radius:50%;
|
||||
background:#3b82f6;border:3px solid white;
|
||||
box-shadow:0 0 0 1px rgba(0,0,0,0.15), 0 2px 6px rgba(0,0,0,0.3);
|
||||
`
|
||||
root.appendChild(pulse)
|
||||
root.appendChild(cone)
|
||||
root.appendChild(dot)
|
||||
return { root, cone }
|
||||
}
|
||||
|
||||
// Inject the pulse keyframes once per document so the animation is
|
||||
// available for every map instance.
|
||||
function ensurePulseStyle() {
|
||||
if (document.getElementById('trek-location-style')) return
|
||||
const s = document.createElement('style')
|
||||
s.id = 'trek-location-style'
|
||||
s.textContent = `
|
||||
@keyframes trek-location-pulse {
|
||||
0% { transform: scale(0.6); opacity: 0.35; }
|
||||
70% { transform: scale(1.6); opacity: 0; }
|
||||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
export interface LocationMarkerHandle {
|
||||
update: (p: GeoPosition | null) => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
// Creates (or reuses) a location marker + accuracy circle on the given
|
||||
// mapbox map. Returns a handle the caller uses to push position updates
|
||||
// and clean up. Keeps its own DOM element and GeoJSON source so it can
|
||||
// coexist with the regular trip markers.
|
||||
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
|
||||
ensurePulseStyle()
|
||||
const { root, cone } = buildLocationEl()
|
||||
const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
|
||||
|
||||
const ensureAccuracyLayer = () => {
|
||||
if (map.getSource('trek-location-accuracy')) return
|
||||
try {
|
||||
map.addSource('trek-location-accuracy', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
})
|
||||
// Draw the accuracy ring as a geographic polygon: it's a real geodesic
|
||||
// circle defined in meters, so mapbox automatically scales it with
|
||||
// zoom the way Apple/Google Maps does — always the same real-world
|
||||
// size regardless of viewport.
|
||||
map.addLayer({
|
||||
id: 'trek-location-accuracy',
|
||||
type: 'fill',
|
||||
source: 'trek-location-accuracy',
|
||||
paint: {
|
||||
'fill-color': '#3b82f6',
|
||||
'fill-opacity': 0.14,
|
||||
'fill-outline-color': '#3b82f6',
|
||||
},
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
// Build a polygon approximating a geodesic circle around (lng, lat)
|
||||
// with the given radius in meters. 48 segments is plenty for a smooth
|
||||
// edge without paying much CPU per fix.
|
||||
const geodesicCircle = (lng: number, lat: number, radiusMeters: number): number[][] => {
|
||||
const earth = 6378137
|
||||
const d = radiusMeters / earth
|
||||
const lat1 = lat * Math.PI / 180
|
||||
const lng1 = lng * Math.PI / 180
|
||||
const coords: number[][] = []
|
||||
const segments = 48
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const bearing = (i / segments) * 2 * Math.PI
|
||||
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(bearing))
|
||||
const lng2 = lng1 + Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(d) * Math.cos(lat1),
|
||||
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2),
|
||||
)
|
||||
coords.push([lng2 * 180 / Math.PI, lat2 * 180 / Math.PI])
|
||||
}
|
||||
return coords
|
||||
}
|
||||
|
||||
const setAccuracy = (p: GeoPosition) => {
|
||||
const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
if (!p.accuracy || p.accuracy < 1) {
|
||||
src.setData({ type: 'FeatureCollection', features: [] })
|
||||
return
|
||||
}
|
||||
const ring = geodesicCircle(p.lng, p.lat, p.accuracy)
|
||||
src.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
let lastPosRef: GeoPosition | null = null
|
||||
|
||||
if (map.loaded()) ensureAccuracyLayer()
|
||||
else map.once('load', ensureAccuracyLayer)
|
||||
|
||||
const handle: LocationMarkerHandle = {
|
||||
update: (p) => {
|
||||
lastPosRef = p
|
||||
if (!p) {
|
||||
marker.remove()
|
||||
const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined
|
||||
src?.setData({ type: 'FeatureCollection', features: [] })
|
||||
return
|
||||
}
|
||||
marker.setLngLat([p.lng, p.lat])
|
||||
if (!marker.getElement().parentElement) marker.addTo(map)
|
||||
if (p.heading !== null && !Number.isNaN(p.heading)) {
|
||||
cone.style.display = 'block'
|
||||
cone.style.transform = `translate(-50%,-50%) rotate(${p.heading}deg)`
|
||||
} else {
|
||||
cone.style.display = 'none'
|
||||
}
|
||||
setAccuracy(p)
|
||||
},
|
||||
destroy: () => {
|
||||
try { marker.remove() } catch { /* noop */ }
|
||||
try {
|
||||
if (map.getLayer('trek-location-accuracy')) map.removeLayer('trek-location-accuracy')
|
||||
if (map.getSource('trek-location-accuracy')) map.removeSource('trek-location-accuracy')
|
||||
} catch { /* noop */ }
|
||||
},
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
|
||||
// "mapbox/standard" and "mapbox/standard-satellite" ship their own 3D
|
||||
// buildings and terrain. For every other style we inject a fill-extrusion
|
||||
// layer against the classic `composite` vector source so the user still
|
||||
// gets real 3D buildings (not just a tilted 2D view) when they toggle 3D.
|
||||
export function isStandardFamily(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||
}
|
||||
|
||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
||||
// offset when the map is pitched. Restrict terrain to satellite.
|
||||
export function wantsTerrain(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||
}
|
||||
|
||||
// 3D can be added to every style now — the standard family has it built-in
|
||||
// and for everything else we either reuse the style's own `composite`
|
||||
// building layer or attach the public `mapbox-streets-v8` tileset as an
|
||||
// extra source (needed for pure satellite, which has no vector data).
|
||||
export function supportsCustom3d(style: string): boolean {
|
||||
return !isStandardFamily(style)
|
||||
}
|
||||
|
||||
// Add a 3D buildings extrusion layer to a non-Standard Mapbox style. For
|
||||
// the pure satellite style we lazily attach `mapbox-streets-v8` as a
|
||||
// fallback source so real building volumes sit on top of the imagery —
|
||||
// the Apple Maps-style "3D satellite" look the user asked for.
|
||||
export function addCustom3dBuildings(map: mapboxgl.Map, dark: boolean) {
|
||||
if (map.getLayer('trek-3d-buildings')) return
|
||||
const baseColor = dark ? '#3b3b3f' : '#cfd2d6'
|
||||
|
||||
// Styles without a `composite` source (pure satellite) need a fallback
|
||||
// vector tileset for building geometry.
|
||||
let sourceId = 'composite'
|
||||
if (!map.getSource('composite')) {
|
||||
sourceId = 'mapbox-streets-v8'
|
||||
if (!map.getSource(sourceId)) {
|
||||
try {
|
||||
map.addSource(sourceId, { type: 'vector', url: 'mapbox://mapbox.mapbox-streets-v8' })
|
||||
} catch { return }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Place extrusions below the first label layer so text stays readable.
|
||||
const layers = map.getStyle()?.layers || []
|
||||
const firstSymbolId = layers.find(l => l.type === 'symbol')?.id
|
||||
map.addLayer({
|
||||
id: 'trek-3d-buildings',
|
||||
source: sourceId,
|
||||
'source-layer': 'building',
|
||||
filter: ['==', 'extrude', 'true'],
|
||||
type: 'fill-extrusion',
|
||||
minzoom: 14,
|
||||
paint: {
|
||||
'fill-extrusion-color': baseColor,
|
||||
'fill-extrusion-height': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
14, 0,
|
||||
15.5, ['coalesce', ['get', 'height'], 0],
|
||||
],
|
||||
'fill-extrusion-base': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
14, 0,
|
||||
15.5, ['coalesce', ['get', 'min_height'], 0],
|
||||
],
|
||||
'fill-extrusion-opacity': 0.85,
|
||||
},
|
||||
}, firstSymbolId)
|
||||
} catch { /* building source-layer unavailable */ }
|
||||
}
|
||||
|
||||
// Terrain + sky that works against any style that has the DEM source.
|
||||
// The Standard family already handles terrain internally, skip there.
|
||||
export function addTerrainAndSky(map: mapboxgl.Map) {
|
||||
try {
|
||||
if (!map.getSource('mapbox-dem')) {
|
||||
map.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14,
|
||||
})
|
||||
}
|
||||
map.setTerrain({ source: 'mapbox-dem', exaggeration: 1.2 })
|
||||
if (!map.getLayer('sky')) {
|
||||
map.addLayer({
|
||||
id: 'sky',
|
||||
type: 'sky',
|
||||
paint: {
|
||||
'sky-type': 'atmosphere',
|
||||
'sky-atmosphere-sun-intensity': 15,
|
||||
} as unknown as mapboxgl.SkyLayerSpecification['paint'],
|
||||
})
|
||||
}
|
||||
} catch { /* style doesn't support terrain */ }
|
||||
}
|
||||
Reference in New Issue
Block a user