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:
@@ -82,6 +82,7 @@ type LiveModal =
|
||||
| 'temp'
|
||||
| 'precip'
|
||||
| 'sea_state'
|
||||
| 'visibility'
|
||||
| 'course'
|
||||
| 'fuel'
|
||||
| 'water'
|
||||
@@ -557,6 +558,12 @@ export default function LiveLogView({
|
||||
remarks: LIVE_EVENT_CODES.PRESSURE
|
||||
})
|
||||
}
|
||||
if (parsed.visibility) {
|
||||
partials.push({
|
||||
visibility: parsed.visibility,
|
||||
remarks: LIVE_EVENT_CODES.VISIBILITY
|
||||
})
|
||||
}
|
||||
if (parsed.tempC) {
|
||||
partials.push({ remarks: liveTempRemark(parsed.tempC) })
|
||||
}
|
||||
@@ -724,6 +731,16 @@ export default function LiveLogView({
|
||||
})
|
||||
}, 'sea_state')
|
||||
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': {
|
||||
const course = primary || lastCourseFromEvents(events)
|
||||
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}>
|
||||
{t('logs.live_sea_state_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
|
||||
{t('logs.live_visibility_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1165,7 +1185,7 @@ export default function LiveLogView({
|
||||
</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" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>
|
||||
@@ -1173,6 +1193,7 @@ export default function LiveLogView({
|
||||
{modal === 'temp' && t('logs.live_temp_btn')}
|
||||
{modal === 'precip' && t('logs.live_precip_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 === 'water' && t('logs.live_water_btn')}
|
||||
{modal === 'sog' && t('logs.live_sog_btn')}
|
||||
@@ -1192,7 +1213,8 @@ export default function LiveLogView({
|
||||
: modal === 'temp' ? t('logs.live_temp_placeholder')
|
||||
: modal === 'precip' ? t('logs.live_precip_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 === 'sog' ? t('logs.live_sog_placeholder')
|
||||
: t('logs.live_stw_placeholder')
|
||||
|
||||
@@ -56,6 +56,25 @@ import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.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 {
|
||||
computeEveningTankMaxLiters,
|
||||
computeRefilledTankMaxLiters,
|
||||
@@ -201,6 +220,7 @@ export default function LogEntryEditor({
|
||||
const [evWindDirection, setEvWindDirection] = useState('')
|
||||
const [evWindStrength, setEvWindStrength] = useState('')
|
||||
const [evSeaState, setEvSeaState] = useState('')
|
||||
const [evVisibility, setEvVisibility] = useState('')
|
||||
const [evWeatherIcon, setEvWeatherIcon] = useState('')
|
||||
const [evCurrent, setEvCurrent] = useState('')
|
||||
const [evHeel, setEvHeel] = useState('')
|
||||
@@ -361,6 +381,7 @@ export default function LogEntryEditor({
|
||||
windDirection: evWindDirection,
|
||||
windStrength: evWindStrength,
|
||||
seaState: evSeaState,
|
||||
visibility: evVisibility,
|
||||
weatherIcon: evWeatherIcon,
|
||||
current: evCurrent,
|
||||
heel: evHeel,
|
||||
@@ -383,7 +404,7 @@ export default function LogEntryEditor({
|
||||
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
||||
}, [
|
||||
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
|
||||
])
|
||||
|
||||
@@ -985,6 +1006,7 @@ export default function LogEntryEditor({
|
||||
setEvWindStrength(parsed.windStrength)
|
||||
setEvWindPressure(parsed.windPressure)
|
||||
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
|
||||
if (parsed.visibility) setEvVisibility(parsed.visibility)
|
||||
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
|
||||
|
||||
showAlert(t('settings.weather_success'))
|
||||
@@ -1047,6 +1069,7 @@ export default function LogEntryEditor({
|
||||
setEvWindDirection('')
|
||||
setEvWindStrength('')
|
||||
setEvSeaState('')
|
||||
setEvVisibility('')
|
||||
setEvWeatherIcon('')
|
||||
setEvCurrent('')
|
||||
setEvHeel('')
|
||||
@@ -1070,6 +1093,7 @@ export default function LogEntryEditor({
|
||||
setEvWindDirection(normalized.windDirection)
|
||||
setEvWindStrength(normalized.windStrength)
|
||||
setEvSeaState(normalized.seaState)
|
||||
setEvVisibility(normalized.visibility)
|
||||
setEvWeatherIcon(normalized.weatherIcon)
|
||||
setEvCurrent(normalized.current)
|
||||
setEvHeel(normalized.heel)
|
||||
@@ -1698,8 +1722,8 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
<div className="input-group course-dial-section">
|
||||
<div className="form-grid weather-metrics-grid mb-4">
|
||||
<div className="input-group course-dial-section weather-metrics-span-2">
|
||||
<label>{t('logs.event_wind_direction')}</label>
|
||||
<CourseDialInput
|
||||
value={evWindDirection}
|
||||
@@ -1723,41 +1747,76 @@ export default function LogEntryEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_wind_pressure')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 1013 hPa"
|
||||
className="input-text"
|
||||
value={evWindPressure}
|
||||
onChange={(e) => setEvWindPressure(e.target.value)}
|
||||
disabled={saving || weatherLoading}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_wind_pressure')}
|
||||
value={evWindPressure}
|
||||
onChange={setEvWindPressure}
|
||||
disabled={saving || weatherLoading}
|
||||
min={PRESSURE_MIN_HPA}
|
||||
max={PRESSURE_MAX_HPA}
|
||||
step={1}
|
||||
defaultNumeric={PRESSURE_DEFAULT_HPA}
|
||||
parse={parsePressureHpa}
|
||||
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">
|
||||
<label>{t('logs.event_sea_state')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 3"
|
||||
className="input-text"
|
||||
value={evSeaState}
|
||||
onChange={(e) => setEvSeaState(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_sea_state')}
|
||||
value={evSeaState}
|
||||
onChange={setEvSeaState}
|
||||
disabled={saving}
|
||||
min={SEA_STATE_MIN}
|
||||
max={SEA_STATE_MAX}
|
||||
step={1}
|
||||
defaultNumeric={0}
|
||||
parse={parseSeaState}
|
||||
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">
|
||||
<label>{t('logs.event_heel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 5"
|
||||
className="input-text"
|
||||
value={evHeel}
|
||||
onChange={(e) => setEvHeel(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_visibility')}
|
||||
value={evVisibility}
|
||||
onChange={setEvVisibility}
|
||||
disabled={saving || weatherLoading}
|
||||
discreteValues={VISIBILITY_STEPS_M}
|
||||
defaultNumeric={10000}
|
||||
parse={parseVisibilityMeters}
|
||||
format={formatVisibilityMeters}
|
||||
formatDisplay={(m) => formatVisibilityMeters(m)}
|
||||
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 className="form-grid mb-4">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user