mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-21 07:04:45 +02:00
270 lines
8.5 KiB
React
270 lines
8.5 KiB
React
|
|
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)
|
||
|
|
}
|