Files
kapteins-daagbok/client/src/services/nmea/nmeaJournalGenerator.ts
T
elpatron 6c866dbad5 Add NMEA journal import with wizard and CRC-based duplicate detection.
Enables importing .nmea logs into travel-day events with interval/change modes, optional GPS track, local encrypted archive, and a test fixture for the Kieler Förde route.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 20:41:42 +02:00

140 lines
4.3 KiB
TypeScript

import type { TFunction } from 'i18next'
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
import { formatCourseAngle } from '../../utils/courseAngle.js'
import { degreesToCardinal } from '../../utils/courseAngle.js'
import type {
NmeaChangeEvent,
NmeaImportMode,
NmeaJournalCandidate,
NmeaTimePoint
} from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { intervalTimestamps, sampleAt, timestampToHHMM } from './nmeaTimeSeries.js'
export interface GeneratedNmeaJournal {
candidates: Array<NmeaJournalCandidate & { event: LogEventPayload }>
}
function pointToLogEvent(
point: NmeaTimePoint,
remarks: string,
sailsOrMotor: string
): LogEventPayload {
const course = point.cog ?? point.hdt ?? point.hdm
const mgk = course != null ? formatCourseAngle(course) : ''
const windDir =
point.windDir != null ? degreesToCardinal(point.windDir) : ''
return normalizeLogEvent({
time: timestampToHHMM(point.timestamp),
mgk,
rwk: '',
windDirection: windDir,
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
sailsOrMotor,
remarks
})
}
function changeToSailsOrMotor(type: NmeaChangeEvent['type']): string {
if (type === 'engine_start') return 'Motor'
if (type === 'engine_stop') return 'Segel'
return ''
}
function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
const parts: string[] = []
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
if (change.data?.depthM != null) {
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
}
if (change.confidence === 'low') {
parts.push(t('logs.nmea_remark_uncertain'))
}
return parts.join(' · ')
}
function dedupeCandidates(
items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }>,
windowMs: number
): Array<NmeaJournalCandidate & { event: LogEventPayload }> {
const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp)
const kept: typeof sorted = []
for (const item of sorted) {
const near = kept.find((k) => Math.abs(k.timestamp - item.timestamp) <= windowMs)
if (!near) {
kept.push(item)
continue
}
if (item.source === 'change' && near.source === 'interval') {
const idx = kept.indexOf(near)
kept[idx] = {
...item,
event: {
...near.event,
remarks: [item.event.remarks, near.event.remarks].filter(Boolean).join(' · ')
}
}
}
}
return kept
}
export function generateNmeaJournalCandidates(options: {
points: NmeaTimePoint[]
mode: NmeaImportMode
intervalMinutes: number
t: TFunction
}): GeneratedNmeaJournal {
const { points, mode, intervalMinutes, t } = options
const items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }> = []
if (mode === 'interval' || mode === 'both') {
for (const ts of intervalTimestamps(points, intervalMinutes)) {
const sample = sampleAt(points, ts)
if (!sample) continue
items.push({
id: `interval-${ts}`,
timestamp: ts,
source: 'interval',
selected: true,
event: pointToLogEvent(sample, t('logs.nmea_remark_interval'), '')
})
}
}
if (mode === 'change' || mode === 'both') {
const changes = detectNmeaChanges(points)
for (const change of changes) {
const sample = change.data ?? sampleAt(points, change.timestamp)
if (!sample) continue
items.push({
id: `change-${change.type}-${change.timestamp}`,
timestamp: change.timestamp,
source: 'change',
changeType: change.type,
confidence: change.confidence,
selected: true,
event: pointToLogEvent(
{ ...sample, timestamp: change.timestamp },
buildRemarks(change, t),
changeToSailsOrMotor(change.type)
)
})
}
}
const deduped = mode === 'both'
? dedupeCandidates(items, 15 * 60 * 1000)
: items
return { candidates: deduped }
}