Files
kapteins-daagbok/client/src/utils/courseAngle.ts
T
elpatron 9e03fcda0a feat(logs): Kompass-Dial für Kurs- und Windeingabe
Ersetzt Textfelder für MgK, rwK und Wind durch einen mobilen Kompass-Ring, normalisiert Kurswinkel beim Speichern und führt Vitest mit Regressionstests für html lang ein.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:08:36 +02:00

161 lines
4.5 KiB
TypeScript

export const CARDINAL_DIRECTIONS = [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
] as const
export type CardinalDirection = (typeof CARDINAL_DIRECTIONS)[number]
export type CourseStep = 1 | 5 | 10
const CARDINAL_SET = new Set<string>(CARDINAL_DIRECTIONS)
export function isCardinalDirection(value: string): boolean {
return CARDINAL_SET.has(value.trim().toUpperCase())
}
export function cardinalToDegrees(label: string): number | null {
const upper = label.trim().toUpperCase()
const index = CARDINAL_DIRECTIONS.indexOf(upper as CardinalDirection)
if (index < 0) return null
return (index * 22.5) % 360
}
export function degreesToCardinal(degrees: number): CardinalDirection {
const normalized = ((degrees % 360) + 360) % 360
const index = Math.round(normalized / 22.5) % 16
return CARDINAL_DIRECTIONS[index]
}
export function snapDegrees(degrees: number, step: CourseStep): number {
const normalized = ((degrees % 360) + 360) % 360
const snapped = Math.round(normalized / step) * step
return snapped >= 360 ? 0 : snapped
}
/** 0° = north, clockwise (maritime compass). */
export function pointerAngleToDegrees(
clientX: number,
clientY: number,
centerX: number,
centerY: number
): number {
const dx = clientX - centerX
const dy = centerY - clientY
const radians = Math.atan2(dx, dy)
let degrees = (radians * 180) / Math.PI
if (degrees < 0) degrees += 360
return degrees
}
export function parseCourseAngle(value: string): number | null {
const trimmed = value.trim().replace(/°/g, '')
if (!trimmed) return null
const cardinalDeg = cardinalToDegrees(trimmed)
if (cardinalDeg !== null) return Math.round(cardinalDeg)
if (!/^\d{1,3}$/.test(trimmed)) return null
const degrees = parseInt(trimmed, 10)
if (Number.isNaN(degrees)) return null
if (degrees === 360) return 0
if (degrees < 0 || degrees > 360) return null
return degrees
}
export function formatCourseAngle(degrees: number, pad = false): string {
const normalized = ((Math.round(degrees) % 360) + 360) % 360
const text = String(normalized)
return pad ? text.padStart(3, '0') : text
}
export function normalizeCourseAngleString(
value: string,
options?: { allowEmpty?: boolean }
): string {
const trimmed = value.trim()
if (!trimmed) return options?.allowEmpty ? '' : ''
if (isCardinalDirection(trimmed)) {
return trimmed.toUpperCase()
}
const parsed = parseCourseAngle(trimmed)
if (parsed === null) return trimmed
return formatCourseAngle(parsed)
}
export function normalizeWindDirectionString(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ''
if (isCardinalDirection(trimmed)) {
return trimmed.toUpperCase()
}
const parsed = parseCourseAngle(trimmed)
if (parsed === null) return trimmed
return formatCourseAngle(parsed)
}
export function valueToDialDegrees(value: string, allowCardinal = false): number {
const parsed = parseCourseAngle(value)
if (parsed !== null) return parsed
if (allowCardinal && isCardinalDirection(value)) {
return cardinalToDegrees(value) ?? 0
}
return 0
}
export type CourseOutputMode = 'degrees' | 'cardinal'
export function resolveCourseOutputMode(
value: string,
displayMode: 'degrees' | 'cardinal' | 'auto',
allowCardinal: boolean
): CourseOutputMode {
if (!allowCardinal || displayMode === 'degrees') return 'degrees'
if (displayMode === 'cardinal') return 'cardinal'
return isCardinalDirection(value) ? 'cardinal' : 'degrees'
}
export function dialDegreesToStorageValue(
degrees: number,
mode: CourseOutputMode,
step: CourseStep
): string {
const snapped = snapDegrees(degrees, step)
if (mode === 'cardinal') return degreesToCardinal(snapped)
return formatCourseAngle(snapped)
}
export function formatCourseDisplay(
value: string,
allowCardinal: boolean
): string {
if (!value.trim()) return '—'
if (allowCardinal && isCardinalDirection(value)) return value.toUpperCase()
const parsed = parseCourseAngle(value)
if (parsed === null) return value
return `${formatCourseAngle(parsed, true)}°`
}
const STEP_STORAGE_KEY = 'kaptein-course-dial-step'
export function loadCourseDialStep(): CourseStep {
try {
const raw = sessionStorage.getItem(STEP_STORAGE_KEY)
if (raw === '5') return 5
if (raw === '10') return 10
} catch {
/* ignore */
}
return 1
}
export function saveCourseDialStep(step: CourseStep): void {
try {
sessionStorage.setItem(STEP_STORAGE_KEY, String(step))
} catch {
/* ignore */
}
}