feat(live): prefill course from GPS heading in Live Log. #1
@@ -73,6 +73,7 @@ import {
|
||||
type GeolocationErrorReason,
|
||||
type GpsSignalQuality
|
||||
} from '../utils/geolocation.js'
|
||||
import { formatCourseAngle } from '../utils/courseAngle.js'
|
||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
dedupeSailNames,
|
||||
@@ -633,16 +634,18 @@ export default function LiveLogView({
|
||||
setModal(type)
|
||||
}
|
||||
|
||||
const liveLogGpsPrefillOptions = {
|
||||
timeoutMs: 8000,
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000
|
||||
} as const
|
||||
|
||||
const openSogModal = async () => {
|
||||
let prefill = ''
|
||||
try {
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (permission === 'granted') {
|
||||
const pos = await getCurrentPosition({
|
||||
timeoutMs: 8000,
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000
|
||||
})
|
||||
const pos = await getCurrentPosition(liveLogGpsPrefillOptions)
|
||||
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
||||
}
|
||||
} catch {
|
||||
@@ -651,6 +654,20 @@ export default function LiveLogView({
|
||||
openValueModal('sog', prefill)
|
||||
}
|
||||
|
||||
const openCourseModal = async () => {
|
||||
let prefill = lastCourseFromEvents(events)
|
||||
try {
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (permission === 'granted') {
|
||||
const pos = await getCurrentPosition(liveLogGpsPrefillOptions)
|
||||
if (pos.headingDeg != null) prefill = formatCourseAngle(pos.headingDeg, true)
|
||||
}
|
||||
} catch {
|
||||
// Fall back to last logged course or manual entry
|
||||
}
|
||||
openValueModal('course', prefill)
|
||||
}
|
||||
|
||||
const handleMotorToggle = () => {
|
||||
hapticPulse()
|
||||
const starting = !motorRunning
|
||||
@@ -1433,7 +1450,7 @@ export default function LiveLogView({
|
||||
onCastOff={handleCastOff}
|
||||
onMoor={handleMoor}
|
||||
onOpenSails={() => { setSelectedSails([]); setModal('sails') }}
|
||||
onOpenCourse={() => openValueModal('course', lastCourseFromEvents(events))}
|
||||
onOpenCourse={() => void openCourseModal()}
|
||||
onOpenSog={() => void openSogModal()}
|
||||
onOpenStw={() => openValueModal('stw')}
|
||||
onOpenFuel={() => openValueModal('fuel')}
|
||||
@@ -1796,6 +1813,7 @@ export default function LiveLogView({
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal live-log-modal--dial" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.live_course_btn')}</h3>
|
||||
<p className="live-log-modal-hint">{t('logs.live_course_hint')}</p>
|
||||
<div className="live-log-dial-field">
|
||||
<label>{t('logs.event_mgk')}</label>
|
||||
<CourseDialInput
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Afstand over jordoverfladen (kn) — GPS-værdien udfyldes automatisk, hvis den er tilgængelig.",
|
||||
"live_course_hint": "Kurs over jord (°) — GPS-værdien udfyldes automatisk, hvis den er tilgængelig.",
|
||||
"delete_entry": "Slet dag",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag endeligt?",
|
||||
"carry_over_tanks_title": "Skal data fra i går overføres?",
|
||||
|
||||
@@ -395,6 +395,7 @@
|
||||
"live_sog_placeholder": "z. B. 5,2",
|
||||
"live_stw_placeholder": "z. B. 4,8",
|
||||
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
|
||||
"live_course_hint": "Kurs über Grund (°) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||
|
||||
@@ -395,6 +395,7 @@
|
||||
"live_sog_placeholder": "e.g. 5.2",
|
||||
"live_stw_placeholder": "e.g. 4.8",
|
||||
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
|
||||
"live_course_hint": "Course over ground (°) — prefilled from GPS when available.",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over from previous day?",
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"live_sog_placeholder": "p. ej., 5,2",
|
||||
"live_stw_placeholder": "p. ej., 4,8",
|
||||
"live_sog_hint": "Distancia recorrida (km) — El valor del GPS se rellena automáticamente si está disponible.",
|
||||
"live_course_hint": "Rumbo sobre el fondo (°) — El valor del GPS se rellena automáticamente si está disponible.",
|
||||
"delete_entry": "Eliminar etiqueta",
|
||||
"delete_confirm": "¿Estás seguro de que quieres eliminar definitivamente este día de viaje?",
|
||||
"carry_over_tanks_title": "¿Importar los datos del día anterior?",
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"live_sog_placeholder": "par exemple 5,2",
|
||||
"live_stw_placeholder": "par exemple 4,8",
|
||||
"live_sog_hint": "Vitesse par rapport au sol (kn) — La valeur GPS est préremplie si elle est disponible.",
|
||||
"live_course_hint": "Cap sur le fond (°) — La valeur GPS est préremplie si elle est disponible.",
|
||||
"delete_entry": "Supprimer la balise",
|
||||
"delete_confirm": "Es-tu sûr de vouloir supprimer définitivement cette date de voyage ?",
|
||||
"carry_over_tanks_title": "Reprendre les données de la veille ?",
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grunn (kn) — GPS-verdien fylles ut automatisk hvis den er tilgjengelig.",
|
||||
"live_course_hint": "Kurs over grunn (°) — GPS-verdien fylles ut automatisk hvis den er tilgjengelig.",
|
||||
"delete_entry": "Slett dag",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen uten mulighet for å angre?",
|
||||
"carry_over_tanks_title": "Skal data fra i går overføres?",
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"live_sog_placeholder": "t.ex. 5,2",
|
||||
"live_stw_placeholder": "t.ex. 4,8",
|
||||
"live_sog_hint": "Avstånd över marken (kn) — GPS-värdet fylls i automatiskt om det finns tillgängligt.",
|
||||
"live_course_hint": "Kurs över mark (°) — GPS-värdet fylls i automatiskt om det finns tillgängligt.",
|
||||
"delete_entry": "Ta bort dag",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här resedagen utan möjlighet att ångra dig?",
|
||||
"carry_over_tanks_title": "Överföra data från föregående dag?",
|
||||
|
||||
@@ -77,11 +77,29 @@ describe('geolocation helpers', () => {
|
||||
lat: '59.910000',
|
||||
lng: '10.750000',
|
||||
speedKn: 4.9,
|
||||
headingDeg: null,
|
||||
accuracyM: 12,
|
||||
signalQuality: 'excellent'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves heading from getCurrentPosition when reported', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
geolocation: {
|
||||
getCurrentPosition: (success: PositionCallback) => {
|
||||
success({
|
||||
coords: { latitude: 59.91, longitude: 10.75, heading: 245.6, accuracy: 8 }
|
||||
} as GeolocationPosition)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toMatchObject({
|
||||
headingDeg: 246,
|
||||
signalQuality: 'excellent'
|
||||
})
|
||||
})
|
||||
|
||||
it('formats GPS accuracy for display', () => {
|
||||
expect(formatGpsAccuracyMeters(12.4)).toBe('12')
|
||||
expect(formatGpsAccuracyMeters(87)).toBe('87')
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface GeoCoordinates {
|
||||
lng: string
|
||||
/** SOG from GPS when available (kn), otherwise null. */
|
||||
speedKn: number | null
|
||||
/** COG from GPS when available (degrees, 0–359), otherwise null. */
|
||||
headingDeg: number | null
|
||||
/** Estimated horizontal accuracy in metres, when reported by the browser. */
|
||||
accuracyM: number | null
|
||||
/** Derived signal quality indicator for UI hints. */
|
||||
@@ -142,10 +144,16 @@ function normalizeGetPositionOptions(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeadingDeg(heading: number | null | undefined): number | null {
|
||||
if (heading == null || !Number.isFinite(heading)) return null
|
||||
return Math.round(((heading % 360) + 360) % 360)
|
||||
}
|
||||
|
||||
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
|
||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||
: null
|
||||
const headingDeg = normalizeHeadingDeg(pos.coords.heading)
|
||||
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
|
||||
? pos.coords.accuracy
|
||||
: null
|
||||
@@ -153,6 +161,7 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
|
||||
lat: formatAppCoordinate(pos.coords.latitude),
|
||||
lng: formatAppCoordinate(pos.coords.longitude),
|
||||
speedKn,
|
||||
headingDeg,
|
||||
accuracyM,
|
||||
signalQuality: classifyGpsAccuracyMeters(accuracyM)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user