From 27ce53f7d4c899333266dc135750bc623e5b5e1a Mon Sep 17 00:00:00 2001 From: Matt Nischan Date: Thu, 31 Dec 2020 08:40:52 -0600 Subject: [PATCH 1/2] First pass of holds. --- .../Airliners/CJ4/FMC/CJ4_FMC.html | 7 +- .../Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js | 500 ++++++++++++++++++ .../CJ4/FMC/CJ4_FMC_InitRefIndexPage.js | 24 +- .../Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js | 79 ++- .../Airliners/CJ4/MFD/CJ4_MFD.html | 2 + .../Airliners/CJ4/PFD/CJ4_PFD.html | 2 + .../CJ4/Shared/Autopilot/AutopilotMath.js | 162 ++++++ .../CJ4/Shared/Autopilot/HoldsDirector.js | 411 ++++++++++++++ .../CJ4/Shared/Autopilot/WT_BaseLnav.js | 20 + .../CJ4/WTLibs/Svg/SvgFlightPlanElement.js | 38 ++ .../src/flightplanning/FlightPlanManager.ts | 35 +- src/wtsdk/src/flightplanning/HoldDetails.ts | 133 +++++ .../src/flightplanning/ManagedFlightPlan.ts | 5 +- src/wtsdk/src/types/fstypes/FSTypes.d.ts | 3 + src/wtsdk/src/wtsdk.ts | 3 +- 15 files changed, 1386 insertions(+), 38 deletions(-) create mode 100644 src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js create mode 100644 src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/AutopilotMath.js create mode 100644 src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/HoldsDirector.js create mode 100644 src/wtsdk/src/flightplanning/HoldDetails.ts diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC.html b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC.html index f20c189588..ddaa5cee9a 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC.html +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC.html @@ -36,6 +36,8 @@ + + @@ -50,11 +52,12 @@ - + - + + diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js new file mode 100644 index 0000000000..b8457dc801 --- /dev/null +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js @@ -0,0 +1,500 @@ +/** + * The ACT/MOD holds and holds list pages for the FMC. + */ +class CJ4_FMC_HoldsPage { + + /** + * Creates an instance of the holds page controller. + * @param {CJ4_FMC} fmc The instance of the FMC to use with this instance. + */ + constructor(fmc) { + + /** The FMC instance. */ + this._fmc = fmc; + + /** The page state. */ + this._state = { + pageNumber: 1, + fromWaypointIndex: 0, + isModifying: false, + page: CJ4_FMC_HoldsPage.FPLN_HOLD + }; + } + + /** + * Initializes the holds page instance. + */ + prepare() { + this.update(true); + } + + /** + * Updates the holds page. + * @param {boolean} forceUpdate Whether or not to force the page update. + */ + update(forceUpdate = false) { + this._fmc.clearDisplay(); + const currentHolds = CJ4_FMC_HoldsPage.getFlightplanHolds(this._fmc); + + if (currentHolds.length === 0) { + CJ4_FMC_LegsPage.ShowPage1(this._fmc, true); + } + else { + if (this._state.page === CJ4_FMC_HoldsPage.HOLD_LIST) { + if (currentHolds.length > 1) { + this.bindHoldListInputs(currentHolds); + this.renderHoldList(currentHolds); + } + else { + this._state.page = CJ4_FMC_HoldsPage.FPLN_HOLD; + } + } + + if (this._state.page === CJ4_FMC_HoldsPage.FPLN_HOLD) { + this._state.fromWaypointIndex = this._fmc.flightPlanManager.getActiveWaypointIndex() - 1; + + if (this._state.pageNumber > currentHolds.length) { + this._state.pageNumber = currentHolds.length; + } + + if (this._state.pageNumber < 1) { + this._state.pageNumber = 1; + } + + const currentHold = currentHolds[this._state.pageNumber - 1]; + const eteSeconds = this.calculateETE(currentHold.index); + + this.bindFplnHoldInputs(currentHold, currentHolds.length); + this.renderFplnHold(currentHold, eteSeconds, currentHolds.length); + } + } + } + + /** + * Binds LSK behavior for the FPLN HOLD page. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold that is being displayed. + * @param {number} numHolds The total number of holds currently in the plan. + */ + bindFplnHoldInputs(currentHold, numHolds) { + this._fmc.onLeftInput[2] = () => this.changeHoldCourse(currentHold); + this._fmc.onLeftInput[3] = () => this.changeHoldTime(currentHold); + this._fmc.onLeftInput[4] = () => this.changeHoldDistance(currentHold); + + this._fmc.onRightInput[0] = () => this.toggleSpeedType(currentHold); + + if (numHolds < 6) { + this._fmc.onRightInput[4] = () => CJ4_FMC_LegsPage.ShowPage1(this._fmc, true); + } + + if (this._state.fromWaypointIndex === currentHold.index) { + this._fmc.onRightInput[5] = () => this.handleExitHold(currentHold); + } + + if (this._state.isModifying) { + this._fmc.onLeftInput[5] = () => this.handleCancelMod(); + } + + this._fmc.onPrevPage = () => { this._state.pageNumber--; this.update(); }; + this._fmc.onNextPage = () => { this._state.pageNumber++; this.update(); }; + this._fmc.onExecPage = () => this.handleExec(); + } + + /** + * Binds LSK behavior for the HOLD LIST page. + * @param {{waypoint: WayPoint, index: number}[]} currentHolds The current holds that are being displayed. + */ + bindHoldListInputs(currentHolds) { + for (let i = 0; i < 6; i++) { + if (i < 5 && currentHolds[i] !== undefined) { + this._fmc.onLeftInput[i] = () => CJ4_FMC_HoldsPage.showHoldPage(this._fmc, currentHolds[i].waypoint.ident); + } + else if (i >= 5 && currentHolds[i] !== undefined) { + this._fmc.onRightInput[i - 5] = () => CJ4_FMC_HoldsPage.showHoldPage(this._fmc, currentHolds[i].waypoint.ident); + } + } + + if (this._state.isModifying) { + this._fmc.onLeftInput[5] = () => this.handleCancelMod(); + } + + if (currentHolds.length < 6) { + this._fmc.onRightInput[5] = () => CJ4_FMC_LegsPage.ShowPage1(this._fmc, true); + } + } + + /** + * Changes the hold's inbound course. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold to change the course for. + */ + changeHoldCourse(currentHold) { + const input = String(this._fmc.inOut); + const parser = /(\d{3})(T?)\/(R|L)/; + + if (parser.test(input)) { + this._fmc.inOut = ''; + const matches = parser.exec(input); + + const course = parseInt(matches[1]); + const isTrueCourse = matches[2] === 'T'; + const isLeftTurn = matches[3] === 'L'; + + this._fmc.ensureCurrentFlightPlanIsTemporary(() => { + const newDetails = Object.assign({}, currentHold.waypoint.holdDetails); + + newDetails.holdCourse = course; + newDetails.isHoldCourseTrue = isTrueCourse; + newDetails.turnDirection = isLeftTurn ? HoldTurnDirection.Left : HoldTurnDirection.Right; + newDetails.entryType = HoldDetails.calculateEntryType(course, currentHold.waypoint.bearingInFP); + + this._state.isModifying = true; + this._fmc.fpHasChanged = true; + this._fmc.flightPlanManager.addHoldAtWaypointIndex(currentHold.index, newDetails) + .then(() => this.update()); + }); + } + else { + this._fmc.showErrorMessage('INVALID ENTRY'); + } + } + + /** + * Changes the hold's leg time. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold to change the leg time for. + */ + changeHoldTime(currentHold) { + const input = parseFloat(this._fmc.inOut); + + if (!isNaN(input)) { + this._fmc.inOut = ''; + + const groundSpeed = Math.min(Simplane.getGroundSpeed(), 120); + const distance = input * (groundSpeed / 60); + + this._fmc.ensureCurrentFlightPlanIsTemporary(() => { + const newDetails = Object.assign({}, currentHold.waypoint.holdDetails); + + newDetails.speed = groundSpeed; + newDetails.legTime = input * 60; + newDetails.legDistance = distance; + + this._state.isModifying = true; + this._fmc.fpHasChanged = true; + this._fmc.flightPlanManager.addHoldAtWaypointIndex(currentHold.index, newDetails) + .then(() => this.update()); + }); + } + else { + this._fmc.showErrorMessage('INVALID ENTRY'); + } + } + + /** + * Changes the hold's leg time. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold to change the leg time for. + */ + changeHoldDistance(currentHold) { + const input = parseFloat(this._fmc.inOut); + + if (!isNaN(input)) { + this._fmc.inOut = ''; + + const groundSpeed = Math.min(Simplane.getGroundSpeed(), 120); + const timeSeconds = input / (groundSpeed / 3600); + + this._fmc.ensureCurrentFlightPlanIsTemporary(() => { + const newDetails = Object.assign({}, currentHold.waypoint.holdDetails); + + newDetails.speed = groundSpeed; + newDetails.legTime = timeSeconds; + newDetails.legDistance = input; + + this._state.isModifying = true; + this._fmc.fpHasChanged = true; + this._fmc.flightPlanManager.addHoldAtWaypointIndex(currentHold.index, newDetails) + .then(() => this.update()); + }); + } + else { + this._fmc.showErrorMessage('INVALID ENTRY'); + } + } + + /** + * Toggles the hold's max speed type. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold to change the max speed type for. + */ + toggleSpeedType(currentHold) { + const speedType = currentHold.waypoint.holdDetails.holdSpeedType === HoldSpeedType.FAA ? HoldSpeedType.ICAO : HoldSpeedType.FAA; + this._fmc.ensureCurrentFlightPlanIsTemporary(() => { + const newDetails = Object.assign({}, currentHold.waypoint.holdDetails); + newDetails.holdSpeedType = speedType; + + this._state.isModifying = true; + this._fmc.flightPlanManager.addHoldAtWaypointIndex(currentHold.index, newDetails) + .then(() => this.update()); + }); + } + + /** + * Renders the FPLN HOLD page. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold. + * @param {number} eteSeconds The ETE to the hold fix in seconds. + * @param {number} numPages The total number of pages. + */ + renderFplnHold(currentHold, eteSeconds, numPages) { + const actMod = this._state.isModifying ? 'MOD[white]' : 'ACT[blue]'; + const ete = `${Math.floor(eteSeconds / 60)}:${eteSeconds % 60}`; + + const holdDetails = currentHold.waypoint.holdDetails; + const speedSwitch = this._fmc._templateRenderer.renderSwitch(["FAA", "ICAO"], holdDetails.holdSpeedType === HoldSpeedType.FAA ? 0 : 1); + + + const rows = [ + [`${actMod} FPLN HOLD[blue]`, `${this._state.pageNumber}/${numPages}[blue]`], + [' FIX[blue]', 'HOLD SPD [blue]', 'ENTRY[blue]'], + [`${currentHold.waypoint.ident}`, speedSwitch, CJ4_FMC_HoldsPage.getEntryTypeString(holdDetails.entryType)], + [' QUAD/RADIAL[blue]', 'MAX KIAS [blue]'], + ['--/---°', this.getMaxKIAS(holdDetails).toFixed(0)], + [' INBD CRS/DIR[blue]', 'FIX ETE [blue]'], + [`${holdDetails.holdCourse.toFixed(0).padStart(3, '0')}${holdDetails.isHoldCourseTrue ? 'T' : ''}°/${holdDetails.turnDirection === HoldTurnDirection.Left ? 'L' : 'R'} TURN`, `${ete}[s-text]`], + [' LEG TIME[blue]', 'EFC TIME [blue]'], + [`${(holdDetails.legTime / 60).toFixed(1)}[d-text] MIN[s-text]`, '--:--'], + [' LEG DIST[blue]'], + [`${holdDetails.legDistance.toFixed(1)}[d-text] NM[s-text]`, `${numPages < 6 ? 'NEW HOLD>' : ''}`], + ['------------------------[blue]'], + [`${this._state.isModifying ? '' : ''}`] + ]; + + this._fmc._templateRenderer.setTemplateRaw(rows); + } + + /** + * Renders the HOLD LIST page. + * @param {{waypoint: WayPoint, index: number}[]} currentHolds The current holds. + */ + renderHoldList(currentHolds) { + const actMod = this._state.isModifying ? 'MOD[white]' : 'ACT[blue]'; + const getLine = (holdIndex) => currentHolds[holdIndex] !== undefined ? currentHolds[holdIndex].waypoint.ident : ''; + + const rows = [ + [`${actMod} HOLD LIST[blue]`, '1/1[blue]'], + [''], + [`${getLine(0)}`, `${getLine(5)}`], + [''], + [`${getLine(1)}`], + [''], + [`${getLine(2)}`], + [''], + [`${getLine(3)}`], + [''], + [`${getLine(4)}`], + [''], + [`${this._state.isModifying ? '' : ''}`] + ]; + + this._fmc._templateRenderer.setTemplateRaw(rows); + } + + /** + * Handles when CANCEL MOD is pressed. + */ + handleCancelMod() { + if (this._fmc.flightPlanManager.getCurrentFlightPlanIndex() === 1) { + this._fmc.eraseTemporaryFlightPlan(() => { + this._fmc.fpHasChanged = false; + this._state.isModifying = false; + this.update(); + }); + } + } + + /** + * Handles when EXEC is pressed. + */ + handleExec() { + if (this._fmc.fpHasChanged) { + this._fmc.fpHasChanged = false; + this._state.isModifying = false; + + this._fmc.activateRoute(() => { + this.update(); + this._fmc.onExecDefault(); + }); + } + } + + /** + * Handles when EXIT HOLD is pressed. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold. + */ + handleExitHold(currentHold) { + this._fmc.exitHoldAtIndex(currentHold.index); + } + + /** + * Handles when CANCEL EXIT is pressed. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold. + */ + handleCancelExit(currentHold) { + this._fmc.cancelExitHoldAtIndex(currentHold.index); + } + + /** + * Gets the maximum KIAS for the hold given the hold speed regulation selection. + * @param {HoldDetails} holdDetails The details about the given hold. + * @returns {number} The maximum hold speed in KIAS. + */ + getMaxKIAS(holdDetails) { + const altitude = Simplane.getAltitude(); + if (holdDetails.holdSpeedType === HoldSpeedType.FAA) { + if (altitude <= 6000) { + return 200; + } + else if (altitude > 6000 && altitude <= 14000) { + return 230; + } + else { + return 265; + } + } + else if (holdDetails.holdSpeedType === HoldSpeedType.ICAO) { + if (altitude <= 14000) { + return 230; + } + else if (altitude > 14000 && altitude <= 20000) { + return 240; + } + else if (altitude > 20000 && altitude <= 34000) { + return 265; + } + else { + return 280; + } + } + } + + /** + * Calculates the estimated time enroute to the specified waypoint index for the hold. + * @param {number} index The waypoint index of the hold. + * @returns {number} The estimated time enroute to the hold, in seconds. + */ + calculateETE(index) { + const activeWaypointIndex = this._fmc.flightPlanManager.getActiveWaypointIndex() - 1; + const waypointsToHold = this._fmc.flightPlanManager.getAllWaypoints() + .slice(activeWaypointIndex, index - activeWaypointIndex); + + const planePosition = CJ4_FMC_HoldsPage.getPlanePosition(); + const groundSpeed = Simplane.getGroundSpeed(); + let distanceToHold = 0; + + for (var i = 0; i < waypointsToHold.length; i++) { + if (i === 0) { + distanceToHold += Avionics.Utils.computeGreatCircleDistance(planePosition, waypointsToHold[i].infos.coordinates); + } + else { + distanceToHold += Avionics.Utils.computeGreatCircleDistance(waypointsToHold[i - 1].infos.coordinates, waypointsToHold[i].infos.coordinates); + } + } + + return Math.round(distanceToHold / (groundSpeed / 3600)); + } + + /** + * Gets the current plane position + * @returns {LatLongAlt} The current plane position. + */ + static getPlanePosition() { + return new LatLongAlt(SimVar.GetSimVarValue("GPS POSITION LAT", "degree latitude"), SimVar.GetSimVarValue("GPS POSITION LON", "degree longitude")); + } + + /** + * Gets the holds defined in the flight plan. + * @param {CJ4_FMC} fmc The FMC to use to look up the holds. + * @returns {{waypoint: WayPoint, index: number}[]} A collection of waypoints that have holds defined. + */ + static getFlightplanHolds(fmc) { + const fromWaypointIndex = fmc.flightPlanManager.getActiveWaypointIndex() - 1; + return fmc.flightPlanManager.getAllWaypoints() + .map((waypoint, index) => ({waypoint, index})) + .slice(fromWaypointIndex) + .filter(x => x.waypoint.hasHold); + } + + /** + * Gets a string for the given entry type. + * @param {HoldEntry} entryType The entry type. + * @returns {string} The string for the entry type. + */ + static getEntryTypeString(entryType) { + switch (entryType) { + case HoldEntry.Direct: + return 'DIRECT'; + case HoldEntry.Parallel: + return 'PARALL'; + case HoldEntry.Teardrop: + return 'TEARDP'; + } + } + + /** + * Shows the FPLN HOLD page optionally for the specified ident. + * @param {CJ4_FMC} fmc The instance of the FMC to use. + * @param {string} ident The ident to show. + */ + static showHoldPage(fmc, ident) { + const instance = new CJ4_FMC_HoldsPage(fmc); + const holds = CJ4_FMC_HoldsPage.getFlightplanHolds(fmc); + + const holdPageIndex = holds.findIndex(x => x.waypoint.ident === ident); + if (holdPageIndex !== -1) { + instance._state.pageNumber = holdPageIndex + 1; + instance._state.page = CJ4_FMC_HoldsPage.FPLN_HOLD; + } + + instance._state.isModifying = fmc._fpHasChanged; + CJ4_FMC_HoldsPage.Instance = instance; + + instance.update(); + } + + /** + * Shows the HOLD LIST page. + * @param {CJ4_FMC} fmc The instance of the FMC to use. + */ + static showHoldList(fmc) { + const instance = new CJ4_FMC_HoldsPage(fmc); + + instance._state.page = CJ4_FMC_HoldsPage.HOLD_LIST; + instance._state.isModifying = fmc._fpHasChanged; + CJ4_FMC_HoldsPage.Instance = instance; + + instance.update(); + } + + /** + * Handles when HOLD is pressed from the IDX page. + * @param {CJ4_FMC} fmc The instance of the FMC to use. + */ + static handleHoldPressed(fmc) { + const holds = CJ4_FMC_HoldsPage.getFlightplanHolds(fmc); + + if (holds.length === 0) { + CJ4_FMC_LegsPage.ShowPage1(fmc, true); + } + else if (holds.length === 1) { + CJ4_FMC_HoldsPage.showHoldPage(fmc); + } + else if (holds.length > 1) { + CJ4_FMC_HoldsPage.showHoldList(fmc); + } + } +} + +CJ4_FMC_HoldsPage.Instance = undefined; + +CJ4_FMC_HoldsPage.FPLN_HOLD = 'FPLN HOLD'; +CJ4_FMC_HoldsPage.HOLD_LIST = 'HOLD LIST'; + +CJ4_FMC_HoldsPage.NONE = 'NONE'; +CJ4_FMC_HoldsPage.ADD = 'ADD'; +CJ4_FMC_HoldsPage.CHANGE_EXISTING= 'CHANGE_EXISTING'; \ No newline at end of file diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_InitRefIndexPage.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_InitRefIndexPage.js index ef3c1538f9..5a69cf5e08 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_InitRefIndexPage.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_InitRefIndexPage.js @@ -10,7 +10,7 @@ class CJ4_FMC_InitRefIndexPage { [""], ["[disabled]"], //Page 2 ---- 11 [""], - ["[disabled]"], //N/A ---- 12 + [""], //N/A ---- 12 [" FMS1[s-text]"], [""], //Page 6 ---- 13, 14 [" FMS1[s-text]"], @@ -25,7 +25,7 @@ class CJ4_FMC_InitRefIndexPage { fmc.onRightInput[0] = () => { CJ4_FMC_InitRefIndexPage.ShowPage9(fmc); }; fmc.onRightInput[1] = () => { CJ4_FMC_FrequencyPage.ShowMainPage(fmc); }; fmc.onRightInput[2] = () => { CJ4_FMC_InitRefIndexPage.ShowPage11(fmc); }; - fmc.onRightInput[3] = () => { CJ4_FMC_InitRefIndexPage.ShowPage12(fmc); }; + fmc.onRightInput[3] = () => { CJ4_FMC_HoldsPage.handleHoldPressed(fmc); }; fmc.onRightInput[4] = () => { CJ4_FMC_InitRefIndexPage.ShowPage13(fmc); }; fmc.onRightInput[5] = () => { CJ4_FMC_InitRefIndexPage.ShowPage15(fmc); }; fmc.onPrevPage = () => { CJ4_FMC_InitRefIndexPage.ShowPage2(fmc); }; @@ -364,25 +364,7 @@ class CJ4_FMC_InitRefIndexPage { ]); fmc.updateSideButtonActiveStatus(); } - static ShowPage12(fmc) { //HOLD - fmc.clearDisplay(); - fmc._templateRenderer.setTemplateRaw([ - [" ACT LEGS[blue]", "1/1[blue] "], - ["FIX ENTRY[blue]", "HOLD SPD[blue]"], - ["fix" + " DIRECT", "FAA/ICAO"], - ["QUAD/RADIAL[blue]", "MAX KIAS[blue]"], - ["NW/290\xB0", "265"], - ["INBD CRS/DIR[blue]", "FIX ETA[blue]"], - ["110\xB0 / R TURN", "time"], - ["LEG TIME[blue]", "EFC TIME[blue]"], - ["2.2 MIN", "18:35"], - ["LEG DIST[blue]"], - ["15.0 NM", "NEW HOLD>"], - ["-------- HOLD AT -------[blue]"], - ["□□□□□", "LEG WIND>"] - ]); - fmc.updateSideButtonActiveStatus(); - } + //method to calculate ETE static calcETEseconds(distance, currGS) { return (distance / currGS) * 3600; diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js index 7804cff980..805397bf33 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js @@ -6,9 +6,10 @@ let LegsPageInstance = undefined; class CJ4_FMC_LegsPage { - constructor(fmc) { + constructor(fmc, isAddingHold) { this._fmc = fmc; this._isDirty = true; // render on first run ofc + this._isAddingHold = isAddingHold; this._currentPage = 1; this._pageCount = 1; @@ -135,7 +136,12 @@ class CJ4_FMC_LegsPage { if (waypoint.fix.icao === '$DISCO') { this._rows[2 * i] = [" THEN[magenta]"]; this._rows[2 * i + 1] = ["□□□□□ - DISCONTINUITY -[magenta]"]; - } else { + } + else if (waypoint.fix.isHold) { + this._rows[2 * i] = [" HOLD AT[magenta]"]; + this._rows[2 * i + 1] = [`${waypoint.fix.ident != "" ? waypoint.fix.ident : "USR"}[magenta]`]; + } + else { this._rows[2 * i] = [" " + bearing.padStart(3, "0") + " " + distance.padStart(4, " ") + "NM[magenta]" + fpaText]; this._rows[2 * i + 1] = [waypoint.fix.ident != "" ? waypoint.fix.ident + "[magenta]" : "USR[magenta]"]; } @@ -145,6 +151,10 @@ class CJ4_FMC_LegsPage { this._rows[2 * i] = [" THEN"]; this._rows[2 * i + 1] = ["□□□□□ - DISCONTINUITY -"]; } + else if (waypoint.fix.isHold) { + this._rows[2 * i] = [" HOLD AT"]; + this._rows[2 * i + 1] = [waypoint.fix.ident != "" ? waypoint.fix.ident : "USR"]; + } else { this._rows[2 * i] = [" " + bearing.padStart(3, "0") + " " + distance.padStart(4, " ") + "NM[shite]" + fpaText]; this._rows[2 * i + 1] = [waypoint.fix.ident != "" ? waypoint.fix.ident : "USR"]; @@ -173,8 +183,8 @@ class CJ4_FMC_LegsPage { this._fmc._templateRenderer.setTemplateRaw([ [" " + modStr + " LEGS[blue]", this._currentPage.toFixed(0) + "/" + Math.max(1, this._pageCount.toFixed(0)) + " [blue]"], ...this._rows, - ["-------------------------[blue]"], - [this._lsk6Field + "", "LEG WIND>"] + [`${this._isAddingHold ? '---------HOLD AT--------' : '------------------------'}[blue]`], + [`${this._isAddingHold ? '□□□□□' : this._lsk6Field}`, "LEG WIND>"] ]); } @@ -193,6 +203,10 @@ class CJ4_FMC_LegsPage { console.log("skipping destination waypoint"); } else { displayWaypoints.push({ index: i, fix: waypoints[i] }); + if (waypoints[i].hasHold) { + displayWaypoints.push({index: i, fix: {ident: waypoints[i].ident, infos: waypoints[i].infos, isHold: true}}); + } + if (waypoints[i].endsInDiscontinuity) { displayWaypoints.push({ index: i, fix: { icao: "$DISCO", isRemovable: waypoints[i].isVectors !== true } }); } @@ -305,11 +319,16 @@ class CJ4_FMC_LegsPage { lskWaypointIndex += 1; } else { - this._fmc.showErrorMessage("UNABLE MOD DISCON"); + this._fmc.showErrorMessage("INVALID DELETE"); isMovable = false; } } + if (waypoint.fix.isHold) { + this._fmc.flightPlanManager.deleteHoldAtWaypointIndex(waypoint.index); + lskWaypointIndex += 1; + } + if (isMovable) { let removeWaypointForLegsMethod = (callback = EmptyCallback.Void) => { if (lskWaypointIndex < scratchPadWaypointIndex) { @@ -351,9 +370,15 @@ class CJ4_FMC_LegsPage { selectedWpIndex = Infinity; } if (waypoint.fix.icao === '$DISCO') { - this._fmc.showErrorMessage("UNABLE MOD DISCON"); - this._fmc.setMsg(); - return; + if (waypoint.fix.isRemovable) { + this._fmc.flightPlanManager.clearDiscontinuity(waypoint.index); + selectedWpIndex += 1; + } + else { + this._fmc.showErrorMessage("INVALID DELETE"); + this._fmc.setMsg(); + return; + } } } let scratchPadWaypointIndex = this._fmc.selectedWaypoint ? this._fmc.selectedWaypoint.index : undefined; @@ -402,9 +427,13 @@ class CJ4_FMC_LegsPage { this.resetAfterOp(); } else { - this._fmc.showErrorMessage("UNABLE CLR DISCON"); + this._fmc.showErrorMessage("INVALID DELETE"); } } + else if (waypoint.fix.isHold) { + this._fmc.flightPlanManager.deleteHoldAtWaypointIndex(waypoint.index); + this.resetAfterOp(); + } else { this._fmc.flightPlanManager.removeWaypoint(selectedWpIndex, false, () => { this.resetAfterOp(); @@ -432,7 +461,10 @@ class CJ4_FMC_LegsPage { bindEvents() { this._fmc.onLeftInput[5] = () => { - if (this._lsk6Field == " ({waypoint, index})) + .slice(this._activeWptIndex) + .find(x => x.waypoint.ident === this._fmc.inOut); + + if (holdWaypoint !== undefined) { + + this._fmc.ensureCurrentFlightPlanIsTemporary(() => { + const details = HoldDetails.createDefault(holdWaypoint.waypoint.bearingInFP, holdWaypoint.waypoint.bearingInFP); + this._fmc.flightPlanManager.addHoldAtWaypointIndex(holdWaypoint.index, details); + this._fmc.fpHasChanged = true; + + this._fmc.inOut = ''; + CJ4_FMC_HoldsPage.showHoldPage(this._fmc, holdWaypoint.waypoint.ident); + }); + } + else { + this._fmc.showErrorMessage('INVALID ENTRY'); + } + } + // TODO, later this could be in the base class invalidate() { this._isDirty = true; @@ -719,12 +774,12 @@ class CJ4_FMC_LegsPage { } } - static ShowPage1(fmc) { + static ShowPage1(fmc, isAddingHold = false) { // console.log("SHOW LEGS PAGE 1"); fmc.clearDisplay(); // create page instance and init - LegsPageInstance = new CJ4_FMC_LegsPage(fmc); + LegsPageInstance = new CJ4_FMC_LegsPage(fmc, isAddingHold); LegsPageInstance.update(); } diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/MFD/CJ4_MFD.html b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/MFD/CJ4_MFD.html index 0ed9347cad..c2bd88a3a8 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/MFD/CJ4_MFD.html +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/MFD/CJ4_MFD.html @@ -340,6 +340,8 @@ + + diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/PFD/CJ4_PFD.html b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/PFD/CJ4_PFD.html index 812a1c8df9..92d1450527 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/PFD/CJ4_PFD.html +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/PFD/CJ4_PFD.html @@ -256,6 +256,8 @@ + + diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/AutopilotMath.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/AutopilotMath.js new file mode 100644 index 0000000000..8561425c99 --- /dev/null +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/AutopilotMath.js @@ -0,0 +1,162 @@ +class AutopilotMath { + + /** + * Calculates the desired intercept angle, taking the current nav sensitivity into account. + * @param {number} xtk The current cross-track error, in NM. + * @param {number} navSensitivity The current nav sensitity mode. + * @param {number} maxAngle The maximum intercept angle, in degrees. + * @returns {number} The desired intercept angle, in degrees. + */ + static interceptAngle(xtk, navSensitivity, maxAngle = 45) { + let sensitivityModifier = 1; + let minimumInterceptAngle = 2.5; + let minimumXtk = 0.025; + + switch (navSensitivity) { + case NavSensitivity.TERMINALLPV: + case NavSensitivity.TERMINAL: + sensitivityModifier = 1.1; + minimumInterceptAngle = 3.0; + minimumXtk = 0.015; + break; + case NavSensitivity.APPROACH: + case NavSensitivity.APPROACHLPV: + sensitivityModifier = 1.25; + minimumInterceptAngle = 3.0; + minimumXtk = 0.005; + break; + } + + let absInterceptAngle = Math.min(Math.pow(Math.abs(xtk) * 20, 1.35) * sensitivityModifier, maxAngle); + + //If we still have some XTK, bake in a minimum intercept angle to keep us on the line + if (Math.abs(xtk) > minimumXtk) { + absInterceptAngle = Math.max(absInterceptAngle, minimumInterceptAngle); + } + + const interceptAngle = xtk < 0 ? absInterceptAngle : -1 * absInterceptAngle; + return interceptAngle; + } + + /** + * Calculates the wind correction angle. + * @param {number} course The current plane true course. + * @param {number} airspeedTrue The current plane true airspeed. + * @param {number} windDirection The direction of the wind, in degrees true. + * @param {number} windSpeed The current speed of the wind. + * @returns {number} The calculated wind correction angle. + */ + static windCorrectionAngle(course, airspeedTrue, windDirection, windSpeed) { + const currCrosswind = windSpeed * (Math.sin((course * Math.PI / 180) - (windDirection * Math.PI / 180))); + const windCorrection = 180 * Math.asin(currCrosswind / airspeedTrue) / Math.PI; + + return windCorrection; + } + + /** + * Calculates the cross track deviation from the provided leg fixes. + * @param {LatLongAlt} fromFix The location of the starting fix of the leg. + * @param {LatLongAlt} toFix The location of the ending fix of the leg. + * @param {LatLongAlt} planeCoords The current plane location coordinates. + * @returns {number} The amount of cross track deviation, in nautical miles. + */ + static crossTrack(fromFix, toFix, planeCoords) { + const planePosition = new LatLon(planeCoords.lat, planeCoords.long); + return planePosition.crossTrackDistanceTo(new LatLon(fromFix.lat, fromFix.long), new LatLon(toFix.lat, toFix.long)) * (0.000539957); + } + + /** + * Calculates the desired track from the provided leg fixes. + * @param {LatLongAlt} fromFix The location of the starting fix of the leg. + * @param {LatLongAlt} toFix The location of the ending fix of the leg. + * @param {LatLongAlt} planeCoords The current plane location coordinates. + * @returns {number} The desired track, in degrees true. + */ + static desiredTrack(fromFix, toFix, planeCoords) { + const planePosition = new LatLon(planeCoords.lat, planeCoords.long); + const legStart = new LatLon(fromFix.lat, fromFix.long); + const legEnd = new LatLon(toFix.lat, toFix.long); + + const totalTrackDistance = legStart.distanceTo(legEnd); + const alongTrackDistance = planePosition.alongTrackDistanceTo(legStart, legEnd); + + const currentTrackPoint = legStart.intermediatePointTo(legEnd, Math.min(Math.max(alongTrackDistance / totalTrackDistance, .05), .95)); + return currentTrackPoint.initialBearingTo(legEnd); + } + + /** + * Calculates the desired track from the provided leg fixes. + * @param {LatLongAlt} fromFix The location of the starting fix of the leg. + * @param {LatLongAlt} toFix The location of the ending fix of the leg. + * @param {LatLongAlt} planeCoords The current plane location coordinates. + * @returns {number} The desired track, in degrees true. + */ + static desiredTrackArc(fromFix, toFix, planeCoords) { + const cLat = (fromFix.lat + toFix.lat) / 2; + const cLon = (fromFix.long + toFix.long) / 2; + + const arcAngle = Math.atan2(planeCoords.lat - cLat, cLon - planeCoords.long) * Avionics.Utils.RAD2DEG; + return AutopilotMath.normalizeHeading(arcAngle); + } + + /** + * Calculates the desired track from the provided leg fixes. + * @param {LatLongAlt} fromFix The location of the starting fix of the leg. + * @param {LatLongAlt} toFix The location of the ending fix of the leg. + * @param {LatLongAlt} planeCoords The current plane location coordinates. + * @returns {number} The desired track, in degrees true. + */ + static crossTrackArc(fromFix, toFix, planeCoords) { + const cLat = (fromFix.lat + toFix.lat) / 2; + const cLon = (fromFix.long + toFix.long) / 2; + const radius = Avionics.Utils.computeGreatCircleDistance(fromFix, toFix) / 2; + + const centerDistance = Avionics.Utils.computeGreatCircleDistance(planeCoords, new LatLongAlt(cLat, cLon)); + return -1 * (centerDistance - radius); + } + + /** + * Normalizes a heading to a 0-360 range. + * @param {number} heading The heading to normalize. + * @returns {number} The normalized heading. + */ + static normalizeHeading(heading) { + let normalized = heading; + while (normalized > 360) { + normalized -= 360; + } + + while (normalized < 0) { + normalized += 360; + } + + return normalized; + } + + /** + * Gets the turn radius for a given true airspeed. + * @param {number} airspeedTrue The true airspeed of the plane. + * @param {number} bankAngle The bank angle of the plane, in degrees. + * @returns {number} The airplane turn radius. + */ + static turnRadius(airspeedTrue, bankAngle) { + return (Math.pow(airspeedTrue, 2) / (11.26 * Math.tan(bankAngle * Avionics.Utils.DEG2RAD))) + / 6076.11549; + } + + /** + * Gets the headwind/tailwind and crosswind components from a wind vector + * relative to a provided heading. + * @param {number} heading The direction to get components for. + * @param {number} windDirection The direction of the wind. + * @param {number} windSpeed The speed of the wind. + * @returns {{headwind: number, crosswind: number}} The wind components. + */ + static windComponents(heading, windDirection, windSpeed) { + const relativeWindHeading = AutopilotMath.normalizeHeading(windDirection - heading); + const headwind = windSpeed * Math.sin(relativeWindHeading * Avionics.Utils.DEG2RAD); + const crosswind = windSpeed * Math.cos(relativeWindHeading * Avionics.Utils.DEG2RAD); + + return {headwind, crosswind}; + } +} \ No newline at end of file diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/HoldsDirector.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/HoldsDirector.js new file mode 100644 index 0000000000..e863c098af --- /dev/null +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/HoldsDirector.js @@ -0,0 +1,411 @@ +/** A class that manages lateral guidance for holds. */ +class HoldsDirector { + + /** + * Creates an instance of a HoldsDirector. + * @param {FlightPlanManager} fpm An instance of the flight plan manager. + * @param {number} holdWaypointIndex The index of the waypoint to hold at. + */ + constructor(fpm, holdWaypointIndex) { + + /** The flight plan manager. */ + this.fpm = fpm; + + /** The hold waypoint index. */ + this.holdWaypointIndex = holdWaypointIndex; + + /** The current flight plan version. */ + this.currentFlightPlanVersion = 0; + + /** The current state of the holds director. */ + this.state = HoldsDirectorState.NONE; + + /** + * The coordinates to hold at. + * @type {LatLongAlt} + */ + this.fixCoords = undefined; + + /** + * The fix coordinates prior to the hold fix. + * @type {LatLongAlt} + */ + this.prevFixCoords = undefined; + + /** The inbound leg for the hold. */ + this.inboundLeg = []; + + /** The outbound leg for the hold. */ + this.outboundLeg = []; + } + + /** + * Sets up the hold in the HoldsDirector. + */ + initializeHold() { + const holdWaypoint = this.fpm.getFlightPlan(0).getWaypoint(this.holdWaypointIndex); + const prevWaypoint = this.fpm.getFlightPlan(0).getWaypoint(this.holdWaypointIndex - 1); + + if (holdWaypoint && prevWaypoint) { + const holdDetails = holdWaypoint.holdDetails; + const fixCoords = holdWaypoint.infos.coordinates; + const prevFixCoords = prevWaypoint.infos.coordinates; + + const trackToHold = new LatLon(prevFixCoords.lat, prevFixCoords.long).finalBearingTo(new LatLon(fixCoords.lat, fixCoords.long)); + + if (this.state === HoldsDirectorState.NONE) { + this.state = HoldsDirector.calculateEntryState(holdDetails.holdCourse, trackToHold); + } + + this.fixCoords = fixCoords; + this.prevFixCoords = prevFixCoords; + + const legFixes = HoldsDirector.calculateHoldFixes(fixCoords, holdDetails); + this.inboundLeg = [legFixes[3], legFixes[0]]; + this.outboundLeg = [legFixes[1], legFixes[2]]; + } + } + + /** + * Recalculates the hold with a new plane speed. + * @param {AircraftState} planeState The current aircraft state. + */ + recalculateHold(planeState) { + const holdWaypoint = this.fpm.getFlightPlan(0).getWaypoint(this.holdWaypointIndex); + const holdDetails = Object.assign(new HoldDetails(), holdWaypoint.holdDetails); + + holdDetails.speed = planeState.groundSpeed; + holdDetails.windDirection = planeState.windDirection; + holdDetails.windSpeed = planeState.windSpeed; + + const windComponents = AutopilotMath.windComponents(holdDetails.holdCourse, planeState.windDirection, planeState.windSpeed); + holdDetails.legDistance = ((holdDetails.speed + Math.abs(windComponents.headwind / 2)) / 3600) * holdDetails.legTime; + + this.fpm.addHoldAtWaypointIndex(this.holdWaypointIndex, holdDetails); + } + + /** + * Updates the hold director. + */ + update() { + const flightPlanVersion = SimVar.GetSimVarValue('L:WT.FlightPlan.Version', 'number'); + if (flightPlanVersion !== this.currentFlightPlanVersion) { + this.initializeHold(); + this.currentFlightPlanVersion = flightPlanVersion; + } + + const planeState = this.getAircraftState(); + switch (this.state) { + case HoldsDirectorState.ENTRY_DIRECT_INBOUND: + this.handleDirectEntry(planeState); + break; + case HoldsDirectorState.ENTRY_TEARDROP_INBOUND: + case HoldsDirectorState.ENTRY_TEARDROP_OUTBOUND: + case HoldsDirectorState.ENTRY_TEARDROP_TURNING: + this.handleTeardropEntry(planeState); + break; + case HoldsDirectorState.ENTRY_PARALLEL_INBOUND: + case HoldsDirectorState.ENTRY_PARALLEL_OUTBOUND: + case HoldsDirectorState.ENTRY_PARALLEL_TURNING: + this.handleParallelEntry(planeState); + break; + case HoldsDirectorState.INBOUND: + case HoldsDirectorState.TURNING_OUTBOUND: + case HoldsDirectorState.OUTBOUND: + case HoldsDirectorState.TURNING_INBOUND: + this.handleInHold(planeState); + break; + } + } + + /** + * Gets the current state of the aircraft. + */ + getAircraftState() { + const state = new AircraftState(); + state.position = new LatLongAlt(SimVar.GetSimVarValue("GPS POSITION LAT", "degree latitude"), SimVar.GetSimVarValue("GPS POSITION LON", "degree longitude")); + state.magVar = SimVar.GetSimVarValue("MAGVAR", "degrees"); + + state.groundSpeed = SimVar.GetSimVarValue("GPS GROUND SPEED", "knots"); + state.trueAirspeed = SimVar.GetSimVarValue('AIRSPEED TRUE', 'knots'); + + state.windDirection = SimVar.GetSimVarValue("AMBIENT WIND DIRECTION", "degrees"); + state.windSpeed = SimVar.GetSimVarValue("AMBIENT WIND VELOCITY", "knots"); + + state.trueHeading = SimVar.GetSimVarValue('PLANE HEADING DEGREES TRUE', 'Radians') * Avionics.Utils.RAD2DEG; + state.magneticHeading = SimVar.GetSimVarValue('PLANE HEADING DEGREES MAGNETIC', 'Radians') * Avionics.Utils.RAD2DEG; + state.trueTrack = SimVar.GetSimVarValue('GPS GROUND TRUE TRACK', 'Radians') * Avionics.Utils.RAD2DEG; + + return state; + } + + /** + * Handles the direct entry state. + * @param {AircraftState} planeState The current aircraft state. + */ + handleDirectEntry(planeState) { + const dtk = AutopilotMath.desiredTrack(this.prevFixCoords, this.fixCoords, planeState.position); + const planeToFixTrack = Avionics.Utils.computeGreatCircleHeading(planeState.position, this.fixCoords); + + const trackDiff = Math.abs(Avionics.Utils.angleDiff(dtk, planeToFixTrack)); + + if (trackDiff > 90) { + HoldsDirector.setCourse(AutopilotMath.normalizeHeading(dtk + 45), planeState); + this.fpm.setActiveWaypointIndex(this.holdWaypointIndex + 1); + this.recalculateHold(planeState); + + this.state = HoldsDirectorState.TURNING_OUTBOUND; + } + else { + this.trackLeg(this.prevFixCoords, this.fixCoords, planeState); + } + } + + /** + * Handles the in-hold state. + * @param {AircraftState} planeState The current aircraft state. + */ + handleInHold(planeState) { + if (this.state === HoldsDirectorState.TURNING_OUTBOUND) { + const dtk = AutopilotMath.desiredTrack(this.outboundLeg[0], this.outboundLeg[1], planeState.position); + + if (this.isAbeam(dtk, planeState.position, this.outboundLeg[0])) { + this.state = HoldsDirectorState.OUTBOUND; + } + else { + this.trackArc(this.inboundLeg[1], this.outboundLeg[0], planeState); + this.trackLeg(this.outboundLeg[0], this.outboundLeg[1], planeState, false); + } + } + + if (this.state === HoldsDirectorState.OUTBOUND) { + const dtk = AutopilotMath.desiredTrack(this.outboundLeg[0], this.outboundLeg[1], planeState.position); + + if (this.isAbeam(dtk, planeState.position, this.outboundLeg[1])) { + this.state = HoldsDirectorState.TURNING_INBOUND; + } + else { + this.trackLeg(this.outboundLeg[0], this.outboundLeg[1], planeState); + } + } + + if (this.state === HoldsDirectorState.TURNING_INBOUND) { + const dtk = AutopilotMath.desiredTrack(this.inboundLeg[0], this.inboundLeg[1], planeState.position); + const trackDiff = Avionics.Utils.angleDiff(dtk, planeState.trueTrack); + + if (this.isAbeam(dtk, planeState.position, this.inboundLeg[0])) { + this.state = HoldsDirectorState.INBOUND; + } + else { + this.trackArc(this.outboundLeg[1], this.inboundLeg[0], planeState); + this.trackLeg(this.inboundLeg[0], this.inboundLeg[1], planeState, false); + } + } + + if (this.state === HoldsDirectorState.INBOUND) { + const dtk = AutopilotMath.desiredTrack(this.inboundLeg[0], this.inboundLeg[1], planeState.position); + + if (this.isAbeam(dtk, planeState.position, this.inboundLeg[1])) { + this.recalculateHold(planeState); + this.state = HoldsDirectorState.TURNING_OUTBOUND; + } + else { + this.trackLeg(this.inboundLeg[0], this.inboundLeg[1], planeState); + } + } + } + + /** + * Tracks the specified leg. + * @param {LatLongAlt} legStart The coordinates of the start of the leg. + * @param {LatLongAlt} legEnd The coordinates of the end of the leg. + * @param {AircraftState} planeState The current aircraft state. + * @param {boolean} execute Whether or not to execute the calculated course. + */ + trackLeg(legStart, legEnd, planeState, execute = true) { + const dtk = AutopilotMath.desiredTrack(legStart, legEnd, planeState.position); + const xtk = AutopilotMath.crossTrack(legStart, legEnd, planeState.position); + + const distanceRemaining = Avionics.Utils.computeGreatCircleDistance(planeState.position, legEnd); + const correctedDtk = GeoMath.correctMagvar(dtk, SimVar.GetSimVarValue("MAGVAR", "degrees")); + + SimVar.SetSimVarValue("L:WT_CJ4_XTK", "number", xtk); + SimVar.SetSimVarValue("L:WT_CJ4_DTK", "number", correctedDtk); + SimVar.SetSimVarValue("L:WT_CJ4_WPT_DISTANCE", "number", distanceRemaining); + + const interceptAngle = AutopilotMath.interceptAngle(xtk, NavSensitivity.NORMAL); + const bearingToWaypoint = Avionics.Utils.computeGreatCircleHeading(planeState.position, legEnd); + const deltaAngle = Math.abs(Avionics.Utils.angleDiff(dtk, bearingToWaypoint)); + + const headingToSet = deltaAngle < Math.abs(interceptAngle) ? AutopilotMath.normalizeHeading(dtk + interceptAngle) : bearingToWaypoint; + + if (distanceRemaining > 1 && execute) { + HoldsDirector.setCourse(headingToSet, planeState); + } + } + + /** + * Tracks an arc leg. + * @param {LatLongAlt} legStart The start of the leg. + * @param {LatLongAlt} legEnd The end of the leg. + * @param {AircraftState} planeState The state of the aircraft. + */ + trackArc(legStart, legEnd, planeState) { + const dtk = AutopilotMath.desiredTrackArc(legStart, legEnd, planeState.position); + const xtk = AutopilotMath.crossTrackArc(legStart, legEnd, planeState.position); + + const distanceRemaining = Avionics.Utils.computeGreatCircleDistance(planeState.position, legEnd); + const correctedDtk = GeoMath.correctMagvar(dtk, SimVar.GetSimVarValue("MAGVAR", "degrees")); + + const interceptAngle = AutopilotMath.interceptAngle(xtk, NavSensitivity.APPROACHLPV, 25); + HoldsDirector.setCourse(AutopilotMath.normalizeHeading(dtk + interceptAngle), planeState); + } + + /** + * Calculates whether or not the aircraft is abeam the provided leg end. + * @param {number} dtk The desired track along the leg. + * @param {LatLongAlt} planePosition The current position of the aircraft. + * @param {LatLongAlt} fixCoords The coordinates of the leg end fix. + */ + isAbeam(dtk, planePosition, fixCoords) { + const planeToFixTrack = Avionics.Utils.computeGreatCircleHeading(planePosition, fixCoords); + const trackDiff = Math.abs(Avionics.Utils.angleDiff(dtk, planeToFixTrack)); + + return trackDiff > 100; + } + + /** + * Calculates a hold entry state given the hold course and current + * inbound course. See FMS guide page 14-21. + * @param {number} holdCourse The course that the hold will be flown with. + * @param {number} inboundCourse The course that is being flown towards the hold point. + * @returns {string} The hold entry state for a given set of courses. + */ + static calculateEntryState(holdCourse, inboundCourse) { + const courseDiff = Avionics.Utils.angleDiff(holdCourse, inboundCourse); + if (courseDiff >= -130 && courseDiff <= 70) { + return HoldsDirectorState.ENTRY_DIRECT_INBOUND; + } + else if (courseDiff < -130 && courseDiff > 175) { + return HoldsDirectorState.ENTRY_TEARDROP_INBOUND; + } + else { + return HoldsDirectorState.ENTRY_DIRECT_INBOUND; + } + } + + /** + * Calculates the hold legs from the provided hold course and airspeed. + * @param {LatLongAlt} holdFixCoords The coordinates of the hold fix. + * @param {HoldDetails} holdDetails The details of the hold. + * @param {AircraftState} planeState The true course that the hold will be flown with. + * @returns {LatLongAlt[]} The four hold corner positions calculated, clockwise starting with the hold fix coordinates. + */ + static calculateHoldFixes(holdFixCoords, holdDetails) { + + const windComponents = AutopilotMath.windComponents(holdDetails.holdCourse, holdDetails.windDirection, holdDetails.windSpeed); + const turnRadius = AutopilotMath.turnRadius(holdDetails.speed + Math.abs(windComponents.crosswind), 25); + + const outboundStart = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + 90), turnRadius * 2, + holdFixCoords.lat, holdFixCoords.long); + + const outboundEnd = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + 180), holdDetails.legDistance, + outboundStart.lat, outboundStart.long); + + const inboundStart = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + 180), holdDetails.legDistance, + holdFixCoords.lat, holdFixCoords.long); + + return [holdFixCoords, outboundStart, outboundEnd, inboundStart]; + } + + /** + * Sets the autopilot course to fly. + * @param {number} degreesTrue The track in degrees true for the autopilot to fly. + * @param {AircraftState} planeState The current state of the aircraft. + */ + static setCourse(degreesTrue, planeState) { + const currWindDirection = GeoMath.removeMagvar(planeState.windDirection, planeState.magVar); + + const windCorrection = AutopilotMath.windCorrectionAngle(degreesTrue, planeState.trueAirspeed, currWindDirection, planeState.windSpeed); + + let targetHeading = AutopilotMath.normalizeHeading(degreesTrue - windCorrection); + targetHeading = GeoMath.correctMagvar(targetHeading, planeState.magVar); + + Coherent.call("HEADING_BUG_SET", 2, targetHeading); + } +} + +class HoldsDirectorState { } +HoldsDirectorState.NONE = 'NONE'; +HoldsDirectorState.ENTRY_DIRECT_INBOUND = 'ENTRY_DIRECT_INBOUND'; +HoldsDirectorState.ENTRY_TEARDROP_INBOUND = 'ENTRY_TEARDROP_INBOUND'; +HoldsDirectorState.ENTRY_TEARDROP_OUTBOUND = 'ENTRY_TEARDROP_OUTBOUND'; +HoldsDirectorState.ENTRY_TEARDROP_TURNING = 'ENTRY_TEARDROP_TURNING'; +HoldsDirectorState.ENTRY_PARALLEL_INBOUND = 'ENTRY_PARALLEL_INBOUND'; +HoldsDirectorState.ENTRY_PARALLEL_OUTBOUND = 'ENTRY_PARALLEL_OUTBOUND'; +HoldsDirectorState.ENTRY_PARALLEL_TURNING = 'ENTRY_PARALLEL_TURNING'; +HoldsDirectorState.TURNING_OUTBOUND = 'TURNING_OUTBOUND'; +HoldsDirectorState.OUTBOUND = 'OUTBOUND'; +HoldsDirectorState.TURNING_INBOUND = 'TURNING_INBOUND'; +HoldsDirectorState.INBOUND = 'INBOUND'; + +/** + * The current state of the aircraft for LNAV. + */ +class AircraftState { + constructor() { + /** + * The true airspeed of the plane. + * @type {number} + */ + this.trueAirspeed = undefined; + + /** + * The ground speed of the plane. + * @type {number} + */ + this.groundSpeed = undefined; + + /** + * The current plane location magvar. + * @type {number} + */ + this.magVar = undefined; + + /** + * The current plane position. + * @type {LatLon} + */ + this.position = undefined; + + /** + * The wind speed. + * @type {number} + */ + this.windSpeed = undefined; + + /** + * The wind direction. + * @type {number} + */ + this.windDirection = undefined; + + /** + * The current heading in degrees true of the plane. + * @type {number} + */ + this.trueHeading = undefined; + + /** + * The current heading in degrees magnetic of the plane. + * @type {number} + */ + this.magneticHeading = undefined; + + /** + * The current track in degrees true of the plane. + * @type {number} + */ + this.trueTrack = undefined; + } +} \ No newline at end of file diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/WT_BaseLnav.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/WT_BaseLnav.js index 024c0ec7b9..6f9ecc904f 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/WT_BaseLnav.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/WT_BaseLnav.js @@ -90,6 +90,26 @@ class WT_BaseLnav { const navModeActive = SimVar.GetSimVarValue("L:WT_CJ4_NAV_ON", "number") == 1; this._inhibitSequence = SimVar.GetSimVarValue("L:WT_CJ4_INHIBIT_SEQUENCE", "number") == 1; + if (this._activeWaypoint.hasHold) { + if (!this._holdsDirector) { + this._holdsDirector = new HoldsDirector(this._fpm, this.flightplan.activeWaypointIndex); + } + + this._holdsDirector.update(); + return; + } + + if (this._previousWaypoint.hasHold) { + if (!this._holdsDirector) { + this._holdsDirector = new HoldsDirector(this._fpm, this.flightplan.activeWaypointIndex - 1); + this._holdsDirector.update(); + } + else if (this._holdsDirector && this._holdsDirector.state !== HoldsDirectorState.NONE) { + this._holdsDirector.update(); + return; + } + } + const flightPlanVersion = SimVar.GetSimVarValue('L:WT.FlightPlan.Version', 'number'); if (flightPlanVersion !== this._currentFlightPlanVersion) { if (this._waypointSequenced) { diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/WTLibs/Svg/SvgFlightPlanElement.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/WTLibs/Svg/SvgFlightPlanElement.js index 2d47cb66a0..2a9621f586 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/WTLibs/Svg/SvgFlightPlanElement.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/WTLibs/Svg/SvgFlightPlanElement.js @@ -108,9 +108,47 @@ class SvgFlightPlanElement extends SvgMapElement { prevWaypoint = waypoint; } + for (let i = 0; i < waypoints.length; i++) { + const waypoint = waypoints[i]; + if (waypoint.hasHold) { + + let course = waypoint.holdDetails.holdCourse; + if (!waypoint.holdDetails.isHoldCourseTrue) { + const magVar = GeoMath.getMagvar(waypoint.infos.coordinates.lat, waypoint.infos.coordinates.long); + course = AutopilotMath.normalizeHeading(course + magVar); + } + + const corners = HoldsDirector.calculateHoldFixes(waypoint.infos.coordinates, waypoint.holdDetails) + .map(c => map.coordinatesToXY(c)); + + context.moveTo(corners[0].x, corners[0].y); + this.drawHoldArc(corners[0], corners[1], context); + context.lineTo(corners[2].x, corners[2].y); + this.drawHoldArc(corners[2], corners[3], context); + context.lineTo(corners[0].x, corners[0].y); + } + } + context.stroke(); } + /** + * + * @param {Vec2} p1 + * @param {Vec2} p2 + * @param {CanvasRenderingContext2D} context + */ + drawHoldArc(p1, p2, context) { + const cx = (p1.x + p2.x) / 2; + const cy = (p1.y + p2.y) / 2; + const radius = p1.Distance(p2) / 2; + + const a1 = Math.atan2(p1.y - cy, p1.x - cx); + const a2 = Math.atan2(p2.y - cy, p2.x - cx); + + context.arc(cx, cy, radius, a1, a2); + } + setAsDashed(_val, _force = false) { if (_force || (_val != this._isDashed)) { this._isDashed = _val; diff --git a/src/wtsdk/src/flightplanning/FlightPlanManager.ts b/src/wtsdk/src/flightplanning/FlightPlanManager.ts index 57e1405907..25de3a7c24 100644 --- a/src/wtsdk/src/flightplanning/FlightPlanManager.ts +++ b/src/wtsdk/src/flightplanning/FlightPlanManager.ts @@ -4,6 +4,7 @@ import { FlightPlanSegment } from './FlightPlanSegment'; import { FlightPlanAsoboSync } from './FlightPlanAsoboSync'; import { LZUTF8, WTDataStore } from 'WorkingTitle' import * as _LZUTF8 from '../utils/LzUtf8' +import { HoldDetails } from './HoldDetails'; /** * A system for managing flight plan data used by various instruments. @@ -267,7 +268,6 @@ export class FlightPlanManager { const currentFlightPlan = this._flightPlans[fplnIndex]; if (index >= 0 && index < currentFlightPlan.length) { currentFlightPlan.activeWaypointIndex = index; - if (currentFlightPlan.directTo.isActive && currentFlightPlan.directTo.waypointIsInFlightPlan && currentFlightPlan.activeWaypointIndex > currentFlightPlan.directTo.planWaypointIndex) { currentFlightPlan.directTo.isActive = false; @@ -1354,6 +1354,39 @@ export class FlightPlanManager { public getCoordinatesHeadingAtDistanceAlongFlightPlan(distance) { } + /** + * Adds a hold at the specified waypoint index in the flight plan. + * @param index The waypoint index to hold at. + * @param details The details of the hold to execute. + */ + public async addHoldAtWaypointIndex(index: number, details: HoldDetails): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const waypoint = currentFlightPlan.getWaypoint(index); + + if (waypoint) { + waypoint.hasHold = true; + waypoint.holdDetails = details; + + await this._updateFlightPlanVersion(); + } + } + + /** + * Deletes a hold at the specified waypoint index in the flight plan. + * @param index The waypoint index to delete the hold at. + */ + public async deleteHoldAtWaypointIndex(index: number): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const waypoint = currentFlightPlan.getWaypoint(index); + + if (waypoint) { + waypoint.hasHold = false; + waypoint.holdDetails = undefined; + + await this._updateFlightPlanVersion(); + } + } + /** * Gets the coordinates of a point that is a specific distance from the destination along the flight plan. * @param distance The distance from destination we want the coordinates for. diff --git a/src/wtsdk/src/flightplanning/HoldDetails.ts b/src/wtsdk/src/flightplanning/HoldDetails.ts new file mode 100644 index 0000000000..cf4ea72798 --- /dev/null +++ b/src/wtsdk/src/flightplanning/HoldDetails.ts @@ -0,0 +1,133 @@ +import { Avionics, Simplane } from 'MSFS'; + +/** + * Details of a hold procedure for a fix. + */ +export class HoldDetails { + /** The course to fly the hold at. */ + public holdCourse: number; + + /** Whether or not the hold course is a true course. */ + public isHoldCourseTrue: boolean; + + /** The direction to turn in the hold. */ + public turnDirection: HoldTurnDirection; + + /** The amount of time for each hold leg, in seconds. */ + public legTime: number; + + /** The amount of distance for each hold leg, in seconds. */ + public legDistance: number; + + /** The speed at which to fly the hold, in knots indicated airspeed. */ + public speed: number; + + /** The type of hold speed restriction. */ + public holdSpeedType: HoldSpeedType; + + /** The time to expect further clearance. */ + public efcTime: Date; + + /** The current hold state. */ + public state: HoldState; + + /** The hold entry type. */ + public entryType: HoldEntry; + + /** The recorded wind direction. */ + public windDirection: number; + + /** The recorded wind speed. */ + public windSpeed: number; + + /** + * Creates a default set of hold details. + * @param course The course to create the hold details for. + * @param courseTowardsHoldFix The course to the hold fix. + * @returns A new set of hold details. + */ + static createDefault(course: number, courseTowardsHoldFix: number): HoldDetails { + const details = new HoldDetails(); + + details.holdCourse = course; + details.holdSpeedType = HoldSpeedType.FAA; + details.legTime = 90; + details.speed = Simplane.getGroundSpeed(); + + details.windDirection = 0; + details.windSpeed = 0; + + details.legDistance = details.legTime * (details.speed / 3600); + details.turnDirection = HoldTurnDirection.Right; + + details.state = HoldState.None; + details.entryType = HoldDetails.calculateEntryType(course, courseTowardsHoldFix); + + return details; + } + + /** + * Calculates a hold entry type given the hold course and current + * inbound course. See FMS guide page 14-21. + * @param holdCourse The course that the hold will be flown with. + * @param inboundCourse The course that is being flown towards the hold point. + * @returns The hold entry type for a given set of courses. + */ + static calculateEntryType(holdCourse: number, inboundCourse: number): HoldEntry { + const courseDiff = Avionics.Utils.angleDiff(inboundCourse, holdCourse); + if (courseDiff >= -130 && courseDiff <= 70) { + return HoldEntry.Direct; + } + else if (courseDiff < -130 || courseDiff > 175) { + return HoldEntry.Teardrop; + } + else { + return HoldEntry.Parallel; + } + } +} + +/** The type of hold speed restriction. */ +export enum HoldSpeedType { + /** Use FAA hold speed rules. */ + FAA, + + /** Use ICAO hold speed rules. */ + ICAO +} + +/** The direction of the hold turn. */ +export enum HoldTurnDirection { + /** Use a right hand turn. */ + Right, + + /** Use a left hand turn. */ + Left +} + +/** The current state of the hold. */ +export enum HoldState { + /** The hold is not active. */ + None, + + /** The hold is currently being entered. */ + Entering, + + /** The hold is active. */ + Holding, + + /** The hold is being exited. */ + Exiting +} + +/** The hold entry type. */ +export enum HoldEntry { + /** Direct hold entry. */ + Direct, + + /** Teardrop hold entry. */ + Teardrop, + + /** Parallel hold entry. */ + Parallel +} \ No newline at end of file diff --git a/src/wtsdk/src/flightplanning/ManagedFlightPlan.ts b/src/wtsdk/src/flightplanning/ManagedFlightPlan.ts index 6baf4e38cc..79bfc0948d 100644 --- a/src/wtsdk/src/flightplanning/ManagedFlightPlan.ts +++ b/src/wtsdk/src/flightplanning/ManagedFlightPlan.ts @@ -400,9 +400,12 @@ export class ManagedFlightPlan { legAltitude2: waypoint.legAltitude2, isVectors: waypoint.isVectors, endsInDiscontinuity: waypoint.endsInDiscontinuity, + bearingInFP: waypoint.bearingInFP, distanceInFP: waypoint.distanceInFP, cumulativeDistanceInFP: waypoint.cumulativeDistanceInFP, isRunway: waypoint.isRunway, + hasHold: waypoint.hasHold, + holdDetails: waypoint.holdDetails, infos: { icao: waypoint.infos.icao, ident: waypoint.infos.ident, @@ -463,7 +466,7 @@ export class ManagedFlightPlan { for (let i = 0; i < this._segments.length; i++) { const seg = this._segments[i]; newFlightPlan._segments[i] = Object.assign(new FlightPlanSegment(seg.type, seg.offset, []), seg); - newFlightPlan._segments[i].waypoints = [...seg.waypoints]; + newFlightPlan._segments[i].waypoints = [...seg.waypoints.map(w => Object.assign(new WayPoint(w.instrument), w))]; } newFlightPlan.procedureDetails = Object.assign(new ProcedureDetails(), this.procedureDetails); diff --git a/src/wtsdk/src/types/fstypes/FSTypes.d.ts b/src/wtsdk/src/types/fstypes/FSTypes.d.ts index 8e41d8b842..cd572ea7e5 100644 --- a/src/wtsdk/src/types/fstypes/FSTypes.d.ts +++ b/src/wtsdk/src/types/fstypes/FSTypes.d.ts @@ -6,6 +6,8 @@ declare module "MSFS" { endsInDiscontinuity?: boolean; isVectors?: boolean; isRunway?: boolean; + hasHold?: boolean; + holdDetails: HoldDetails; infos: WayPointInfo; type: string; bearingInFP: number; @@ -137,6 +139,7 @@ declare module "MSFS" { export class Simplane { static getHeadingMagnetic(): number; + static getGroundSpeed(): number; } export class EmptyCallback { diff --git a/src/wtsdk/src/wtsdk.ts b/src/wtsdk/src/wtsdk.ts index 93f9c774d0..bb5e5bcb54 100644 --- a/src/wtsdk/src/wtsdk.ts +++ b/src/wtsdk/src/wtsdk.ts @@ -2,9 +2,10 @@ import { DirectTo } from './flightplanning/DirectTo'; import { FlightPlanManager } from './flightplanning/FlightPlanManager'; import { FlightPlanSegment } from './flightplanning/FlightPlanSegment'; import { GPS } from './flightplanning/GPS'; +import { HoldDetails } from './flightplanning/HoldDetails'; import { LegsProcedure } from './flightplanning/LegsProcedure'; import { ManagedFlightPlan } from './flightplanning/ManagedFlightPlan'; import { ProcedureDetails } from './flightplanning/ProcedureDetails'; import { RawDataMapper } from './flightplanning/RawDataMapper'; -export { DirectTo, FlightPlanManager, FlightPlanSegment, GPS, LegsProcedure, ManagedFlightPlan, ProcedureDetails, RawDataMapper } \ No newline at end of file +export { DirectTo, FlightPlanManager, FlightPlanSegment, GPS, LegsProcedure, ManagedFlightPlan, ProcedureDetails, RawDataMapper, HoldDetails } \ No newline at end of file From 8f62a9d5e8152f10f70ba965379b21220684bcc1 Mon Sep 17 00:00:00 2001 From: Matt Nischan Date: Thu, 31 Dec 2020 17:12:47 -0600 Subject: [PATCH 2/2] Finished holds implementation. --- .../Instruments/Airliners/Shared/WT/NDInfo.js | 32 ++- .../Airliners/CJ4/FMC/CJ4_FMC.html | 6 +- .../Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js | 147 +++++++--- .../Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js | 33 ++- .../CJ4/Shared/Autopilot/AutopilotMath.js | 18 ++ .../CJ4/Shared/Autopilot/HoldsDirector.js | 263 ++++++++++++++++-- .../CJ4/Shared/Autopilot/WT_BaseLnav.js | 20 +- .../CJ4/WTLibs/Svg/SvgFlightPlanElement.js | 8 +- src/wtsdk/src/flightplanning/HoldDetails.ts | 40 ++- 9 files changed, 464 insertions(+), 103 deletions(-) diff --git a/src/workingtitle-vcockpits-instruments-airliners/html_ui/Pages/VCockpit/Instruments/Airliners/Shared/WT/NDInfo.js b/src/workingtitle-vcockpits-instruments-airliners/html_ui/Pages/VCockpit/Instruments/Airliners/Shared/WT/NDInfo.js index b0e717ba2c..c0e60dc628 100644 --- a/src/workingtitle-vcockpits-instruments-airliners/html_ui/Pages/VCockpit/Instruments/Airliners/Shared/WT/NDInfo.js +++ b/src/workingtitle-vcockpits-instruments-airliners/html_ui/Pages/VCockpit/Instruments/Airliners/Shared/WT/NDInfo.js @@ -221,7 +221,19 @@ class Jet_MFD_NDInfo extends HTMLElement { forceUpdate = true; } - this.setWaypoint(Simplane.getNextWaypointName(), Math.round(Simplane.getNextWaypointTrack()), Simplane.getNextWaypointDistance(), Simplane.getNextWaypointETA(), forceUpdate); + const holdIndex = SimVar.GetSimVarValue('L:WT_NAV_HOLD_INDEX', 'number'); + const holdFix = FlightPlanManager.DEBUG_INSTANCE.getFlightPlan(0).getWaypoint(holdIndex); + if (holdFix && holdFix.holdDetails) { + const waypointName = holdFix.ident; + const waypointTrack = holdFix.holdDetails.holdCourse; + + const distance = SimVar.GetSimVarValue("L:WT_CJ4_WPT_DISTANCE", "number"); + this.setWaypoint(waypointName, Math.round(waypointTrack), distance, Simplane.getNextWaypointETA(), forceUpdate); + } + else { + this.setWaypoint(Simplane.getNextWaypointName(), Math.round(Simplane.getNextWaypointTrack()), Simplane.getNextWaypointDistance(), Simplane.getNextWaypointETA(), forceUpdate); + } + this._previousNavMode = this._navMode; } setGroundSpeed(_speed, _force = false) { @@ -699,7 +711,15 @@ class VORDMENavAid { * @param {Jet_NDCompass_Navigation} parentNavMode The navigation mode of the parent PFD/MFD map. */ handleFMSModeUpdate(parentNavMode) { - const waypointName = Simplane.getNextWaypointName(); + + let waypointName = Simplane.getNextWaypointName(); + + const holdIndex = SimVar.GetSimVarValue('L:WT_NAV_HOLD_INDEX', 'number'); + const holdFix = FlightPlanManager.DEBUG_INSTANCE.getFlightPlan(0).getWaypoint(holdIndex); + if (holdFix && holdFix.holdDetails) { + waypointName = holdFix.ident; + } + const hasNav = waypointName !== null && waypointName !== undefined && waypointName !== ''; if (this.hasNav !== hasNav) { @@ -708,8 +728,14 @@ class VORDMENavAid { } let hideDistance = parentNavMode === Jet_NDCompass_Navigation.NAV; - this.setDistanceValue(hideDistance ? 0 : Simplane.getNextWaypointDistance()); + if (holdFix && holdFix.holdDetails) { + this.setDistanceValue(hideDistance ? 0 : SimVar.GetSimVarValue("L:WT_CJ4_WPT_DISTANCE", "number")); + } + else { + this.setDistanceValue(hideDistance ? 0 : Simplane.getNextWaypointDistance()); + } + if (hasNav) { const waypointBearing = Simplane.getNextWaypointTrack(); const planeHeading = Simplane.getHeadingMagnetic(); diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC.html b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC.html index ddaa5cee9a..70e7a5e6b2 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC.html +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC.html @@ -52,12 +52,12 @@ - + - - + + diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js index b8457dc801..f2a0dc6fd7 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_HoldsPage.js @@ -62,12 +62,17 @@ class CJ4_FMC_HoldsPage { } const currentHold = currentHolds[this._state.pageNumber - 1]; - const eteSeconds = this.calculateETE(currentHold.index); + const eta = this.calculateETA(currentHold); this.bindFplnHoldInputs(currentHold, currentHolds.length); - this.renderFplnHold(currentHold, eteSeconds, currentHolds.length); + this.renderFplnHold(currentHold, eta, currentHolds.length); } } + + this._fmc.registerPeriodicPageRefresh(() => { + this.update(); + return true; + }, 1000, false); } /** @@ -81,13 +86,18 @@ class CJ4_FMC_HoldsPage { this._fmc.onLeftInput[4] = () => this.changeHoldDistance(currentHold); this._fmc.onRightInput[0] = () => this.toggleSpeedType(currentHold); + this._fmc.onRightInput[3] = () => this.changeEFCTime(currentHold); if (numHolds < 6) { this._fmc.onRightInput[4] = () => CJ4_FMC_LegsPage.ShowPage1(this._fmc, true); } - if (this._state.fromWaypointIndex === currentHold.index) { - this._fmc.onRightInput[5] = () => this.handleExitHold(currentHold); + if (this.isHoldActive(currentHold)) { + this._fmc.onRightInput[5] = () => this.handleExitHold(); + } + + if (this.isHoldExiting(currentHold)) { + this._fmc.onRightInput[5] = () => this.handleCancelExit(); } if (this._state.isModifying) { @@ -167,7 +177,7 @@ class CJ4_FMC_HoldsPage { if (!isNaN(input)) { this._fmc.inOut = ''; - const groundSpeed = Math.min(Simplane.getGroundSpeed(), 120); + const groundSpeed = Math.max(Simplane.getGroundSpeed(), 140); const distance = input * (groundSpeed / 60); this._fmc.ensureCurrentFlightPlanIsTemporary(() => { @@ -198,7 +208,7 @@ class CJ4_FMC_HoldsPage { if (!isNaN(input)) { this._fmc.inOut = ''; - const groundSpeed = Math.min(Simplane.getGroundSpeed(), 120); + const groundSpeed = Math.max(Simplane.getGroundSpeed(), 120); const timeSeconds = input / (groundSpeed / 3600); this._fmc.ensureCurrentFlightPlanIsTemporary(() => { @@ -235,19 +245,32 @@ class CJ4_FMC_HoldsPage { }); } + /** + * Updates the EFC time for the current hold. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold to change the EFC time for. + */ + changeEFCTime(currentHold) { + const pattern = /\d\d\d\d/; + if (pattern.test(this._fmc.inOut)) { + currentHold.waypoint.holdDetails.efcTime = this._fmc.inOut; + } + else { + this._fmc.showErrorMessage('INVALID ENTRY'); + } + } + /** * Renders the FPLN HOLD page. * @param {{waypoint: WayPoint, index: number}} currentHold The current hold. - * @param {number} eteSeconds The ETE to the hold fix in seconds. + * @param {Date} eta The ETA to arrive at the hold fix. * @param {number} numPages The total number of pages. */ - renderFplnHold(currentHold, eteSeconds, numPages) { + renderFplnHold(currentHold, eta, numPages) { const actMod = this._state.isModifying ? 'MOD[white]' : 'ACT[blue]'; - const ete = `${Math.floor(eteSeconds / 60)}:${eteSeconds % 60}`; + const etaDisplay = `${eta.getHours().toFixed(0).padStart(2, '0')}:${eta.getMinutes().toFixed(0).padStart(2, '0')}`; const holdDetails = currentHold.waypoint.holdDetails; const speedSwitch = this._fmc._templateRenderer.renderSwitch(["FAA", "ICAO"], holdDetails.holdSpeedType === HoldSpeedType.FAA ? 0 : 1); - const rows = [ [`${actMod} FPLN HOLD[blue]`, `${this._state.pageNumber}/${numPages}[blue]`], @@ -255,14 +278,14 @@ class CJ4_FMC_HoldsPage { [`${currentHold.waypoint.ident}`, speedSwitch, CJ4_FMC_HoldsPage.getEntryTypeString(holdDetails.entryType)], [' QUAD/RADIAL[blue]', 'MAX KIAS [blue]'], ['--/---°', this.getMaxKIAS(holdDetails).toFixed(0)], - [' INBD CRS/DIR[blue]', 'FIX ETE [blue]'], - [`${holdDetails.holdCourse.toFixed(0).padStart(3, '0')}${holdDetails.isHoldCourseTrue ? 'T' : ''}°/${holdDetails.turnDirection === HoldTurnDirection.Left ? 'L' : 'R'} TURN`, `${ete}[s-text]`], + [' INBD CRS/DIR[blue]', 'FIX ETA [blue]'], + [`${holdDetails.holdCourse.toFixed(0).padStart(3, '0')}${holdDetails.isHoldCourseTrue ? 'T' : ''}°/${holdDetails.turnDirection === HoldTurnDirection.Left ? 'L' : 'R'} TURN`, `${etaDisplay}[s-text]`], [' LEG TIME[blue]', 'EFC TIME [blue]'], [`${(holdDetails.legTime / 60).toFixed(1)}[d-text] MIN[s-text]`, '--:--'], [' LEG DIST[blue]'], [`${holdDetails.legDistance.toFixed(1)}[d-text] NM[s-text]`, `${numPages < 6 ? 'NEW HOLD>' : ''}`], ['------------------------[blue]'], - [`${this._state.isModifying ? '' : ''}`] + [`${this._state.isModifying ? '' : this.isHoldExiting(currentHold) ? 'CANCEL EXIT>': ''}`] ]; this._fmc._templateRenderer.setTemplateRaw(rows); @@ -325,18 +348,52 @@ class CJ4_FMC_HoldsPage { /** * Handles when EXIT HOLD is pressed. - * @param {{waypoint: WayPoint, index: number}} currentHold The current hold. */ - handleExitHold(currentHold) { - this._fmc.exitHoldAtIndex(currentHold.index); + handleExitHold() { + const holdsDirector = this._fmc._lnav && this._fmc._lnav._holdsDirector; + if (holdsDirector) { + holdsDirector.exitActiveHold(); + this.update(); + } } /** * Handles when CANCEL EXIT is pressed. - * @param {{waypoint: WayPoint, index: number}} currentHold The current hold. */ - handleCancelExit(currentHold) { - this._fmc.cancelExitHoldAtIndex(currentHold.index); + handleCancelExit() { + const holdsDirector = this._fmc._lnav && this._fmc._lnav._holdsDirector; + if (holdsDirector) { + holdsDirector.cancelHoldExit(); + this.update(); + } + } + + /** + * Whether or not the current hold is active. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold. + * @returns {boolean} True if active, false otherwise. + */ + isHoldActive(currentHold) { + const holdsDirector = this._fmc._lnav && this._fmc._lnav._holdsDirector; + if (holdsDirector) { + return holdsDirector.isHoldActive(currentHold.index); + } + + return false; + } + + /** + * Whether or not the current hold is exiting. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold. + * @returns {boolean} True if exiting, false otherwise. + */ + isHoldExiting(currentHold) { + const holdsDirector = this._fmc._lnav && this._fmc._lnav._holdsDirector; + if (holdsDirector) { + return holdsDirector.isHoldExiting(currentHold.index); + } + + return false; } /** @@ -374,29 +431,45 @@ class CJ4_FMC_HoldsPage { } /** - * Calculates the estimated time enroute to the specified waypoint index for the hold. - * @param {number} index The waypoint index of the hold. - * @returns {number} The estimated time enroute to the hold, in seconds. + * Calculates the estimated time of arrival at the specified hold. + * @param {{waypoint: WayPoint, index: number}} currentHold The current hold to change the course for. + * @returns {Date} The ETA to the hold fix. */ - calculateETE(index) { - const activeWaypointIndex = this._fmc.flightPlanManager.getActiveWaypointIndex() - 1; - const waypointsToHold = this._fmc.flightPlanManager.getAllWaypoints() - .slice(activeWaypointIndex, index - activeWaypointIndex); + calculateETA(currentHold) { + + let simtime = SimVar.GetSimVarValue("E:ZULU TIME", "seconds"); + const currentDate = new Date(0, 0, 0, 0, 0, 0); + const activeHoldIndex = SimVar.GetSimVarValue('L:WT_NAV_HOLD_INDEX', 'number'); - const planePosition = CJ4_FMC_HoldsPage.getPlanePosition(); - const groundSpeed = Simplane.getGroundSpeed(); - let distanceToHold = 0; + const groundSpeed = Math.max(Simplane.getGroundSpeed(), 140); + if (activeHoldIndex !== -1 && activeHoldIndex === currentHold.index) { + const eteSeconds = Math.round(SimVar.GetSimVarValue("L:WT_CJ4_WPT_DISTANCE", "number") / (groundSpeed / 3600)); + currentDate.setSeconds(simtime + eteSeconds); - for (var i = 0; i < waypointsToHold.length; i++) { - if (i === 0) { - distanceToHold += Avionics.Utils.computeGreatCircleDistance(planePosition, waypointsToHold[i].infos.coordinates); - } - else { - distanceToHold += Avionics.Utils.computeGreatCircleDistance(waypointsToHold[i - 1].infos.coordinates, waypointsToHold[i].infos.coordinates); - } + return currentDate; } + else { + const activeWaypointIndex = this._fmc.flightPlanManager.getActiveWaypointIndex() - 1; + const waypointsToHold = this._fmc.flightPlanManager.getAllWaypoints() + .slice(activeWaypointIndex, currentHold.index - activeWaypointIndex); + + const planePosition = CJ4_FMC_HoldsPage.getPlanePosition(); + let distanceToHold = 0; + + for (var i = 0; i < waypointsToHold.length; i++) { + if (i === 0) { + distanceToHold += Avionics.Utils.computeGreatCircleDistance(planePosition, waypointsToHold[i].infos.coordinates); + } + else { + distanceToHold += Avionics.Utils.computeGreatCircleDistance(waypointsToHold[i - 1].infos.coordinates, waypointsToHold[i].infos.coordinates); + } + } - return Math.round(distanceToHold / (groundSpeed / 3600)); + const eteSeconds = Math.round(distanceToHold / (groundSpeed / 3600)); + currentDate.setSeconds(simtime + eteSeconds); + + return currentDate; + } } /** diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js index 805397bf33..919f6e4687 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/FMC/CJ4_FMC_LegsPage.js @@ -179,12 +179,22 @@ class CJ4_FMC_LegsPage { } let modStr = this._fmc.fpHasChanged ? "MOD[white]" : "ACT[blue]"; + let holdActive = false; + let holdExiting = false; + + const holdsDirector = this._fmc._lnav && this._fmc._lnav._holdsDirector; + + if (holdsDirector) { + const holdIndex = this._fmc.flightPlanManager.getActiveWaypointIndex() - 1; + holdActive = holdsDirector.isHoldActive(holdIndex); + holdExiting = holdsDirector.isHoldExiting(holdIndex); + } this._fmc._templateRenderer.setTemplateRaw([ [" " + modStr + " LEGS[blue]", this._currentPage.toFixed(0) + "/" + Math.max(1, this._pageCount.toFixed(0)) + " [blue]"], ...this._rows, - [`${this._isAddingHold ? '---------HOLD AT--------' : '------------------------'}[blue]`], - [`${this._isAddingHold ? '□□□□□' : this._lsk6Field}`, "LEG WIND>"] + [`${this._isAddingHold ? '---------HOLD AT--------' : holdExiting ? '-------EXIT ARMED-------' : '------------------------'}[blue]`], + [`${this._isAddingHold ? '□□□□□' : holdExiting ? '"] ]); } @@ -461,6 +471,17 @@ class CJ4_FMC_LegsPage { bindEvents() { this._fmc.onLeftInput[5] = () => { + let holdActive = false; + let holdExiting = false; + + const holdsDirector = this._fmc._lnav && this._fmc._lnav._holdsDirector; + + if (holdsDirector) { + const holdIndex = this._fmc.flightPlanManager.getActiveWaypointIndex() - 1; + holdActive = holdsDirector.isHoldActive(holdIndex); + holdExiting = holdsDirector.isHoldExiting(holdIndex); + } + if (this._isAddingHold) { this.addHold(); } @@ -471,6 +492,14 @@ class CJ4_FMC_LegsPage { this._fmc.eraseTemporaryFlightPlan(() => { this.resetAfterOp(); }); } } + else if (holdExiting) { + holdsDirector.cancelHoldExit(); + this.update(true); + } + else if (holdActive) { + holdsDirector.exitActiveHold(); + this.update(true); + } }; this._fmc.onRightInput[0] = () => { if (this._currentPage == 1) { diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/AutopilotMath.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/AutopilotMath.js index 8561425c99..d679537b94 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/AutopilotMath.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/AutopilotMath.js @@ -115,6 +115,24 @@ class AutopilotMath { return -1 * (centerDistance - radius); } + /** + * Calculates the distance the plane has traveled along the arc. + * @param {LatLongAlt} fromFix The location of the starting fix of the leg. + * @param {LatLongAlt} toFix The location of the ending fix of the leg. + * @param {LatLongAlt} planeCoords The current plane location coordinates. + * @returns {number} The distance traveled, in NM. + */ + static distanceAlongArc(fromFix, toFix, planeCoords) { + const cLat = (fromFix.lat + toFix.lat) / 2; + const cLon = (fromFix.long + toFix.long) / 2; + const radius = Avionics.Utils.computeGreatCircleDistance(fromFix, toFix) / 2; + + const planeAngle = Math.atan2(planeCoords.lat - cLat, cLon - planeCoords.long); + const endAngle = Math.atan2(toFix.lat - cLat, cLon - toFix.long); + + return Math.abs((endAngle - planeAngle) * radius); + } + /** * Normalizes a heading to a 0-360 range. * @param {number} heading The heading to normalize. diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/HoldsDirector.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/HoldsDirector.js index e863c098af..90cf6fea2f 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/HoldsDirector.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/HoldsDirector.js @@ -4,15 +4,14 @@ class HoldsDirector { /** * Creates an instance of a HoldsDirector. * @param {FlightPlanManager} fpm An instance of the flight plan manager. - * @param {number} holdWaypointIndex The index of the waypoint to hold at. */ - constructor(fpm, holdWaypointIndex) { + constructor(fpm) { /** The flight plan manager. */ this.fpm = fpm; /** The hold waypoint index. */ - this.holdWaypointIndex = holdWaypointIndex; + this.holdWaypointIndex = -1; /** The current flight plan version. */ this.currentFlightPlanVersion = 0; @@ -37,6 +36,12 @@ class HoldsDirector { /** The outbound leg for the hold. */ this.outboundLeg = []; + + /** The parallel entry leg for the hold. */ + this.parallelLeg = []; + + /** The direction of the turn. */ + this.turnDirection = HoldTurnDirection.Right; } /** @@ -54,7 +59,17 @@ class HoldsDirector { const trackToHold = new LatLon(prevFixCoords.lat, prevFixCoords.long).finalBearingTo(new LatLon(fixCoords.lat, fixCoords.long)); if (this.state === HoldsDirectorState.NONE) { - this.state = HoldsDirector.calculateEntryState(holdDetails.holdCourse, trackToHold); + switch (holdDetails.entryType) { + case HoldEntry.Direct: + this.state = HoldsDirectorState.ENTRY_DIRECT_INBOUND; + break; + case HoldEntry.Teardrop: + this.state = HoldsDirectorState.ENTRY_TEARDROP_INBOUND; + break; + case HoldEntry.Parallel: + this.state = HoldsDirectorState.ENTRY_PARALLEL_INBOUND; + break; + } } this.fixCoords = fixCoords; @@ -63,6 +78,9 @@ class HoldsDirector { const legFixes = HoldsDirector.calculateHoldFixes(fixCoords, holdDetails); this.inboundLeg = [legFixes[3], legFixes[0]]; this.outboundLeg = [legFixes[1], legFixes[2]]; + this.parallelLeg = [legFixes[4], legFixes[5]]; + + this.turnDirection = holdDetails.turnDirection; } } @@ -85,10 +103,17 @@ class HoldsDirector { } /** - * Updates the hold director. + * Updates the hold director. + * @param {number} holdWaypointIndex The current waypoint index to hold at. */ - update() { + update(holdWaypointIndex) { const flightPlanVersion = SimVar.GetSimVarValue('L:WT.FlightPlan.Version', 'number'); + + if (this.holdWaypointIndex !== holdWaypointIndex) { + this.initializeHold(); + this.holdWaypointIndex = holdWaypointIndex; + } + if (flightPlanVersion !== this.currentFlightPlanVersion) { this.initializeHold(); this.currentFlightPlanVersion = flightPlanVersion; @@ -115,7 +140,13 @@ class HoldsDirector { case HoldsDirectorState.TURNING_INBOUND: this.handleInHold(planeState); break; + case HoldsDirectorState.EXITING: + this.handleExitingHold(planeState); + break; } + + const distanceRemaining = this.calculateDistanceRemaining(planeState); + SimVar.SetSimVarValue("L:WT_CJ4_WPT_DISTANCE", "number", distanceRemaining); } /** @@ -143,24 +174,84 @@ class HoldsDirector { * Handles the direct entry state. * @param {AircraftState} planeState The current aircraft state. */ - handleDirectEntry(planeState) { + handleDirectEntry(planeState) { const dtk = AutopilotMath.desiredTrack(this.prevFixCoords, this.fixCoords, planeState.position); - const planeToFixTrack = Avionics.Utils.computeGreatCircleHeading(planeState.position, this.fixCoords); - const trackDiff = Math.abs(Avionics.Utils.angleDiff(dtk, planeToFixTrack)); - - if (trackDiff > 90) { - HoldsDirector.setCourse(AutopilotMath.normalizeHeading(dtk + 45), planeState); + if (this.isAbeam(dtk, planeState.position, this.fixCoords)) { this.fpm.setActiveWaypointIndex(this.holdWaypointIndex + 1); + this.recalculateHold(planeState); + this.cancelAlert(); + SimVar.SetSimVarValue('L:WT_NAV_HOLD_INDEX', 'number', this.holdWaypointIndex); this.state = HoldsDirectorState.TURNING_OUTBOUND; } else { + this.alertIfClose(planeState, this.fixCoords); this.trackLeg(this.prevFixCoords, this.fixCoords, planeState); } } + /** + * Handles the teardrop entry state. + * @param {AircraftState} planeState The current aircraft state. + */ + handleTeardropEntry(planeState) { + if (this.state === HoldsDirectorState.ENTRY_TEARDROP_INBOUND) { + const dtk = AutopilotMath.desiredTrack(this.prevFixCoords, this.fixCoords, planeState.position); + + if (this.isAbeam(dtk, planeState.position, this.fixCoords)) { + this.fpm.setActiveWaypointIndex(this.holdWaypointIndex + 1); + + this.recalculateHold(planeState); + this.cancelAlert(); + + SimVar.SetSimVarValue('L:WT_NAV_HOLD_INDEX', 'number', this.holdWaypointIndex); + this.state = HoldsDirectorState.OUTBOUND; + } + else { + this.alertIfClose(planeState, this.fixCoords); + this.trackLeg(this.prevFixCoords, this.fixCoords, planeState); + } + } + } + + /** + * Handles the teardrop entry state. + * @param {AircraftState} planeState The current aircraft state. + */ + handleParallelEntry(planeState) { + + if (this.state === HoldsDirectorState.ENTRY_PARALLEL_INBOUND) { + const dtk = AutopilotMath.desiredTrack(this.prevFixCoords, this.fixCoords, planeState.position); + + if (this.isAbeam(dtk, planeState.position, this.fixCoords)) { + this.fpm.setActiveWaypointIndex(this.holdWaypointIndex + 1); + + this.recalculateHold(planeState); + this.cancelAlert(); + + SimVar.SetSimVarValue('L:WT_NAV_HOLD_INDEX', 'number', this.holdWaypointIndex); + this.state = HoldsDirectorState.ENTRY_PARALLEL_OUTBOUND; + } + else { + this.alertIfClose(planeState, this.fixCoords); + this.trackLeg(this.prevFixCoords, this.fixCoords, planeState); + } + } + + if (this.state === HoldsDirectorState.ENTRY_PARALLEL_OUTBOUND) { + const dtk = AutopilotMath.desiredTrack(this.parallelLeg[0], this.parallelLeg[1], planeState.position); + + if (this.isAbeam(dtk, planeState.position, this.parallelLeg[1])) { + this.state = HoldsDirectorState.INBOUND; + } + else { + this.trackLeg(this.parallelLeg[0], this.parallelLeg[1], planeState); + } + } + } + /** * Handles the in-hold state. * @param {AircraftState} planeState The current aircraft state. @@ -191,7 +282,6 @@ class HoldsDirector { if (this.state === HoldsDirectorState.TURNING_INBOUND) { const dtk = AutopilotMath.desiredTrack(this.inboundLeg[0], this.inboundLeg[1], planeState.position); - const trackDiff = Avionics.Utils.angleDiff(dtk, planeState.trueTrack); if (this.isAbeam(dtk, planeState.position, this.inboundLeg[0])) { this.state = HoldsDirectorState.INBOUND; @@ -207,11 +297,54 @@ class HoldsDirector { if (this.isAbeam(dtk, planeState.position, this.inboundLeg[1])) { this.recalculateHold(planeState); + this.cancelAlert(); + this.state = HoldsDirectorState.TURNING_OUTBOUND; } else { this.trackLeg(this.inboundLeg[0], this.inboundLeg[1], planeState); - } + this.alertIfClose(planeState, this.inboundLeg[1]); + } + } + } + + /** + * Activates the waypoint alert if close enough to the provided fix. + * @param {AircraftState} planeState The current aircraft state. + * @param {LatLongAlt} fix The fix to alert for. + */ + alertIfClose(planeState, fix) { + const alertDistance = 3 * (planeState.groundSpeed / 3600); + const fixDistance = Avionics.Utils.computeGreatCircleDistance(planeState.position, fix); + + if (fixDistance <= alertDistance) { + SimVar.SetSimVarValue('L:WT_CJ4_WPT_ALERT', 'number', 1); + } + } + + /** + * Cancels the waypoint alert. + */ + cancelAlert() { + SimVar.SetSimVarValue('L:WT_CJ4_WPT_ALERT', 'number', 0); + } + + /** + * Handles the exiting state. + * @param {AircraftState} planeState The current aircraft state. + */ + handleExitingHold(planeState) { + const dtk = AutopilotMath.desiredTrack(this.inboundLeg[0], this.inboundLeg[1], planeState.position); + + if (this.isAbeam(dtk, planeState.position, this.inboundLeg[1])) { + this.cancelAlert(); + SimVar.SetSimVarValue('L:WT_NAV_HOLD_INDEX', 'number', -1); + + this.state = HoldsDirectorState.EXITED; + } + else { + this.alertIfClose(planeState, this.inboundLeg[1]); + this.trackLeg(this.inboundLeg[0], this.inboundLeg[1], planeState); } } @@ -231,7 +364,6 @@ class HoldsDirector { SimVar.SetSimVarValue("L:WT_CJ4_XTK", "number", xtk); SimVar.SetSimVarValue("L:WT_CJ4_DTK", "number", correctedDtk); - SimVar.SetSimVarValue("L:WT_CJ4_WPT_DISTANCE", "number", distanceRemaining); const interceptAngle = AutopilotMath.interceptAngle(xtk, NavSensitivity.NORMAL); const bearingToWaypoint = Avionics.Utils.computeGreatCircleHeading(planeState.position, legEnd); @@ -251,13 +383,17 @@ class HoldsDirector { * @param {AircraftState} planeState The state of the aircraft. */ trackArc(legStart, legEnd, planeState) { - const dtk = AutopilotMath.desiredTrackArc(legStart, legEnd, planeState.position); - const xtk = AutopilotMath.crossTrackArc(legStart, legEnd, planeState.position); + let dtk = AutopilotMath.desiredTrackArc(legStart, legEnd, planeState.position); + if (this.turnDirection === HoldTurnDirection.Left) { + dtk = AutopilotMath.normalizeHeading(dtk + 180); + } - const distanceRemaining = Avionics.Utils.computeGreatCircleDistance(planeState.position, legEnd); - const correctedDtk = GeoMath.correctMagvar(dtk, SimVar.GetSimVarValue("MAGVAR", "degrees")); + let xtk = AutopilotMath.crossTrackArc(legStart, legEnd, planeState.position); + if (this.turnDirection === HoldTurnDirection.Left) { + xtk = -1 * xtk; + } - const interceptAngle = AutopilotMath.interceptAngle(xtk, NavSensitivity.APPROACHLPV, 25); + const interceptAngle = AutopilotMath.interceptAngle(xtk, NavSensitivity.APPROACHLPV, 35); HoldsDirector.setCourse(AutopilotMath.normalizeHeading(dtk + interceptAngle), planeState); } @@ -271,7 +407,71 @@ class HoldsDirector { const planeToFixTrack = Avionics.Utils.computeGreatCircleHeading(planePosition, fixCoords); const trackDiff = Math.abs(Avionics.Utils.angleDiff(dtk, planeToFixTrack)); - return trackDiff > 100; + return trackDiff > 91; + } + + /** + * Exits the active hold. + */ + exitActiveHold() { + this.state = HoldsDirectorState.EXITING; + } + + /** + * Cancels exiting the hold at the hold fix. + */ + cancelHoldExit() { + this.state = HoldsDirectorState.INBOUND; + } + + /** + * Calculates the distance remaining to the hold fix. + * @param {AircraftState} planeState The current aircraft state. + * @returns {number} The distance remaining to the hold fix, in NM. + */ + calculateDistanceRemaining(planeState) { + const holdWaypoint = this.fpm.getFlightPlan(0).getWaypoint(this.holdWaypointIndex); + const legDistance = holdWaypoint.holdDetails.legDistance; + const turnDistance = Avionics.Utils.computeGreatCircleDistance(this.inboundLeg[1], this.outboundLeg[0]) * Math.PI; + + let distance = (2 * legDistance) + turnDistance; + if (this.state === HoldsDirectorState.TURNING_OUTBOUND) { + return distance + AutopilotMath.distanceAlongArc(this.inboundLeg[1], this.outboundLeg[0], planeState.position); + } + + distance -= legDistance; + if (this.state === HoldsDirectorState.OUTBOUND) { + return distance + Avionics.Utils.computeGreatCircleDistance(planeState.position, this.outboundLeg[1]); + } + + distance -= turnDistance; + if (this.state === HoldsDirectorState.TURNING_INBOUND) { + return distance + AutopilotMath.distanceAlongArc(this.outboundLeg[1], this.inboundLeg[0], planeState.position); + } + + return Avionics.Utils.computeGreatCircleDistance(planeState.position, this.inboundLeg[1]); + } + + /** + * Whether or not the current waypoint index is in active hold. + * @param {number} index The waypoint index to check against. + * @returns {boolean} True if active, false otherwise. + */ + isHoldActive(index) { + return this.holdWaypointIndex === index + && this.state !== HoldsDirectorState.NONE + && this.state !== HoldsDirectorState.EXITED + && this.state !== HoldsDirectorState.ENTRY_TEARDROP_INBOUND + && this.state !== HoldsDirectorState.ENTRY_PARALLEL_INBOUND; + } + + /** + * Whether or not the current hold is exiting. + * @param {number} index The waypoint index to check against. + * @returns {boolean} True if exiting, false otherwise. + */ + isHoldExiting(index) { + return this.holdWaypointIndex === index && this.state === HoldsDirectorState.EXITING; } /** @@ -299,23 +499,32 @@ class HoldsDirector { * @param {LatLongAlt} holdFixCoords The coordinates of the hold fix. * @param {HoldDetails} holdDetails The details of the hold. * @param {AircraftState} planeState The true course that the hold will be flown with. - * @returns {LatLongAlt[]} The four hold corner positions calculated, clockwise starting with the hold fix coordinates. + * @returns {LatLongAlt[]} The four hold corner positions calculated, clockwise starting with the hold fix coordinates, plus 2 + * parallel leg fixes. */ static calculateHoldFixes(holdFixCoords, holdDetails) { const windComponents = AutopilotMath.windComponents(holdDetails.holdCourse, holdDetails.windDirection, holdDetails.windSpeed); const turnRadius = AutopilotMath.turnRadius(holdDetails.speed + Math.abs(windComponents.crosswind), 25); - const outboundStart = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + 90), turnRadius * 2, + const turnDirection = holdDetails.turnDirection === HoldTurnDirection.Right ? 1 : -1; + + const outboundStart = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + (turnDirection * 90)), turnRadius * 2, holdFixCoords.lat, holdFixCoords.long); - const outboundEnd = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + 180), holdDetails.legDistance, + const outboundEnd = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + (turnDirection * 180)), holdDetails.legDistance, outboundStart.lat, outboundStart.long); - const inboundStart = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + 180), holdDetails.legDistance, + const inboundStart = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + (turnDirection * 180)), holdDetails.legDistance, holdFixCoords.lat, holdFixCoords.long); - return [holdFixCoords, outboundStart, outboundEnd, inboundStart]; + const parallelStart = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + (turnDirection * -90)), 1, + holdFixCoords.lat, holdFixCoords.long); + + const parallelEnd = Avionics.Utils.bearingDistanceToCoordinates(AutopilotMath.normalizeHeading(holdDetails.holdCourse + (turnDirection * 180)), holdDetails.legDistance, + parallelStart.lat, parallelStart.long); + + return [holdFixCoords, outboundStart, outboundEnd, inboundStart, parallelStart, parallelEnd]; } /** @@ -348,6 +557,8 @@ HoldsDirectorState.TURNING_OUTBOUND = 'TURNING_OUTBOUND'; HoldsDirectorState.OUTBOUND = 'OUTBOUND'; HoldsDirectorState.TURNING_INBOUND = 'TURNING_INBOUND'; HoldsDirectorState.INBOUND = 'INBOUND'; +HoldsDirectorState.EXITING = 'EXITING'; +HoldsDirectorState.EXITED = 'EXITED'; /** * The current state of the aircraft for LNAV. diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/WT_BaseLnav.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/WT_BaseLnav.js index 6f9ecc904f..c651357b2e 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/WT_BaseLnav.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/Shared/Autopilot/WT_BaseLnav.js @@ -13,6 +13,7 @@ class WT_BaseLnav { this._fpm = fpm; this._navModeSelector = navModeSelector; + this._holdsDirector = new HoldsDirector(fpm); this._flightPlanVersion = undefined; this._activeWaypointChanged = true; @@ -90,22 +91,11 @@ class WT_BaseLnav { const navModeActive = SimVar.GetSimVarValue("L:WT_CJ4_NAV_ON", "number") == 1; this._inhibitSequence = SimVar.GetSimVarValue("L:WT_CJ4_INHIBIT_SEQUENCE", "number") == 1; - if (this._activeWaypoint.hasHold) { - if (!this._holdsDirector) { - this._holdsDirector = new HoldsDirector(this._fpm, this.flightplan.activeWaypointIndex); - } - - this._holdsDirector.update(); - return; - } + if ((this._activeWaypoint && this._activeWaypoint.hasHold) || (this._previousWaypoint && this._previousWaypoint.hasHold)) { + const holdWaypointIndex = (this._activeWaypoint && this._activeWaypoint.hasHold) ? this.flightplan.activeWaypointIndex : this.flightplan.activeWaypointIndex - 1; + this._holdsDirector.update(holdWaypointIndex); - if (this._previousWaypoint.hasHold) { - if (!this._holdsDirector) { - this._holdsDirector = new HoldsDirector(this._fpm, this.flightplan.activeWaypointIndex - 1); - this._holdsDirector.update(); - } - else if (this._holdsDirector && this._holdsDirector.state !== HoldsDirectorState.NONE) { - this._holdsDirector.update(); + if (this._holdsDirector.state !== HoldsDirectorState.NONE && this._holdsDirector.state !== HoldsDirectorState.EXITED) { return; } } diff --git a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/WTLibs/Svg/SvgFlightPlanElement.js b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/WTLibs/Svg/SvgFlightPlanElement.js index 2a9621f586..1ca1f546c0 100644 --- a/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/WTLibs/Svg/SvgFlightPlanElement.js +++ b/src/workingtitle-vcockpits-instruments-cj4/html_ui/Pages/VCockpit/Instruments/Airliners/CJ4/WTLibs/Svg/SvgFlightPlanElement.js @@ -122,9 +122,9 @@ class SvgFlightPlanElement extends SvgMapElement { .map(c => map.coordinatesToXY(c)); context.moveTo(corners[0].x, corners[0].y); - this.drawHoldArc(corners[0], corners[1], context); + this.drawHoldArc(corners[0], corners[1], context, waypoint.holdDetails.turnDirection === 1); context.lineTo(corners[2].x, corners[2].y); - this.drawHoldArc(corners[2], corners[3], context); + this.drawHoldArc(corners[2], corners[3], context, waypoint.holdDetails.turnDirection === 1); context.lineTo(corners[0].x, corners[0].y); } } @@ -138,7 +138,7 @@ class SvgFlightPlanElement extends SvgMapElement { * @param {Vec2} p2 * @param {CanvasRenderingContext2D} context */ - drawHoldArc(p1, p2, context) { + drawHoldArc(p1, p2, context, counterClockwise) { const cx = (p1.x + p2.x) / 2; const cy = (p1.y + p2.y) / 2; const radius = p1.Distance(p2) / 2; @@ -146,7 +146,7 @@ class SvgFlightPlanElement extends SvgMapElement { const a1 = Math.atan2(p1.y - cy, p1.x - cx); const a2 = Math.atan2(p2.y - cy, p2.x - cx); - context.arc(cx, cy, radius, a1, a2); + context.arc(cx, cy, radius, a1, a2, counterClockwise); } setAsDashed(_val, _force = false) { diff --git a/src/wtsdk/src/flightplanning/HoldDetails.ts b/src/wtsdk/src/flightplanning/HoldDetails.ts index cf4ea72798..ee7cead4e5 100644 --- a/src/wtsdk/src/flightplanning/HoldDetails.ts +++ b/src/wtsdk/src/flightplanning/HoldDetails.ts @@ -1,4 +1,4 @@ -import { Avionics, Simplane } from 'MSFS'; +import { Avionics, Simplane, SimVar } from 'MSFS'; /** * Details of a hold procedure for a fix. @@ -51,17 +51,17 @@ export class HoldDetails { details.holdCourse = course; details.holdSpeedType = HoldSpeedType.FAA; - details.legTime = 90; - details.speed = Simplane.getGroundSpeed(); + details.legTime = 60; + details.speed = Math.max(Simplane.getGroundSpeed(), 140); - details.windDirection = 0; - details.windSpeed = 0; + details.windDirection = SimVar.GetSimVarValue("AMBIENT WIND DIRECTION", "degrees"); + details.windSpeed = SimVar.GetSimVarValue("AMBIENT WIND VELOCITY", "knots"); details.legDistance = details.legTime * (details.speed / 3600); details.turnDirection = HoldTurnDirection.Right; details.state = HoldState.None; - details.entryType = HoldDetails.calculateEntryType(course, courseTowardsHoldFix); + details.entryType = HoldDetails.calculateEntryType(course, courseTowardsHoldFix, details.turnDirection); return details; } @@ -71,18 +71,32 @@ export class HoldDetails { * inbound course. See FMS guide page 14-21. * @param holdCourse The course that the hold will be flown with. * @param inboundCourse The course that is being flown towards the hold point. + * @param turnDirection The direction of the hold turn. * @returns The hold entry type for a given set of courses. */ - static calculateEntryType(holdCourse: number, inboundCourse: number): HoldEntry { + static calculateEntryType(holdCourse: number, inboundCourse: number, turnDirection: HoldTurnDirection): HoldEntry { const courseDiff = Avionics.Utils.angleDiff(inboundCourse, holdCourse); - if (courseDiff >= -130 && courseDiff <= 70) { - return HoldEntry.Direct; - } - else if (courseDiff < -130 || courseDiff > 175) { - return HoldEntry.Teardrop; + if (turnDirection === HoldTurnDirection.Right) { + if (courseDiff >= -130 && courseDiff <= 70) { + return HoldEntry.Direct; + } + else if (courseDiff < -130 || courseDiff > 175) { + return HoldEntry.Teardrop; + } + else { + return HoldEntry.Parallel; + } } else { - return HoldEntry.Parallel; + if (courseDiff >= -130 && courseDiff <= 70) { + return HoldEntry.Direct; + } + else if (courseDiff > 70 || courseDiff < -175) { + return HoldEntry.Teardrop; + } + else { + return HoldEntry.Parallel; + } } } }