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 = (
+
+
+
+
+
+ )
+
+ 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,
+ }
+}