import mapboxgl from 'mapbox-gl'; import 'mapbox-gl/dist/mapbox-gl.css'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; import { useSettingsStore } from '../../store/settingsStore'; import { addCustom3dBuildings, addTerrainAndSky, isStandardFamily, supportsCustom3d, wantsTerrain, } 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, '''); } 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 = ` ${label} `; wrap.appendChild(inner); return wrap; } const EMPTY_TRAIL: { lat: number; lng: number }[] = []; const JourneyMapGL = forwardRef(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(null); const mapRef = useRef(null); const markersRef = useRef>(new Map()); const itemsRef = useRef([]); const highlightedRef = useRef(null); const popupRef = useRef(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(`${place}`); if (date) subParts.push(`${date}`); const subline = subParts.length === 2 ? `${subParts[0]}\u00B7${subParts[1]}` : subParts.join(''); const html = `
${primary}
${subline ? `
${subline}
` : ''} `; // 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 (
No Mapbox access token configured.
Settings → Map → Mapbox GL
); } return (
); }); export default JourneyMapGL;