Spaces:
Sleeping
Sleeping
| 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 | |
| dayColor: string | |
| dayLabel: number | |
| } | |
| 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 | |
| dayColor?: string | |
| dayLabel?: number | |
| } | |
| 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, | |
| dayColor: e.dayColor || '#52525B', | |
| dayLabel: e.dayLabel ?? 1, | |
| }) | |
| } | |
| } | |
| items.sort((a, b) => a.time.localeCompare(b.time)) | |
| return items | |
| } | |
| const MARKER_W = 28 | |
| const MARKER_H = 36 | |
| function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string { | |
| const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' | |
| const shadow = highlighted | |
| ? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' | |
| : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' | |
| const label = String(dayLabel) | |
| 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="${dayColor}" stroke="${stroke}" stroke-width="1.5"/> | |
| <circle cx="14" cy="13" r="8" fill="${dayColor}"/> | |
| <text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',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) { | |
| marker.setIcon(L.divIcon({ | |
| className: '', | |
| iconSize: [MARKER_W, MARKER_H], | |
| iconAnchor: [MARKER_W / 2, MARKER_H], | |
| html: markerSvg(item.dayColor, item.dayLabel, false), | |
| })) | |
| marker.setZIndexOffset(0) | |
| } | |
| } | |
| if (id) { | |
| const marker = markersRef.current.get(id) | |
| const item = itemsRef.current.find(i => i.id === id) | |
| if (marker && item) { | |
| marker.setIcon(L.divIcon({ | |
| className: '', | |
| iconSize: [MARKER_W, MARKER_H], | |
| iconAnchor: [MARKER_W / 2, MARKER_H], | |
| html: markerSvg(item.dayColor, item.dayLabel, true), | |
| })) | |
| 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(item.dayColor, item.dayLabel, false), | |
| }) | |
| 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 | |