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
co-authored by Cursor
parent 847c73fda9
commit cdcef2e106
18 changed files with 640 additions and 42 deletions
+73
View File
@@ -4241,6 +4241,79 @@ html.theme-cupertino .events-scroll-container {
} }
} }
/* Compact weather metric sliders (LogEntryEditor) */
.weather-metrics-grid {
gap: 12px 16px;
}
.weather-metrics-grid .weather-metrics-span-2 {
grid-column: 1 / -1;
}
.metric-range-input--compact {
gap: 0;
margin: 0;
}
.metric-range-input--compact .metric-range-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.metric-range-input--compact .metric-range-header label {
margin: 0;
font-size: 13px;
line-height: 1.25;
}
.metric-range-input--compact .metric-range-value {
font-size: 0.8125rem;
font-weight: 600;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
}
.metric-range-input--compact .metric-range-control-row {
display: flex;
align-items: center;
gap: 10px;
}
.metric-range-input--compact .metric-range-slider {
flex: 1;
min-width: 0;
width: auto;
margin: 0;
}
.metric-range-input--compact .metric-range-number {
width: 4.25rem;
min-width: 4.25rem;
max-width: 4.25rem;
flex-shrink: 0;
padding: 8px 6px;
text-align: center;
font-size: 0.9375rem;
}
@media (max-width: 480px) {
.metric-range-input--compact .metric-range-slider {
--tank-slider-thumb: 28px;
}
.metric-range-input--compact .metric-range-number {
width: 3.75rem;
min-width: 3.75rem;
max-width: 3.75rem;
padding: 10px 4px;
}
}
.vessel-tanks-section { .vessel-tanks-section {
grid-column: 1 / -1; grid-column: 1 / -1;
margin-top: 8px; margin-top: 8px;
+24 -2
View File
@@ -82,6 +82,7 @@ type LiveModal =
| 'temp' | 'temp'
| 'precip' | 'precip'
| 'sea_state' | 'sea_state'
| 'visibility'
| 'course' | 'course'
| 'fuel' | 'fuel'
| 'water' | 'water'
@@ -557,6 +558,12 @@ export default function LiveLogView({
remarks: LIVE_EVENT_CODES.PRESSURE remarks: LIVE_EVENT_CODES.PRESSURE
}) })
} }
if (parsed.visibility) {
partials.push({
visibility: parsed.visibility,
remarks: LIVE_EVENT_CODES.VISIBILITY
})
}
if (parsed.tempC) { if (parsed.tempC) {
partials.push({ remarks: liveTempRemark(parsed.tempC) }) partials.push({ remarks: liveTempRemark(parsed.tempC) })
} }
@@ -724,6 +731,16 @@ export default function LiveLogView({
}) })
}, 'sea_state') }, 'sea_state')
break break
case 'visibility':
if (!primary) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
visibility: primary,
remarks: LIVE_EVENT_CODES.VISIBILITY
})
}, 'visibility')
break
case 'course': { case 'course': {
const course = primary || lastCourseFromEvents(events) const course = primary || lastCourseFromEvents(events)
if (!course) return if (!course) return
@@ -923,6 +940,9 @@ export default function LiveLogView({
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}> <button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
{t('logs.live_sea_state_btn')} {t('logs.live_sea_state_btn')}
</button> </button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
{t('logs.live_visibility_btn')}
</button>
</div> </div>
)} )}
</div> </div>
@@ -1165,7 +1185,7 @@ export default function LiveLogView({
</div> </div>
)} )}
{['pressure', 'temp', 'precip', 'sea_state', 'fuel', 'water', 'sog', 'stw'].includes(modal) && ( {['pressure', 'temp', 'precip', 'sea_state', 'visibility', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}> <div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3> <h3>
@@ -1173,6 +1193,7 @@ export default function LiveLogView({
{modal === 'temp' && t('logs.live_temp_btn')} {modal === 'temp' && t('logs.live_temp_btn')}
{modal === 'precip' && t('logs.live_precip_btn')} {modal === 'precip' && t('logs.live_precip_btn')}
{modal === 'sea_state' && t('logs.live_sea_state_btn')} {modal === 'sea_state' && t('logs.live_sea_state_btn')}
{modal === 'visibility' && t('logs.live_visibility_btn')}
{modal === 'fuel' && t('logs.live_fuel_btn')} {modal === 'fuel' && t('logs.live_fuel_btn')}
{modal === 'water' && t('logs.live_water_btn')} {modal === 'water' && t('logs.live_water_btn')}
{modal === 'sog' && t('logs.live_sog_btn')} {modal === 'sog' && t('logs.live_sog_btn')}
@@ -1192,7 +1213,8 @@ export default function LiveLogView({
: modal === 'temp' ? t('logs.live_temp_placeholder') : modal === 'temp' ? t('logs.live_temp_placeholder')
: modal === 'precip' ? t('logs.live_precip_placeholder') : modal === 'precip' ? t('logs.live_precip_placeholder')
: modal === 'sea_state' ? t('logs.live_sea_state_placeholder') : modal === 'sea_state' ? t('logs.live_sea_state_placeholder')
: modal === 'fuel' ? t('logs.live_fuel_placeholder') : modal === 'visibility' ? t('logs.live_visibility_placeholder')
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
: modal === 'water' ? t('logs.live_water_placeholder') : modal === 'water' ? t('logs.live_water_placeholder')
: modal === 'sog' ? t('logs.live_sog_placeholder') : modal === 'sog' ? t('logs.live_sog_placeholder')
: t('logs.live_stw_placeholder') : t('logs.live_stw_placeholder')
+95 -36
View File
@@ -56,6 +56,25 @@ import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js' import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx' import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
import TankLiterInput from './TankLiterInput.tsx' import TankLiterInput from './TankLiterInput.tsx'
import MetricRangeInput from './MetricRangeInput.tsx'
import {
formatHeelDeg,
formatPressureHpa,
formatSeaState,
formatVisibilityMeters,
HEEL_MAX_DEG,
HEEL_MIN_DEG,
parseHeelDeg,
parsePressureHpa,
parseSeaState,
parseVisibilityMeters,
PRESSURE_DEFAULT_HPA,
PRESSURE_MAX_HPA,
PRESSURE_MIN_HPA,
SEA_STATE_MAX,
SEA_STATE_MIN,
VISIBILITY_STEPS_M
} from '../utils/weatherMetrics.js'
import { import {
computeEveningTankMaxLiters, computeEveningTankMaxLiters,
computeRefilledTankMaxLiters, computeRefilledTankMaxLiters,
@@ -201,6 +220,7 @@ export default function LogEntryEditor({
const [evWindDirection, setEvWindDirection] = useState('') const [evWindDirection, setEvWindDirection] = useState('')
const [evWindStrength, setEvWindStrength] = useState('') const [evWindStrength, setEvWindStrength] = useState('')
const [evSeaState, setEvSeaState] = useState('') const [evSeaState, setEvSeaState] = useState('')
const [evVisibility, setEvVisibility] = useState('')
const [evWeatherIcon, setEvWeatherIcon] = useState('') const [evWeatherIcon, setEvWeatherIcon] = useState('')
const [evCurrent, setEvCurrent] = useState('') const [evCurrent, setEvCurrent] = useState('')
const [evHeel, setEvHeel] = useState('') const [evHeel, setEvHeel] = useState('')
@@ -361,6 +381,7 @@ export default function LogEntryEditor({
windDirection: evWindDirection, windDirection: evWindDirection,
windStrength: evWindStrength, windStrength: evWindStrength,
seaState: evSeaState, seaState: evSeaState,
visibility: evVisibility,
weatherIcon: evWeatherIcon, weatherIcon: evWeatherIcon,
current: evCurrent, current: evCurrent,
heel: evHeel, heel: evHeel,
@@ -383,7 +404,7 @@ export default function LogEntryEditor({
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events) return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
}, [ }, [
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState, evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance, evVisibility, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
]) ])
@@ -985,6 +1006,7 @@ export default function LogEntryEditor({
setEvWindStrength(parsed.windStrength) setEvWindStrength(parsed.windStrength)
setEvWindPressure(parsed.windPressure) setEvWindPressure(parsed.windPressure)
if (parsed.windDirection) setEvWindDirection(parsed.windDirection) if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
if (parsed.visibility) setEvVisibility(parsed.visibility)
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon) if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
showAlert(t('settings.weather_success')) showAlert(t('settings.weather_success'))
@@ -1047,6 +1069,7 @@ export default function LogEntryEditor({
setEvWindDirection('') setEvWindDirection('')
setEvWindStrength('') setEvWindStrength('')
setEvSeaState('') setEvSeaState('')
setEvVisibility('')
setEvWeatherIcon('') setEvWeatherIcon('')
setEvCurrent('') setEvCurrent('')
setEvHeel('') setEvHeel('')
@@ -1070,6 +1093,7 @@ export default function LogEntryEditor({
setEvWindDirection(normalized.windDirection) setEvWindDirection(normalized.windDirection)
setEvWindStrength(normalized.windStrength) setEvWindStrength(normalized.windStrength)
setEvSeaState(normalized.seaState) setEvSeaState(normalized.seaState)
setEvVisibility(normalized.visibility)
setEvWeatherIcon(normalized.weatherIcon) setEvWeatherIcon(normalized.weatherIcon)
setEvCurrent(normalized.current) setEvCurrent(normalized.current)
setEvHeel(normalized.heel) setEvHeel(normalized.heel)
@@ -1698,8 +1722,8 @@ export default function LogEntryEditor({
</div> </div>
</div> </div>
<div className="form-grid mb-4"> <div className="form-grid weather-metrics-grid mb-4">
<div className="input-group course-dial-section"> <div className="input-group course-dial-section weather-metrics-span-2">
<label>{t('logs.event_wind_direction')}</label> <label>{t('logs.event_wind_direction')}</label>
<CourseDialInput <CourseDialInput
value={evWindDirection} value={evWindDirection}
@@ -1723,41 +1747,76 @@ export default function LogEntryEditor({
/> />
</div> </div>
<div className="input-group"> <MetricRangeInput
<label>{t('logs.event_wind_pressure')}</label> label={t('logs.event_wind_pressure')}
<input value={evWindPressure}
type="text" onChange={setEvWindPressure}
placeholder="e.g. 1013 hPa" disabled={saving || weatherLoading}
className="input-text" min={PRESSURE_MIN_HPA}
value={evWindPressure} max={PRESSURE_MAX_HPA}
onChange={(e) => setEvWindPressure(e.target.value)} step={1}
disabled={saving || weatherLoading} defaultNumeric={PRESSURE_DEFAULT_HPA}
/> parse={parsePressureHpa}
</div> format={formatPressureHpa}
formatDisplay={(hpa) =>
t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })}
numberMin={PRESSURE_MIN_HPA}
numberMax={PRESSURE_MAX_HPA}
numberStep={1}
numberPlaceholder="1013"
/>
<div className="input-group"> <MetricRangeInput
<label>{t('logs.event_sea_state')}</label> label={t('logs.event_sea_state')}
<input value={evSeaState}
type="text" onChange={setEvSeaState}
placeholder="e.g. 3" disabled={saving}
className="input-text" min={SEA_STATE_MIN}
value={evSeaState} max={SEA_STATE_MAX}
onChange={(e) => setEvSeaState(e.target.value)} step={1}
disabled={saving} defaultNumeric={0}
/> parse={parseSeaState}
</div> format={formatSeaState}
formatDisplay={(level) =>
t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })}
numberMin={SEA_STATE_MIN}
numberMax={SEA_STATE_MAX}
numberStep={1}
numberPlaceholder="3"
allowLegacyText
/>
<div className="input-group"> <MetricRangeInput
<label>{t('logs.event_heel')}</label> label={t('logs.event_visibility')}
<input value={evVisibility}
type="text" onChange={setEvVisibility}
placeholder="e.g. 5" disabled={saving || weatherLoading}
className="input-text" discreteValues={VISIBILITY_STEPS_M}
value={evHeel} defaultNumeric={10000}
onChange={(e) => setEvHeel(e.target.value)} parse={parseVisibilityMeters}
disabled={saving} format={formatVisibilityMeters}
/> formatDisplay={(m) => formatVisibilityMeters(m)}
</div> hideNumberInput
/>
<MetricRangeInput
label={t('logs.event_heel')}
value={evHeel}
onChange={setEvHeel}
disabled={saving}
min={HEEL_MIN_DEG}
max={HEEL_MAX_DEG}
step={1}
defaultNumeric={0}
parse={parseHeelDeg}
format={formatHeelDeg}
formatDisplay={(deg) =>
t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })}
numberMin={HEEL_MIN_DEG}
numberMax={HEEL_MAX_DEG}
numberStep={1}
numberPlaceholder="5"
/>
</div> </div>
<div className="form-grid mb-4"> <div className="form-grid mb-4">
+196
View File
@@ -0,0 +1,196 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export interface MetricRangeInputProps {
id?: string
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
min?: number
max?: number
step?: number
discreteValues?: readonly number[]
parse: (value: string) => number | null
format: (numeric: number) => string
defaultNumeric: number
/** Shown next to the label (current value). */
formatDisplay: (numeric: number, unset: boolean) => string
numberMin?: number
numberMax?: number
numberStep?: number | 'any'
numberPlaceholder?: string
allowLegacyText?: boolean
hideNumberInput?: boolean
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export default function MetricRangeInput({
id,
label,
value,
onChange,
disabled = false,
min,
max,
discreteValues,
parse,
format,
defaultNumeric,
formatDisplay,
numberMin,
numberMax,
numberStep = 'any',
numberPlaceholder,
allowLegacyText = false,
hideNumberInput = false
}: MetricRangeInputProps) {
const { t } = useTranslation()
const unsetLabel = t('logs.weather_slider_unset', { defaultValue: '—' })
const isLegacyText =
allowLegacyText && value.trim() !== '' && parse(value) === null
const emitNumeric = useCallback(
(numeric: number) => {
onChange(format(numeric))
},
[onChange, format]
)
const parsed = parse(value)
const unset = parsed === null
const sliderNumeric = unset ? defaultNumeric : parsed
const useDiscrete = discreteValues != null && discreteValues.length > 1
let sliderMin = 0
let sliderMax = 0
let sliderValue = 0
if (useDiscrete) {
sliderMin = 0
sliderMax = discreteValues.length - 1
if (unset) {
sliderValue = 0
} else {
let bestIdx = 0
let bestDiff = Math.abs(discreteValues[0] - sliderNumeric)
for (let i = 1; i < discreteValues.length; i++) {
const diff = Math.abs(discreteValues[i] - sliderNumeric)
if (diff < bestDiff) {
bestDiff = diff
bestIdx = i
}
}
sliderValue = bestIdx
}
} else if (min != null && max != null) {
sliderMin = min
sliderMax = max
sliderValue = clamp(sliderNumeric, min, max)
}
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.value)
if (useDiscrete && discreteValues) {
emitNumeric(discreteValues[clamp(idx, 0, discreteValues.length - 1)])
return
}
if (min != null && max != null) {
emitNumeric(Number(e.target.value))
}
}
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleNumberBlur = () => {
const next = parse(value)
if (next == null) {
if (!value.trim()) onChange('')
return
}
onChange(format(next))
}
const hintNumeric = useDiscrete && discreteValues
? discreteValues[sliderValue]
: sliderValue
const displayLabel = unset ? unsetLabel : formatDisplay(hintNumeric, false)
if (isLegacyText) {
return (
<div className="input-group metric-range-input metric-range-input--compact">
<div className="metric-range-header">
<label htmlFor={id}>{label}</label>
</div>
<input
id={id}
type="text"
className="input-text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={numberPlaceholder}
/>
</div>
)
}
const hasSlider = useDiscrete || (min != null && max != null)
return (
<div className="input-group metric-range-input metric-range-input--compact">
<div className="metric-range-header">
<label htmlFor={hideNumberInput ? undefined : id}>{label}</label>
{hasSlider && (
<span className="metric-range-value" aria-live="polite">
{displayLabel}
</span>
)}
</div>
{hasSlider && (
<div className="metric-range-control-row">
<input
type="range"
className="tank-liter-slider metric-range-slider"
min={sliderMin}
max={sliderMax}
step={1}
value={sliderValue}
onChange={handleSliderChange}
disabled={disabled}
aria-valuemin={sliderMin}
aria-valuemax={sliderMax}
aria-valuenow={sliderValue}
aria-label={label}
aria-valuetext={displayLabel}
/>
{!hideNumberInput && (
<input
id={id}
type="number"
className="input-text metric-range-number"
value={unset ? '' : value.replace(/\s*hPa\s*$/i, '').replace(/°\s*$/, '')}
onChange={handleNumberChange}
onBlur={handleNumberBlur}
disabled={disabled}
min={numberMin}
max={numberMax}
step={numberStep}
placeholder={numberPlaceholder}
inputMode="decimal"
aria-label={label}
/>
)}
</div>
)}
</div>
)
}
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Lufttryk", "live_pressure_btn": "Lufttryk",
"live_precip_btn": "Nedbør", "live_precip_btn": "Nedbør",
"live_sea_state_btn": "Søgang", "live_sea_state_btn": "Søgang",
"live_visibility_btn": "Sigtbarhed",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "Diesel",
"live_water_btn": "Vand", "live_water_btn": "Vand",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Lufttryk {{value}} hPa", "live_pressure_entry": "Lufttryk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}", "live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Søgang {{value}}", "live_sea_state_entry": "Søgang {{value}}",
"live_visibility_entry": "Sigtbarhed {{value}}",
"live_course_entry": "Kurs {{course}}", "live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L", "live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vand +{{liters}} L", "live_water_entry": "Vand +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "f.eks. 18", "live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn", "live_precip_placeholder": "f.eks. let regn",
"live_sea_state_placeholder": "f.eks. 3", "live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245", "live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Optankede liter", "live_fuel_placeholder": "Optankede liter",
"live_water_placeholder": "Optankede liter", "live_water_placeholder": "Optankede liter",
@@ -339,6 +342,12 @@
"event_wind_direction": "Vindretning", "event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke", "event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand", "event_sea_state": "Havets tilstand",
"event_visibility": "Sigtbarhed",
"event_visibility_placeholder": "f.eks. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Trin {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Vejret", "event_weather": "Vejret",
"event_log": "Log (sm)", "event_log": "Log (sm)",
"event_gps": "GPS-position", "event_gps": "GPS-position",
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Luftdruck", "live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag", "live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang", "live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "Diesel",
"live_water_btn": "Wasser", "live_water_btn": "Wasser",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Luftdruck {{value}} hPa", "live_pressure_entry": "Luftdruck {{value}} hPa",
"live_precip_entry": "Niederschlag {{value}}", "live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}", "live_sea_state_entry": "Seegang {{value}}",
"live_visibility_entry": "Sichtweite {{value}}",
"live_course_entry": "Kurs {{course}}", "live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L", "live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L", "live_water_entry": "Wasser +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "z. B. 18", "live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen", "live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3", "live_sea_state_placeholder": "z. B. 3",
"live_visibility_placeholder": "z. B. 10 km",
"live_course_placeholder": "z. B. 245", "live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter", "live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter", "live_water_placeholder": "Nachgefüllte Liter",
@@ -339,6 +342,12 @@
"event_wind_direction": "Wind-Richtung", "event_wind_direction": "Wind-Richtung",
"event_wind_strength": "Windstärke", "event_wind_strength": "Windstärke",
"event_sea_state": "Seegang", "event_sea_state": "Seegang",
"event_visibility": "Sichtweite",
"event_visibility_placeholder": "z. B. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Stufe {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Wetter", "event_weather": "Wetter",
"event_log": "Logge (sm)", "event_log": "Logge (sm)",
"event_gps": "GPS-Position", "event_gps": "GPS-Position",
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Pressure", "live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation", "live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state", "live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility",
"live_course_btn": "Course", "live_course_btn": "Course",
"live_fuel_btn": "Fuel", "live_fuel_btn": "Fuel",
"live_water_btn": "Water", "live_water_btn": "Water",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Pressure {{value}} hPa", "live_pressure_entry": "Pressure {{value}} hPa",
"live_precip_entry": "Precipitation {{value}}", "live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}", "live_sea_state_entry": "Sea state {{value}}",
"live_visibility_entry": "Visibility {{value}}",
"live_course_entry": "Course {{course}}", "live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L", "live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L", "live_water_entry": "Water +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "e.g. 18", "live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain", "live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3", "live_sea_state_placeholder": "e.g. 3",
"live_visibility_placeholder": "e.g. 10 km",
"live_course_placeholder": "e.g. 245", "live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled", "live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled", "live_water_placeholder": "Liters refilled",
@@ -339,6 +342,12 @@
"event_wind_direction": "Wind Dir", "event_wind_direction": "Wind Dir",
"event_wind_strength": "Wind Str", "event_wind_strength": "Wind Str",
"event_sea_state": "Sea State", "event_sea_state": "Sea State",
"event_visibility": "Visibility",
"event_visibility_placeholder": "e.g. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "State {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Weather", "event_weather": "Weather",
"event_log": "Log (nm)", "event_log": "Log (nm)",
"event_gps": "GPS Position", "event_gps": "GPS Position",
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Lufttrykk", "live_pressure_btn": "Lufttrykk",
"live_precip_btn": "Nedbør", "live_precip_btn": "Nedbør",
"live_sea_state_btn": "Sjøgang", "live_sea_state_btn": "Sjøgang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "Diesel",
"live_water_btn": "Vann", "live_water_btn": "Vann",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Lufttrykk {{value}} hPa", "live_pressure_entry": "Lufttrykk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}", "live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Sjøgang {{value}}", "live_sea_state_entry": "Sjøgang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}", "live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L", "live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vann +{{liters}} L", "live_water_entry": "Vann +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "f.eks. 18", "live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn", "live_precip_placeholder": "f.eks. lett regn",
"live_sea_state_placeholder": "f.eks. 3", "live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245", "live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Påfylte liter", "live_fuel_placeholder": "Påfylte liter",
"live_water_placeholder": "Påfylte liter", "live_water_placeholder": "Påfylte liter",
@@ -339,6 +342,12 @@
"event_wind_direction": "Vindretning", "event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke", "event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand", "event_sea_state": "Havets tilstand",
"event_visibility": "Sikt",
"event_visibility_placeholder": "f.eks. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Grad {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Været", "event_weather": "Været",
"event_log": "Logg (sm)", "event_log": "Logg (sm)",
"event_gps": "GPS-posisjon", "event_gps": "GPS-posisjon",
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Lufttryck", "live_pressure_btn": "Lufttryck",
"live_precip_btn": "Nederbörd", "live_precip_btn": "Nederbörd",
"live_sea_state_btn": "Sjögang", "live_sea_state_btn": "Sjögang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "Diesel",
"live_water_btn": "Vatten", "live_water_btn": "Vatten",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Lufttryck {{value}} hPa", "live_pressure_entry": "Lufttryck {{value}} hPa",
"live_precip_entry": "Nederbörd {{value}}", "live_precip_entry": "Nederbörd {{value}}",
"live_sea_state_entry": "Sjögang {{value}}", "live_sea_state_entry": "Sjögang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}", "live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L", "live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vatten +{{liters}} L", "live_water_entry": "Vatten +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "t.ex. 18", "live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn", "live_precip_placeholder": "t.ex. lätt regn",
"live_sea_state_placeholder": "t.ex. 3", "live_sea_state_placeholder": "t.ex. 3",
"live_visibility_placeholder": "t.ex. 10 km",
"live_course_placeholder": "t.ex. 245", "live_course_placeholder": "t.ex. 245",
"live_fuel_placeholder": "Påfyllda liter", "live_fuel_placeholder": "Påfyllda liter",
"live_water_placeholder": "Påfyllda liter", "live_water_placeholder": "Påfyllda liter",
@@ -339,6 +342,12 @@
"event_wind_direction": "Vindriktning", "event_wind_direction": "Vindriktning",
"event_wind_strength": "Vindstyrka", "event_wind_strength": "Vindstyrka",
"event_sea_state": "Havets tillstånd", "event_sea_state": "Havets tillstånd",
"event_visibility": "Sikt",
"event_visibility_placeholder": "t.ex. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Grad {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Väder", "event_weather": "Väder",
"event_log": "Log (sm)", "event_log": "Log (sm)",
"event_gps": "GPS-position", "event_gps": "GPS-position",
+3 -2
View File
@@ -78,7 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks', 'Latitude', 'Longitude', 'Remarks',
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)', 'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
@@ -129,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '',
'', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '',
fwM, fwR, fwE, fwCons, fwM, fwR, fwE, fwCons,
@@ -147,6 +147,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '', ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
fwM, fwR, fwE, fwCons, fwM, fwR, fwE, fwCons,
@@ -24,6 +24,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_event_generic': 'Event', 'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`, 'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`, 'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_visibility_entry': `Visibility ${opts?.value}`,
'logs.live_wind_entry': `Wind ${opts?.value}`, 'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`, 'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured', 'logs.live_photo_entry_plain': 'Photo captured',
@@ -94,6 +95,15 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa') 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', () => { it('formats SOG entry', () => {
const event = normalizeLogEvent({ const event = normalizeLogEvent({
time: '10:15', 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 }) 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:')) { if (code && !code.startsWith('__live:')) {
return code return code
} }
@@ -92,6 +96,7 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' ')) parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
} }
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`) 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) { if (event.gpsLat && event.gpsLng) {
parts.push(`${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', COURSE: '__live:course',
WIND: '__live:wind', WIND: '__live:wind',
PRESSURE: '__live:pressure', PRESSURE: '__live:pressure',
SEA_STATE: '__live:sea_state' SEA_STATE: '__live:sea_state',
VISIBILITY: '__live:visibility'
} as const } as const
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES] 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 windDirection: string
windStrength: string windStrength: string
seaState: string seaState: string
visibility: string
weatherIcon: string weatherIcon: string
current: string current: string
heel: string heel: string
@@ -75,7 +76,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [ const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState', 'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance', 'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks' 'gpsLat', 'gpsLng', 'remarks'
] ]
@@ -91,6 +92,7 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')), windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
windStrength: '', windStrength: '',
seaState: '', seaState: '',
visibility: '',
weatherIcon: '', weatherIcon: '',
current: '', current: '',
heel: '', heel: '',
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
formatOwmVisibilityMeters,
formatWindStrengthBeaufort, formatWindStrengthBeaufort,
mpsToBeaufort, mpsToBeaufort,
parseOwmCurrentWeather parseOwmCurrentWeather
@@ -13,15 +14,23 @@ describe('openWeatherMap', () => {
expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)') 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', () => { it('parses OWM current weather payload', () => {
const parsed = parseOwmCurrentWeather({ const parsed = parseOwmCurrentWeather({
wind: { speed: 8.5, deg: 225 }, wind: { speed: 8.5, deg: 225 },
main: { pressure: 1018, temp: 17.4 }, main: { pressure: 1018, temp: 17.4 },
visibility: 10000,
weather: [{ icon: '04d', description: 'Bedeckt' }] weather: [{ icon: '04d', description: 'Bedeckt' }]
}) })
expect(parsed.windDirection).toBe('SW') expect(parsed.windDirection).toBe('SW')
expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)') expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)')
expect(parsed.windPressure).toBe('1018') expect(parsed.windPressure).toBe('1018')
expect(parsed.visibility).toBe('10 km')
expect(parsed.tempC).toBe('17.4') expect(parsed.tempC).toBe('17.4')
expect(parsed.precipText).toBe('Bedeckt') expect(parsed.precipText).toBe('Bedeckt')
expect(parsed.weatherIcon).toBe('04d') expect(parsed.weatherIcon).toBe('04d')
+12
View File
@@ -1,9 +1,14 @@
import { degreesToCardinal } from './courseAngle.js' import { degreesToCardinal } from './courseAngle.js'
import { formatVisibilityMeters } from './weatherMetrics.js'
/** @deprecated Use formatVisibilityMeters */
export const formatOwmVisibilityMeters = formatVisibilityMeters
export interface ParsedOwmCurrent { export interface ParsedOwmCurrent {
windDirection: string windDirection: string
windStrength: string windStrength: string
windPressure: string windPressure: string
visibility: string
tempC: string | null tempC: string | null
precipText: string | null precipText: string | null
weatherIcon: 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 weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null
const visibilityRaw = data.visibility
const visibility =
typeof visibilityRaw === 'number'
? formatVisibilityMeters(visibilityRaw)
: ''
return { return {
windDirection, windDirection,
windStrength, windStrength,
windPressure, windPressure,
visibility,
tempC, tempC,
precipText, precipText,
weatherIcon 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'