feat(map): add radial context menu with reverse-geocode action

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.
This commit is contained in:
Matt 2026-04-26 05:42:48 +00:00
commit 2e975ea59e
3 changed files with 486 additions and 2 deletions

View file

@ -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,7 +618,113 @@ 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
// 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 (
<div className="relative w-full h-full">
<div ref={mapRef} className="w-full h-full" />
<div ref={mapRef} className="w-full h-full" {...contextMenuHandlers} />
{/* Zoom level indicator - bottom-left corner */}
<div
className="absolute bottom-4 left-4 z-50 px-2 py-1 rounded-full text-xs font-mono pointer-events-none"
@ -1155,6 +1265,17 @@ const MapView = forwardRef(function MapView(_, ref) {
>
Z {zoomLevel.toFixed(1)}
</div>
{/* Radial context menu */}
<RadialMenu
open={radialMenu.open}
x={radialMenu.x}
y={radialMenu.y}
lat={radialMenu.lat}
lon={radialMenu.lon}
wedges={radialWedges}
centerLabel={radialMenu.centerLabel}
onDismiss={() => setRadialMenu((m) => ({ ...m, open: false }))}
/>
</div>
)
})

View file

@ -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 = (
<div
ref={containerRef}
className="radial-menu-container"
style={{
position: 'fixed',
left: clampedX,
top: clampedY,
zIndex: 9999,
transform: 'translate(-50%, -50%)',
animation: 'radialFadeIn 100ms ease-out',
}}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
>
<svg
width={outerRadius * 2 + 40}
height={outerRadius * 2 + 40}
viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`}
style={{ overflow: 'visible' }}
>
{/* Wedges */}
{wedges.map((wedge, i) => {
const iconPos = getIconPosition(i)
const Icon = wedge.icon
return (
<g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}>
<path
d={generateWedgePath(i)}
fill="rgba(30, 28, 26, 0.85)"
stroke="rgba(180, 160, 140, 0.3)"
strokeWidth="1"
style={{ transition: 'fill 100ms ease' }}
className="wedge-path"
/>
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
{Icon && (
<Icon
size={18}
stroke="rgba(230, 220, 210, 0.9)"
strokeWidth={1.5}
style={{ transform: 'translate(-9px, -12px)' }}
/>
)}
{wedge.requiresAuth && (
<Lock
size={10}
stroke="rgba(230, 220, 210, 0.6)"
strokeWidth={1.5}
style={{ transform: 'translate(4px, -14px)' }}
/>
)}
<text
y={10}
textAnchor="middle"
fontSize="9"
fill="rgba(230, 220, 210, 0.8)"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{wedge.label}
</text>
</g>
</g>
)
})}
{/* Center disc */}
<circle
cx={0}
cy={0}
r={innerRadius - 2}
fill="rgba(50, 45, 40, 0.95)"
stroke="rgba(180, 160, 140, 0.4)"
strokeWidth="1"
/>
<text
y={-4}
textAnchor="middle"
fontSize="10"
fontFamily="monospace"
fill="rgba(230, 220, 210, 0.9)"
>
{lat?.toFixed(4)}
</text>
<text
y={8}
textAnchor="middle"
fontSize="10"
fontFamily="monospace"
fill="rgba(230, 220, 210, 0.9)"
>
{lon?.toFixed(4)}
</text>
{centerLabel && (
<text
y={20}
textAnchor="middle"
fontSize="9"
fill="rgba(200, 180, 160, 0.9)"
style={{ fontStyle: 'italic' }}
>
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
</text>
)}
</svg>
<style>{`
.radial-wedge.active .wedge-path {
fill: rgba(180, 160, 140, 0.4) !important;
}
.radial-wedge:hover .wedge-path {
fill: rgba(180, 160, 140, 0.3);
}
@keyframes radialFadeIn {
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
`}</style>
</div>
)
return createPortal(content, document.body)
}

View file

@ -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,
}
}