UNPKG

@signalk/course-provider

Version:
365 lines 13.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.parseSKPaths = parseSKPaths; exports.calcs = calcs; exports.vmg = vmg; exports.vmc = vmc; exports.timeCalcs = timeCalcs; exports.targetSpeed = targetSpeed; exports.routeRemaining = routeRemaining; exports.passedPerpendicular = passedPerpendicular; const worker_threads_1 = require("worker_threads"); const latlon_spherical_js_1 = require("../lib/geodesy/latlon-spherical.js"); let activeDest = false; // process message from main thread worker_threads_1.parentPort?.on('message', (message) => { if (parseSKPaths(message)) { worker_threads_1.parentPort?.postMessage(calcs(message)); activeDest = true; } else { if (activeDest) { worker_threads_1.parentPort?.postMessage({ gc: {}, rl: {} }); activeDest = false; } } }); function parseSKPaths(src) { return src['navigation.position'] && src['navigation.course.nextPoint']?.position && src['navigation.course.previousPoint']?.position ? true : false; } const toRadians = (degrees) => (degrees * Math.PI) / 180; const toDegrees = (radians) => (180 / Math.PI) * radians; /** Normalises angle to a value within the range of a compass * @param angle: angle (in radians) * @returns value between 0 - 2*PI */ function compassAngle(angle) { const maxAngle = Math.PI * 2; return angle < 0 ? angle + maxAngle : angle >= maxAngle ? angle - maxAngle : angle; } let trackBearingCache = null; function trackBearings(startPoint, destination, magVar) { const c = trackBearingCache; if (c && c.magVar === magVar && c.prevLat === startPoint.lat && c.prevLon === startPoint.lon && c.nextLat === destination.lat && c.nextLon === destination.lon) { return c; } const gcTrue = toRadians(startPoint.initialBearingTo(destination)); const gcMagnetic = compassAngle(gcTrue + magVar); const rlTrue = toRadians(startPoint.rhumbBearingTo(destination)); const rlMagnetic = compassAngle(rlTrue + magVar); const fresh = { prevLat: startPoint.lat, prevLon: startPoint.lon, nextLat: destination.lat, nextLon: destination.lon, magVar, gcTrue, gcMagnetic, rlTrue, rlMagnetic }; trackBearingCache = fresh; return fresh; } // course calculations function calcs(src) { const vesselPosition = src['navigation.position'] ? new latlon_spherical_js_1.LatLonSpherical(src['navigation.position'].latitude, src['navigation.position'].longitude) : null; const destination = src['navigation.course.nextPoint'] ? new latlon_spherical_js_1.LatLonSpherical(src['navigation.course.nextPoint'].position.latitude, src['navigation.course.nextPoint'].position.longitude) : null; const startPoint = src['navigation.course.previousPoint'].position ? new latlon_spherical_js_1.LatLonSpherical(src['navigation.course.previousPoint'].position.latitude, src['navigation.course.previousPoint'].position.longitude) : null; const res = { gc: {}, rl: {}, passedPerpendicular: false }; if (!vesselPosition || !destination || !startPoint) { return res; } const xte = vesselPosition?.crossTrackDistanceTo(startPoint, destination); const magVar = src['navigation.magneticVariation'] ?? 0.0; const vmgValue = vmg(src); const tb = trackBearings(startPoint, destination, magVar); // GreatCircle const bearingTrackTrue = tb.gcTrue; const bearingTrue = toRadians(vesselPosition?.initialBearingTo(destination)); const bearingTrackMagnetic = tb.gcMagnetic; const bearingMagnetic = compassAngle(bearingTrue + magVar); const gcDistance = vesselPosition?.distanceTo(destination); const gcVmg = vmgValue; const gcVmc = vmc(src, bearingTrue, 'true'); // for ETA, TTG - prefer 'true' values const gcTime = timeCalcs(src, gcDistance, gcVmc, false); res.gc = { calcMethod: 'GreatCircle', bearingTrackTrue: bearingTrackTrue, bearingTrackMagnetic: bearingTrackMagnetic, crossTrackError: xte, distance: gcDistance, bearingTrue: bearingTrue, bearingMagnetic: bearingMagnetic, velocityMadeGood: gcVmg, velocityMadeGoodToCourse: gcVmc, timeToGo: gcTime.nextPoint.ttg, estimatedTimeOfArrival: gcTime.nextPoint.eta, previousPoint: { distance: vesselPosition?.distanceTo(startPoint) }, route: { timeToGo: gcTime.route.ttg, estimatedTimeOfArrival: gcTime.route.eta, distance: gcTime.route.dtg }, targetSpeed: targetSpeed(src, gcDistance) }; // Rhumbline const rlBearingTrackTrue = tb.rlTrue; const rlBearingTrue = toRadians(vesselPosition?.rhumbBearingTo(destination)); const rlBearingTrackMagnetic = tb.rlMagnetic; const rlBearingMagnetic = compassAngle(rlBearingTrue + magVar); const rlDistance = vesselPosition?.rhumbDistanceTo(destination); const rlVmg = vmgValue; const rlVmc = vmc(src, rlBearingTrue, 'true'); // for ETA, TTG - prefer 'true' values const rlTime = timeCalcs(src, rlDistance, rlVmc, true); res.rl = { calcMethod: 'Rhumbline', bearingTrackTrue: rlBearingTrackTrue, bearingTrackMagnetic: rlBearingTrackMagnetic, crossTrackError: xte, distance: rlDistance, bearingTrue: rlBearingTrue, bearingMagnetic: rlBearingMagnetic, velocityMadeGood: rlVmg, velocityMadeGoodToCourse: rlVmc, timeToGo: rlTime.nextPoint.ttg, estimatedTimeOfArrival: rlTime.nextPoint.eta, previousPoint: { distance: vesselPosition?.rhumbDistanceTo(startPoint) }, route: { timeToGo: rlTime.route.ttg, estimatedTimeOfArrival: rlTime.route.eta, distance: rlTime.route.dtg }, targetSpeed: targetSpeed(src, rlDistance, true) }; // passed destination perpendicular res.passedPerpendicular = passedPerpendicular(vesselPosition, destination, startPoint); return res; } // Velocity Made Good to wind function vmg(src) { if (typeof src['environment.wind.angleTrueGround'] !== 'number' || typeof src['navigation.speedOverGround'] !== 'number') { return null; } return (Math.cos(src['environment.wind.angleTrueGround']) * src['navigation.speedOverGround']); } // Velocity Made Good to Course (used for ETA / TTG calcs) function vmc(src, bearing, bearingType = 'true') { const cog = bearingType === 'true' ? src['navigation.courseOverGroundTrue'] : src['navigation.courseOverGroundMagnetic']; if (typeof cog !== 'number' || typeof src['navigation.speedOverGround'] !== 'number') { return null; } return (Math.cos(Math.abs(Angle.difference(bearing, cog))) * src['navigation.speedOverGround']); } /** * Resolve the millisecond timestamp from a SignalK datetime-like field. * Accepts: * - ISO 8601 string (per SignalK spec) * - number (epoch-ms, legacy tolerance) * - undefined / null -> current time * Returns NaN on an unparseable string; callers must check Number.isFinite. */ function resolveDateMsec(raw) { if (raw === undefined || raw === null) return Date.now(); if (typeof raw === 'number') return raw; return Date.parse(raw); } // Time to Go & Estimated time of arrival at the nextPoint / route destination function timeCalcs(src, distance, vmc, rhumbLine) { const isRoute = Array.isArray(src['activeRoute']?.waypoints) && src['activeRoute']?.waypoints.length !== 0; const result = { nextPoint: { ttg: null, eta: null }, route: { ttg: null, eta: null, dtg: null } }; if (typeof distance !== 'number' || !Number.isFinite(distance) || typeof vmc !== 'number' || !Number.isFinite(vmc) || vmc <= 0) { return result; } const dateMsec = resolveDateMsec(src['navigation.datetime']); if (!Number.isFinite(dateMsec)) { return result; } const nextTtgMsec = Math.floor((distance / vmc) * 1000); const nextEtaMsec = dateMsec + nextTtgMsec; result.nextPoint.ttg = nextTtgMsec / 1000; result.nextPoint.eta = new Date(nextEtaMsec).toISOString(); if (isRoute) { const rteDistance = distance + routeRemaining(src, rhumbLine); const routeTtgMsec = Math.floor((rteDistance / vmc) * 1000); const routeEtaMsec = dateMsec + routeTtgMsec; result.route.ttg = routeTtgMsec / 1000; result.route.eta = new Date(routeEtaMsec).toISOString(); result.route.dtg = rteDistance; } return result; } // Avg speed required to arrive at destination at targetArrivalTime function targetSpeed(src, distance, rhumbLine) { if (typeof distance !== 'number' || !Number.isFinite(distance) || !src['navigation.course.targetArrivalTime']) { return null; } // if route totalDistance = distance plus + length of remaining route segments if (src['activeRoute']?.waypoints) { distance += routeRemaining(src, rhumbLine); } const dateMsec = resolveDateMsec(src['navigation.datetime']); if (!Number.isFinite(dateMsec)) { return null; } const tatMsec = resolveDateMsec(src['navigation.course.targetArrivalTime']); if (!Number.isFinite(tatMsec)) { return null; } if (tatMsec <= dateMsec) { // current time is after targetArrivalTime return null; } const tDiffSec = (tatMsec - dateMsec) / 1000; return distance / tDiffSec; } let routeRemainingCache = null; // total distance in meters of remaining route segments function routeRemaining(src, rhumbLine) { if (src['activeRoute']?.pointIndex === null || !Array.isArray(src['activeRoute']?.waypoints)) { return 0; } const waypoints = src['activeRoute'].waypoints; if (waypoints.length < 2) { return 0; } const reverse = !!src['activeRoute'].reverse; const ptIndex = src['activeRoute'].pointIndex; const lastIndex = waypoints.length - 1; const useRhumbLine = !!rhumbLine; // determine segments to sum let fromIndex; let toIndex; if (reverse) { fromIndex = 0; toIndex = lastIndex - ptIndex; if (toIndex === fromIndex) { return 0; } } else { if (ptIndex === lastIndex) { return 0; } fromIndex = ptIndex; toIndex = lastIndex; } // The main thread bumps `waypointsVersion` on every (re)assignment of // activeRoute.waypoints. We need a primitive cache key here because the // worker receives a freshly-cloned waypoints array on every postMessage, // so reference equality would never hold across ticks. const waypointsVersion = src['activeRoute'].waypointsVersion; const canCache = typeof waypointsVersion === 'number'; if (canCache) { const cache = routeRemainingCache; if (cache && cache.waypointsVersion === waypointsVersion && cache.pointIndex === ptIndex && cache.reverse === reverse) { return useRhumbLine ? cache.totalRl : cache.totalGc; } } // Sum segment lengths for both flavours in a single pass. Advance one // LatLon cursor instead of allocating two LatLon objects per iteration; // on a 50-waypoint route that is ~50 fewer allocations per cache miss. const fromWp = waypoints[fromIndex]; let pt = new latlon_spherical_js_1.LatLonSpherical(fromWp[1], fromWp[0]); let totalGc = 0; let totalRl = 0; for (let idx = fromIndex; idx < toIndex; idx++) { const wp = waypoints[idx + 1]; const next = new latlon_spherical_js_1.LatLonSpherical(wp[1], wp[0]); totalGc += pt.distanceTo(next); totalRl += pt.rhumbDistanceTo(next); pt = next; } if (canCache) { routeRemainingCache = { waypointsVersion, pointIndex: ptIndex, reverse, totalGc, totalRl }; } return useRhumbLine ? totalRl : totalGc; } // return true if vessel is past perpendicular of destination function passedPerpendicular(vesselPosition, destination, startPoint) { const ds = destination.initialBearingTo(startPoint); const dv = destination.initialBearingTo(vesselPosition); const diff = toDegrees(Angle.difference(toRadians(ds), toRadians(dv))); return Math.abs(diff) > 90; } class Angle { /** difference between two angles (in radians) * @param h: angle 1 * @param b: angle 2 * @returns angle (-ive = port) */ static difference(h, b) { const d = Math.PI * 2 - b; const hd = h + d; const a = Angle.normalise(hd); return a < Math.PI ? 0 - a : Math.PI * 2 - a; } /** Add two angles (in radians) * @param h: angle 1 * @param b: angle 2 * @returns sum angle */ static add(h, b) { return Angle.normalise(h + b); } /** Normalises angle to a value between 0 & 2Pi radians * @param a: angle * @returns value between 0-2Pi */ static normalise(a) { const pi2 = Math.PI * 2; return a < 0 ? a + pi2 : a >= pi2 ? a - pi2 : a; } } //# sourceMappingURL=course.js.map