mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
25bdf56d16
- 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.
173 lines
6.2 KiB
TypeScript
173 lines
6.2 KiB
TypeScript
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
|
|
}
|