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