diff --git a/src/components/RadialMenu.jsx b/src/components/RadialMenu.jsx index b0da0f4..3079791 100644 --- a/src/components/RadialMenu.jsx +++ b/src/components/RadialMenu.jsx @@ -1,357 +1,362 @@ -import { useEffect, useRef, useCallback } from 'react' -import { createPortal } from 'react-dom' - -/** - * RadialMenu - ATAK-style radial context menu - * Themed to match Navi light/dark palette using CSS custom properties. - * - * 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]) - - // 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]) - - // Handle backdrop click (dismiss menu) - const handleBackdropClick = useCallback((e) => { - e.stopPropagation() - onDismiss?.() - }, [onDismiss]) - - // Prevent menu container clicks from reaching backdrop - const handleContainerClick = useCallback((e) => { - e.stopPropagation() - }, []) - - // 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 = ( - <> - {/* Full-screen backdrop for dismiss — matches modal overlay opacity */} -
- - {/* Radial menu container */} -
- - {/* Wedges */} - {wedges.map((wedge, i) => { - const iconPos = getIconPosition(i) - const Icon = wedge.icon - const wedgeClasses = `radial-wedge${wedge.requiresAuth ? ' auth-required' : ''}` - return ( - - - - {Icon && ( - - - - )} - - {wedge.label} - - - - ) - })} - - {/* Center disc */} - - - {lat?.toFixed(4)} - - - {lon?.toFixed(4)} - - {centerLabel && ( - - {centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} - - )} - - - - - - ) - - return createPortal(content, document.body) -} +import { useEffect, useRef, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { useStore } from '../store' + +/** + * RadialMenu - ATAK-style radial context menu + * Themed to match Navi light/dark palette using CSS custom properties. + * + * 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) + const auth = useStore((s) => s.auth) + const isAuthenticated = auth?.authenticated ?? false + + // 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]) + + // 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]) + + // Handle backdrop click (dismiss menu) + const handleBackdropClick = useCallback((e) => { + e.stopPropagation() + onDismiss?.() + }, [onDismiss]) + + // Prevent menu container clicks from reaching backdrop + const handleContainerClick = useCallback((e) => { + e.stopPropagation() + }, []) + + // 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 = ( + <> + {/* Full-screen backdrop for dismiss — matches modal overlay opacity */} +
+ + {/* Radial menu container */} +
+ + {/* Wedges */} + {wedges.map((wedge, i) => { + const iconPos = getIconPosition(i) + const Icon = wedge.icon + // Only apply auth-required styling when requiresAuth AND user is NOT authenticated + const needsAuth = wedge.requiresAuth && !isAuthenticated + const wedgeClasses = `radial-wedge${needsAuth ? ' auth-required' : ''}` + return ( + + + + {Icon && ( + + + + )} + + {wedge.label} + + + + ) + })} + + {/* Center disc */} + + + {lat?.toFixed(4)} + + + {lon?.toFixed(4)} + + {centerLabel && ( + + {centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} + + )} + + + + + + ) + + return createPortal(content, document.body) +}