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 computeIntersection = (p1, brng1, p2, brng2) => { if (isNaN(brng1)) throw new TypeError(`invalid brng1 ${brng1}`); if (isNaN(brng2)) throw new TypeError(`invalid brng2 ${brng2}`); // see www.edwilliams.org/avform.htm#Intersection const φ1 = p1.lat.toRadians(), λ1 = p1.lon.toRadians(); const φ2 = p2.lat.toRadians(), λ2 = p2.lon.toRadians(); const θ13 = Number(brng1).toRadians(), θ23 = Number(brng2).toRadians(); const Δφ = φ2 - φ1, Δλ = λ2 - λ1; // angular distance p1-p2 const δ12 = 2 * Math.asin( Math.sqrt( Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2) ) ); if (Math.abs(δ12) < Number.EPSILON) return p1; // coincident points // initial/final bearings between points const cosθa = (Math.sin(φ2) - Math.sin(φ1) * Math.cos(δ12)) / (Math.sin(δ12) * Math.cos(φ1)); const cosθb = (Math.sin(φ1) - Math.sin(φ2) * Math.cos(δ12)) / (Math.sin(δ12) * Math.cos(φ2)); const θa = Math.acos(Math.min(Math.max(cosθa, -1), 1)); // protect against rounding errors const θb = Math.acos(Math.min(Math.max(cosθb, -1), 1)); // protect against rounding errors const θ12 = Math.sin(λ2 - λ1) > 0 ? θa : 2 * π - θa; const θ21 = Math.sin(λ2 - λ1) > 0 ? 2 * π - θb : θb; const α1 = θ13 - θ12; // angle 2-1-3 const α2 = θ21 - θ23; // angle 1-2-3 if (Math.sin(α1) == 0 && Math.sin(α2) == 0) return null; // infinite intersections if (Math.sin(α1) * Math.sin(α2) < 0) return null; // ambiguous intersection (antipodal/360°) const cosα3 = -Math.cos(α1) * Math.cos(α2) + Math.sin(α1) * Math.sin(α2) * Math.cos(δ12); const δ13 = Math.atan2( Math.sin(δ12) * Math.sin(α1) * Math.sin(α2), Math.cos(α2) + Math.cos(α1) * cosα3 ); const φ3 = Math.asin( Math.min( Math.max( Math.sin(φ1) * Math.cos(δ13) + Math.cos(φ1) * Math.sin(δ13) * Math.cos(θ13), -1 ), 1 ) ); const Δλ13 = Math.atan2( Math.sin(θ13) * Math.sin(δ13) * Math.cos(φ1), Math.cos(δ13) - Math.sin(φ1) * Math.sin(φ3) ); const λ3 = λ1 + Δλ13; const lat = φ3.toDegrees(); const lon = λ3.toDegrees(); 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)); }; /* 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`)); /* Output */ const points = [ { lat: runway.Latitude, lon: runway.Longitude, alt: runway.Elevation }, ]; const lines = []; let lastCourse = 0; /* 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) { case "AF": case "CA": 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, }); 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); 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": 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": 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.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 ); 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));