mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
464 lines
17 KiB
TypeScript
464 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
|
||
dayColor?: string
|
||
dayLabel?: number
|
||
}
|
||
|
||
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
|
||
dayColor: string
|
||
dayLabel: number
|
||
}
|
||
|
||
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,
|
||
dayColor: e.dayColor || '#52525B',
|
||
dayLabel: e.dayLabel ?? 1,
|
||
})
|
||
}
|
||
}
|
||
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(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||
const fill = dayColor
|
||
const textColor = '#fff'
|
||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||
const shadow = highlighted
|
||
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||
const scale = highlighted ? 1.2 : 1
|
||
const label = String(dayLabel)
|
||
|
||
// 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="1.5"/>
|
||
<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 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(item.dayColor, item.dayLabel, highlighted)
|
||
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) => {
|
||
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||
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
|