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:
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +1213,7 @@ 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 === 'visibility' ? t('logs.live_visibility_placeholder')
|
||||||
: modal === 'fuel' ? t('logs.live_fuel_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')
|
||||||
|
|||||||
@@ -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
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. 1013 hPa"
|
|
||||||
className="input-text"
|
|
||||||
value={evWindPressure}
|
value={evWindPressure}
|
||||||
onChange={(e) => setEvWindPressure(e.target.value)}
|
onChange={setEvWindPressure}
|
||||||
disabled={saving || weatherLoading}
|
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>
|
|
||||||
|
|
||||||
<div className="input-group">
|
<MetricRangeInput
|
||||||
<label>{t('logs.event_sea_state')}</label>
|
label={t('logs.event_sea_state')}
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. 3"
|
|
||||||
className="input-text"
|
|
||||||
value={evSeaState}
|
value={evSeaState}
|
||||||
onChange={(e) => setEvSeaState(e.target.value)}
|
onChange={setEvSeaState}
|
||||||
disabled={saving}
|
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>
|
|
||||||
|
|
||||||
<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}
|
||||||
|
defaultNumeric={10000}
|
||||||
|
parse={parseVisibilityMeters}
|
||||||
|
format={formatVisibilityMeters}
|
||||||
|
formatDisplay={(m) => formatVisibilityMeters(m)}
|
||||||
|
hideNumberInput
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetricRangeInput
|
||||||
|
label={t('logs.event_heel')}
|
||||||
value={evHeel}
|
value={evHeel}
|
||||||
onChange={(e) => setEvHeel(e.target.value)}
|
onChange={setEvHeel}
|
||||||
disabled={saving}
|
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>
|
||||||
|
|
||||||
<div className="form-grid mb-4">
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 0–9. */
|
||||||
|
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'
|
||||||
Reference in New Issue
Block a user