From 37a5eb5b1b8c092dc74e28451b2ce11fee62cc17 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 26 Apr 2026 07:17:33 +0000 Subject: [PATCH] feat(map): two-click selection model with precise center dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/MapView.jsx | 115 ++++++++++++++++++++++++++--------- src/components/SearchBar.jsx | 7 +++ src/index.css | 12 ++++ src/store.js | 5 +- 4 files changed, 110 insertions(+), 29 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 9715c8d..4116f1d 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -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,45 +805,97 @@ 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 } - 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 - useStore.getState().setSelectedPlace({ - lat, - lon: lng, - name: 'Dropped pin', - address: null, - type: null, - source: 'map_click', - matchCode: null, - raw: {}, - }) + 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 - // 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, + 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 + + // 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.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) diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index e1fd1af..4b7a454 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -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('') diff --git a/src/index.css b/src/index.css index 8fca9fe..1a87652 100644 --- a/src/index.css +++ b/src/index.css @@ -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 { diff --git a/src/store.js b/src/store.js index 8a1097d..03aabc8 100644 --- a/src/store.js +++ b/src/store.js @@ -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 }),