From 5b39d61cdb02b84e6dc891e718b4a88b7da10297 Mon Sep 17 00:00:00 2001 From: Kilian Hofmann Date: Sun, 13 Jul 2025 04:52:26 +0200 Subject: [PATCH] Types CF/I/R;FM;TF;VA/D/I/M --- .gitignore | 4 +- README.md | 22 +- geojson.js | 651 ++++++++++++++++++++++++++++++++++++++++----------- package.json | 15 +- 4 files changed, 546 insertions(+), 146 deletions(-) diff --git a/.gitignore b/.gitignore index 40b878d..7175957 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -node_modules/ \ No newline at end of file +node_modules/ + +output.json \ No newline at end of file diff --git a/README.md b/README.md index 1176e56..2850a41 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ LGAV BIBE1L SID (Cycle 2507, ID 10654) The leg terminates upon reaching `Alt`. This intercept point then becomes the origin fix of the succeeding leg. +This new origin is an implicit overfly. ## Course to DME Distance (CD) @@ -92,6 +93,7 @@ LGAV BIBE2F SID (Cycle 2507, ID 10657) The leg terminates upon reaching `Distance`. This intercept point then becomes the origin fix of the succeeding leg. +This new origin is an implicit overfly. ## Course to Fix (CF) @@ -155,6 +157,7 @@ LGAV BIBE1L SID (Cycle 2507, ID 10654) This needs to look into the succeeding leg in order to calculate the intercept point. This intercept point then becomes the origin fix of the succeeding leg. +This new origin can never be an overfly due to the intercept nature. ## Course to Radial Termination (CR) @@ -234,6 +237,7 @@ LGAV BIBE2F SID (Cycle 2507, ID 10657) The leg terminates upon reaching `Alt`. This intercept point then becomes the origin fix of the succeeding leg. +This new origin is an implicit overfly. ## Track from Fix for Distance (FC) @@ -255,12 +259,13 @@ LIED CAR6F SID (Cycle 2507, ID 11798) ### Instructions - From the fix identified by (`WptID`, `WptLat`, `WptLon`) *or* - (`NavID`, `NavLat`, `NavLon`, `NavDist`, `NavBear`), fly for `Distance`. + (`NavID`, `NavLat`, `NavLon`, `NavDist`, `NavBear`), fly `Course` until reaching `Distance`. ### Units - `NavBear` and `Course` are in **degrees magnetic**. - `NavDist` and `Distance` are in **nmi**. `NavDist` and `Distance` are geodesic. +- `IsFlyOver`: Boolean indicating if fix is a mandatory fly over. ### Notes @@ -277,7 +282,6 @@ LGAV BIBE2T SID (Cycle 2507, ID 10659) ### Minimum Required Fields - `WptID`: FixIdentifier -- `IsFlyOver`: FlyOver - `NavID`: RecommendedNavaid - `NavBear`: Theta - `NavDist`: RHO @@ -294,7 +298,12 @@ LGAV BIBE2T SID (Cycle 2507, ID 10659) - `NavBear` and `Course` are in **degrees magnetic**. - `NavDist` and `Distance` are in **nmi**. `NavDist` is geodesic, `Distance` is slant. -- `IsFlyOver`: Boolean indicating if fix is a mandatory fly over. + +### Notes + +The leg terminates upon reaching `Distance`. +This intercept point then becomes the origin fix of the succeeding leg. +This new origin is an implicit overfly. ## From Fix to Manual termination (FM) @@ -522,7 +531,7 @@ Example has `NavBear` set to `null`, significance of the inbound tangential trac Same for the `Course`, which is set, but lacks any documentation. -## Tract to Fix (TF) +## Track to Fix (TF) ### Example @@ -556,7 +565,7 @@ LFRK LGL4X SID (Cycle 2507, ID 10475) ### Instructions -- From preceding fix, fly heading specified in `Course` until reaching `Alt` +- From preceding fix, fly heading specified in `Course` until reaching `Alt`. - **No wind corrections shall be applied** ### Units @@ -568,6 +577,7 @@ LFRK LGL4X SID (Cycle 2507, ID 10475) The leg terminates upon reaching `Alt`. This intercept point then becomes the origin fix of the succeeding leg. +This new origin is an implicit overfly. ## Heading to DME Distance (VD) @@ -597,6 +607,7 @@ LFRK NEVI4Y SID (Cycle 2507, ID 10482) The leg terminates upon reaching `Distance`. This intercept point then becomes the origin fix of the succeeding leg. +This new origin is an implicit overfly. ## Heading to Intercept (VI) @@ -623,6 +634,7 @@ LFRK LUSI4Y SID (Cycle 2507, ID 10480) This needs to look into the succeeding leg in order to calculate the intercept point. This intercept point then becomes the origin fix of the succeeding leg. +This new origin can never be an overfly due to the intercept nature. ## Heading to Manual Termination (VM) diff --git a/geojson.js b/geojson.js index 2310e0a..d2a765f 100644 --- a/geojson.js +++ b/geojson.js @@ -2,18 +2,56 @@ import fs from "fs"; import geo from "geojson"; import geolib from "geolib"; +/* Constants */ const π = Math.PI; +const AC_SPEED = 250; +const AC_VS = 3000; +// ADJ to location, (NG), E is neg. +/* LFPV */ +//const MAGVAR = -1; +//const PROCEDURE_ID = 10394; +//const PROCEDURE_ID = 10395; +/* LFRK */ +const MAGVAR = 0; +//const PROCEDURE_ID = 10475; +//const PROCEDURE_ID = 10480; +//const PROCEDURE_ID = 10482; +const PROCEDURE_ID = 10485; +/* Prototypes */ Number.prototype.toRadians = function () { return (this * Math.PI) / 180; }; Number.prototype.toDegrees = function () { return (this * 180) / Math.PI; }; +Number.prototype.flipCourse = function () { + let inv = this + 180; + inv = inv >= 360 ? inv - 360 : inv; + return inv; +}; +Number.prototype.normaliseDegrees = function () { + return this >= 360 ? this - 360 : this < 0 ? this + 360 : this; +}; +Number.prototype.toTrue = function () { + return (this - MAGVAR).normaliseDegrees(); +}; +/* Functions */ +const updateLastCourse = (line) => { + lastCourse = geolib.getGreatCircleBearing( + { + latitude: line.at(-2)[1], + longitude: line.at(-2)[0], + }, + { + latitude: line.at(-1)[1], + longitude: line.at(-1)[0], + } + ); +}; const nmiToMetre = (nmi) => nmi * 1852.0; - -const intersection = (p1, brng1, p2, brng2) => { +const computeIntersection = (p1, brng1, p2, brng2) => { if (isNaN(brng1)) throw new TypeError(`invalid brng1 ${brng1}`); if (isNaN(brng2)) throw new TypeError(`invalid brng2 ${brng2}`); @@ -86,33 +124,223 @@ const intersection = (p1, brng1, p2, brng2) => { const lat = φ3.toDegrees(); const lon = λ3.toDegrees(); - return { lat, lon, name: "INTC" }; + return { lat, lon, name: "INTC", alt: p1.alt }; +}; +const generateTangentialArc = (inbdCrs, outbCrs, arcStart, legEnd, turnDir) => { + const line = []; + + if (inbdCrs !== outbCrs) { + // Course to the end of the arc + let crsToArcEnd; + if (!turnDir || turnDir === "E") { + let prov = outbCrs - inbdCrs; + prov = prov > 180 ? prov - 360 : prov <= -180 ? prov + 360 : prov; + turnDir = prov > 0 ? "L" : "R"; + } + if (turnDir === "R") { + const delta = (360 - outbCrs + inbdCrs).normaliseDegrees(); + crsToArcEnd = (outbCrs + delta / 2).normaliseDegrees(); + } else { + const delta = (outbCrs + 360 - inbdCrs).normaliseDegrees(); + crsToArcEnd = (outbCrs - delta / 2).normaliseDegrees(); + } + + // Arc end + const arcEnd = computeIntersection( + arcStart, + crsToArcEnd, + legEnd, + inbdCrs.flipCourse() + ); + + if (!arcEnd) { + // Early end due to no intercept + return null; + } + + let startPerpCrs; + let endPerpCrs; + if (turnDir === "R") { + startPerpCrs = (outbCrs + 90).normaliseDegrees(); + endPerpCrs = (inbdCrs + 90).normaliseDegrees(); + } else { + startPerpCrs = (outbCrs - 90).normaliseDegrees(); + endPerpCrs = (inbdCrs - 90).normaliseDegrees(); + } + + // Generate arc + const arcCenter = computeIntersection( + arcStart, + startPerpCrs, + arcEnd, + endPerpCrs + ); + const arcRad = geolib.getDistance( + { + latitude: arcCenter.lat, + longitude: arcCenter.lon, + }, + { + latitude: arcStart.lat, + longitude: arcStart.lon, + } + ); + + startPerpCrs = startPerpCrs.flipCourse(); + endPerpCrs = endPerpCrs.flipCourse(); + // Start turn immediately + if (turnDir === "R") { + startPerpCrs += startPerpCrs < 1 ? startPerpCrs : 1; + } else { + startPerpCrs -= startPerpCrs < 1 ? startPerpCrs : 1; + } + + while (startPerpCrs !== endPerpCrs) { + if (turnDir === "R") { + const delta = (endPerpCrs - startPerpCrs).normaliseDegrees(); + startPerpCrs += delta < 1 ? delta : 1; + startPerpCrs = startPerpCrs.normaliseDegrees(); + } else { + const delta = (startPerpCrs - endPerpCrs).normaliseDegrees(); + startPerpCrs -= delta < 1 ? delta : 1; + startPerpCrs = startPerpCrs.normaliseDegrees(); + } + if (startPerpCrs === endPerpCrs) break; + + const arcFix = geolib.computeDestinationPoint( + arcCenter, + arcRad, + startPerpCrs + ); + + line.push([arcFix.longitude, arcFix.latitude]); + } + } + + return line; +}; +const generatePerformanceArc = (inbdCrs, outbCrs, arcStart, turnDir) => { + const line = [[arcStart.lon, arcStart.lat]]; + + if (inbdCrs !== outbCrs) { + // Turn Dir + if (!turnDir || turnDir === "E") { + let prov = outbCrs - inbdCrs; + prov = prov > 180 ? prov - 360 : prov <= -180 ? prov + 360 : prov; + turnDir = prov > 0 ? "L" : "R"; + } + + // Generate arc + while (outbCrs !== inbdCrs) { + if (turnDir === "R") { + const delta = (inbdCrs - outbCrs).normaliseDegrees(); + outbCrs += delta < 1 ? delta : 1; + outbCrs = outbCrs.normaliseDegrees(); + } else { + const delta = (outbCrs - inbdCrs).normaliseDegrees(); + outbCrs -= delta < 1 ? delta : 1; + outbCrs = outbCrs.normaliseDegrees(); + } + if (outbCrs === inbdCrs) break; + + const arcFix = geolib.computeDestinationPoint( + { + latitude: line.at(-1)[1], + longitude: line.at(-1)[0], + }, + nmiToMetre(240 / 3600), + outbCrs + ); + + line.push([arcFix.longitude, arcFix.latitude]); + } + } else { + line.push([arcStart.lon, arcStart.lat]); + } + + return line.slice(1); +}; +const getCourseAndFixForInterceptions = (leg) => { + switch (leg.TrackCode) { + case "CF": { + return [ + leg.Course.flipCourse().toTrue(), + { lat: leg.WptLat, lon: leg.WptLon }, + ]; + } + case "FM": { + return [leg.Course.toTrue(), { lat: leg.WptLat, lon: leg.WptLon }]; + } + } +}; +const handleTurnAtFix = (inbdCrs, inbdCrs2, start, end, turnDir) => { + // Begin line drawing at previous end + const line = [[start.lon, start.lat]]; + + // Draw arcs only if origin was flyover + if (start.isFlyOver) { + const arc1 = generateTangentialArc( + inbdCrs, + lastCourse, + start, + end, + turnDir + ); + + const arc2 = generatePerformanceArc(inbdCrs2, lastCourse, start, turnDir); + + let arc; + if (arc1) { + const endCrs = geolib.getGreatCircleBearing( + { + latitude: arc1.at(-1)[1], + longitude: arc1.at(-1)[0], + }, + { + latitude: end.lat, + longitude: end.lon, + } + ); + + if (endCrs <= inbdCrs + 1 && endCrs >= inbdCrs - 1) arc = arc1; + else arc = arc2; + } else { + arc = arc2; + } + + // Push line + line.push(...arc); + } else { + //FIXME: Non Flyover + //line.push([end.lon, end.lat]); + } + + return line; +}; +const parseAltitude = (alt) => { + return Number.parseInt(alt.substring(0, 5)); }; -const SPEED = 4 / 60; //nmi/s -const TURN = 3; //deg/s -const VS = 1; //fps - -// ADJ to location, (NG), E is neg. -const MAGVAR = -1; - -const PROCEDURE_ID = 10394; - +/* Data */ const waypoints = JSON.parse(fs.readFileSync("Waypoints.json")); - const terminal = JSON.parse(fs.readFileSync("Terminals.json")).filter( ({ ID }) => ID === PROCEDURE_ID )[0]; const runway = JSON.parse(fs.readFileSync("Runways.json")).filter( ({ ID }) => ID === terminal.RwyID )[0]; - const procedure = JSON.parse(fs.readFileSync(`TermID_${PROCEDURE_ID}.json`)); -const points = [{ lat: runway.Latitude, lon: runway.Longitude }]; +/* Output */ +const points = [ + { lat: runway.Latitude, lon: runway.Longitude, alt: runway.Elevation }, +]; const lines = []; +let lastCourse = 0; -procedure.forEach((leg) => { +/* Main */ +for (let index = 0; index < procedure.length; index++) { + const leg = procedure[index]; const waypoint = waypoints.filter(({ ID }) => ID === leg.WptID)[0]; switch (leg.TrackCode) { @@ -121,160 +349,315 @@ procedure.forEach((leg) => { case "CD": break; case "CF": { + // Push in ending waypoint points.push({ lat: leg.WptLat, lon: leg.WptLon, name: waypoint?.Ident ?? undefined, + "marker-color": leg.IsFlyOver !== 0 ? "#ff0000" : undefined, + isFlyOver: leg.IsFlyOver !== 0, + alt: points.at(-1).alt, }); - if (leg.TurnDir) { - const line = [ - [points[points.length - 2].lon, points[points.length - 2].lat], - ]; - - let inbdCrs = Math.round( - geolib.getGreatCircleBearing( - { - latitude: points[points.length - 3].lat, - longitude: points[points.length - 3].lon, - }, - { - latitude: points[points.length - 2].lat, - longitude: points[points.length - 2].lon, - } - ) - ); - let outbCrs = Math.round(leg.Course - MAGVAR); - outbCrs = - outbCrs >= 360 - ? outbCrs - 360 - : outbCrs < 0 - ? outbCrs + 360 - : outbCrs; - - const delta = Math.abs(inbdCrs - 180 - outbCrs); - - let perpCrs; - if (leg.TurnDir === "R") { - perpCrs = inbdCrs + 90 + delta / 2; - perpCrs = perpCrs >= 360 ? perpCrs - 360 : perpCrs; - } else { - perpCrs = inbdCrs - 90 - delta / 2; - perpCrs = perpCrs < 0 ? perpCrs + 360 : perpCrs; - } - - const perpFix = intersection( - points[points.length - 2], - Math.round(perpCrs - MAGVAR), - points[points.length - 1], - Math.round(leg.Course + 180 - MAGVAR) - ); - - const midFix = geolib.getCenter([ - { - latitude: points[points.length - 2].lat, - longitude: points[points.length - 2].lon, - }, - { - latitude: perpFix.lat, - longitude: perpFix.lon, - }, - ]); - const dist = geolib.getDistance( - { - latitude: points[points.length - 2].lat, - longitude: points[points.length - 2].lon, - }, - { - latitude: perpFix.lat, - longitude: perpFix.lon, - } - ); - const mid1 = geolib.computeDestinationPoint(midFix, dist / 2, inbdCrs); - - let invCrs = outbCrs + 180; - invCrs = invCrs >= 360 ? invCrs - 360 : invCrs; - const mid2 = geolib.computeDestinationPoint(midFix, dist / 2, invCrs); - - line.push([mid1.longitude, mid1.latitude]); - line.push([mid2.longitude, mid2.latitude]); - line.push([perpFix.lon, perpFix.lat]); - line.push([ - points[points.length - 1].lon, - points[points.length - 1].lat, - ]); - - lines.push({ line }); - } else { - lines.push({ - line: [ - [points[points.length - 2].lon, points[points.length - 2].lat], - [points[points.length - 1].lon, points[points.length - 1].lat], - ], - }); - } - break; - } - case "CI": - break; - case "CR": { - const intc = intersection( - points[points.length - 1], - Math.round(leg.Course - MAGVAR), - { lat: leg.NavLat, lon: leg.NavLon }, - Math.round(leg.NavBear - MAGVAR) + const line = handleTurnAtFix( + leg.Course.toTrue(), + leg.Course.toTrue(), + points.at(-2), + points.at(-1), + leg.TurnDir, + index ); + line.push([leg.WptLon, leg.WptLat]); + lines.push({ line }); + updateLastCourse(lines.at(-1).line); + + break; + } + case "CI": { + // Course into the destination fix and said fix + const [inbdCrs, fix] = getCourseAndFixForInterceptions( + procedure[index + 1] + ); + + // Compute INTC + const intc = computeIntersection( + points.at(-1), + leg.Course.toTrue(), + fix, + inbdCrs + ); + + const line = handleTurnAtFix( + inbdCrs, + leg.Course.toTrue(), + points.at(-1), + intc, + leg.TurnDir, + index + ); + lines.push({ line }); + updateLastCourse(line); + + const intc2 = computeIntersection( + { lat: line.at(-1)[1], lon: line.at(-1)[0] }, + leg.Course.toTrue(), + fix, + inbdCrs + ); + if (intc2) { + points.push(intc2); + lines.push({ line: [line.at(-1), [intc2.lon, intc2.lat]] }); + updateLastCourse(line); + } else { + points.push(intc); + lines.push({ + line: [line.at(-1), [intc.lon, intc.lat]], + }); + } + + break; + } + case "CR": { + // Course into the destination fix + const inbdCrs = leg.Course.toTrue(); + + // Compute INTC + const intc = computeIntersection( + points.at(-1), + inbdCrs, + { lat: leg.NavLat, lon: leg.NavLon }, + leg.NavBear.toTrue() + ); points.push(intc); - lines.push({ - line: [ - [points[points.length - 2].lon, points[points.length - 2].lat], - [points[points.length - 1].lon, points[points.length - 1].lat], - ], - }); + + const line = handleTurnAtFix( + inbdCrs, + leg.Course.toTrue(), + points.at(-2), + points.at(-1), + leg.TurnDir, + index + ); + line.push([intc.lon, intc.lat]); + lines.push({ line }); + updateLastCourse(line); + + break; } case "DF": case "FA": case "FC": case "FD": - case "FM": + break; + case "FM": { + const end = geolib.computeDestinationPoint( + { + latitude: points.at(-1).lat, + longitude: points.at(-1).lon, + }, + nmiToMetre(10), + leg.Course.toTrue() + ); + + const line = handleTurnAtFix( + leg.Course.toTrue(), + leg.Course.toTrue(), + points.at(-1), + { lat: end.latitude, lon: end.longitude }, + leg.TurnDir, + index + ); + lines.push({ line }); + line.push([end.longitude, end.latitude]); + updateLastCourse(line); + + break; + } case "HA": case "HF": case "HM": case "IF": case "PI": case "RF": - case "TF": - case "VA": - case "VD": - case "VI": break; + case "TF": { + // Push in ending waypoint + points.push({ + lat: leg.WptLat, + lon: leg.WptLon, + name: waypoint?.Ident ?? undefined, + "marker-color": leg.IsFlyOver !== 0 ? "#ff0000" : undefined, + isFlyOver: leg.IsFlyOver !== 0, + alt: points.at(-1).alt, + }); + + const inbdCrs = geolib.getGreatCircleBearing( + { latitude: points.at(-2).lat, longitude: points.at(-2).lon }, + { latitude: points.at(-1).lat, longitude: points.at(-1).lon } + ); + + const line = handleTurnAtFix( + inbdCrs, + inbdCrs, + points.at(-2), + points.at(-1), + leg.TurnDir, + index + ); + line.push([leg.WptLon, leg.WptLat]); + lines.push({ line }); + updateLastCourse(line); + + break; + } + case "VA": { + // NOTE: No wind adjustments to be made, no clue how *that* would draw + + const end = geolib.computeDestinationPoint( + { + latitude: points.at(-1).lat, + longitude: points.at(-1).lon, + }, + nmiToMetre( + ((parseAltitude(leg.Alt) - points.at(-1).alt) / AC_VS) * + (AC_SPEED / 60) + ), + leg.Course.toTrue() + ); + points.push({ + lat: end.latitude, + lon: end.longitude, + name: leg.Alt, + "marker-color": "#ff0000", + isFlyOver: true, + alt: parseAltitude(leg.Alt), + }); + + const line = handleTurnAtFix( + leg.Course.toTrue(), + leg.Course.toTrue(), + points.at(-2), + { lat: end.latitude, lon: end.longitude }, + leg.TurnDir, + index + ); + line.push([end.longitude, end.latitude]); + lines.push({ line }); + updateLastCourse(line); + + break; + } + case "VD": { + // NOTE: No wind adjustments to be made, no clue how *that* would draw + + const end = geolib.computeDestinationPoint( + { + latitude: points.at(-1).lat, + longitude: points.at(-1).lon, + }, + //NOTE: Does not account for slant + nmiToMetre(leg.Distance), + leg.Course.toTrue() + ); + points.push({ + lat: end.latitude, + lon: end.longitude, + name: leg.Alt, + "marker-color": "#ff0000", + isFlyOver: true, + alt: points.at(-1).alt, + }); + + const line = handleTurnAtFix( + leg.Course.toTrue(), + leg.Course.toTrue(), + points.at(-2), + { lat: end.latitude, lon: end.longitude }, + leg.TurnDir, + index + ); + line.push([end.longitude, end.latitude]); + lines.push({ line }); + updateLastCourse(line); + + break; + } + case "VI": { + // NOTE: No wind adjustments to be made, no clue how *that* would draw + + // Course into the destination fix and said fix + const [inbdCrs, fix] = getCourseAndFixForInterceptions( + procedure[index + 1] + ); + + // Compute INTC + const intc = computeIntersection( + points.at(-1), + leg.Course.toTrue(), + fix, + inbdCrs + ); + + const line = handleTurnAtFix( + inbdCrs, + leg.Course.toTrue(), + points.at(-1), + intc, + leg.TurnDir, + index + ); + lines.push({ line }); + updateLastCourse(line); + + const intc2 = computeIntersection( + { lat: line.at(-1)[1], lon: line.at(-1)[0] }, + leg.Course.toTrue(), + fix, + inbdCrs + ); + if (intc2) { + points.push(intc2); + lines.push({ line: [line.at(-1), [intc2.lon, intc2.lat]] }); + updateLastCourse(line); + } + + break; + } case "VM": { const end = geolib.computeDestinationPoint( { - latitude: points[points.length - 1].lat, - longitude: points[points.length - 1].lon, + latitude: points.at(-1).lat, + longitude: points.at(-1).lon, }, nmiToMetre(10), - leg.Course - MAGVAR + leg.Course.toTrue() ); - lines.push({ - line: [ - [points[points.length - 1].lon, points[points.length - 1].lat], - [end.longitude, end.latitude], - ], - }); + + const line = handleTurnAtFix( + leg.Course.toTrue(), + leg.Course.toTrue(), + points.at(-1), + { lat: end.latitude, lon: end.longitude }, + leg.TurnDir, + index + ); + line.push([end.longitude, end.latitude]); + lines.push({ line }); + updateLastCourse(line); + + break; } case "VR": case "AF": default: break; } -}); +} +/* geoJSON */ const output = geo.parse([...points, ...lines], { Point: ["lat", "lon"], LineString: "line", }); - console.log(JSON.stringify(output, null, 2)); diff --git a/package.json b/package.json index ee96876..2f76404 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { - "dependencies": { - "geojson": "^0.5.0", - "geolib": "^3.3.4", - "magvar": "^2.0.0" - }, - "type": "module" + "dependencies": { + "geojson": "^0.5.0", + "geolib": "^3.3.4", + "magvar": "^2.0.0" + }, + "type": "module", + "scripts": { + "parse": "node geojson.js > output.json" + } }