diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 6ca4238..85b4458 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState, useMemo } from 'react' +import { useEffect, useRef, useState, useMemo, useCallback } from 'react' import DOM from 'react-dom' -import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet' +import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' import L from 'leaflet' import 'leaflet.markercluster/dist/MarkerCluster.css' @@ -240,6 +240,96 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { const mapPhotoCache = new Map() const mapPhotoInFlight = new Set() +// Live location tracker — blue dot with pulse animation (like Apple/Google Maps) +function LocationTracker() { + const map = useMap() + const [position, setPosition] = useState<[number, number] | null>(null) + const [accuracy, setAccuracy] = useState(0) + const [tracking, setTracking] = useState(false) + const watchId = useRef(null) + + const startTracking = useCallback(() => { + if (!('geolocation' in navigator)) return + setTracking(true) + watchId.current = navigator.geolocation.watchPosition( + (pos) => { + const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude] + setPosition(latlng) + setAccuracy(pos.coords.accuracy) + }, + () => setTracking(false), + { enableHighAccuracy: true, maximumAge: 5000 } + ) + }, []) + + const stopTracking = useCallback(() => { + if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) + watchId.current = null + setTracking(false) + setPosition(null) + }, []) + + const toggleTracking = useCallback(() => { + if (tracking) { stopTracking() } else { startTracking() } + }, [tracking, startTracking, stopTracking]) + + // Center map on position when first acquired + const centered = useRef(false) + useEffect(() => { + if (position && !centered.current) { + map.setView(position, 15) + centered.current = true + } + }, [position, map]) + + // Cleanup on unmount + useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, []) + + return ( + <> + {/* Location button */} +
+ +
+ + {/* Blue dot + accuracy circle */} + {position && ( + <> + {accuracy < 500 && ( + + )} + + + )} + + {/* Pulse animation CSS */} + {position && ( + + )} + + ) +} + export function MapView({ places = [], dayPlaces = [], @@ -318,6 +408,7 @@ export function MapView({ + ({ type: 'place' as const, sortKey: a.order_index, data: a })), + // Build base list: untimed places + notes sorted by order_index/sort_order + const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null) + const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null) + + const baseItems = [ + ...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })), ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })), - ...transport.map(r => ({ - type: 'transport' as const, - sortKey: r.day_plan_position ?? computeTransportPosition(r, da), - data: r, - })), ].sort((a, b) => a.sortKey - b.sortKey) + + // Timed places + transports: compute sortKeys based on time, inserted among base items + const allTimed = [ + ...timedPlaces.map(a => ({ type: 'place' as const, data: a, minutes: parseTimeToMinutes(a.place?.place_time)! })), + ...transport.map(r => ({ type: 'transport' as const, data: r, minutes: parseTimeToMinutes(r.reservation_time) ?? 0 })), + ].sort((a, b) => a.minutes - b.minutes) + + if (allTimed.length === 0) return baseItems + if (baseItems.length === 0) { + return allTimed.map((item, i) => ({ ...item, sortKey: i })) + } + + // Insert timed items among base items using time-to-position mapping. + // Each timed item finds the last base place whose order_index corresponds + // to a reasonable position, then gets a fractional sortKey after it. + const result = [...baseItems] + for (let ti = 0; ti < allTimed.length; ti++) { + const timed = allTimed[ti] + const minutes = timed.minutes + + // For transports, use persisted position if available + if (timed.type === 'transport' && timed.data.day_plan_position != null) { + result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data }) + continue + } + + // Find insertion position: after the last base item with time <= this item's time + let insertAfterKey = -Infinity + for (const item of result) { + if (item.type === 'place') { + const pm = parseTimeToMinutes(item.data?.place?.place_time) + if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey + } else if (item.type === 'transport') { + const tm = parseTimeToMinutes(item.data?.reservation_time) + if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey + } + } + + const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0 + const sortKey = insertAfterKey === -Infinity + ? lastKey + 0.5 + ti * 0.01 + : insertAfterKey + 0.01 + ti * 0.001 + + result.push({ type: timed.type, sortKey, data: timed.data }) + } + + return result.sort((a, b) => a.sortKey - b.sortKey) } const openAddNote = (dayId, e) => {