222 lines
6.4 KiB
TypeScript
222 lines
6.4 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
interface CompassSelectorProps {
|
|
value: number // 0-360 degrees
|
|
onChange: (value: number) => void
|
|
range?: number // Optional range to display as arc (±degrees)
|
|
size?: number
|
|
className?: string
|
|
}
|
|
|
|
export function CompassSelector({ value, onChange, range, size = 200, className = '' }: CompassSelectorProps) {
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const compassRef = useRef<SVGSVGElement>(null)
|
|
|
|
const handlePointerMove = (e: PointerEvent) => {
|
|
if (!isDragging || !compassRef.current) return
|
|
|
|
const rect = compassRef.current.getBoundingClientRect()
|
|
const centerX = rect.left + rect.width / 2
|
|
const centerY = rect.top + rect.height / 2
|
|
|
|
const dx = e.clientX - centerX
|
|
const dy = e.clientY - centerY
|
|
|
|
// Calculate angle in degrees (0° = North/top)
|
|
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
|
|
if (angle < 0) angle += 360
|
|
|
|
// Round to nearest 5 degrees for easier selection
|
|
angle = Math.round(angle / 5) * 5
|
|
|
|
onChange(angle % 360)
|
|
}
|
|
|
|
const handlePointerDown = (e: React.PointerEvent) => {
|
|
setIsDragging(true)
|
|
// Trigger initial update
|
|
const rect = compassRef.current!.getBoundingClientRect()
|
|
const centerX = rect.left + rect.width / 2
|
|
const centerY = rect.top + rect.height / 2
|
|
|
|
const dx = e.clientX - centerX
|
|
const dy = e.clientY - centerY
|
|
|
|
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
|
|
if (angle < 0) angle += 360
|
|
angle = Math.round(angle / 5) * 5
|
|
|
|
onChange(angle % 360)
|
|
}
|
|
|
|
const handlePointerUp = () => {
|
|
setIsDragging(false)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (isDragging) {
|
|
window.addEventListener('pointermove', handlePointerMove)
|
|
window.addEventListener('pointerup', handlePointerUp)
|
|
|
|
return () => {
|
|
window.removeEventListener('pointermove', handlePointerMove)
|
|
window.removeEventListener('pointerup', handlePointerUp)
|
|
}
|
|
}
|
|
}, [isDragging])
|
|
|
|
// Convert degrees to radians for calculations
|
|
const angleRad = (value - 90) * (Math.PI / 180)
|
|
const radius = size / 2 - 10
|
|
const needleLength = radius * 0.7
|
|
|
|
// Calculate needle endpoint
|
|
const needleX = size / 2 + Math.cos(angleRad) * needleLength
|
|
const needleY = size / 2 + Math.sin(angleRad) * needleLength
|
|
|
|
// Helper to convert degrees to compass direction
|
|
const getCompassDirection = (degrees: number): string => {
|
|
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
|
const index = Math.round(degrees / 22.5) % 16
|
|
return directions[index]
|
|
}
|
|
|
|
return (
|
|
<div className={`flex flex-col items-center ${className}`}>
|
|
<svg
|
|
ref={compassRef}
|
|
width={size}
|
|
height={size}
|
|
className="cursor-pointer select-none"
|
|
onPointerDown={handlePointerDown}
|
|
style={{ touchAction: 'none' }}
|
|
>
|
|
{/* Outer circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="hsl(var(--secondary))"
|
|
stroke="hsl(var(--border))"
|
|
strokeWidth="2"
|
|
/>
|
|
|
|
{/* Acceptable range arc (green) */}
|
|
{range !== undefined && (() => {
|
|
const startAngle = value - range
|
|
const endAngle = value + range
|
|
|
|
// Convert to radians for calculation (adjusting for SVG coordinate system)
|
|
const startRad = (startAngle - 90) * (Math.PI / 180)
|
|
const endRad = (endAngle - 90) * (Math.PI / 180)
|
|
|
|
// Calculate start and end points on the circle
|
|
const x1 = size / 2 + Math.cos(startRad) * radius
|
|
const y1 = size / 2 + Math.sin(startRad) * radius
|
|
const x2 = size / 2 + Math.cos(endRad) * radius
|
|
const y2 = size / 2 + Math.sin(endRad) * radius
|
|
|
|
// Determine if the arc should be large (>180°) or small
|
|
const largeArcFlag = range * 2 > 180 ? 1 : 0
|
|
|
|
// Create SVG arc path
|
|
const arcPath = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`
|
|
|
|
return (
|
|
<path
|
|
d={arcPath}
|
|
fill="none"
|
|
stroke="#22c55e"
|
|
strokeWidth="6"
|
|
strokeLinecap="round"
|
|
/>
|
|
)
|
|
})()}
|
|
|
|
{/* Cardinal direction markers */}
|
|
{[
|
|
{ angle: 0, label: 'N' },
|
|
{ angle: 90, label: 'E' },
|
|
{ angle: 180, label: 'S' },
|
|
{ angle: 270, label: 'W' },
|
|
].map(({ angle, label }) => {
|
|
const rad = (angle - 90) * (Math.PI / 180)
|
|
const x = size / 2 + Math.cos(rad) * (radius - 20)
|
|
const y = size / 2 + Math.sin(rad) * (radius - 20)
|
|
|
|
return (
|
|
<text
|
|
key={angle}
|
|
x={x}
|
|
y={y}
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
className="text-sm font-bold fill-foreground"
|
|
>
|
|
{label}
|
|
</text>
|
|
)
|
|
})}
|
|
|
|
{/* Degree tick marks every 30° */}
|
|
{Array.from({ length: 12 }, (_, i) => {
|
|
const angle = i * 30
|
|
const rad = (angle - 90) * (Math.PI / 180)
|
|
const x1 = size / 2 + Math.cos(rad) * (radius - 5)
|
|
const y1 = size / 2 + Math.sin(rad) * (radius - 5)
|
|
const x2 = size / 2 + Math.cos(rad) * radius
|
|
const y2 = size / 2 + Math.sin(rad) * radius
|
|
|
|
return (
|
|
<line
|
|
key={angle}
|
|
x1={x1}
|
|
y1={y1}
|
|
x2={x2}
|
|
y2={y2}
|
|
stroke="hsl(var(--border))"
|
|
strokeWidth="2"
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{/* Center dot */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r="4"
|
|
fill="hsl(var(--primary))"
|
|
/>
|
|
|
|
{/* Direction needle */}
|
|
<line
|
|
x1={size / 2}
|
|
y1={size / 2}
|
|
x2={needleX}
|
|
y2={needleY}
|
|
stroke="hsl(var(--primary))"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
/>
|
|
|
|
{/* Needle tip */}
|
|
<circle
|
|
cx={needleX}
|
|
cy={needleY}
|
|
r="6"
|
|
fill="hsl(var(--primary))"
|
|
stroke="hsl(var(--background))"
|
|
strokeWidth="2"
|
|
/>
|
|
</svg>
|
|
|
|
{/* Value display */}
|
|
<div className="mt-2 text-center">
|
|
<div className="text-lg font-bold">{value}° ({getCompassDirection(value)})</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|