mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
288d33ba42
- Defer activeIndex updates until scrolling settles (150ms debounce) instead of updating every RAF — mid-swipe card resize (240→320px) caused layout reflow on every frame, which is the main stutter source - Switch scrollSnapType from 'proximity' to 'mandatory' for reliable browser-native snapping without needing a JS re-center pass - Remove scroll-smooth CSS class (conflicts with mandatory snap) - Remove the post-settle scrollIntoView call (mandatory snap handles it) - Drop the now-unused activeIndexRef Closes #818
245 lines
8.1 KiB
TypeScript
245 lines
8.1 KiB
TypeScript
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<JourneyMapHandle>(null)
|
|
const carouselRef = useRef<HTMLDivElement>(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<string, number>()
|
|
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<Map<number, HTMLDivElement>>(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 (
|
|
<div
|
|
className="fixed left-0 right-0 z-10"
|
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
|
>
|
|
<JourneyMap
|
|
ref={mapRef}
|
|
entries={mapEntries}
|
|
checkins={[]}
|
|
trail={trail}
|
|
height={9999}
|
|
dark={dark}
|
|
onMarkerClick={handleMarkerClick}
|
|
fullScreen
|
|
/>
|
|
{!readOnly && onAddEntry && (
|
|
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
|
<button
|
|
onClick={onAddEntry}
|
|
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
|
>
|
|
<Plus size={20} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="fixed left-0 right-0 z-10"
|
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
|
>
|
|
{/* Full-screen map */}
|
|
<JourneyMap
|
|
ref={mapRef}
|
|
entries={mapEntries}
|
|
checkins={[]}
|
|
trail={trail}
|
|
height={9999}
|
|
dark={dark}
|
|
activeMarkerId={activeEntryId}
|
|
onMarkerClick={handleMarkerClick}
|
|
fullScreen
|
|
paddingBottom={200}
|
|
/>
|
|
|
|
{/* Bottom carousel */}
|
|
<div
|
|
className="fixed left-0 right-0 z-40"
|
|
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
|
>
|
|
<div
|
|
ref={carouselRef}
|
|
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
|
style={{
|
|
scrollSnapType: 'x mandatory',
|
|
WebkitOverflowScrolling: 'touch',
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none',
|
|
}}
|
|
>
|
|
{entries.map((entry: any, i: number) => (
|
|
<div
|
|
key={entry.id}
|
|
data-idx={i}
|
|
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
|
style={{ scrollSnapAlign: 'center' }}
|
|
>
|
|
<MobileEntryCard
|
|
entry={entry}
|
|
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
|
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
|
isActive={i === activeIndex}
|
|
onClick={() => handleCardTap(entry, i)}
|
|
publicPhotoUrl={publicPhotoUrl}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* FAB: add entry — bottom right, above the timeline carousel */}
|
|
{!readOnly && onAddEntry && (
|
|
<div
|
|
className="fixed right-4 z-30"
|
|
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
|
>
|
|
<button
|
|
onClick={onAddEntry}
|
|
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
|
>
|
|
<Plus size={20} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|