import { useCallback, useId, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { type CourseOutputMode, type CourseStep, dialDegreesToStorageValue, formatCourseAngle, formatCourseDisplay, isCardinalDirection, loadCourseDialStep, parseCourseAngle, pointerAngleToDegrees, resolveCourseOutputMode, saveCourseDialStep, snapDegrees, valueToDialDegrees } from '../utils/courseAngle.js' interface CourseDialInputProps { value: string onChange: (value: string) => void disabled?: boolean step?: CourseStep allowCardinal?: boolean displayMode?: 'degrees' | 'cardinal' | 'auto' size?: 'md' | 'sm' 'aria-label': string id?: string } const TICK_DEGREES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330] function polarPoint(degrees: number, radius: number): { x: number; y: number } { const rad = (degrees * Math.PI) / 180 return { x: 100 + Math.sin(rad) * radius, y: 100 - Math.cos(rad) * radius } } export default function CourseDialInput({ value, onChange, disabled = false, step: stepProp, allowCardinal = false, displayMode = 'degrees', size = 'md', 'aria-label': ariaLabel, id: idProp }: CourseDialInputProps) { const { t } = useTranslation() const generatedId = useId() const inputId = idProp ?? `${generatedId}-input` const svgRef = useRef(null) const [step, setStep] = useState(() => stepProp ?? loadCourseDialStep()) const [inputDraft, setInputDraft] = useState(null) const [inputError, setInputError] = useState(null) const [outputModeOverride, setOutputModeOverride] = useState(null) const effectiveStep = stepProp ?? step const outputMode = outputModeOverride ?? resolveCourseOutputMode(value, displayMode, allowCardinal) const dialDegrees = useMemo( () => snapDegrees(valueToDialDegrees(value, allowCardinal), effectiveStep), [value, allowCardinal, effectiveStep] ) const centerLabel = useMemo( () => formatCourseDisplay(value, allowCardinal), [value, allowCardinal] ) const tickLabel = useCallback( (degrees: number) => { if (degrees === 0) return t('logs.compass_n') if (degrees === 90) return t('logs.compass_e') if (degrees === 180) return t('logs.compass_s') if (degrees === 270) return t('logs.compass_w') return String(degrees).padStart(3, '0') }, [t] ) const applyDegrees = useCallback( (degrees: number) => { onChange(dialDegreesToStorageValue(degrees, outputMode, effectiveStep)) setInputDraft(null) setInputError(null) }, [onChange, outputMode, effectiveStep] ) const updateFromPointer = useCallback( (clientX: number, clientY: number) => { const svg = svgRef.current if (!svg || disabled) return const rect = svg.getBoundingClientRect() const cx = rect.left + rect.width / 2 const cy = rect.top + rect.height / 2 const raw = pointerAngleToDegrees(clientX, clientY, cx, cy) applyDegrees(raw) }, [applyDegrees, disabled] ) const handlePointerDown = (e: React.PointerEvent) => { if (disabled) return e.preventDefault() e.currentTarget.setPointerCapture(e.pointerId) updateFromPointer(e.clientX, e.clientY) } const handlePointerMove = (e: React.PointerEvent) => { if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return updateFromPointer(e.clientX, e.clientY) } const handlePointerUp = (e: React.PointerEvent) => { if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId) } } const handleInputChange = (e: React.ChangeEvent) => { setInputDraft(e.target.value) } const commitInput = () => { const draft = (inputDraft ?? value).trim() setInputDraft(null) if (!draft) { onChange('') setInputError(null) return } if (allowCardinal && outputMode === 'cardinal' && isCardinalDirection(draft)) { onChange(draft.toUpperCase()) setInputError(null) return } const parsed = parseCourseAngle(draft) if (parsed === null) { setInputError(t('logs.course_invalid')) return } onChange(formatCourseAngle(snapDegrees(parsed, effectiveStep))) setInputError(null) } const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() commitInput() return } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() const base = parseCourseAngle(value) ?? dialDegrees const delta = e.key === 'ArrowUp' ? effectiveStep : -effectiveStep applyDegrees(base + delta) } } const handleStepChange = (next: CourseStep) => { if (stepProp !== undefined) return setStep(next) saveCourseDialStep(next) const parsed = parseCourseAngle(value) if (parsed !== null) { onChange(formatCourseAngle(snapDegrees(parsed, next))) } } const toggleOutputMode = () => { const next: CourseOutputMode = outputMode === 'cardinal' ? 'degrees' : 'cardinal' setOutputModeOverride(next) const deg = valueToDialDegrees(value, allowCardinal) onChange(dialDegreesToStorageValue(deg, next, effectiveStep)) } const inputValue = inputDraft ?? value const sliderNow = dialDegrees return (
{!stepProp && (
{([1, 5, 10] as const).map((s) => ( ))}
)}
{TICK_DEGREES.map((deg) => { const inner = polarPoint(deg, 76) const outer = polarPoint(deg, 88) const label = polarPoint(deg, 64) return ( {tickLabel(deg)} ) })} {centerLabel}

{t('logs.course_dial_hint')}

{inputError &&

{inputError}

} {allowCardinal && displayMode === 'auto' && ( )}
) }