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:
Matt 2026-04-26 07:17:33 +00:00
commit 37a5eb5b1b
4 changed files with 111 additions and 30 deletions

View file

@ -610,6 +610,9 @@ const MapView = forwardRef(function MapView(_, ref) {
const route = useStore((s) => s.route) const route = useStore((s) => s.route)
const theme = useStore((s) => s.theme) const theme = useStore((s) => s.theme)
const selectedPlace = useStore((s) => s.selectedPlace) 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 gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission) const geoPermission = useStore((s) => s.geoPermission)
const setSheetState = useStore((s) => s.setSheetState) const setSheetState = useStore((s) => s.setSheetState)
@ -802,45 +805,97 @@ const MapView = forwardRef(function MapView(_, ref) {
map.addControl(new maplibregl.NavigationControl(), 'top-right') map.addControl(new maplibregl.NavigationControl(), 'top-right')
// Map click drop pin and reverse geocode // Map click two-click selection model
map.on('click', (e) => { 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) { if (pinClickedRef.current) {
pinClickedRef.current = false pinClickedRef.current = false
return return
} }
if (window.innerWidth < 768) setSheetState('collapsed') const store = useStore.getState()
const marker = store.clickMarker
const { lng, lat } = e.lngLat 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)
// Immediately set a "Dropped pin" placeholder so PlaceDetail opens with coords if (dist <= marker.circleRadiusPx) {
useStore.getState().setSelectedPlace({ // Inside circle open radial at marker location
lat, const rect = mapRef.current?.getBoundingClientRect()
lon: lng, const screenX = rect ? markerScreen.x + rect.left : markerScreen.x
name: 'Dropped pin', const screenY = rect ? markerScreen.y + rect.top : markerScreen.y
address: null,
type: null,
source: 'map_click',
matchCode: null,
raw: {},
})
// Reverse geocode in background update place when result arrives setRadialMenu({
fetchReverse(lat, lng).then((place) => { open: true,
if (!place) return x: screenX,
// Only update if the selected place is still this pin (user hasn't clicked elsewhere) y: screenY,
const current = useStore.getState().selectedPlace lat: marker.lat,
if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) { lon: marker.lon,
useStore.getState().setSelectedPlace({ centerLabel: store.selectedPlace?.name || null,
...place,
lat,
lon: lng,
}) })
}
})
})
// 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
// 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',
address: null,
type: null,
source: 'map_click',
matchCode: null,
raw: {},
})
// Reverse geocode in background
fetchReverse(lat, lng).then((place) => {
if (!place) return
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', () => {
map.addSource(ROUTE_SOURCE, { map.addSource(ROUTE_SOURCE, {
type: 'geojson', type: 'geojson',
@ -1000,6 +1055,10 @@ const MapView = forwardRef(function MapView(_, ref) {
// Create preview marker // Create preview marker
const el = document.createElement('div') const el = document.createElement('div')
el.className = 'navi-pin-preview' 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 }) previewMarkerRef.current = new maplibregl.Marker({ element: el })
.setLngLat([selectedPlace.lon, selectedPlace.lat]) .setLngLat([selectedPlace.lon, selectedPlace.lat])
.addTo(map) .addTo(map)

View file

@ -50,6 +50,7 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen) const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
const addStop = useStore((s) => s.addStop) const addStop = useStore((s) => s.addStop)
const setSelectedPlace = useStore((s) => s.setSelectedPlace) const setSelectedPlace = useStore((s) => s.setSelectedPlace)
const setClickMarker = useStore((s) => s.setClickMarker)
const setEditingContact = useStore((s) => s.setEditingContact) const setEditingContact = useStore((s) => s.setEditingContact)
const clearPendingDestination = useStore((s) => s.clearPendingDestination) const clearPendingDestination = useStore((s) => s.clearPendingDestination)
const mapCenter = useStore((s) => s.mapCenter) const mapCenter = useStore((s) => s.mapCenter)
@ -165,6 +166,12 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
matchCode: result.match_code, matchCode: result.match_code,
raw: result.raw || {}, 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('') setQuery('')

View file

@ -259,6 +259,7 @@ body {
/* ═══ PREVIEW PIN (selected but not committed) ═══ */ /* ═══ PREVIEW PIN (selected but not committed) ═══ */
.navi-pin-preview { .navi-pin-preview {
position: relative;
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
@ -267,6 +268,17 @@ body {
box-shadow: 0 0 0 3px var(--accent-muted), var(--shadow); box-shadow: 0 0 0 3px var(--accent-muted), var(--shadow);
pointer-events: none; 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 ═══ */ /* ═══ PLACE DETAIL PANEL ═══ */
.navi-place-detail { .navi-place-detail {

View file

@ -60,11 +60,14 @@ export const useStore = create((set, get) => ({
// ── Place detail ── // ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw } 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 gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }), 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 }), setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }), setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }), clearPendingDestination: () => set({ pendingDestination: null }),