feat(logs): Sichtweite und kompakte Wetter-Slider im Ereignisprotokoll

Ergänzt visibility in Editor und Live-Log inkl. OWM-Übernahme, CSV-Export
und touch-taugliche Slider für Luftdruck, Seegang, Sichtweite und Krängung.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 21:50:05 +02:00
parent 847c73fda9
commit cdcef2e106
18 changed files with 640 additions and 42 deletions
@@ -24,6 +24,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_visibility_entry': `Visibility ${opts?.value}`,
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
@@ -94,6 +95,15 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
})
it('formats visibility entry', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.VISIBILITY,
visibility: '10 km'
})
expect(formatEventSummary(event, t)).toBe('Visibility 10 km')
})
it('formats SOG entry', () => {
const event = normalizeLogEvent({
time: '10:15',
+5
View File
@@ -81,6 +81,10 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
return t('logs.live_sea_state_entry', { value: event.seaState })
}
if (code === LIVE_EVENT_CODES.VISIBILITY && event.visibility) {
return t('logs.live_visibility_entry', { value: event.visibility })
}
if (code && !code.startsWith('__live:')) {
return code
}
@@ -92,6 +96,7 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
}
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`)
if (event.visibility) parts.push(`${t('logs.event_visibility')}: ${event.visibility}`)
if (event.gpsLat && event.gpsLng) {
parts.push(`${event.gpsLat}, ${event.gpsLng}`)
}
+2 -1
View File
@@ -9,7 +9,8 @@ export const LIVE_EVENT_CODES = {
COURSE: '__live:course',
WIND: '__live:wind',
PRESSURE: '__live:pressure',
SEA_STATE: '__live:sea_state'
SEA_STATE: '__live:sea_state',
VISIBILITY: '__live:visibility'
} as const
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
+3 -1
View File
@@ -12,6 +12,7 @@ export interface LogEventPayload {
windDirection: string
windStrength: string
seaState: string
visibility: string
weatherIcon: string
current: string
heel: string
@@ -75,7 +76,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks'
]
@@ -91,6 +92,7 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
windStrength: '',
seaState: '',
visibility: '',
weatherIcon: '',
current: '',
heel: '',
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
formatOwmVisibilityMeters,
formatWindStrengthBeaufort,
mpsToBeaufort,
parseOwmCurrentWeather
@@ -13,15 +14,23 @@ describe('openWeatherMap', () => {
expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)')
})
it('formats visibility in metres', () => {
expect(formatOwmVisibilityMeters(500)).toBe('500 m')
expect(formatOwmVisibilityMeters(10000)).toBe('10 km')
expect(formatOwmVisibilityMeters(2500)).toBe('2.5 km')
})
it('parses OWM current weather payload', () => {
const parsed = parseOwmCurrentWeather({
wind: { speed: 8.5, deg: 225 },
main: { pressure: 1018, temp: 17.4 },
visibility: 10000,
weather: [{ icon: '04d', description: 'Bedeckt' }]
})
expect(parsed.windDirection).toBe('SW')
expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)')
expect(parsed.windPressure).toBe('1018')
expect(parsed.visibility).toBe('10 km')
expect(parsed.tempC).toBe('17.4')
expect(parsed.precipText).toBe('Bedeckt')
expect(parsed.weatherIcon).toBe('04d')
+12
View File
@@ -1,9 +1,14 @@
import { degreesToCardinal } from './courseAngle.js'
import { formatVisibilityMeters } from './weatherMetrics.js'
/** @deprecated Use formatVisibilityMeters */
export const formatOwmVisibilityMeters = formatVisibilityMeters
export interface ParsedOwmCurrent {
windDirection: string
windStrength: string
windPressure: string
visibility: string
tempC: string | null
precipText: string | null
weatherIcon: string | null
@@ -57,10 +62,17 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
const weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null
const visibilityRaw = data.visibility
const visibility =
typeof visibilityRaw === 'number'
? formatVisibilityMeters(visibilityRaw)
: ''
return {
windDirection,
windStrength,
windPressure,
visibility,
tempC,
precipText,
weatherIcon
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
formatPressureHpa,
formatSeaState,
formatVisibilityMeters,
parseHeelDeg,
parsePressureHpa,
parseSeaState,
parseVisibilityMeters,
visibilityMetersFromStepIndex,
visibilityStepIndex
} from './weatherMetrics.js'
describe('weatherMetrics', () => {
it('parses and formats pressure', () => {
expect(parsePressureHpa('1014')).toBe(1014)
expect(parsePressureHpa('1014 hPa')).toBe(1014)
expect(parsePressureHpa('')).toBeNull()
expect(formatPressureHpa(1014)).toBe('1014')
})
it('parses and formats sea state', () => {
expect(parseSeaState('3')).toBe(3)
expect(parseSeaState('leicht')).toBeNull()
expect(formatSeaState(3)).toBe('3')
})
it('parses and formats heel', () => {
expect(parseHeelDeg('12')).toBe(12)
expect(parseHeelDeg('12°')).toBe(12)
})
it('parses visibility with units', () => {
expect(parseVisibilityMeters('10 km')).toBe(10000)
expect(parseVisibilityMeters('500 m')).toBe(500)
expect(formatVisibilityMeters(10000)).toBe('10 km')
expect(formatVisibilityMeters(500)).toBe('500 m')
})
it('maps visibility to log steps', () => {
expect(visibilityStepIndex(10000)).toBe(8)
expect(visibilityMetersFromStepIndex(8)).toBe(10000)
expect(visibilityMetersFromStepIndex(0)).toBe(0)
})
})
+118
View File
@@ -0,0 +1,118 @@
/** Barometric pressure (hPa), typical marine range. */
export const PRESSURE_MIN_HPA = 960
export const PRESSURE_MAX_HPA = 1050
export const PRESSURE_DEFAULT_HPA = 1013
/** Douglas sea state 09. */
export const SEA_STATE_MIN = 0
export const SEA_STATE_MAX = 9
/** Heel angle in degrees. */
export const HEEL_MIN_DEG = 0
export const HEEL_MAX_DEG = 45
/** Log-spaced visibility steps in metres; index 0 = not set. */
export const VISIBILITY_STEPS_M = [
0, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000
] as const
function parseDecimal(value: string): number | null {
const trimmed = value.trim().replace(',', '.')
if (!trimmed) return null
const n = Number(trimmed)
return Number.isFinite(n) ? n : null
}
export function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export function parsePressureHpa(value: string): number | null {
const raw = value.trim().replace(/\s*hPa\s*$/i, '')
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
return clamp(Math.round(n), PRESSURE_MIN_HPA, PRESSURE_MAX_HPA)
}
export function formatPressureHpa(hpa: number): string {
return String(clamp(Math.round(hpa), PRESSURE_MIN_HPA, PRESSURE_MAX_HPA))
}
export function parseSeaState(value: string): number | null {
const raw = value.trim()
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
if (!Number.isInteger(n) || n < SEA_STATE_MIN || n > SEA_STATE_MAX) return null
return n
}
export function formatSeaState(level: number): string {
return String(clamp(Math.round(level), SEA_STATE_MIN, SEA_STATE_MAX))
}
export function parseHeelDeg(value: string): number | null {
const raw = value.trim().replace(/°\s*$/, '')
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
return clamp(Math.round(n), HEEL_MIN_DEG, HEEL_MAX_DEG)
}
export function formatHeelDeg(deg: number): string {
return String(clamp(Math.round(deg), HEEL_MIN_DEG, HEEL_MAX_DEG))
}
export function parseVisibilityMeters(value: string): number | null {
const raw = value.trim()
if (!raw) return null
const kmMatch = raw.match(/^([\d.,]+)\s*km$/i)
if (kmMatch) {
const km = parseDecimal(kmMatch[1])
return km == null ? null : Math.round(km * 1000)
}
const mMatch = raw.match(/^([\d.,]+)\s*m$/i)
if (mMatch) {
const m = parseDecimal(mMatch[1])
return m == null ? null : Math.round(m)
}
const bare = parseDecimal(raw)
if (bare == null) return null
return Math.round(bare >= 100 ? bare : bare)
}
export function formatVisibilityMeters(meters: number): string {
if (meters <= 0) return ''
if (meters >= 1000) {
const km = meters / 1000
const rounded = Math.round(km * 10) / 10
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
}
return `${Math.round(meters)} m`
}
export function visibilityStepIndex(meters: number): number {
if (meters <= 0) return 0
let bestIdx = 1
let bestDiff = Math.abs(VISIBILITY_STEPS_M[1] - meters)
for (let i = 2; i < VISIBILITY_STEPS_M.length; i++) {
const diff = Math.abs(VISIBILITY_STEPS_M[i] - meters)
if (diff < bestDiff) {
bestDiff = diff
bestIdx = i
}
}
return bestIdx
}
export function visibilityMetersFromStepIndex(index: number): number {
const i = clamp(Math.round(index), 0, VISIBILITY_STEPS_M.length - 1)
return VISIBILITY_STEPS_M[i]
}
/** Re-export for OWM formatting consistency. */
export { formatOwmVisibilityMeters } from './openWeatherMap.js'