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.
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||
import L from 'leaflet'
|
||
import { useSettingsStore } from '../../store/settingsStore'
|
||
|
||
export interface MapMarkerItem {
|
||
id: string
|
||
lat: number
|
||
lng: number
|
||
label: string
|
||
mood?: string | null
|
||
time: string
|
||
}
|
||
|
||
export interface JourneyMapHandle {
|
||
highlightMarker: (id: string | null) => void
|
||
focusMarker: (id: string) => void
|
||
invalidateSize: () => void
|
||
}
|
||
|
||
interface MapEntry {
|
||
id: string
|
||
lat: number
|
||
lng: number
|
||
title?: string | null
|
||
mood?: string | null
|
||
entry_date: string
|
||
}
|
||
|
||
interface Props {
|
||
checkins: any[]
|
||
entries: MapEntry[]
|
||
trail?: { lat: number; lng: number }[]
|
||
height?: number
|
||
dark?: boolean
|
||
activeMarkerId?: string | null
|
||
onMarkerClick?: (id: string, type?: string) => void
|
||
fullScreen?: boolean
|
||
paddingBottom?: number
|
||
}
|
||
|
||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||
const items: MapMarkerItem[] = []
|
||
for (const e of entries) {
|
||
if (e.lat && e.lng) {
|
||
items.push({
|
||
id: e.id,
|
||
lat: e.lat,
|
||
lng: e.lng,
|
||
label: e.title || 'Entry',
|
||
mood: e.mood,
|
||
time: e.entry_date,
|
||
})
|
||
}
|
||
}
|
||
items.sort((a, b) => a.time.localeCompare(b.time))
|
||
return items
|
||
}
|
||
|
||
const MARKER_W = 28
|
||
const MARKER_H = 36
|
||
|
||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
||
const fill = dark
|
||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||
: (highlighted ? '#18181B' : '#52525B')
|
||
const textColor = dark
|
||
? (highlighted ? '#18181B' : '#18181B')
|
||
: (highlighted ? '#fff' : '#fff')
|
||
const stroke = highlighted
|
||
? (dark ? '#fff' : '#18181B')
|
||
: (dark ? '#3F3F46' : '#fff')
|
||
const shadow = highlighted
|
||
? (dark
|
||
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||
const label = String(index + 1)
|
||
const scale = highlighted ? 1.2 : 1
|
||
|
||
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
||
<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>
|
||
</div>`
|
||
}
|
||
|
||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||
|
||
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||
ref
|
||
) {
|
||
const stableTrail = trail || EMPTY_TRAIL
|
||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const mapRef = useRef<L.Map | null>(null)
|
||
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
||
const itemsRef = useRef<MapMarkerItem[]>([])
|
||
const highlightedRef = useRef<string | null>(null)
|
||
const onMarkerClickRef = useRef(onMarkerClick)
|
||
onMarkerClickRef.current = onMarkerClick
|
||
|
||
const darkRef = useRef(dark)
|
||
darkRef.current = dark
|
||
|
||
const highlightMarker = useCallback((id: string | null) => {
|
||
const prev = highlightedRef.current
|
||
highlightedRef.current = id
|
||
const isDark = !!darkRef.current
|
||
|
||
if (prev && prev !== id) {
|
||
const marker = markersRef.current.get(prev)
|
||
const item = itemsRef.current.find(i => i.id === prev)
|
||
if (marker && item) {
|
||
const idx = itemsRef.current.indexOf(item)
|
||
marker.setIcon(L.divIcon({
|
||
className: '',
|
||
iconSize: [MARKER_W, MARKER_H],
|
||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||
html: markerSvg(idx, false, isDark),
|
||
}))
|
||
marker.setZIndexOffset(0)
|
||
}
|
||
}
|
||
|
||
if (id) {
|
||
const marker = markersRef.current.get(id)
|
||
const item = itemsRef.current.find(i => i.id === id)
|
||
if (marker && item) {
|
||
const idx = itemsRef.current.indexOf(item)
|
||
marker.setIcon(L.divIcon({
|
||
className: '',
|
||
iconSize: [MARKER_W, MARKER_H],
|
||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||
html: markerSvg(idx, true, isDark),
|
||
}))
|
||
marker.setZIndexOffset(1000)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const focusMarker = useCallback((id: string) => {
|
||
highlightMarker(id)
|
||
const marker = markersRef.current.get(id)
|
||
if (marker && mapRef.current) {
|
||
try {
|
||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||
} catch { /* map not yet initialized */ }
|
||
}
|
||
}, [])
|
||
|
||
const invalidateSize = useCallback(() => {
|
||
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
|
||
}, [])
|
||
|
||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
|
||
|
||
useEffect(() => {
|
||
if (!containerRef.current) return
|
||
|
||
if (mapRef.current) {
|
||
mapRef.current.remove()
|
||
mapRef.current = null
|
||
}
|
||
markersRef.current.clear()
|
||
|
||
const map = L.map(containerRef.current, {
|
||
zoomControl: false,
|
||
attributionControl: true,
|
||
scrollWheelZoom: fullScreen ? true : false,
|
||
dragging: true,
|
||
touchZoom: true,
|
||
})
|
||
mapRef.current = map
|
||
|
||
const defaultTile = dark
|
||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||
L.tileLayer(mapTileUrl || defaultTile, {
|
||
maxZoom: 18,
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||
// Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle
|
||
// before loading tiles). On the journey mobile combined view we flyTo
|
||
// constantly when switching cards, so tiles lag visibly — force eager
|
||
// updates and keep a larger ring of off-screen tiles ready.
|
||
updateWhenIdle: false,
|
||
keepBuffer: 4,
|
||
} as any).addTo(map)
|
||
|
||
const items = buildMarkerItems(entries)
|
||
itemsRef.current = items
|
||
|
||
const allCoords: L.LatLngTuple[] = []
|
||
|
||
if (stableTrail.length > 1) {
|
||
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
||
L.polyline(coords, {
|
||
color: '#6366f1', weight: 3, opacity: 0.4,
|
||
dashArray: '6 4', lineCap: 'round',
|
||
}).addTo(map)
|
||
coords.forEach(c => allCoords.push(c))
|
||
}
|
||
|
||
// route polyline — only in non-fullscreen (sidebar map) mode
|
||
if (!fullScreen && items.length > 1) {
|
||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||
L.polyline(routeCoords, {
|
||
color: dark ? '#71717A' : '#A1A1AA',
|
||
weight: 1.5,
|
||
opacity: 0.5,
|
||
dashArray: '4 6',
|
||
lineCap: 'round', lineJoin: 'round',
|
||
}).addTo(map)
|
||
}
|
||
|
||
// place markers
|
||
items.forEach((item, i) => {
|
||
const pos: L.LatLngTuple = [item.lat, item.lng]
|
||
allCoords.push(pos)
|
||
|
||
const icon = L.divIcon({
|
||
className: '',
|
||
iconSize: [MARKER_W, MARKER_H],
|
||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||
html: markerSvg(i, false, !!dark),
|
||
})
|
||
|
||
const marker = L.marker(pos, { icon }).addTo(map)
|
||
marker.bindTooltip(item.label, {
|
||
direction: 'top',
|
||
offset: [0, -MARKER_H],
|
||
className: 'map-tooltip',
|
||
})
|
||
|
||
marker.on('click', () => {
|
||
onMarkerClickRef.current?.(item.id)
|
||
})
|
||
|
||
markersRef.current.set(item.id, marker)
|
||
})
|
||
|
||
// fit bounds
|
||
requestAnimationFrame(() => {
|
||
if (!mapRef.current) return
|
||
try {
|
||
map.invalidateSize()
|
||
if (allCoords.length > 0) {
|
||
const pb = paddingBottom || 50
|
||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||
} else {
|
||
map.setView([30, 0], 2)
|
||
}
|
||
} catch {}
|
||
})
|
||
|
||
setTimeout(() => {
|
||
if (mapRef.current) map.invalidateSize()
|
||
}, 200)
|
||
|
||
return () => {
|
||
map.remove()
|
||
mapRef.current = null
|
||
markersRef.current.clear()
|
||
}
|
||
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
|
||
|
||
// react to activeMarkerId prop changes — runs after map is built
|
||
useEffect(() => {
|
||
if (!activeMarkerId || !mapRef.current) return
|
||
// small delay to ensure markers are rendered after map build
|
||
const timer = setTimeout(() => {
|
||
highlightMarker(activeMarkerId)
|
||
const marker = markersRef.current.get(activeMarkerId)
|
||
if (!marker || !mapRef.current) return
|
||
// fitBounds may still be pending when this fires — getZoom() throws
|
||
// "Set map center and zoom first" until the map has a view. Guard it.
|
||
try {
|
||
const currentZoom = mapRef.current.getZoom()
|
||
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
|
||
} catch {
|
||
mapRef.current.setView(marker.getLatLng(), 12)
|
||
}
|
||
}, 50)
|
||
return () => clearTimeout(timer)
|
||
}, [activeMarkerId])
|
||
|
||
const zoomIn = () => mapRef.current?.zoomIn()
|
||
const zoomOut = () => mapRef.current?.zoomOut()
|
||
|
||
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 style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
<button
|
||
onClick={zoomIn}
|
||
style={{
|
||
width: 32, height: 32, borderRadius: 8,
|
||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||
backdropFilter: 'blur(8px)',
|
||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||
color: dark ? '#fff' : '#18181B',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||
}}
|
||
>+</button>
|
||
<button
|
||
onClick={zoomOut}
|
||
style={{
|
||
width: 32, height: 32, borderRadius: 8,
|
||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||
backdropFilter: 'blur(8px)',
|
||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||
color: dark ? '#fff' : '#18181B',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||
}}
|
||
>−</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
|
||
export default JourneyMap
|