feat: live GPS location on map + auto-sort timed places — closes #136
Live location: - Crosshair button on the map toggles GPS tracking - Blue dot shows live position with accuracy circle (<500m) - Uses watchPosition for continuous updates - Button turns blue when active, click again to stop Auto-sort: - Places with a time now auto-sort chronologically among other timed items (transports, other timed places) - Adding a time to a place immediately moves it to the correct position in the timeline - Untimed places keep their manual order_index
This commit is contained in:
@@ -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 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 MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||||
@@ -240,6 +240,96 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|||||||
const mapPhotoCache = new Map()
|
const mapPhotoCache = new Map()
|
||||||
const mapPhotoInFlight = new Set()
|
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<number | null>(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 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
<button onClick={toggleTracking} style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
|
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
|
||||||
|
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'background 0.2s, color 0.2s',
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blue dot + accuracy circle */}
|
||||||
|
{position && (
|
||||||
|
<>
|
||||||
|
{accuracy < 500 && (
|
||||||
|
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
|
||||||
|
)}
|
||||||
|
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pulse animation CSS */}
|
||||||
|
{position && (
|
||||||
|
<style>{`
|
||||||
|
@keyframes location-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.6; }
|
||||||
|
100% { transform: scale(2.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MapView({
|
export function MapView({
|
||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
@@ -318,6 +408,7 @@ export function MapView({
|
|||||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||||
<MapClickHandler onClick={onMapClick} />
|
<MapClickHandler onClick={onMapClick} />
|
||||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||||
|
<LocationTracker />
|
||||||
|
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
chunkedLoading
|
chunkedLoading
|
||||||
|
|||||||
@@ -266,19 +266,61 @@ export default function DayPlanSidebar({
|
|||||||
initTransportPositions(dayId)
|
initTransportPositions(dayId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// All items use the same sortKey space:
|
// Build base list: untimed places + notes sorted by order_index/sort_order
|
||||||
// - Places: order_index (0, 1, 2, ...)
|
const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null)
|
||||||
// - Notes: sort_order (floats between place indices)
|
const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null)
|
||||||
// - Transports: day_plan_position (persisted float)
|
|
||||||
return [
|
const baseItems = [
|
||||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
...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 })),
|
...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)
|
].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) => {
|
const openAddNote = (dayId, e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user