import { useRef, useState, useEffect, useCallback, useMemo } from 'react' import { Plus } from 'lucide-react' import JourneyMap from './JourneyMap' import MobileEntryCard from './MobileEntryCard' import type { JourneyMapHandle } from './JourneyMap' import type { JourneyEntry } from '../../store/journeyStore' import { DAY_COLORS } from './dayColors' interface MapEntry { id: string lat: number lng: number title?: string | null mood?: string | null entry_date: string } interface Props { entries: JourneyEntry[] | any[] mapEntries: MapEntry[] trail?: { lat: number; lng: number }[] dark?: boolean readOnly?: boolean onEntryClick: (entry: any) => void onAddEntry?: () => void publicPhotoUrl?: (photoId: number) => string carouselBottom?: string } export default function MobileMapTimeline({ entries, mapEntries, trail, dark, readOnly, onEntryClick, onAddEntry, publicPhotoUrl, carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)', }: Props) { const mapRef = useRef(null) const carouselRef = useRef(null) const [activeIndex, setActiveIndex] = useState(0) const entryDayMeta = useMemo(() => { const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())] const counters = new Map() return entries.map((e: any) => { const dayIdx = uniqueDates.indexOf(e.entry_date) const dayLabel = (counters.get(e.entry_date) ?? 0) + 1 counters.set(e.entry_date, dayLabel) return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] } }) }, [entries]) const cardRefs = useRef>(new Map()) // Sync map focus when carousel scrolls (with guard for uninitialized map) const syncMapToCarousel = useCallback((index: number) => { const entry = entries[index] if (!entry) return const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id)) if (mapEntry) { try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {} } else { try { mapRef.current?.highlightMarker(null) } catch {} } }, [entries, mapEntries]) // Pick the card that's currently closest to the carousel horizontal center. // More stable than IntersectionObserver thresholds when the active card can // drift toward the viewport edge with proximity snapping. const pickNearestCard = useCallback(() => { const el = carouselRef.current if (!el) return const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2 let bestIdx = 0 let bestDist = Infinity cardRefs.current.forEach((node, idx) => { const r = node.getBoundingClientRect() const cardCenter = r.left + r.width / 2 const d = Math.abs(cardCenter - containerCenter) if (d < bestDist) { bestDist = d; bestIdx = idx } }) setActiveIndex(prev => { if (prev !== bestIdx) syncMapToCarousel(bestIdx) return bestIdx }) }, [syncMapToCarousel]) // Defer all state updates until scrolling settles — updating activeIndex // mid-swipe resizes cards (240→320px), causing layout reflow every frame. useEffect(() => { const el = carouselRef.current if (!el || entries.length === 0) return let settleTimer: number | null = null const onScroll = () => { if (settleTimer != null) window.clearTimeout(settleTimer) settleTimer = window.setTimeout(pickNearestCard, 150) } el.addEventListener('scroll', onScroll, { passive: true }) return () => { el.removeEventListener('scroll', onScroll) if (settleTimer != null) window.clearTimeout(settleTimer) } }, [entries.length, pickNearestCard]) // Scroll a given card into the horizontal center of the carousel const scrollCardIntoCenter = useCallback((idx: number) => { const card = cardRefs.current.get(idx) card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) }, []) // Scroll carousel to entry when map marker is clicked const handleMarkerClick = useCallback((id: string) => { const idx = entries.findIndex((e: any) => String(e.id) === id) if (idx === -1) return setActiveIndex(idx) scrollCardIntoCenter(idx) }, [entries, scrollCardIntoCenter]) // Tap on a card: if it's already active, open the edit view; otherwise // activate + center it first (don't jump straight into the editor). const handleCardTap = useCallback((entry: any, idx: number) => { if (idx === activeIndex) { onEntryClick(entry) } else { setActiveIndex(idx) scrollCardIntoCenter(idx) } }, [activeIndex, onEntryClick, scrollCardIntoCenter]) // Initial map focus — delay to let Leaflet initialize and fitBounds useEffect(() => { if (entries.length > 0) { const timer = setTimeout(() => syncMapToCarousel(0), 500) return () => clearTimeout(timer) } }, [entries.length]) const activeEntryId = entries[activeIndex] ? String(entries[activeIndex].id) : null if (entries.length === 0) { return (
{!readOnly && onAddEntry && (
)}
) } return (
{/* Full-screen map */} {/* Bottom carousel */}
{entries.map((entry: any, i: number) => (
{ if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }} style={{ scrollSnapAlign: 'center' }} > handleCardTap(entry, i)} publicPhotoUrl={publicPhotoUrl} />
))}
{/* FAB: add entry — bottom right, above the timeline carousel */} {!readOnly && onAddEntry && (
)}
) }