fix: Save wedge highlights when authenticated

This commit is contained in:
Matt 2026-04-29 00:08:08 +00:00
commit cb7c6d1497

View file

@ -1,357 +1,362 @@
import { useEffect, useRef, useCallback } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom' 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. * RadialMenu - ATAK-style radial context menu
* * Themed to match Navi light/dark palette using CSS custom properties.
* Props: *
* - open: boolean * Props:
* - x, y: screen coordinates of trigger point * - open: boolean
* - lat, lon: geographic coordinates * - x, y: screen coordinates of trigger point
* - wedges: array of { id, label, icon: LucideIcon, onSelect, requiresAuth? } * - lat, lon: geographic coordinates
* - centerLabel: string (coords by default, replaced by reverse-geocode async) * - wedges: array of { id, label, icon: LucideIcon, onSelect, requiresAuth? }
* - onDismiss: callback when menu should close * - centerLabel: string (coords by default, replaced by reverse-geocode async)
*/ * - onDismiss: callback when menu should close
export default function RadialMenu({ */
open, export default function RadialMenu({
x, open,
y, x,
lat, y,
lon, lat,
wedges = [], lon,
centerLabel, wedges = [],
onDismiss, centerLabel,
}) { onDismiss,
const containerRef = useRef(null) }) {
const activeWedgeRef = useRef(null) const containerRef = useRef(null)
const activeWedgeRef = useRef(null)
// Geometry constants const auth = useStore((s) => s.auth)
const outerRadius = 80 const isAuthenticated = auth?.authenticated ?? false
const innerRadius = 40
const wedgeCount = wedges.length || 6 // Geometry constants
const wedgeAngle = 360 / wedgeCount const outerRadius = 80
const innerRadius = 40
// Handle escape key const wedgeCount = wedges.length || 6
useEffect(() => { const wedgeAngle = 360 / wedgeCount
if (!open) return
const handleKey = (e) => { // Handle escape key
if (e.key === 'Escape') { useEffect(() => {
onDismiss?.() if (!open) return
} const handleKey = (e) => {
} if (e.key === 'Escape') {
window.addEventListener('keydown', handleKey) onDismiss?.()
return () => window.removeEventListener('keydown', handleKey) }
}, [open, onDismiss]) }
window.addEventListener('keydown', handleKey)
// Calculate which wedge the pointer is over return () => window.removeEventListener('keydown', handleKey)
const getWedgeAtPoint = useCallback((clientX, clientY) => { }, [open, onDismiss])
const dx = clientX - x
const dy = clientY - y // Calculate which wedge the pointer is over
const dist = Math.sqrt(dx * dx + dy * dy) const getWedgeAtPoint = useCallback((clientX, clientY) => {
const dx = clientX - x
// Inside inner radius = center (no wedge) const dy = clientY - y
if (dist < innerRadius) return null const dist = Math.sqrt(dx * dx + dy * dy)
// Outside outer radius = no wedge
if (dist > outerRadius + 20) return null // Inside inner radius = center (no wedge)
if (dist < innerRadius) return null
// Calculate angle (0 = top, clockwise) // Outside outer radius = no wedge
let angle = Math.atan2(dx, -dy) * (180 / Math.PI) if (dist > outerRadius + 20) return null
if (angle < 0) angle += 360
// Calculate angle (0 = top, clockwise)
// Find which wedge let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
const wedgeIndex = Math.floor(angle / wedgeAngle) if (angle < 0) angle += 360
return wedges[wedgeIndex] || null
}, [x, y, wedges, wedgeAngle]) // Find which wedge
const wedgeIndex = Math.floor(angle / wedgeAngle)
// Handle mouse/touch move for highlighting return wedges[wedgeIndex] || null
const handlePointerMove = useCallback((e) => { }, [x, y, wedges, wedgeAngle])
const clientX = e.touches ? e.touches[0].clientX : e.clientX
const clientY = e.touches ? e.touches[0].clientY : e.clientY // Handle mouse/touch move for highlighting
activeWedgeRef.current = getWedgeAtPoint(clientX, clientY) const handlePointerMove = useCallback((e) => {
// Force re-render for highlight const clientX = e.touches ? e.touches[0].clientX : e.clientX
containerRef.current?.querySelectorAll('.radial-wedge').forEach((el, i) => { const clientY = e.touches ? e.touches[0].clientY : e.clientY
if (wedges[i] && wedges[i].id === activeWedgeRef.current?.id) { activeWedgeRef.current = getWedgeAtPoint(clientX, clientY)
el.classList.add('active') // Force re-render for highlight
} else { containerRef.current?.querySelectorAll('.radial-wedge').forEach((el, i) => {
el.classList.remove('active') if (wedges[i] && wedges[i].id === activeWedgeRef.current?.id) {
} el.classList.add('active')
}) } else {
}, [getWedgeAtPoint, wedges]) el.classList.remove('active')
}
// Handle release })
const handlePointerUp = useCallback((e) => { }, [getWedgeAtPoint, wedges])
const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX
const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY // Handle release
const wedge = getWedgeAtPoint(clientX, clientY) const handlePointerUp = useCallback((e) => {
const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX
if (wedge) { const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY
wedge.onSelect?.({ lat, lon }) const wedge = getWedgeAtPoint(clientX, clientY)
}
onDismiss?.() if (wedge) {
}, [getWedgeAtPoint, lat, lon, onDismiss]) wedge.onSelect?.({ lat, lon })
}
// Handle backdrop click (dismiss menu) onDismiss?.()
const handleBackdropClick = useCallback((e) => { }, [getWedgeAtPoint, lat, lon, onDismiss])
e.stopPropagation()
onDismiss?.() // Handle backdrop click (dismiss menu)
}, [onDismiss]) const handleBackdropClick = useCallback((e) => {
e.stopPropagation()
// Prevent menu container clicks from reaching backdrop onDismiss?.()
const handleContainerClick = useCallback((e) => { }, [onDismiss])
e.stopPropagation()
}, []) // Prevent menu container clicks from reaching backdrop
const handleContainerClick = useCallback((e) => {
// Generate wedge paths e.stopPropagation()
const generateWedgePath = (index) => { }, [])
const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180)
const endAngle = ((index + 1) * wedgeAngle - 90) * (Math.PI / 180) // Generate wedge paths
const generateWedgePath = (index) => {
const x1 = innerRadius * Math.cos(startAngle) const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180)
const y1 = innerRadius * Math.sin(startAngle) const endAngle = ((index + 1) * wedgeAngle - 90) * (Math.PI / 180)
const x2 = outerRadius * Math.cos(startAngle)
const y2 = outerRadius * Math.sin(startAngle) const x1 = innerRadius * Math.cos(startAngle)
const x3 = outerRadius * Math.cos(endAngle) const y1 = innerRadius * Math.sin(startAngle)
const y3 = outerRadius * Math.sin(endAngle) const x2 = outerRadius * Math.cos(startAngle)
const x4 = innerRadius * Math.cos(endAngle) const y2 = outerRadius * Math.sin(startAngle)
const y4 = innerRadius * Math.sin(endAngle) const x3 = outerRadius * Math.cos(endAngle)
const y3 = outerRadius * 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` const x4 = innerRadius * Math.cos(endAngle)
} const y4 = innerRadius * Math.sin(endAngle)
// Calculate icon position for each wedge 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`
const getIconPosition = (index) => { }
const midAngle = ((index + 0.5) * wedgeAngle - 90) * (Math.PI / 180)
const r = (innerRadius + outerRadius) / 2 // Calculate icon position for each wedge
return { const getIconPosition = (index) => {
x: r * Math.cos(midAngle), const midAngle = ((index + 0.5) * wedgeAngle - 90) * (Math.PI / 180)
y: r * Math.sin(midAngle), 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 if (!open) return null
const clampedX = Math.max(padding, Math.min(window.innerWidth - padding, x))
const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y)) // Clamp position to viewport
const padding = outerRadius + 20
const content = ( const clampedX = Math.max(padding, Math.min(window.innerWidth - padding, x))
<> const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y))
{/* Full-screen backdrop for dismiss — matches modal overlay opacity */}
<div const content = (
className="radial-backdrop" <>
onClick={handleBackdropClick} {/* Full-screen backdrop for dismiss — matches modal overlay opacity */}
onContextMenu={handleBackdropClick} <div
/> className="radial-backdrop"
onClick={handleBackdropClick}
{/* Radial menu container */} onContextMenu={handleBackdropClick}
<div />
ref={containerRef}
className="radial-menu-container" {/* Radial menu container */}
onClick={handleContainerClick} <div
style={{ ref={containerRef}
position: 'fixed', className="radial-menu-container"
left: clampedX, onClick={handleContainerClick}
top: clampedY, style={{
zIndex: 9999, position: 'fixed',
transform: 'translate(-50%, -50%)', left: clampedX,
animation: 'radialFadeIn 100ms ease-out', top: clampedY,
filter: 'drop-shadow(var(--shadow-lg))', zIndex: 9999,
}} transform: 'translate(-50%, -50%)',
onMouseMove={handlePointerMove} animation: 'radialFadeIn 100ms ease-out',
onMouseUp={handlePointerUp} filter: 'drop-shadow(var(--shadow-lg))',
onTouchMove={handlePointerMove} }}
onTouchEnd={handlePointerUp} onMouseMove={handlePointerMove}
> onMouseUp={handlePointerUp}
<svg onTouchMove={handlePointerMove}
width={outerRadius * 2 + 40} onTouchEnd={handlePointerUp}
height={outerRadius * 2 + 40} >
viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`} <svg
style={{ overflow: 'visible' }} width={outerRadius * 2 + 40}
> height={outerRadius * 2 + 40}
{/* Wedges */} viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`}
{wedges.map((wedge, i) => { style={{ overflow: 'visible' }}
const iconPos = getIconPosition(i) >
const Icon = wedge.icon {/* Wedges */}
const wedgeClasses = `radial-wedge${wedge.requiresAuth ? ' auth-required' : ''}` {wedges.map((wedge, i) => {
return ( const iconPos = getIconPosition(i)
<g key={wedge.id} className={wedgeClasses} data-wedge-id={wedge.id}> const Icon = wedge.icon
<path // Only apply auth-required styling when requiresAuth AND user is NOT authenticated
d={generateWedgePath(i)} const needsAuth = wedge.requiresAuth && !isAuthenticated
className="wedge-path" const wedgeClasses = `radial-wedge${needsAuth ? ' auth-required' : ''}`
/> return (
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}> <g key={wedge.id} className={wedgeClasses} data-wedge-id={wedge.id}>
{Icon && ( <path
<foreignObject d={generateWedgePath(i)}
x={-9} className="wedge-path"
y={-12} />
width={18} <g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
height={18} {Icon && (
style={{ overflow: 'visible' }} <foreignObject
> x={-9}
<Icon y={-12}
size={18} width={18}
className="wedge-icon" height={18}
strokeWidth={1.5} style={{ overflow: 'visible' }}
/> >
</foreignObject> <Icon
)} size={18}
<text className="wedge-icon"
y={10} strokeWidth={1.5}
textAnchor="middle" />
className="wedge-label" </foreignObject>
> )}
{wedge.label} <text
</text> y={10}
</g> textAnchor="middle"
</g> className="wedge-label"
) >
})} {wedge.label}
</text>
{/* Center disc */} </g>
<circle </g>
cx={0} )
cy={0} })}
r={innerRadius - 2}
className="center-disc" {/* Center disc */}
/> <circle
<text cx={0}
y={-4} cy={0}
textAnchor="middle" r={innerRadius - 2}
className="center-coords" className="center-disc"
> />
{lat?.toFixed(4)} <text
</text> y={-4}
<text textAnchor="middle"
y={8} className="center-coords"
textAnchor="middle" >
className="center-coords" {lat?.toFixed(4)}
> </text>
{lon?.toFixed(4)} <text
</text> y={8}
{centerLabel && ( textAnchor="middle"
<text className="center-coords"
y={20} >
textAnchor="middle" {lon?.toFixed(4)}
className="center-label" </text>
> {centerLabel && (
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} <text
</text> y={20}
)} textAnchor="middle"
</svg> className="center-label"
>
<style>{` {centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
/* Backdrop — matches modal overlay */ </text>
.radial-backdrop { )}
position: fixed; </svg>
inset: 0;
z-index: 9998; <style>{`
background: rgba(0, 0, 0, 0.4); /* Backdrop — matches modal overlay */
cursor: default; .radial-backdrop {
} position: fixed;
inset: 0;
/* Wedge paths — themed surface */ z-index: 9998;
.wedge-path { background: rgba(0, 0, 0, 0.4);
fill: var(--bg-overlay); cursor: default;
fill-opacity: 0.92; }
stroke: var(--border);
stroke-width: 1; /* Wedge paths — themed surface */
transition: fill 100ms ease, fill-opacity 100ms ease; .wedge-path {
} fill: var(--bg-overlay);
fill-opacity: 0.92;
.radial-wedge:hover .wedge-path { stroke: var(--border);
fill: var(--accent-muted); stroke-width: 1;
fill-opacity: 1; transition: fill 100ms ease, fill-opacity 100ms ease;
} }
.radial-wedge.active .wedge-path { .radial-wedge:hover .wedge-path {
fill: var(--accent-muted); fill: var(--accent-muted);
fill-opacity: 1; fill-opacity: 1;
} }
/* Wedge icons — secondary text color */ .radial-wedge.active .wedge-path {
.wedge-icon { fill: var(--accent-muted);
color: var(--text-secondary); fill-opacity: 1;
transition: color 100ms ease; }
}
/* Wedge icons — secondary text color */
.radial-wedge:hover .wedge-icon, .wedge-icon {
.radial-wedge.active .wedge-icon { color: var(--text-secondary);
color: var(--text-primary); transition: color 100ms ease;
} }
/* Wedge labels — secondary text */ .radial-wedge:hover .wedge-icon,
.wedge-label { .radial-wedge.active .wedge-icon {
font-family: var(--font-sans); color: var(--text-primary);
font-size: 9px; }
fill: var(--text-secondary);
pointer-events: none; /* Wedge labels — secondary text */
transition: fill 100ms ease; .wedge-label {
} font-family: var(--font-sans);
font-size: 9px;
.radial-wedge:hover .wedge-label, fill: var(--text-secondary);
.radial-wedge.active .wedge-label { pointer-events: none;
fill: var(--text-primary); transition: fill 100ms ease;
} }
/* Auth-required wedges — grayed out */ .radial-wedge:hover .wedge-label,
.radial-wedge.auth-required .wedge-icon { .radial-wedge.active .wedge-label {
color: var(--text-tertiary); fill: var(--text-primary);
} }
.radial-wedge.auth-required .wedge-label { /* Auth-required wedges — grayed out (only when NOT authenticated) */
fill: var(--text-tertiary); .radial-wedge.auth-required .wedge-icon {
} color: var(--text-tertiary);
}
/* Auth-required wedges — suppress hover highlight (still clickable) */
.radial-wedge.auth-required:hover .wedge-path, .radial-wedge.auth-required .wedge-label {
.radial-wedge.auth-required.active .wedge-path { fill: var(--text-tertiary);
fill: var(--bg-overlay); }
fill-opacity: 0.92;
} /* Auth-required wedges — suppress hover highlight (still clickable) */
.radial-wedge.auth-required:hover .wedge-path,
/* Auth-required hover — content stays muted */ .radial-wedge.auth-required.active .wedge-path {
.radial-wedge.auth-required:hover .wedge-icon, fill: var(--bg-overlay);
.radial-wedge.auth-required.active .wedge-icon { fill-opacity: 0.92;
color: var(--text-tertiary); }
}
/* Auth-required hover — content stays muted */
.radial-wedge.auth-required:hover .wedge-label, .radial-wedge.auth-required:hover .wedge-icon,
.radial-wedge.auth-required.active .wedge-label { .radial-wedge.auth-required.active .wedge-icon {
fill: var(--text-tertiary); color: var(--text-tertiary);
} }
/* Center disc — raised surface */ .radial-wedge.auth-required:hover .wedge-label,
.center-disc { .radial-wedge.auth-required.active .wedge-label {
fill: var(--bg-raised); fill: var(--text-tertiary);
stroke: var(--border); }
stroke-width: 1;
} /* Center disc — raised surface */
.center-disc {
/* Center coordinates — monospace primary */ fill: var(--bg-raised);
.center-coords { stroke: var(--border);
font-family: var(--font-mono); stroke-width: 1;
font-size: 10px; }
fill: var(--text-primary);
} /* Center coordinates — monospace primary */
.center-coords {
/* Center label — secondary italic */ font-family: var(--font-mono);
.center-label { font-size: 10px;
font-family: var(--font-sans); fill: var(--text-primary);
font-size: 9px; }
font-style: italic;
fill: var(--text-secondary); /* Center label — secondary italic */
} .center-label {
font-family: var(--font-sans);
@keyframes radialFadeIn { font-size: 9px;
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } font-style: italic;
to { opacity: 1; transform: translate(-50%, -50%) scale(1); } fill: var(--text-secondary);
} }
`}</style>
</div> @keyframes radialFadeIn {
</> from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
) to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
return createPortal(content, document.body) `}</style>
} </div>
</>
)
return createPortal(content, document.body)
}