mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
feat(navi): copy popover + map-click pin drop with reverse geocode
Replaces Share button with Copy dropdown (Address / Coordinates). Map single-click drops preview pin, opens PlaceDetail with "Dropped pin" placeholder, reverse geocodes via /api/reverse to fill in address. Stop-pin clicks preserved via flag ref. Escape key closes PlaceDetail. Double-click zoom unaffected. Phase 3 Step 5 C1.5 of Navi. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
02f2b25db3
commit
a819458865
3 changed files with 183 additions and 26 deletions
31
src/api.js
31
src/api.js
|
|
@ -110,3 +110,34 @@ export async function fetchElevation(lat, lon) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REVERSE_URL = "/api/reverse"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse geocode a point. Returns a place object or null.
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @returns {Promise<{lat, lon, name, address, type, source, raw}|null>}
|
||||||
|
*/
|
||||||
|
export async function fetchReverse(lat, lon) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) })
|
||||||
|
const resp = await fetch(`${REVERSE_URL}?${params}`, { timeout: 5000 })
|
||||||
|
if (!resp.ok) return null
|
||||||
|
const data = await resp.json()
|
||||||
|
if (!data.results || data.results.length === 0) return null
|
||||||
|
const r = data.results[0]
|
||||||
|
return {
|
||||||
|
lat: r.lat,
|
||||||
|
lon: r.lon,
|
||||||
|
name: r.name,
|
||||||
|
address: null,
|
||||||
|
type: r.type,
|
||||||
|
source: r.source,
|
||||||
|
matchCode: null,
|
||||||
|
raw: r.raw || {},
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Protocol } from 'pmtiles'
|
||||||
import { layers, namedTheme } from 'protomaps-themes-base'
|
import { layers, namedTheme } from 'protomaps-themes-base'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { decodePolyline } from '../utils/decode'
|
import { decodePolyline } from '../utils/decode'
|
||||||
|
import { fetchReverse } from '../api'
|
||||||
|
|
||||||
const ROUTE_SOURCE = 'route-source'
|
const ROUTE_SOURCE = 'route-source'
|
||||||
const ROUTE_LAYER_PREFIX = 'route-layer-'
|
const ROUTE_LAYER_PREFIX = 'route-layer-'
|
||||||
|
|
@ -41,6 +42,8 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
const previewMarkerRef = useRef(null)
|
const previewMarkerRef = useRef(null)
|
||||||
const watchIdRef = useRef(null)
|
const watchIdRef = useRef(null)
|
||||||
const currentThemeRef = useRef('dark')
|
const currentThemeRef = useRef('dark')
|
||||||
|
// Flag to suppress map-click when a stop pin was clicked
|
||||||
|
const pinClickedRef = useRef(false)
|
||||||
|
|
||||||
const stops = useStore((s) => s.stops)
|
const stops = useStore((s) => s.stops)
|
||||||
const route = useStore((s) => s.route)
|
const route = useStore((s) => s.route)
|
||||||
|
|
@ -110,9 +113,43 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
map.on('click', () => {
|
// Map click — drop pin and reverse geocode
|
||||||
|
map.on('click', (e) => {
|
||||||
|
// If a stop pin was just clicked, skip the pin-drop
|
||||||
|
if (pinClickedRef.current) {
|
||||||
|
pinClickedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (window.innerWidth < 768) setSheetState('collapsed')
|
if (window.innerWidth < 768) setSheetState('collapsed')
|
||||||
useStore.getState().clearSelectedPlace()
|
|
||||||
|
const { lng, lat } = e.lngLat
|
||||||
|
|
||||||
|
// Immediately set a "Dropped pin" placeholder so PlaceDetail opens with coords
|
||||||
|
useStore.getState().setSelectedPlace({
|
||||||
|
lat,
|
||||||
|
lon: lng,
|
||||||
|
name: 'Dropped pin',
|
||||||
|
address: null,
|
||||||
|
type: null,
|
||||||
|
source: 'map_click',
|
||||||
|
matchCode: null,
|
||||||
|
raw: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reverse geocode in background — update place when result arrives
|
||||||
|
fetchReverse(lat, lng).then((place) => {
|
||||||
|
if (!place) return
|
||||||
|
// Only update if the selected place is still this pin (user hasn't clicked elsewhere)
|
||||||
|
const current = useStore.getState().selectedPlace
|
||||||
|
if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) {
|
||||||
|
useStore.getState().setSelectedPlace({
|
||||||
|
...place,
|
||||||
|
lat,
|
||||||
|
lon: lng,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
|
|
@ -204,8 +241,10 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
|
|
||||||
if (!selectedPlace) return
|
if (!selectedPlace) return
|
||||||
|
|
||||||
// Fly to selected place
|
// Only fly to place if it came from search (not map-click which already centered)
|
||||||
|
if (selectedPlace.source !== 'map_click') {
|
||||||
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
|
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
|
||||||
|
}
|
||||||
|
|
||||||
// Create preview marker
|
// Create preview marker
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
|
|
@ -342,6 +381,8 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
|
|
||||||
el.addEventListener('click', (e) => {
|
el.addEventListener('click', (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
// Flag so the map-level click handler doesn't fire
|
||||||
|
pinClickedRef.current = true
|
||||||
if (popupRef.current) popupRef.current.remove()
|
if (popupRef.current) popupRef.current.remove()
|
||||||
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
|
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
|
||||||
.setLngLat([stop.lon, stop.lat])
|
.setLngLat([stop.lon, stop.lat])
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
import { X, Navigation, Plus, Bookmark, Share2 } from 'lucide-react'
|
import { X, Navigation, Plus, Bookmark, ChevronDown, Copy } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { fetchElevation } from '../api'
|
import { fetchElevation } from '../api'
|
||||||
|
|
@ -15,6 +15,77 @@ function buildAddress(place) {
|
||||||
return parts.join(', ') || null
|
return parts.join(', ') || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Copy popover — small dropdown beneath the Copy button */
|
||||||
|
function CopyPopover({ address, selectedPlace, onClose }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
// Close on click-outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) onClose()
|
||||||
|
}
|
||||||
|
function handleKey(e) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClick)
|
||||||
|
document.removeEventListener('keydown', handleKey)
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const copyAddress = () => {
|
||||||
|
const text = [selectedPlace.name, address].filter(Boolean).join('\n')
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => toast('Address copied'),
|
||||||
|
() => toast.error('Failed to copy')
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCoords = () => {
|
||||||
|
const text = `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => toast('Coordinates copied'),
|
||||||
|
() => toast.error('Failed to copy')
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-overlay)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={address ? copyAddress : undefined}
|
||||||
|
disabled={!address}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-xs"
|
||||||
|
style={{
|
||||||
|
color: address ? 'var(--text-primary)' : 'var(--text-tertiary)',
|
||||||
|
cursor: address ? 'pointer' : 'not-allowed',
|
||||||
|
}}
|
||||||
|
title={!address ? 'No address available' : undefined}
|
||||||
|
>
|
||||||
|
Address
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={copyCoords}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
Coordinates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function PlaceDetail() {
|
export default function PlaceDetail() {
|
||||||
const selectedPlace = useStore((s) => s.selectedPlace)
|
const selectedPlace = useStore((s) => s.selectedPlace)
|
||||||
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
||||||
|
|
@ -25,6 +96,9 @@ export default function PlaceDetail() {
|
||||||
|
|
||||||
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
const [copyForPlace, setCopyForPlace] = useState(null)
|
||||||
|
|
||||||
|
const closeCopy = useCallback(() => setCopyForPlace(null), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setIsMobile(window.innerWidth < 768)
|
const check = () => setIsMobile(window.innerWidth < 768)
|
||||||
|
|
@ -33,6 +107,17 @@ export default function PlaceDetail() {
|
||||||
return () => window.removeEventListener('resize', check)
|
return () => window.removeEventListener('resize', check)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
// Escape key closes panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPlace) return
|
||||||
|
function handleKey(e) {
|
||||||
|
if (e.key === 'Escape') clearSelectedPlace()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [selectedPlace, clearSelectedPlace])
|
||||||
|
|
||||||
// Fetch elevation when place changes
|
// Fetch elevation when place changes
|
||||||
const placeLat = selectedPlace?.lat
|
const placeLat = selectedPlace?.lat
|
||||||
const placeLon = selectedPlace?.lon
|
const placeLon = selectedPlace?.lon
|
||||||
|
|
@ -49,6 +134,7 @@ export default function PlaceDetail() {
|
||||||
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
|
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
|
||||||
const elevation = !elevLoading ? elevResult.value : null
|
const elevation = !elevLoading ? elevResult.value : null
|
||||||
|
|
||||||
|
const placeKey = selectedPlace ? `${selectedPlace.lat},${selectedPlace.lon}` : null
|
||||||
if (!selectedPlace) return null
|
if (!selectedPlace) return null
|
||||||
|
|
||||||
const address = buildAddress(selectedPlace)
|
const address = buildAddress(selectedPlace)
|
||||||
|
|
@ -82,18 +168,6 @@ export default function PlaceDetail() {
|
||||||
toast('Saved places coming soon')
|
toast('Saved places coming soon')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleShare = () => {
|
|
||||||
const text = [
|
|
||||||
selectedPlace.name,
|
|
||||||
address,
|
|
||||||
`${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`,
|
|
||||||
].filter(Boolean).join('\n')
|
|
||||||
navigator.clipboard.writeText(text).then(
|
|
||||||
() => toast('Copied to clipboard'),
|
|
||||||
() => toast.error('Failed to copy')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const panelContent = (
|
const panelContent = (
|
||||||
<>
|
<>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
|
|
@ -191,14 +265,25 @@ export default function PlaceDetail() {
|
||||||
<Bookmark size={14} />
|
<Bookmark size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Copy dropdown */}
|
||||||
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleShare}
|
onClick={() => setCopyForPlace((v) => v === placeKey ? null : placeKey)}
|
||||||
className="p-2 rounded-lg"
|
className="p-2 rounded-lg flex items-center gap-0.5"
|
||||||
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
|
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
|
||||||
aria-label="Share place"
|
aria-label="Copy"
|
||||||
>
|
>
|
||||||
<Share2 size={14} />
|
<Copy size={14} />
|
||||||
|
<ChevronDown size={10} />
|
||||||
</button>
|
</button>
|
||||||
|
{copyForPlace === placeKey && (
|
||||||
|
<CopyPopover
|
||||||
|
address={address}
|
||||||
|
selectedPlace={selectedPlace}
|
||||||
|
onClose={closeCopy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue