mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
feat(map): two-click selection model with precise center dot
Replaces 'every click selects something' with a deliberate two-click flow: - First click drops marker (existing circle plus new precise center dot) and opens place panel - Second click INSIDE the marker circle opens the radial menu - Second click OUTSIDE the circle deselects without selecting the new spot — requires another click to select The 4px filled center dot at exact click coordinates gives precise visual feedback for GPS-coord readout. The existing circle's radius defines the same-spot tolerance, visually showing the radial-trigger hit area. Right-click radial unchanged. Search-dropdown selection drops a marker for consistency.
This commit is contained in:
parent
a11fc13b33
commit
37a5eb5b1b
4 changed files with 111 additions and 30 deletions
|
|
@ -610,6 +610,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
const route = useStore((s) => s.route)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const selectedPlace = useStore((s) => s.selectedPlace)
|
||||
const clickMarker = useStore((s) => s.clickMarker)
|
||||
const setClickMarker = useStore((s) => s.setClickMarker)
|
||||
const clearClickMarker = useStore((s) => s.clearClickMarker)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
|
|
@ -802,20 +805,73 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
|
||||
// Map click — drop pin and reverse geocode
|
||||
// Map click — two-click selection model
|
||||
map.on('click', (e) => {
|
||||
// If a stop pin was just clicked, skip the pin-drop
|
||||
// If a stop pin was just clicked, skip
|
||||
if (pinClickedRef.current) {
|
||||
pinClickedRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const store = useStore.getState()
|
||||
const marker = store.clickMarker
|
||||
|
||||
if (marker) {
|
||||
// State B: marker present — check if click is inside the circle
|
||||
const markerScreen = map.project([marker.lon, marker.lat])
|
||||
const dx = e.point.x - markerScreen.x
|
||||
const dy = e.point.y - markerScreen.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (dist <= marker.circleRadiusPx) {
|
||||
// Inside circle → open radial at marker location
|
||||
const rect = mapRef.current?.getBoundingClientRect()
|
||||
const screenX = rect ? markerScreen.x + rect.left : markerScreen.x
|
||||
const screenY = rect ? markerScreen.y + rect.top : markerScreen.y
|
||||
|
||||
setRadialMenu({
|
||||
open: true,
|
||||
x: screenX,
|
||||
y: screenY,
|
||||
lat: marker.lat,
|
||||
lon: marker.lon,
|
||||
centerLabel: store.selectedPlace?.name || null,
|
||||
})
|
||||
|
||||
// Fetch reverse geocode for center label if not already loaded
|
||||
if (!store.selectedPlace?.name || store.selectedPlace.name === 'Dropped pin') {
|
||||
fetchReverse(marker.lat, marker.lon).then((place) => {
|
||||
if (place) {
|
||||
setRadialMenu((m) => {
|
||||
if (m.open && Math.abs(m.lat - marker.lat) < 0.00001) {
|
||||
return { ...m, centerLabel: place.name }
|
||||
}
|
||||
return m
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Outside circle → deselect, no new selection
|
||||
store.clearClickMarker()
|
||||
store.clearSelectedPlace()
|
||||
}
|
||||
} else {
|
||||
// State A: nothing selected → select
|
||||
if (window.innerWidth < 768) setSheetState('collapsed')
|
||||
|
||||
const { lng, lat } = e.lngLat
|
||||
const MARKER_RADIUS_PX = 14 // half of 28px preview marker
|
||||
|
||||
// Immediately set a "Dropped pin" placeholder so PlaceDetail opens with coords
|
||||
useStore.getState().setSelectedPlace({
|
||||
// Set click marker
|
||||
store.setClickMarker({
|
||||
lat,
|
||||
lon: lng,
|
||||
circleRadiusPx: MARKER_RADIUS_PX,
|
||||
})
|
||||
|
||||
// Immediately set a "Dropped pin" placeholder
|
||||
store.setSelectedPlace({
|
||||
lat,
|
||||
lon: lng,
|
||||
name: 'Dropped pin',
|
||||
|
|
@ -826,10 +882,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
raw: {},
|
||||
})
|
||||
|
||||
// Reverse geocode in background — update place when result arrives
|
||||
// Reverse geocode in background
|
||||
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({
|
||||
|
|
@ -839,8 +894,8 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource(ROUTE_SOURCE, {
|
||||
type: 'geojson',
|
||||
|
|
@ -1000,6 +1055,10 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
// Create preview marker
|
||||
const el = document.createElement('div')
|
||||
el.className = 'navi-pin-preview'
|
||||
// Add precise center dot
|
||||
const dot = document.createElement('div')
|
||||
dot.className = 'navi-pin-center-dot'
|
||||
el.appendChild(dot)
|
||||
previewMarkerRef.current = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([selectedPlace.lon, selectedPlace.lat])
|
||||
.addTo(map)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
|
|||
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
|
||||
const addStop = useStore((s) => s.addStop)
|
||||
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
||||
const setClickMarker = useStore((s) => s.setClickMarker)
|
||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
|
||||
const mapCenter = useStore((s) => s.mapCenter)
|
||||
|
|
@ -165,6 +166,12 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
|
|||
matchCode: result.match_code,
|
||||
raw: result.raw || {},
|
||||
})
|
||||
// Set click marker for two-click model consistency
|
||||
setClickMarker({
|
||||
lat: result.lat,
|
||||
lon: result.lon,
|
||||
circleRadiusPx: 14, // matches preview marker size
|
||||
})
|
||||
}
|
||||
|
||||
setQuery('')
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ body {
|
|||
|
||||
/* ═══ PREVIEW PIN (selected but not committed) ═══ */
|
||||
.navi-pin-preview {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
|
|
@ -267,6 +268,17 @@ body {
|
|||
box-shadow: 0 0 0 3px var(--accent-muted), var(--shadow);
|
||||
pointer-events: none;
|
||||
}
|
||||
.navi-pin-center-dot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ═══ PLACE DETAIL PANEL ═══ */
|
||||
.navi-place-detail {
|
||||
|
|
|
|||
|
|
@ -60,11 +60,14 @@ export const useStore = create((set, get) => ({
|
|||
|
||||
// ── Place detail ──
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
|
||||
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
|
||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||
|
||||
setSelectedPlace: (place) => set({ selectedPlace: place }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
|
||||
setClickMarker: (marker) => set({ clickMarker: marker }),
|
||||
clearClickMarker: () => set({ clickMarker: null }),
|
||||
setGpsOrigin: (val) => set({ gpsOrigin: val }),
|
||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue