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.
465 lines
17 KiB
TypeScript
465 lines
17 KiB
TypeScript
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||
import mapboxgl from 'mapbox-gl'
|
||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||
import { useSettingsStore } from '../../store/settingsStore'
|
||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||
|
||
export interface JourneyMapGLHandle {
|
||
highlightMarker: (id: string | null) => void
|
||
focusMarker: (id: string) => void
|
||
invalidateSize: () => void
|
||
}
|
||
|
||
interface MapEntry {
|
||
id: string
|
||
lat: number
|
||
lng: number
|
||
title?: string | null
|
||
location_name?: string | null
|
||
mood?: string | null
|
||
entry_date: string
|
||
}
|
||
|
||
interface Props {
|
||
checkins: unknown[]
|
||
entries: MapEntry[]
|
||
trail?: { lat: number; lng: number }[]
|
||
height?: number
|
||
dark?: boolean
|
||
activeMarkerId?: string | null
|
||
onMarkerClick?: (id: string, type?: string) => void
|
||
fullScreen?: boolean
|
||
paddingBottom?: number
|
||
}
|
||
|
||
interface Item {
|
||
id: string
|
||
lat: number
|
||
lng: number
|
||
label: string
|
||
locationName: string
|
||
time: string
|
||
}
|
||
|
||
const MARKER_W = 28
|
||
const MARKER_H = 36
|
||
|
||
function buildItems(entries: MapEntry[]): Item[] {
|
||
const items: Item[] = []
|
||
for (const e of entries) {
|
||
if (e.lat && e.lng) {
|
||
items.push({
|
||
id: e.id,
|
||
lat: e.lat,
|
||
lng: e.lng,
|
||
label: e.title || '',
|
||
locationName: e.location_name || '',
|
||
time: e.entry_date,
|
||
})
|
||
}
|
||
}
|
||
items.sort((a, b) => a.time.localeCompare(b.time))
|
||
return items
|
||
}
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
function formatEntryDate(iso: string): string {
|
||
if (!iso) return ''
|
||
try {
|
||
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
|
||
if (Number.isNaN(d.getTime())) return iso
|
||
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
|
||
} catch {
|
||
return iso
|
||
}
|
||
}
|
||
|
||
// Inject the popup styles once per document. Two-line frosted-glass card in
|
||
// the Apple/Google Maps idiom — title on top, location / date subtly below.
|
||
function ensureJourneyPopupStyle() {
|
||
if (document.getElementById('trek-journey-popup-style')) return
|
||
const s = document.createElement('style')
|
||
s.id = 'trek-journey-popup-style'
|
||
s.textContent = `
|
||
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
|
||
padding: 9px 14px 10px;
|
||
border-radius: 14px;
|
||
background: rgba(255, 255, 255, 0.94);
|
||
backdrop-filter: blur(16px) saturate(180%);
|
||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||
font-family: -apple-system, system-ui, sans-serif;
|
||
min-width: 160px;
|
||
max-width: 280px;
|
||
}
|
||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
|
||
background: rgba(24, 24, 27, 0.88);
|
||
border-color: rgba(255, 255, 255, 0.08);
|
||
color: #FAFAFA;
|
||
}
|
||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
|
||
border-top-color: rgba(255, 255, 255, 0.94);
|
||
border-bottom-color: rgba(255, 255, 255, 0.94);
|
||
}
|
||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
|
||
border-top-color: rgba(24, 24, 27, 0.88);
|
||
border-bottom-color: rgba(24, 24, 27, 0.88);
|
||
}
|
||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
||
.trek-journey-popup-title {
|
||
font-size: 13.5px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.01em;
|
||
color: #18181B;
|
||
line-height: 1.3;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||
.trek-journey-popup-sub {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 7px;
|
||
margin-top: 3px;
|
||
font-size: 11.5px;
|
||
color: #71717A;
|
||
line-height: 1.35;
|
||
white-space: nowrap;
|
||
}
|
||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||
.trek-journey-popup-place {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.trek-journey-popup-sep {
|
||
flex: 0 0 auto;
|
||
opacity: 0.55;
|
||
font-weight: 500;
|
||
}
|
||
.trek-journey-popup-date { flex: 0 0 auto; }
|
||
@keyframes trek-journey-popup-in {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
`
|
||
document.head.appendChild(s)
|
||
}
|
||
|
||
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
|
||
const fill = dark
|
||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||
: (highlighted ? '#18181B' : '#52525B')
|
||
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
|
||
const stroke = highlighted
|
||
? (dark ? '#fff' : '#18181B')
|
||
: (dark ? '#3F3F46' : '#fff')
|
||
const shadow = highlighted
|
||
? (dark
|
||
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||
const scale = highlighted ? 1.2 : 1
|
||
const label = String(index + 1)
|
||
|
||
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||
// the CSS transition would catch the map's per-frame translate updates and
|
||
// the marker smears all over the viewport while scrolling / flying.
|
||
const wrap = document.createElement('div')
|
||
wrap.style.cssText = `width:${MARKER_W}px;height:${MARKER_H}px;cursor:pointer;`
|
||
const inner = document.createElement('div')
|
||
inner.className = 'trek-journey-marker-inner'
|
||
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
||
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||
</svg>`
|
||
wrap.appendChild(inner)
|
||
return wrap
|
||
}
|
||
|
||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||
|
||
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||
ref
|
||
) {
|
||
const stableTrail = trail || EMPTY_TRAIL
|
||
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 containerRef = useRef<HTMLDivElement>(null)
|
||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
||
const itemsRef = useRef<Item[]>([])
|
||
const highlightedRef = useRef<string | null>(null)
|
||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||
const onMarkerClickRef = useRef(onMarkerClick)
|
||
onMarkerClickRef.current = onMarkerClick
|
||
const darkRef = useRef(dark)
|
||
darkRef.current = dark
|
||
|
||
const showPopup = useCallback((id: string) => {
|
||
const item = itemsRef.current.find(i => i.id === id)
|
||
if (!item || !mapRef.current) return
|
||
ensureJourneyPopupStyle()
|
||
// Primary line: user-given title. If none, fall back to the location
|
||
// name so we always show *something* useful on the top line.
|
||
const primaryRaw = item.label || item.locationName || 'Entry'
|
||
const secondaryPlace = item.label ? item.locationName : ''
|
||
const dateStr = formatEntryDate(item.time)
|
||
const primary = escapeHtml(primaryRaw)
|
||
const place = escapeHtml(secondaryPlace)
|
||
const date = escapeHtml(dateStr)
|
||
|
||
const subParts: string[] = []
|
||
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`)
|
||
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`)
|
||
const subline = subParts.length === 2
|
||
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
|
||
: subParts.join('')
|
||
|
||
const html = `
|
||
<div class="trek-journey-popup-title">${primary}</div>
|
||
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
|
||
`
|
||
// Marker is bottom-anchored with a visible height of 36px (1.2× on
|
||
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
|
||
const offset: [number, number] = [0, -46]
|
||
if (popupRef.current) {
|
||
popupRef.current.setLngLat([item.lng, item.lat])
|
||
popupRef.current.setHTML(html)
|
||
popupRef.current.setOffset(offset)
|
||
const el = popupRef.current.getElement()
|
||
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||
} else {
|
||
popupRef.current = new mapboxgl.Popup({
|
||
closeButton: false,
|
||
closeOnClick: false,
|
||
closeOnMove: false,
|
||
anchor: 'bottom',
|
||
offset,
|
||
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
|
||
maxWidth: '280px',
|
||
})
|
||
.setLngLat([item.lng, item.lat])
|
||
.setHTML(html)
|
||
.addTo(mapRef.current)
|
||
}
|
||
}, [])
|
||
|
||
const hidePopup = useCallback(() => {
|
||
if (popupRef.current) {
|
||
try { popupRef.current.remove() } catch { /* noop */ }
|
||
popupRef.current = null
|
||
}
|
||
}, [])
|
||
|
||
const setMarkerStyle = useCallback((id: string, highlighted: boolean) => {
|
||
const item = itemsRef.current.find(i => i.id === id)
|
||
const marker = markersRef.current.get(id)
|
||
if (!item || !marker) return
|
||
const idx = itemsRef.current.indexOf(item)
|
||
const el = marker.getElement()
|
||
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||
if (!currentInner) return
|
||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||
// would wipe mapbox's positional transform and make the marker flicker.
|
||
const next = markerHtml(idx, highlighted, !!darkRef.current)
|
||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||
currentInner.style.cssText = nextInner.style.cssText
|
||
currentInner.innerHTML = nextInner.innerHTML
|
||
el.style.zIndex = highlighted ? '1000' : '0'
|
||
}, [])
|
||
|
||
const highlightMarker = useCallback((id: string | null) => {
|
||
const prev = highlightedRef.current
|
||
highlightedRef.current = id
|
||
if (prev && prev !== id) setMarkerStyle(prev, false)
|
||
if (id) {
|
||
setMarkerStyle(id, true)
|
||
showPopup(id)
|
||
} else {
|
||
hidePopup()
|
||
}
|
||
}, [setMarkerStyle, showPopup, hidePopup])
|
||
|
||
const focusMarker = useCallback((id: string) => {
|
||
highlightMarker(id)
|
||
const marker = markersRef.current.get(id)
|
||
if (!marker || !mapRef.current) return
|
||
try {
|
||
mapRef.current.flyTo({
|
||
center: marker.getLngLat(),
|
||
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||
pitch: mapbox3d ? 45 : 0,
|
||
duration: 600,
|
||
})
|
||
} catch { /* map not yet ready */ }
|
||
}, [highlightMarker, mapbox3d])
|
||
|
||
const invalidateSize = useCallback(() => {
|
||
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||
}, [])
|
||
|
||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [highlightMarker, focusMarker, invalidateSize])
|
||
|
||
// Build map once per style/token change. Markers and layers are rebuilt
|
||
// inside the same effect so they stay in sync with the active style.
|
||
useEffect(() => {
|
||
if (!containerRef.current || !mapboxToken) return
|
||
mapboxgl.accessToken = mapboxToken
|
||
|
||
const items = buildItems(entries)
|
||
itemsRef.current = items
|
||
|
||
const bounds = new mapboxgl.LngLatBounds()
|
||
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
||
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
||
const hasPoints = items.length > 0 || stableTrail.length > 0
|
||
|
||
const map = new mapboxgl.Map({
|
||
container: containerRef.current,
|
||
style: mapboxStyle,
|
||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||
zoom: hasPoints ? 2 : 1,
|
||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||
attributionControl: true,
|
||
antialias: mapboxQuality,
|
||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||
})
|
||
mapRef.current = map
|
||
|
||
map.on('load', () => {
|
||
if (mapbox3d) {
|
||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||
}
|
||
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
||
// stay pinned to their coordinates at every zoom and pitch.
|
||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||
try { map.setTerrain(null) } catch { /* noop */ }
|
||
}
|
||
|
||
// route trail — dashed line connecting entries in time order
|
||
if (items.length > 1) {
|
||
const coords = items.map(i => [i.lng, i.lat])
|
||
if (map.getSource('journey-route')) (map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({
|
||
type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
|
||
})
|
||
else {
|
||
map.addSource('journey-route', {
|
||
type: 'geojson',
|
||
data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString },
|
||
})
|
||
map.addLayer({
|
||
id: 'journey-route-line',
|
||
type: 'line',
|
||
source: 'journey-route',
|
||
paint: {
|
||
'line-color': darkRef.current ? '#71717A' : '#A1A1AA',
|
||
'line-width': 1.5,
|
||
'line-opacity': 0.5,
|
||
'line-dasharray': [2, 3],
|
||
},
|
||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||
})
|
||
}
|
||
}
|
||
|
||
// markers
|
||
items.forEach((item, i) => {
|
||
const el = markerHtml(i, false, !!darkRef.current)
|
||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||
.setLngLat([item.lng, item.lat])
|
||
.addTo(map)
|
||
el.addEventListener('click', (ev) => {
|
||
ev.stopPropagation()
|
||
onMarkerClickRef.current?.(item.id)
|
||
})
|
||
markersRef.current.set(item.id, marker)
|
||
})
|
||
|
||
// fit bounds to all points
|
||
if (hasPoints) {
|
||
const pb = paddingBottom || 50
|
||
try {
|
||
map.fitBounds(bounds, {
|
||
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
||
maxZoom: 16,
|
||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||
duration: 0,
|
||
})
|
||
} catch { /* empty bounds */ }
|
||
}
|
||
})
|
||
|
||
return () => {
|
||
markersRef.current.forEach(m => m.remove())
|
||
markersRef.current.clear()
|
||
if (popupRef.current) {
|
||
try { popupRef.current.remove() } catch { /* noop */ }
|
||
popupRef.current = null
|
||
}
|
||
highlightedRef.current = null
|
||
try { map.remove() } catch { /* noop */ }
|
||
mapRef.current = null
|
||
}
|
||
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||
|
||
// external activeMarkerId → highlight + flyTo
|
||
useEffect(() => {
|
||
if (!activeMarkerId || !mapRef.current) return
|
||
const t = setTimeout(() => {
|
||
highlightMarker(activeMarkerId)
|
||
const marker = markersRef.current.get(activeMarkerId)
|
||
if (!marker || !mapRef.current) return
|
||
try {
|
||
mapRef.current.flyTo({
|
||
center: marker.getLngLat(),
|
||
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||
duration: 500,
|
||
})
|
||
} catch { /* map not ready */ }
|
||
}, 50)
|
||
return () => clearTimeout(t)
|
||
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
||
|
||
if (!mapboxToken) {
|
||
return (
|
||
<div
|
||
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
||
className="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>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||
</div>
|
||
)
|
||
})
|
||
|
||
export default JourneyMapGL
|