From 2e975ea59efe6ae8026e9b086874325e3b386bd6 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 26 Apr 2026 05:42:48 +0000 Subject: [PATCH] feat(map): add radial context menu with reverse-geocode action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements RadialMenu component (general-purpose, configurable wedges) and useContextMenu hook (right-click on desktop, 450ms long-press with 8px movement threshold on touch). First wired action: "What's here" — reverse-geocodes the trigger location and opens the place panel for the result. Remaining wedges (Drop pin, Directions from here, Directions to here, Add as stop, Save place) render but stub to a toast — wiring deferred to follow-up sessions. Per design doc NAVI-DIRECTIONS-REDESIGN.md sections covering Phases a and b of the implementation sequence. --- src/components/MapView.jsx | 125 +++++++++++++++- src/components/RadialMenu.jsx | 270 ++++++++++++++++++++++++++++++++++ src/hooks/useContextMenu.js | 93 ++++++++++++ 3 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 src/components/RadialMenu.jsx create mode 100644 src/hooks/useContextMenu.js diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index e852306..ab4cdf0 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -7,6 +7,10 @@ import { useStore } from '../store' import { decodePolyline } from '../utils/decode' import { fetchReverse } from '../api' import { getConfig, hasFeature } from '../config' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Info } from 'lucide-react' +import RadialMenu from './RadialMenu' +import useContextMenu from '../hooks/useContextMenu' +import toast from 'react-hot-toast' const ROUTE_SOURCE = 'route-source' const ROUTE_LAYER_PREFIX = 'route-layer-' @@ -614,8 +618,114 @@ const MapView = forwardRef(function MapView(_, ref) { // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) + // Radial menu state + const [radialMenu, setRadialMenu] = useState({ + open: false, + x: 0, + y: 0, + lat: 0, + lon: 0, + centerLabel: null, + }) + // Expose map methods to parent - useImperativeHandle(ref, () => ({ + // Radial menu wedges configuration + const radialWedges = [ + { + id: 'drop-pin', + label: 'Drop pin', + icon: MapPin, + onSelect: () => toast('Drop pin coming soon', { icon: '📍' }), + }, + { + id: 'directions-to', + label: 'To here', + icon: ArrowDownLeft, + onSelect: () => toast('Directions to here coming soon', { icon: '🧭' }), + }, + { + id: 'save-place', + label: 'Save', + icon: Star, + requiresAuth: true, + onSelect: () => toast('Save place coming soon', { icon: '⭐' }), + }, + { + id: 'whats-here', + label: "What's here", + icon: Info, + onSelect: async ({ lat, lon }) => { + setRadialMenu((m) => ({ ...m, open: false })) + // Immediately show dropped pin + useStore.getState().setSelectedPlace({ + lat, + lon, + name: 'Dropped pin', + address: null, + type: null, + source: 'radial_menu', + matchCode: null, + raw: {}, + }) + // Reverse geocode in background + const place = await fetchReverse(lat, lon) + if (place) { + const current = useStore.getState().selectedPlace + if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lon) < 0.00001) { + useStore.getState().setSelectedPlace({ ...place, lat, lon }) + } + } + }, + }, + { + id: 'add-stop', + label: 'Add stop', + icon: Plus, + onSelect: () => toast('Add stop coming soon', { icon: '➕' }), + }, + { + id: 'directions-from', + label: 'From here', + icon: ArrowUpRight, + onSelect: () => toast('Directions from here coming soon', { icon: '🧭' }), + }, + ] + + // Context menu trigger handler + const handleContextMenuTrigger = ({ x, y }) => { + const map = mapInstance.current + if (!map || !mapRef.current) return + + // Convert screen coords to lat/lon + const rect = mapRef.current.getBoundingClientRect() + const lngLat = map.unproject([x - rect.left, y - rect.top]) + + setRadialMenu({ + open: true, + x, + y, + lat: lngLat.lat, + lon: lngLat.lng, + centerLabel: null, + }) + + // Async reverse geocode for center label + fetchReverse(lngLat.lat, lngLat.lng).then((place) => { + if (place) { + setRadialMenu((m) => { + if (m.open && Math.abs(m.lat - lngLat.lat) < 0.00001) { + return { ...m, centerLabel: place.name } + } + return m + }) + } + }) + } + + // Context menu hook + const contextMenuHandlers = useContextMenu(handleContextMenuTrigger) + + useImperativeHandle(ref, () => ({ flyTo(lat, lon, zoom = 14) { mapInstance.current?.flyTo({ center: [lon, lat], zoom }) }, @@ -1141,7 +1251,7 @@ const MapView = forwardRef(function MapView(_, ref) { return (
-
+
{/* Zoom level indicator - bottom-left corner */}
Z {zoomLevel.toFixed(1)}
+ {/* Radial context menu */} + setRadialMenu((m) => ({ ...m, open: false }))} + />
) }) diff --git a/src/components/RadialMenu.jsx b/src/components/RadialMenu.jsx new file mode 100644 index 0000000..4cff975 --- /dev/null +++ b/src/components/RadialMenu.jsx @@ -0,0 +1,270 @@ +import { useEffect, useRef, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { Lock } from 'lucide-react' + +/** + * RadialMenu - ATAK-style radial context menu + * + * Props: + * - open: boolean + * - x, y: screen coordinates of trigger point + * - lat, lon: geographic coordinates + * - wedges: array of { id, label, icon: LucideIcon, onSelect, requiresAuth? } + * - centerLabel: string (coords by default, replaced by reverse-geocode async) + * - onDismiss: callback when menu should close + */ +export default function RadialMenu({ + open, + x, + y, + lat, + lon, + wedges = [], + centerLabel, + onDismiss, +}) { + const containerRef = useRef(null) + const activeWedgeRef = useRef(null) + + // Geometry constants + const outerRadius = 80 + const innerRadius = 40 + const wedgeCount = wedges.length || 6 + const wedgeAngle = 360 / wedgeCount + + // Handle escape key + useEffect(() => { + if (!open) return + const handleKey = (e) => { + if (e.key === 'Escape') { + onDismiss?.() + } + } + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) + }, [open, onDismiss]) + + // Handle click outside + useEffect(() => { + if (!open) return + const handleClick = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) { + onDismiss?.() + } + } + // Delay to avoid triggering on the same click that opened the menu + const timer = setTimeout(() => { + window.addEventListener('click', handleClick) + }, 50) + return () => { + clearTimeout(timer) + window.removeEventListener('click', handleClick) + } + }, [open, onDismiss]) + + // Calculate which wedge the pointer is over + const getWedgeAtPoint = useCallback((clientX, clientY) => { + const dx = clientX - x + const dy = clientY - y + const dist = Math.sqrt(dx * dx + dy * dy) + + // Inside inner radius = center (no wedge) + if (dist < innerRadius) return null + // Outside outer radius = no wedge + if (dist > outerRadius + 20) return null + + // Calculate angle (0 = top, clockwise) + let angle = Math.atan2(dx, -dy) * (180 / Math.PI) + if (angle < 0) angle += 360 + + // Find which wedge + const wedgeIndex = Math.floor(angle / wedgeAngle) + return wedges[wedgeIndex] || null + }, [x, y, wedges, wedgeAngle]) + + // Handle mouse/touch move for highlighting + const handlePointerMove = useCallback((e) => { + const clientX = e.touches ? e.touches[0].clientX : e.clientX + const clientY = e.touches ? e.touches[0].clientY : e.clientY + activeWedgeRef.current = getWedgeAtPoint(clientX, clientY) + // Force re-render for highlight + containerRef.current?.querySelectorAll('.radial-wedge').forEach((el, i) => { + if (wedges[i] && wedges[i].id === activeWedgeRef.current?.id) { + el.classList.add('active') + } else { + el.classList.remove('active') + } + }) + }, [getWedgeAtPoint, wedges]) + + // Handle release + const handlePointerUp = useCallback((e) => { + const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX + const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY + const wedge = getWedgeAtPoint(clientX, clientY) + + if (wedge) { + wedge.onSelect?.({ lat, lon }) + } + onDismiss?.() + }, [getWedgeAtPoint, lat, lon, onDismiss]) + + // Generate wedge paths + const generateWedgePath = (index) => { + const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180) + const endAngle = ((index + 1) * wedgeAngle - 90) * (Math.PI / 180) + + const x1 = innerRadius * Math.cos(startAngle) + const y1 = innerRadius * Math.sin(startAngle) + const x2 = outerRadius * Math.cos(startAngle) + const y2 = outerRadius * Math.sin(startAngle) + const x3 = outerRadius * Math.cos(endAngle) + const y3 = outerRadius * Math.sin(endAngle) + const x4 = innerRadius * Math.cos(endAngle) + const y4 = innerRadius * Math.sin(endAngle) + + return `M ${x1} ${y1} L ${x2} ${y2} A ${outerRadius} ${outerRadius} 0 0 1 ${x3} ${y3} L ${x4} ${y4} A ${innerRadius} ${innerRadius} 0 0 0 ${x1} ${y1} Z` + } + + // Calculate icon position for each wedge + const getIconPosition = (index) => { + const midAngle = ((index + 0.5) * wedgeAngle - 90) * (Math.PI / 180) + const r = (innerRadius + outerRadius) / 2 + return { + x: r * Math.cos(midAngle), + y: r * Math.sin(midAngle), + } + } + + if (!open) return null + + // Clamp position to viewport + const padding = outerRadius + 20 + const clampedX = Math.max(padding, Math.min(window.innerWidth - padding, x)) + const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y)) + + const content = ( +
+ + {/* Wedges */} + {wedges.map((wedge, i) => { + const iconPos = getIconPosition(i) + const Icon = wedge.icon + return ( + + + + {Icon && ( + + )} + {wedge.requiresAuth && ( + + )} + + {wedge.label} + + + + ) + })} + + {/* Center disc */} + + + {lat?.toFixed(4)} + + + {lon?.toFixed(4)} + + {centerLabel && ( + + {centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} + + )} + + + +
+ ) + + return createPortal(content, document.body) +} diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js new file mode 100644 index 0000000..ca41169 --- /dev/null +++ b/src/hooks/useContextMenu.js @@ -0,0 +1,93 @@ +import { useRef, useCallback } from 'react' + +/** + * useContextMenu - Combined right-click and long-press trigger + * + * Returns event handlers to attach to a container element. + * Fires onTrigger with { x, y, originalEvent } when: + * - Right-click (desktop) - immediate + * - Long-press 450ms with <8px movement (touch) - after delay + * + * @param {function} onTrigger - Callback receiving { x, y, originalEvent } + * @param {object} options + * @param {number} options.delay - Long-press delay in ms (default 450) + * @param {number} options.moveThreshold - Max movement in px before abort (default 8) + */ +export default function useContextMenu(onTrigger, options = {}) { + const { delay = 450, moveThreshold = 8 } = options + + const timerRef = useRef(null) + const startPosRef = useRef({ x: 0, y: 0 }) + const triggeredRef = useRef(false) + + // Clear any pending timer + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + // Right-click handler (desktop) + const onContextMenu = useCallback((e) => { + e.preventDefault() + onTrigger?.({ + x: e.clientX, + y: e.clientY, + originalEvent: e, + }) + }, [onTrigger]) + + // Touch start - begin long-press timer + const onTouchStart = useCallback((e) => { + if (e.touches.length !== 1) return + + const touch = e.touches[0] + startPosRef.current = { x: touch.clientX, y: touch.clientY } + triggeredRef.current = false + + clearTimer() + timerRef.current = setTimeout(() => { + if (!triggeredRef.current) { + triggeredRef.current = true + onTrigger?.({ + x: startPosRef.current.x, + y: startPosRef.current.y, + originalEvent: e, + }) + } + }, delay) + }, [onTrigger, delay, clearTimer]) + + // Touch move - abort if moved too far + const onTouchMove = useCallback((e) => { + if (!timerRef.current || triggeredRef.current) return + + const touch = e.touches[0] + const dx = touch.clientX - startPosRef.current.x + const dy = touch.clientY - startPosRef.current.y + const dist = Math.sqrt(dx * dx + dy * dy) + + if (dist > moveThreshold) { + clearTimer() + } + }, [moveThreshold, clearTimer]) + + // Touch end - clear timer + const onTouchEnd = useCallback(() => { + clearTimer() + }, [clearTimer]) + + // Touch cancel - clear timer + const onTouchCancel = useCallback(() => { + clearTimer() + }, [clearTimer]) + + return { + onContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel, + } +}