From e1bf51e72542196b9e54e1b6098be9ca8b8321da Mon Sep 17 00:00:00 2001 From: Michael Corcoran Date: Mon, 13 Nov 2023 00:55:10 +1300 Subject: [PATCH] chore: bring in a380x instruments @2dc636d7 (#8289) * chore: bring in a380 instruments @2dc636d7 Build scripts and imports have been adjusted for the monorepo environment. * fix: eslintrc path * fix: unignore and commit mach config * chore: copy eslintrc from a32nx * build: disable fms-v2 tests * build: disable a380x lint * doc: merge local var docs from a380x repo Co-authored-by: Sven Czarnian Co-authored-by: Richard Pilbery Co-authored-by: Benedict Etzel Co-authored-by: svengcz <64070348+svengcz@users.noreply.github.com> Co-authored-by: BBK <22713769+BlueberryKing@users.noreply.github.com> Co-authored-by: ErickSharp Co-authored-by: 2hwk Co-authored-by: Nufflee Co-authored-by: davydecorps <38904654+crocket63@users.noreply.github.com> Co-authored-by: Taz5150 --- .gitignore | 1 + fbw-a380x/docs/a380-simvars.md | 104 + fbw-a380x/mach.config.js | 66 + fbw-a380x/src/.eslintrc.js | 141 + .../base/flybywire-aircraft-a380-842/.keep | 2 - .../html_ui/Fonts/A380X_FCU.ttf | Bin 0 -> 9048 bytes .../html_ui/Fonts/ECAMFontRegular.ttf | Bin 0 -> 13680 bytes .../html_ui/Fonts/FBW-Display-RMP-10.ttf | Bin 0 -> 6732 bytes .../html_ui/Fonts/FBW-Display-RMP-11.ttf | Bin 0 -> 5588 bytes .../html_ui/Fonts/FBW-Display-RMP-13.ttf | Bin 0 -> 5096 bytes .../html_ui/Fonts/FBW-Display-RMP-16(1).ttf | Bin 0 -> 6116 bytes .../html_ui/Fonts/FBW-Display-RMP-19.ttf | Bin 0 -> 6192 bytes .../Images/Engine-Hyd-Bleed-Dithered.png | Bin 0 -> 44592 bytes .../html_ui/Images/HYD_8-7_TRIMMED.png | Bin 0 -> 57379 bytes .../html_ui/Images/TRIM_INDICATOR.png | Bin 0 -> 106469 bytes .../Instruments/LegacyA380X/FCU/A380_FCU.css | 182 ++ .../Instruments/LegacyA380X/FCU/A380_FCU.html | 116 + .../Instruments/LegacyA380X/FCU/A380_FCU.js | 1376 ++++++++ .../LegacyA380X/FCU/Images/ARPT.png | Bin 0 -> 2850 bytes .../LegacyA380X/FCU/Images/Blank.png | Bin 0 -> 2742 bytes .../LegacyA380X/FCU/Images/CSTR.png | Bin 0 -> 2850 bytes .../LegacyA380X/FCU/Images/NDB.png | Bin 0 -> 2839 bytes .../LegacyA380X/FCU/Images/POINTER1.png | Bin 0 -> 2819 bytes .../LegacyA380X/FCU/Images/POINTER2.png | Bin 0 -> 2801 bytes .../LegacyA380X/FCU/Images/TERR.png | Bin 0 -> 2842 bytes .../LegacyA380X/FCU/Images/TRAF.png | Bin 0 -> 2852 bytes .../LegacyA380X/FCU/Images/VORD.png | Bin 0 -> 2854 bytes .../LegacyA380X/FCU/Images/WPT.png | Bin 0 -> 2835 bytes .../Instruments/LegacyA380X/FCU/Images/WX.png | Bin 0 -> 2829 bytes fbw-a380x/src/base/manifest-base.json | 23 + fbw-a380x/src/systems/atsu/rollup.config.js | 71 + fbw-a380x/src/systems/atsu/src/AOC.ts | 90 + fbw-a380x/src/systems/atsu/src/ATC.ts | 643 ++++ fbw-a380x/src/systems/atsu/src/ATSU.ts | 304 ++ .../src/systems/atsu/src/AtsuStatusCodes.ts | 22 + fbw-a380x/src/systems/atsu/src/Common.ts | 80 + .../src/systems/atsu/src/InputValidation.ts | 650 ++++ .../src/systems/atsu/src/com/Datalink.ts | 184 ++ .../atsu/src/com/FutureAirNavigationSystem.ts | 73 + .../src/systems/atsu/src/com/vhf/Common.ts | 33 + fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts | 255 ++ fbw-a380x/src/systems/atsu/src/com/vhf/VHF.ts | 308 ++ .../src/com/webinterfaces/HoppieConnector.ts | 385 +++ .../src/com/webinterfaces/NXApiConnector.ts | 229 ++ .../src/systems/atsu/src/components/ATS623.ts | 101 + .../systems/atsu/src/components/DcduLink.ts | 522 +++ .../src/components/FlightStateObserver.ts | 130 + .../src/components/InputValidationFansA.ts | 54 + .../src/components/InputValidationFansB.ts | 53 + .../components/UplinkMessageInterpretation.ts | 253 ++ .../src/components/UplinkMessageMonitoring.ts | 217 ++ .../components/UplinkMessageStateMachine.ts | 45 + fbw-a380x/src/systems/atsu/src/index.ts | 60 + .../systems/atsu/src/messages/AtisMessage.ts | 48 + .../systems/atsu/src/messages/AtsuMessage.ts | 85 + .../atsu/src/messages/AtsuTimestamp.ts | 32 + .../systems/atsu/src/messages/CpdlcMessage.ts | 160 + .../atsu/src/messages/CpdlcMessageElements.ts | 953 ++++++ .../systems/atsu/src/messages/DclMessage.ts | 72 + .../atsu/src/messages/FreetextMessage.ts | 31 + .../systems/atsu/src/messages/MetarMessage.ts | 17 + .../systems/atsu/src/messages/OclMessage.ts | 66 + .../systems/atsu/src/messages/TafMessage.ts | 17 + .../atsu/src/messages/WeatherMessage.ts | 64 + fbw-a380x/src/systems/atsu/tsconfig.json | 13 + fbw-a380x/src/systems/failures/index.ts | 1 + .../src/systems/failures/rollup.config.js | 43 + fbw-a380x/src/systems/failures/src/a320.ts | 28 + .../__tests__/integration.spec.ts | 83 + .../src/communication/callback-reader.ts | 8 + .../failures/src/communication/index.ts | 7 + .../failures/src/communication/reader.ts | 3 + .../src/communication/readers/index.ts | 2 + .../readers/queued-sim-var-reader.spec.ts | 64 + .../readers/queued-sim-var-reader.ts | 38 + .../readers/transaction-reader.spec.ts | 44 + .../readers/transaction-reader.ts | 29 + .../sim-var-reader-writer.spec.ts | 63 + .../communication/sim-var-reader-writer.ts | 42 + .../failures/src/communication/updatable.ts | 3 + .../failures/src/communication/writer.ts | 8 + .../src/communication/writers/index.ts | 2 + .../src/communication/writers/queue.spec.ts | 50 + .../src/communication/writers/queue.ts | 15 + .../writers/queued-sim-var-writer.spec.ts | 92 + .../writers/queued-sim-var-writer.ts | 84 + .../writers/transaction-writer.spec.ts | 119 + .../writers/transaction-writer.ts | 111 + .../failures/src/failures-consumer.spec.ts | 91 + .../systems/failures/src/failures-consumer.ts | 45 + .../src/failures-orchestrator.spec.ts | 107 + .../failures/src/failures-orchestrator.ts | 90 + fbw-a380x/src/systems/failures/src/index.ts | 4 + .../src/systems/failures/src/sim-vars.ts | 7 + .../systems/failures/src/test-functions.ts | 3 + fbw-a380x/src/systems/failures/tsconfig.json | 13 + fbw-a380x/src/systems/fmgc/rollup.config.js | 85 + .../systems/fmgc/src/NavigationDatabase.ts | 122 + .../systems/fmgc/src/components/EfisLabels.ts | 36 + .../fmgc/src/components/FmgcComponent.ts | 6 + .../fmgc/src/components/ReadySignal.ts | 23 + .../components/fms-messages/FmsMessages.ts | 180 ++ .../src/components/fms-messages/GpsPrimary.ts | 20 + .../components/fms-messages/GpsPrimaryLost.ts | 43 + .../fms-messages/MapPartlyDisplayed.ts | 41 + .../fmgc/src/components/fms-messages/index.ts | 1 + .../src/systems/fmgc/src/components/index.ts | 25 + .../src/systems/fmgc/src/efis/EfisCommon.ts | 77 + .../src/systems/fmgc/src/efis/EfisSymbols.ts | 618 ++++ .../src/systems/fmgc/src/efis/EfisVectors.ts | 175 + .../systems/fmgc/src/efis/NearbyFacilities.ts | 139 + .../src/flightphase/FlightPhaseManager.ts | 192 ++ .../src/systems/fmgc/src/flightphase/Phase.ts | 132 + .../src/systems/fmgc/src/flightphase/index.ts | 9 + .../fmgc/src/flightplanning/DirectTo.ts | 51 + .../fmgc/src/flightplanning/FixInfo.ts | 90 + .../src/flightplanning/FixNamingScheme.ts | 75 + .../src/flightplanning/FlightPlanAsoboSync.ts | 306 ++ .../src/flightplanning/FlightPlanManager.ts | 1977 ++++++++++++ .../src/flightplanning/FlightPlanSegment.ts | 68 + .../systems/fmgc/src/flightplanning/GPS.ts | 105 + .../fmgc/src/flightplanning/GeoMath.ts | 113 + .../fmgc/src/flightplanning/LegsProcedure.ts | 697 ++++ .../src/flightplanning/ManagedFlightPlan.ts | 1611 ++++++++++ .../src/flightplanning/ProcedureDetails.ts | 64 + .../fmgc/src/flightplanning/RawDataMapper.ts | 168 + .../src/flightplanning/SegmentedFlightPlan.ts | 147 + .../src/flightplanning/WaypointBuilder.ts | 130 + .../src/flightplanning/WorldMagneticModel.ts | 695 ++++ .../src/flightplanning/data/flightplan.ts | 68 + .../fmgc/src/flightplanning/data/geo.ts | 9 + .../src/flightplanning/new/DataLoading.ts | 100 + .../new/FlightPlanDefinition.ts | 36 + .../flightplanning/new/FlightPlanEditor.ts | 36 + .../new/FlightPlanManager.spec.ts | 83 + .../flightplanning/new/FlightPlanManager.ts | 84 + .../new/FlightPlanService.spec.ts | 202 ++ .../flightplanning/new/FlightPlanService.ts | 317 ++ .../fmgc/src/flightplanning/new/FpmConfig.ts | 49 + .../src/flightplanning/new/LegUtils.spec.ts | 101 + .../fmgc/src/flightplanning/new/LegUtils.ts | 78 + .../new/NavigationDatabaseService.ts | 21 + .../flightplanning/new/legs/FlightPlanLeg.ts | 187 ++ .../new/legs/FlightPlanLegDefinition.ts | 145 + .../new/legs/FlightPlanLegNaming.ts | 102 + .../new/plans/AlternateFlightPlan.ts | 65 + .../new/plans/BaseFlightPlan.ts | 904 ++++++ .../new/plans/FlightPlan.spec.ts | 262 ++ .../flightplanning/new/plans/FlightPlan.ts | 87 + .../new/plans/PendingAirways.spec.ts | 121 + .../new/plans/PendingAirways.ts | 166 + .../performance/FlightPlanPerformanceData.ts | 47 + .../new/segments/ApproachSegment.ts | 146 + .../new/segments/ApproachViaSegment.ts | 63 + .../ArrivalEnrouteTransitionSegment.ts | 62 + .../ArrivalRunwayTransitionSegment.ts | 42 + .../new/segments/ArrivalSegment.ts | 73 + .../DepartureEnrouteTransitionSegment.ts | 62 + .../DepartureRunwayTransitionSegment.ts | 43 + .../new/segments/DepartureSegment.ts | 71 + .../new/segments/DestinationSegment.ts | 114 + .../new/segments/EnrouteSegment.spec.ts | 68 + .../new/segments/EnrouteSegment.ts | 37 + .../new/segments/FlightPlanSegment.ts | 143 + .../new/segments/MissedApproachSegment.ts | 30 + .../new/segments/OriginSegment.ts | 99 + .../new/segments/SegmentClass.ts | 10 + .../segments/enroute/AirwayLoading.spec.ts | 18 + .../new/segments/enroute/AirwayLoading.ts | 50 + .../segments/enroute/WaypointLoading.spec.ts | 39 + .../new/segments/enroute/WaypointLoading.ts | 78 + .../src/flightplanning/new/test/Database.ts | 11 + .../src/flightplanning/new/test/FlightPlan.ts | 18 + .../src/flightplanning/new/test/LegUtils.ts | 18 + .../src/flightplanning/new/types/DirectTo.ts | 25 + .../new/waypoints/WaypointFactory.ts | 53 + .../systems/fmgc/src/guidance/ControlLaws.ts | 67 + .../src/systems/fmgc/src/guidance/FmsState.ts | 17 + .../src/systems/fmgc/src/guidance/Geometry.ts | 538 ++++ .../src/systems/fmgc/src/guidance/Guidable.ts | 148 + .../fmgc/src/guidance/GuidanceComponent.ts | 14 + .../fmgc/src/guidance/GuidanceConstants.ts | 8 + .../fmgc/src/guidance/GuidanceController.ts | 471 +++ .../fmgc/src/guidance/GuidanceManager.ts | 17 + .../systems/fmgc/src/guidance/LnavConfig.ts | 72 + .../fmgc/src/guidance/PsuedoWaypoint.ts | 67 + .../systems/fmgc/src/guidance/TaskQueue.ts | 54 + .../src/guidance/geometry/GeometryFactory.ts | 309 ++ .../fmgc/src/guidance/lnav/CommonGeometry.ts | 331 ++ .../fmgc/src/guidance/lnav/LnavDriver.ts | 503 +++ .../fmgc/src/guidance/lnav/PathVector.ts | 79 + .../fmgc/src/guidance/lnav/PseudoWaypoints.ts | 324 ++ .../fmgc/src/guidance/lnav/Transition.ts | 64 + .../src/guidance/lnav/TransitionPicker.ts | 486 +++ .../systems/fmgc/src/guidance/lnav/legs/AF.ts | 167 + .../systems/fmgc/src/guidance/lnav/legs/CA.ts | 184 ++ .../systems/fmgc/src/guidance/lnav/legs/CD.ts | 121 + .../systems/fmgc/src/guidance/lnav/legs/CF.ts | 159 + .../systems/fmgc/src/guidance/lnav/legs/CI.ts | 161 + .../systems/fmgc/src/guidance/lnav/legs/CR.ts | 167 + .../systems/fmgc/src/guidance/lnav/legs/DF.ts | 127 + .../systems/fmgc/src/guidance/lnav/legs/FD.ts | 146 + .../systems/fmgc/src/guidance/lnav/legs/HX.ts | 594 ++++ .../systems/fmgc/src/guidance/lnav/legs/IF.ts | 91 + .../fmgc/src/guidance/lnav/legs/Leg.ts | 67 + .../systems/fmgc/src/guidance/lnav/legs/RF.ts | 161 + .../systems/fmgc/src/guidance/lnav/legs/TF.ts | 144 + .../systems/fmgc/src/guidance/lnav/legs/VM.ts | 119 + .../systems/fmgc/src/guidance/lnav/legs/XF.ts | 63 + .../fmgc/src/guidance/lnav/legs/index.ts | 209 ++ .../transitions/CourseCaptureTransition.ts | 210 ++ .../lnav/transitions/DirectToFixTransition.ts | 372 +++ .../lnav/transitions/DmeArcTransition.ts | 285 ++ .../lnav/transitions/FixedRadiusTransition.ts | 313 ++ .../lnav/transitions/HoldEntryTransition.ts | 851 +++++ .../lnav/transitions/PathCaptureTransition.ts | 479 +++ .../lnav/transitions/utilss/CourseChange.ts | 73 + .../guidance/vnav/AtmosphericConditions.ts | 107 + .../src/guidance/vnav/CoarsePredictions.ts | 48 + .../fmgc/src/guidance/vnav/CostIndex.ts | 108 + .../fmgc/src/guidance/vnav/EngineModel.ts | 226 ++ .../fmgc/src/guidance/vnav/FlightModel.ts | 287 ++ .../fmgc/src/guidance/vnav/Predictions.ts | 580 ++++ .../fmgc/src/guidance/vnav/SpeedLimit.ts | 4 + .../fmgc/src/guidance/vnav/VnavConfig.ts | 39 + .../fmgc/src/guidance/vnav/VnavDriver.ts | 138 + .../guidance/vnav/climb/ClimbPathBuilder.ts | 85 + .../vnav/climb/ClimbProfileBuilderResult.ts | 3 + .../systems/fmgc/src/guidance/vnav/common.ts | 202 ++ .../guidance/vnav/descent/DecelPathBuilder.ts | 328 ++ .../guidance/vnav/descent/DescentBuilder.ts | 49 + .../vnav/descent/GeomtricPathBuilder.ts | 71 + .../vnav/descent/TheoreticalDescentPath.ts | 20 + .../verticalFlightPlan/VerticalFlightPlan.ts | 50 + .../VerticalFlightPlanBuilder.ts | 50 + fbw-a380x/src/systems/fmgc/src/index.ts | 54 + .../systems/fmgc/src/navigation/FlightArea.ts | 16 + .../systems/fmgc/src/navigation/Navigation.ts | 112 + .../src/navigation/RequiredPerformance.ts | 92 + .../fmgc/src/radionav/NavRadioManager.ts | 26 + .../src/systems/fmgc/src/radionav/index.ts | 3 + .../systems/fmgc/src/types/A32NX_Util.d.ts | 65 + .../systems/fmgc/src/types/fstypes/FSEnums.ts | 226 ++ .../fmgc/src/types/fstypes/FSTypes.d.ts | 375 +++ .../systems/fmgc/src/types/fstypes/main.d.ts | 25 + fbw-a380x/src/systems/fmgc/src/utils/Geo.ts | 148 + .../src/systems/fmgc/src/utils/LzUtf8.js | 579 ++++ fbw-a380x/src/systems/fmgc/src/wtsdk.ts | 36 + fbw-a380x/src/systems/fmgc/tsconfig.json | 14 + fbw-a380x/src/systems/instruments/README.md | 27 + .../systems/instruments/buildSrc/aceBuild.mjs | 35 + .../instruments/buildSrc/directories.mjs | 15 + .../instruments/buildSrc/igniter/tasks.mjs | 19 + .../systems/instruments/buildSrc/plugins.mjs | 85 + .../src/Common/Assets/ECAMFont.ttf | Bin 0 -> 23800 bytes .../src/Common/CdsDisplayUnit.scss | 3 + .../instruments/src/Common/CdsDisplayUnit.tsx | 196 ++ .../src/Common/ComponentPosition.ts | 4 + .../src/Common/EWDMessageParser.tsx | 169 + .../instruments/src/Common/EWDMessages.tsx | 227 ++ .../instruments/src/Common/FuelFunctions.tsx | 12 + .../systems/instruments/src/Common/NXLogic.ts | 224 ++ .../systems/instruments/src/Common/Shapes.tsx | 25 + .../systems/instruments/src/Common/Valve.tsx | 20 + .../instruments/src/Common/arinc429.tsx | 10 + .../instruments/src/Common/common.scss | 21 + .../instruments/src/Common/defaults.ts | 28 + .../instruments/src/Common/definitions.scss | 25 + .../instruments/src/Common/flightplan.tsx | 49 + .../instruments/src/Common/gauges.scss | 65 + .../systems/instruments/src/Common/gauges.tsx | 286 ++ .../systems/instruments/src/Common/hooks.tsx | 112 + .../instruments/src/Common/hooks/index.tsx | 46 + .../systems/instruments/src/Common/index.tsx | 41 + .../systems/instruments/src/Common/input.tsx | 131 + .../instruments/src/Common/persistence.tsx | 41 + .../instruments/src/Common/pixels.scss | 11 + .../instruments/src/Common/simVars.tsx | 288 ++ .../systems/instruments/src/Common/types.tsx | 48 + .../systems/instruments/src/Common/utils.tsx | 38 + .../src/EWD/EngineWarningDisplay.tsx | 63 + .../systems/instruments/src/EWD/config.json | 9 + .../src/EWD/elements/Checklist.tsx | 101 + .../instruments/src/EWD/elements/EGT.tsx | 108 + .../instruments/src/EWD/elements/EWDMemo.tsx | 46 + .../src/EWD/elements/EngineGauge.tsx | 15 + .../src/EWD/elements/IgnitionBorder.tsx | 27 + .../instruments/src/EWD/elements/N1.tsx | 257 ++ .../src/EWD/elements/PseudoFWC.tsx | 838 +++++ .../src/EWD/elements/ThrustGauge.tsx | 270 ++ .../src/EWD/elements/ThrustRatingMode.tsx | 55 + .../src/systems/instruments/src/EWD/index.tsx | 16 + .../systems/instruments/src/EWD/style.scss | 216 ++ .../src/MFD/ATCCOM/Elements/Atis.tsx | 70 + .../src/MFD/ATCCOM/Elements/AutoPrintIcon.tsx | 112 + .../MFD/ATCCOM/Elements/AutoUpdateIcon.tsx | 15 + .../MFD/ATCCOM/Elements/MessageElement.tsx | 19 + .../src/MFD/ATCCOM/Elements/NewAtisIcon.tsx | 18 + .../src/MFD/ATCCOM/Elements/SwitchButton.tsx | 47 + .../MFD/ATCCOM/Elements/TrashbinButton.tsx | 20 + .../Clearance/RequestDepartureClearance.tsx | 69 + .../Clearance/RequestGenericClearance.tsx | 17 + .../Clearance/RequestOceanicClearance.tsx | 68 + .../ATCCOM/Messages/Lateral/RequestDirect.tsx | 46 + .../Messages/Lateral/RequestGroundTrack.tsx | 47 + .../Messages/Lateral/RequestHeading.tsx | 47 + .../ATCCOM/Messages/Lateral/RequestOffset.tsx | 114 + .../Lateral/RequestWeatherDeviation.tsx | 51 + .../src/MFD/ATCCOM/Messages/Registry.tsx | 65 + .../ATCCOM/Messages/Speed/RequestSpeed.tsx | 61 + .../Messages/Speed/RequestSpeedRange.tsx | 110 + .../ATCCOM/Messages/Vertical/RequestClimb.tsx | 123 + .../Messages/Vertical/RequestCruiseClimb.tsx | 60 + .../Messages/Vertical/RequestDescend.tsx | 123 + .../ATCCOM/Messages/Vertical/RequestItp.tsx | 14 + .../ATCCOM/Messages/Vertical/RequestLevel.tsx | 57 + .../Messages/Vertical/RequestLevelBlock.tsx | 103 + .../WhenCanWe/WhenCanWeExpectBackOnRoute.tsx | 23 + .../WhenCanWe/WhenCanWeExpectClimb.tsx | 60 + .../WhenCanWe/WhenCanWeExpectCruiseClimb.tsx | 60 + .../WhenCanWe/WhenCanWeExpectDescend.tsx | 60 + .../WhenCanWe/WhenCanWeExpectHigherLevel.tsx | 23 + .../WhenCanWe/WhenCanWeExpectLowerLevel.tsx | 23 + .../WhenCanWe/WhenCanWeExpectSpeed.tsx | 61 + .../WhenCanWe/WhenCanWeExpectSpeedRange.tsx | 110 + .../instruments/src/MFD/ATCCOM/Pages/Atis.tsx | 23 + .../src/MFD/ATCCOM/Pages/Connect/Mainpage.tsx | 53 + .../ATCCOM/Pages/Connect/MaxUplinkDelay.tsx | 30 + .../src/MFD/ATCCOM/Pages/Emergency.tsx | 75 + .../ATCCOM/Pages/MsgRecord/AllMessages.tsx | 3 + .../MFD/ATCCOM/Pages/MsgRecord/Mainpage.tsx | 23 + .../Pages/MsgRecord/MonitoredMessages.tsx | 3 + .../src/MFD/ATCCOM/Pages/Report/Mainpage.tsx | 29 + .../src/MFD/ATCCOM/Pages/Report/Modify.tsx | 0 .../src/MFD/ATCCOM/Pages/Report/Other.tsx | 56 + .../ATCCOM/Pages/Report/PositionReport.tsx | 149 + .../src/MFD/ATCCOM/Pages/Request.tsx | 97 + .../src/MFD/ATCCOM/Pages/Request/Common.tsx | 8 + .../src/MFD/ATCCOM/Pages/Request/FansA.tsx | 163 + .../src/MFD/ATCCOM/Pages/Request/FansB.tsx | 61 + .../src/MFD/ATCCOM/Pages/index.tsx | 31 + .../instruments/src/MFD/ATCCOM/index.tsx | 268 ++ .../instruments/src/MFD/Components/Arrows.tsx | 17 + .../instruments/src/MFD/Components/Button.tsx | 106 + .../src/MFD/Components/Checkbox.tsx | 124 + .../src/MFD/Components/Dropdown.tsx | 330 ++ .../instruments/src/MFD/Components/Layer.tsx | 7 + .../src/MFD/Components/MFDRoute.tsx | 28 + .../src/MFD/Components/Textbox.tsx | 233 ++ .../instruments/src/MFD/Components/tabs.tsx | 96 + .../src/MFD/FMS/Pages/Active/Fpln/Airways.tsx | 196 ++ .../MFD/FMS/Pages/Active/Fpln/Arrivals.tsx | 154 + .../MFD/FMS/Pages/Active/Fpln/Departures.tsx | 123 + .../src/MFD/FMS/Pages/Active/Fpln/LegRow.tsx | 122 + .../FMS/Pages/Active/Fpln/RevisionsMenu.tsx | 179 ++ .../src/MFD/FMS/Pages/Active/Fpln/index.tsx | 271 ++ .../src/MFD/FMS/Pages/Active/FuelLoad.tsx | 104 + .../src/MFD/FMS/Pages/Active/Init.tsx | 129 + .../src/MFD/FMS/Pages/Data/Airport.tsx | 140 + .../src/MFD/FMS/Pages/Data/Navaid.tsx | 70 + .../src/MFD/FMS/Pages/Data/Status.tsx | 122 + .../src/MFD/FMS/Pages/Data/Waypoint.tsx | 39 + .../instruments/src/MFD/FMS/Pages/index.tsx | 27 + .../systems/instruments/src/MFD/FMS/index.tsx | 131 + .../src/MFD/Messages/MFDMessage.ts | 144 + .../src/MFD/Messages/MFDMessageArea.tsx | 114 + .../src/MFD/Messages/MFDMessageManager.tsx | 115 + .../src/MFD/Messages/MFDMessageTranslation.ts | 660 ++++ .../src/MFD/Messages/MFDMessagesList.tsx | 36 + .../src/MFD/MultiFunctionDisplay.tsx | 112 + .../systems/instruments/src/MFD/config.json | 9 + .../src/systems/instruments/src/MFD/index.tsx | 45 + .../systems/instruments/src/MFD/styles.scss | 99 + .../instruments/src/OIT/Components/Button.tsx | 67 + .../src/OIT/Components/Dropdown.tsx | 232 ++ .../instruments/src/OIT/Components/Layer.tsx | 7 + .../src/OIT/OnboardInformationTerminal.tsx | 130 + .../src/OIT/Pages/FlightOps/LoadBox.tsx | 11 + .../src/OIT/Pages/FlightOps/Login.tsx | 26 + .../src/OIT/Pages/FlightOps/Menu.tsx | 11 + .../src/OIT/Pages/FlightOps/STS.tsx | 71 + .../instruments/src/OIT/Pages/index.tsx | 6 + .../systems/instruments/src/OIT/config.json | 8 + .../systems/instruments/src/OIT/index.scss | 3 + .../src/systems/instruments/src/OIT/index.tsx | 15 + .../systems/instruments/src/PFD/.eslintrc.js | 10 + .../instruments/src/PFD/AltitudeIndicator.tsx | 707 ++++ .../src/PFD/AttitudeIndicatorFixed.tsx | 465 +++ .../src/PFD/AttitudeIndicatorHorizon.tsx | 591 ++++ .../src/PFD/DigitalAltitudeReadout.tsx | 326 ++ .../src/systems/instruments/src/PFD/FMA.tsx | 1587 +++++++++ .../src/PFD/FlightPathDirector.tsx | 183 ++ .../instruments/src/PFD/FlightPathVector.tsx | 122 + .../instruments/src/PFD/HeadingIndicator.tsx | 341 ++ .../instruments/src/PFD/HorizontalTape.tsx | 180 ++ .../src/PFD/LandingSystemIndicator.tsx | 486 +++ .../src/systems/instruments/src/PFD/PFD.svg | 2860 +++++++++++++++++ .../src/systems/instruments/src/PFD/PFD.tsx | 155 + .../systems/instruments/src/PFD/PFDUtils.tsx | 136 + .../instruments/src/PFD/SpeedIndicator.tsx | 969 ++++++ .../src/PFD/VerticalSpeedIndicator.tsx | 459 +++ .../instruments/src/PFD/VerticalTape.tsx | 149 + .../instruments/src/PFD/animations.scss | 71 + .../systems/instruments/src/PFD/config.json | 4 + .../instruments/src/PFD/instrument.tsx | 224 ++ .../src/PFD/shared/AdirsValueProvider.tsx | 45 + .../src/PFD/shared/ArincValueProvider.ts | 216 ++ .../src/PFD/shared/PFDSimvarPublisher.tsx | 362 +++ .../src/PFD/shared/SimplaneValueProvider.ts | 41 + .../instruments/src/PFD/shared/common.scss | 23 + .../src/PFD/shared/definitions.scss | 20 + .../src/PFD/shared/displayUnit.tsx | 148 + .../instruments/src/PFD/shared/pixels.scss | 10 + .../systems/instruments/src/PFD/style.scss | 270 ++ .../systems/instruments/src/PFD/tsconfig.json | 17 + .../src/RMP/Components/ActiveFrequency.tsx | 12 + .../instruments/src/RMP/Components/Arrow.tsx | 18 + .../instruments/src/RMP/Components/Layer.tsx | 7 + .../src/RMP/Components/MessageArea.tsx | 17 + .../src/RMP/Components/StandbyFrequency.tsx | 28 + .../src/RMP/Components/TextRow.tsx | 19 + .../instruments/src/RMP/Pages/HfPage.tsx | 54 + .../Menu/Datalink/DatalinkRouterPage.tsx | 7 + .../src/RMP/Pages/Menu/MenuPage.tsx | 15 + .../instruments/src/RMP/Pages/NavPage.tsx | 7 + .../instruments/src/RMP/Pages/SqwkPage.tsx | 7 + .../instruments/src/RMP/Pages/TelPage.tsx | 7 + .../instruments/src/RMP/Pages/VhfPage.tsx | 177 + .../instruments/src/RMP/Pages/index.tsx | 19 + .../src/RMP/RadioManagementPanel.tsx | 55 + .../systems/instruments/src/RMP/config.json | 4 + .../src/systems/instruments/src/RMP/index.css | 163 + .../src/systems/instruments/src/RMP/index.tsx | 12 + .../instruments/src/SD/Pages/ApuPage.tsx | 600 ++++ .../src/SD/Pages/Bleed/BleedPage.tsx | 68 + .../src/SD/Pages/Bleed/config.json | 4 + .../src/SD/Pages/Bleed/elements/BleedApu.tsx | 24 + .../Pages/Bleed/elements/BleedCrossbleed.tsx | 21 + .../SD/Pages/Bleed/elements/BleedEngine.tsx | 111 + .../SD/Pages/Bleed/elements/BleedGauge.tsx | 90 + .../SD/Pages/Bleed/elements/BleedHotAir.tsx | 29 + .../Pages/Bleed/elements/BleedMixerUnit.tsx | 61 + .../src/SD/Pages/Bleed/elements/BleedPack.tsx | 34 + .../src/SD/Pages/Bleed/elements/Valve.tsx | 21 + .../instruments/src/SD/Pages/BleedPage.tsx | 6 + .../instruments/src/SD/Pages/CbPage.tsx | 6 + .../instruments/src/SD/Pages/CondPage.tsx | 6 + .../src/SD/Pages/Cruise/CruisePage.tsx | 76 + .../src/SD/Pages/Cruise/config.json | 4 + .../SD/Pages/Cruise/elements/A380Cruise.tsx | 31 + .../SD/Pages/Cruise/elements/CruiseCond.tsx | 33 + .../Pages/Cruise/elements/CruisePressure.tsx | 104 + .../instruments/src/SD/Pages/CruisePage.tsx | 6 + .../src/SD/Pages/Doors/DoorPage.tsx | 59 + .../src/SD/Pages/Doors/config.json | 4 + .../SD/Pages/Doors/elements/A380XDoors.tsx | 59 + .../src/SD/Pages/Doors/elements/CabinDoor.tsx | 69 + .../src/SD/Pages/Doors/elements/CargoDoor.tsx | 25 + .../src/SD/Pages/Doors/elements/Oxygen.tsx | 34 + .../instruments/src/SD/Pages/ElecAcPage.tsx | 6 + .../src/SD/Pages/ElecDc/ElecDcPage.tsx | 29 + .../src/SD/Pages/ElecDc/config.json | 4 + .../src/SD/Pages/ElecDc/elements/BusBar.tsx | 40 + .../ElecDc/elements/ElectricalNetwork.tsx | 49 + .../Pages/ElecDc/elements/IndicationBox.tsx | 98 + .../src/SD/Pages/Engine/EngPage.tsx | 49 + .../src/SD/Pages/Engine/config.json | 4 + .../Pages/Engine/elements/DecimalValues.tsx | 31 + .../SD/Pages/Engine/elements/EngineColumn.tsx | 89 + .../Pages/Engine/elements/IgnitionBorder.tsx | 26 + .../elements/NacelleTemperatureGauge.tsx | 99 + .../Engine/elements/OilPressureGauge.tsx | 102 + .../Engine/elements/OilQuantityGauge.tsx | 101 + .../SD/Pages/Engine/elements/StartValve.tsx | 32 + .../instruments/src/SD/Pages/FctlPage.tsx | 6 + .../instruments/src/SD/Pages/FuelPage.tsx | 6 + .../instruments/src/SD/Pages/Generic/Inop.tsx | 7 + .../src/SD/Pages/Generic/PageTitle.tsx | 21 + .../instruments/src/SD/Pages/Hyd/HydPage.tsx | 412 +++ .../src/SD/Pages/Press/PressPage.tsx | 58 + .../src/SD/Pages/Press/config.json | 4 + .../src/SD/Pages/Press/elements/A380Press.tsx | 19 + .../src/SD/Pages/Press/elements/CabAlt.tsx | 136 + .../Press/elements/CabinVerticalSpeed.tsx | 125 + .../src/SD/Pages/Press/elements/DeltaP.tsx | 107 + .../SD/Pages/Press/elements/ExtractValve.tsx | 36 + .../Pages/Press/elements/LandingElevation.tsx | 23 + .../SD/Pages/Press/elements/OutflowValve.tsx | 99 + .../src/SD/Pages/Press/elements/Packs.tsx | 20 + .../instruments/src/SD/Pages/StatusPage.tsx | 6 + .../instruments/src/SD/Pages/WheelPage.tsx | 6 + .../systems/instruments/src/SD/StatusArea.tsx | 176 + .../instruments/src/SD/SystemDisplay.tsx | 65 + .../systems/instruments/src/SD/config.json | 10 + .../src/systems/instruments/src/SD/index.tsx | 14 + .../systems/instruments/src/SD/styles.scss | 106 + .../src/systems/instruments/src/index.scss | 352 ++ fbw-a380x/src/systems/instruments/src/util.js | 86 + .../systems/instruments/template/rollup.js | 37 + .../instruments/template/template.html | 21 + .../systems/instruments/template/template.js | 53 + .../src/systems/instruments/tsconfig.json | 16 + .../src/systems/shared/src/Coherent.d.ts | 23 + fbw-a380x/src/systems/shared/src/Constants.ts | 4 + .../src/systems/shared/src/FlowEventSync.ts | 90 + .../src/systems/shared/src/FmMessages.ts | 183 ++ fbw-a380x/src/systems/shared/src/MagVar.ts | 19 + .../src/systems/shared/src/MathUtils.spec.ts | 44 + fbw-a380x/src/systems/shared/src/MathUtils.ts | 432 +++ fbw-a380x/src/systems/shared/src/NXUnits.ts | 29 + .../systems/shared/src/NavigationDisplay.ts | 145 + .../src/systems/shared/src/UpdateThrottler.ts | 50 + fbw-a380x/src/systems/shared/src/arinc429.ts | 148 + fbw-a380x/src/systems/shared/src/ata.ts | 103 + fbw-a380x/src/systems/shared/src/autopilot.ts | 67 + .../src/systems/shared/src/electrical.ts | 5 + .../src/systems/shared/src/flightphase.ts | 52 + .../src/systems/shared/src/flightplan.ts | 39 + fbw-a380x/src/systems/shared/src/logic.ts | 62 + .../src/systems/shared/src/notification.ts | 155 + .../src/systems/shared/src/persistence.ts | 51 + fbw-a380x/src/systems/shared/src/popup.ts | 136 + fbw-a380x/src/systems/shared/src/simvar.ts | 97 + fbw-a380x/src/systems/shared/src/units.ts | 134 + fbw-a380x/src/systems/tcas/rollup.config.js | 67 + .../tcas/src/components/TcasComputer.ts | 1169 +++++++ .../tcas/src/components/TcasSoundManager.ts | 127 + fbw-a380x/src/systems/tcas/src/index.ts | 8 + .../src/systems/tcas/src/lib/TcasComponent.ts | 7 + .../src/systems/tcas/src/lib/TcasConstants.ts | 680 ++++ fbw-a380x/src/systems/tcas/src/loop.ts | 14 + fbw-a380x/src/systems/tcas/tsconfig.json | 13 + fbw-a380x/src/systems/tsconfig.json | 17 + igniter.config.mjs | 18 +- jest.config.js | 2 + package-lock.json | 61 + package.json | 11 +- scripts/build_a380x.js | 98 + 538 files changed, 67981 insertions(+), 6 deletions(-) create mode 100644 fbw-a380x/mach.config.js create mode 100644 fbw-a380x/src/.eslintrc.js delete mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/.keep create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/A380X_FCU.ttf create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/ECAMFontRegular.ttf create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-RMP-10.ttf create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-RMP-11.ttf create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-RMP-13.ttf create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-RMP-16(1).ttf create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-RMP-19.ttf create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/Engine-Hyd-Bleed-Dithered.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/HYD_8-7_TRIMMED.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/TRIM_INDICATOR.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.css create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.html create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.js create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/ARPT.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/Blank.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/CSTR.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/NDB.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/POINTER1.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/POINTER2.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/TERR.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/TRAF.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/VORD.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/WPT.png create mode 100644 fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/WX.png create mode 100644 fbw-a380x/src/base/manifest-base.json create mode 100644 fbw-a380x/src/systems/atsu/rollup.config.js create mode 100644 fbw-a380x/src/systems/atsu/src/AOC.ts create mode 100644 fbw-a380x/src/systems/atsu/src/ATC.ts create mode 100644 fbw-a380x/src/systems/atsu/src/ATSU.ts create mode 100644 fbw-a380x/src/systems/atsu/src/AtsuStatusCodes.ts create mode 100644 fbw-a380x/src/systems/atsu/src/Common.ts create mode 100644 fbw-a380x/src/systems/atsu/src/InputValidation.ts create mode 100644 fbw-a380x/src/systems/atsu/src/com/Datalink.ts create mode 100644 fbw-a380x/src/systems/atsu/src/com/FutureAirNavigationSystem.ts create mode 100644 fbw-a380x/src/systems/atsu/src/com/vhf/Common.ts create mode 100644 fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts create mode 100644 fbw-a380x/src/systems/atsu/src/com/vhf/VHF.ts create mode 100644 fbw-a380x/src/systems/atsu/src/com/webinterfaces/HoppieConnector.ts create mode 100644 fbw-a380x/src/systems/atsu/src/com/webinterfaces/NXApiConnector.ts create mode 100644 fbw-a380x/src/systems/atsu/src/components/ATS623.ts create mode 100644 fbw-a380x/src/systems/atsu/src/components/DcduLink.ts create mode 100644 fbw-a380x/src/systems/atsu/src/components/FlightStateObserver.ts create mode 100644 fbw-a380x/src/systems/atsu/src/components/InputValidationFansA.ts create mode 100644 fbw-a380x/src/systems/atsu/src/components/InputValidationFansB.ts create mode 100644 fbw-a380x/src/systems/atsu/src/components/UplinkMessageInterpretation.ts create mode 100644 fbw-a380x/src/systems/atsu/src/components/UplinkMessageMonitoring.ts create mode 100644 fbw-a380x/src/systems/atsu/src/components/UplinkMessageStateMachine.ts create mode 100644 fbw-a380x/src/systems/atsu/src/index.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/AtisMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/AtsuMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/AtsuTimestamp.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/CpdlcMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/CpdlcMessageElements.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/DclMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/FreetextMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/MetarMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/OclMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/TafMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/src/messages/WeatherMessage.ts create mode 100644 fbw-a380x/src/systems/atsu/tsconfig.json create mode 100644 fbw-a380x/src/systems/failures/index.ts create mode 100644 fbw-a380x/src/systems/failures/rollup.config.js create mode 100644 fbw-a380x/src/systems/failures/src/a320.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/__tests__/integration.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/callback-reader.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/index.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/reader.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/readers/index.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/readers/queued-sim-var-reader.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/readers/queued-sim-var-reader.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/readers/transaction-reader.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/readers/transaction-reader.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/sim-var-reader-writer.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/sim-var-reader-writer.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/updatable.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/writer.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/writers/index.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/writers/queue.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/writers/queue.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/writers/queued-sim-var-writer.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/writers/queued-sim-var-writer.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/writers/transaction-writer.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/communication/writers/transaction-writer.ts create mode 100644 fbw-a380x/src/systems/failures/src/failures-consumer.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/failures-consumer.ts create mode 100644 fbw-a380x/src/systems/failures/src/failures-orchestrator.spec.ts create mode 100644 fbw-a380x/src/systems/failures/src/failures-orchestrator.ts create mode 100644 fbw-a380x/src/systems/failures/src/index.ts create mode 100644 fbw-a380x/src/systems/failures/src/sim-vars.ts create mode 100644 fbw-a380x/src/systems/failures/src/test-functions.ts create mode 100644 fbw-a380x/src/systems/failures/tsconfig.json create mode 100644 fbw-a380x/src/systems/fmgc/rollup.config.js create mode 100644 fbw-a380x/src/systems/fmgc/src/NavigationDatabase.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/EfisLabels.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/FmgcComponent.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/ReadySignal.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/fms-messages/FmsMessages.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/fms-messages/GpsPrimary.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/fms-messages/GpsPrimaryLost.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/fms-messages/MapPartlyDisplayed.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/fms-messages/index.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/components/index.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/efis/EfisCommon.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/efis/EfisSymbols.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/efis/EfisVectors.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/efis/NearbyFacilities.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightphase/FlightPhaseManager.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightphase/Phase.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightphase/index.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/DirectTo.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/FixInfo.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/FixNamingScheme.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanAsoboSync.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanManager.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/GPS.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/GeoMath.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/LegsProcedure.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/ManagedFlightPlan.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/ProcedureDetails.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/RawDataMapper.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/SegmentedFlightPlan.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/WaypointBuilder.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/WorldMagneticModel.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/data/flightplan.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/data/geo.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/DataLoading.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanDefinition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanEditor.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanManager.spec.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanManager.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanService.spec.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanService.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/FpmConfig.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/LegUtils.spec.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/LegUtils.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/NavigationDatabaseService.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLeg.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLegDefinition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLegNaming.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/AlternateFlightPlan.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/BaseFlightPlan.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/FlightPlan.spec.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/FlightPlan.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/PendingAirways.spec.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/PendingAirways.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/performance/FlightPlanPerformanceData.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ApproachSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ApproachViaSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalEnrouteTransitionSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalRunwayTransitionSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureEnrouteTransitionSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureRunwayTransitionSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DestinationSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/EnrouteSegment.spec.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/EnrouteSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/FlightPlanSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/MissedApproachSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/OriginSegment.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/SegmentClass.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/AirwayLoading.spec.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/AirwayLoading.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/WaypointLoading.spec.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/WaypointLoading.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/Database.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/FlightPlan.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/LegUtils.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/types/DirectTo.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/flightplanning/new/waypoints/WaypointFactory.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/ControlLaws.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/FmsState.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/Geometry.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/Guidable.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/GuidanceComponent.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/GuidanceConstants.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/GuidanceController.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/GuidanceManager.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/LnavConfig.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/PsuedoWaypoint.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/TaskQueue.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/geometry/GeometryFactory.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/CommonGeometry.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/LnavDriver.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/PathVector.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/PseudoWaypoints.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/Transition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/TransitionPicker.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/AF.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CA.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CD.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CF.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CI.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CR.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/DF.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/FD.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/HX.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/IF.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/Leg.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/RF.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/TF.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/VM.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/XF.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/index.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/CourseCaptureTransition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/DirectToFixTransition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/DmeArcTransition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/FixedRadiusTransition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/HoldEntryTransition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/PathCaptureTransition.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/utilss/CourseChange.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/AtmosphericConditions.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/CoarsePredictions.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/CostIndex.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/EngineModel.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/FlightModel.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/Predictions.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/SpeedLimit.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/VnavConfig.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/VnavDriver.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/climb/ClimbProfileBuilderResult.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/common.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/DescentBuilder.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/GeomtricPathBuilder.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/TheoreticalDescentPath.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/verticalFlightPlan/VerticalFlightPlan.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/guidance/vnav/verticalFlightPlan/VerticalFlightPlanBuilder.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/index.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/navigation/FlightArea.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/navigation/Navigation.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/navigation/RequiredPerformance.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/radionav/NavRadioManager.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/radionav/index.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/types/A32NX_Util.d.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/types/fstypes/FSEnums.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/types/fstypes/FSTypes.d.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/types/fstypes/main.d.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/utils/Geo.ts create mode 100644 fbw-a380x/src/systems/fmgc/src/utils/LzUtf8.js create mode 100644 fbw-a380x/src/systems/fmgc/src/wtsdk.ts create mode 100644 fbw-a380x/src/systems/fmgc/tsconfig.json create mode 100644 fbw-a380x/src/systems/instruments/README.md create mode 100644 fbw-a380x/src/systems/instruments/buildSrc/aceBuild.mjs create mode 100644 fbw-a380x/src/systems/instruments/buildSrc/directories.mjs create mode 100644 fbw-a380x/src/systems/instruments/buildSrc/igniter/tasks.mjs create mode 100644 fbw-a380x/src/systems/instruments/buildSrc/plugins.mjs create mode 100644 fbw-a380x/src/systems/instruments/src/Common/Assets/ECAMFont.ttf create mode 100644 fbw-a380x/src/systems/instruments/src/Common/CdsDisplayUnit.scss create mode 100644 fbw-a380x/src/systems/instruments/src/Common/CdsDisplayUnit.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/ComponentPosition.ts create mode 100644 fbw-a380x/src/systems/instruments/src/Common/EWDMessageParser.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/EWDMessages.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/FuelFunctions.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/NXLogic.ts create mode 100644 fbw-a380x/src/systems/instruments/src/Common/Shapes.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/Valve.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/arinc429.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/common.scss create mode 100644 fbw-a380x/src/systems/instruments/src/Common/defaults.ts create mode 100644 fbw-a380x/src/systems/instruments/src/Common/definitions.scss create mode 100644 fbw-a380x/src/systems/instruments/src/Common/flightplan.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/gauges.scss create mode 100644 fbw-a380x/src/systems/instruments/src/Common/gauges.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/hooks.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/hooks/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/input.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/persistence.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/pixels.scss create mode 100644 fbw-a380x/src/systems/instruments/src/Common/simVars.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/types.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/Common/utils.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/EngineWarningDisplay.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/Checklist.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/EGT.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/EWDMemo.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/EngineGauge.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/IgnitionBorder.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/N1.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/PseudoFWC.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/ThrustGauge.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/elements/ThrustRatingMode.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/EWD/style.scss create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/Atis.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/AutoPrintIcon.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/AutoUpdateIcon.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/MessageElement.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/NewAtisIcon.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/SwitchButton.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/TrashbinButton.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestDepartureClearance.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestGenericClearance.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestOceanicClearance.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestDirect.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestGroundTrack.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestHeading.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestOffset.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestWeatherDeviation.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Registry.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Speed/RequestSpeed.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Speed/RequestSpeedRange.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestClimb.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestCruiseClimb.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestDescend.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestItp.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestLevel.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestLevelBlock.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectBackOnRoute.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectClimb.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectCruiseClimb.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectDescend.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectHigherLevel.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectLowerLevel.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectSpeed.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectSpeedRange.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Atis.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Connect/Mainpage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Connect/MaxUplinkDelay.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Emergency.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/AllMessages.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/Mainpage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/MonitoredMessages.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Mainpage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Modify.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Other.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/PositionReport.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/Common.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/FansA.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/FansB.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Components/Arrows.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Components/Button.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Components/Checkbox.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Components/Dropdown.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Components/Layer.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Components/MFDRoute.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Components/Textbox.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Components/tabs.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Airways.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Arrivals.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Departures.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/LegRow.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/RevisionsMenu.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/FuelLoad.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Init.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Airport.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Navaid.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Status.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Waypoint.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/FMS/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessage.ts create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageArea.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageManager.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageTranslation.ts create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessagesList.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/MultiFunctionDisplay.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/MFD/styles.scss create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/Components/Button.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/Components/Dropdown.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/Components/Layer.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/OnboardInformationTerminal.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/LoadBox.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/Login.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/Menu.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/STS.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/Pages/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/index.scss create mode 100644 fbw-a380x/src/systems/instruments/src/OIT/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/.eslintrc.js create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/AltitudeIndicator.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/AttitudeIndicatorFixed.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/AttitudeIndicatorHorizon.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/DigitalAltitudeReadout.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/FMA.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/FlightPathDirector.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/FlightPathVector.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/HeadingIndicator.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/HorizontalTape.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/LandingSystemIndicator.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/PFD.svg create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/PFD.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/PFDUtils.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/SpeedIndicator.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/VerticalSpeedIndicator.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/VerticalTape.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/animations.scss create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/instrument.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/shared/AdirsValueProvider.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/shared/ArincValueProvider.ts create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/shared/PFDSimvarPublisher.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/shared/SimplaneValueProvider.ts create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/shared/common.scss create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/shared/definitions.scss create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/shared/displayUnit.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/shared/pixels.scss create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/style.scss create mode 100644 fbw-a380x/src/systems/instruments/src/PFD/tsconfig.json create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Components/ActiveFrequency.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Components/Arrow.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Components/Layer.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Components/MessageArea.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Components/StandbyFrequency.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Components/TextRow.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Pages/HfPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Pages/Menu/Datalink/DatalinkRouterPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Pages/Menu/MenuPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Pages/NavPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Pages/SqwkPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Pages/TelPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Pages/VhfPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/Pages/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/RadioManagementPanel.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/index.css create mode 100644 fbw-a380x/src/systems/instruments/src/RMP/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/ApuPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/BleedPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedApu.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedCrossbleed.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedEngine.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedGauge.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedHotAir.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedMixerUnit.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedPack.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/Valve.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/BleedPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/CbPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/CondPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/CruisePage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/A380Cruise.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/CruiseCond.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/CruisePressure.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/CruisePage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/DoorPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/A380XDoors.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/CabinDoor.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/CargoDoor.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/Oxygen.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/ElecAcPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/ElecDcPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/BusBar.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/ElectricalNetwork.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/IndicationBox.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/EngPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/DecimalValues.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/EngineColumn.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/IgnitionBorder.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/NacelleTemperatureGauge.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/OilPressureGauge.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/OilQuantityGauge.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/StartValve.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/FctlPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/FuelPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Generic/Inop.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Generic/PageTitle.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Hyd/HydPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/PressPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/A380Press.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/CabAlt.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/CabinVerticalSpeed.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/DeltaP.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/ExtractValve.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/LandingElevation.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/OutflowValve.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/Packs.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/StatusPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/Pages/WheelPage.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/StatusArea.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/SystemDisplay.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/config.json create mode 100644 fbw-a380x/src/systems/instruments/src/SD/index.tsx create mode 100644 fbw-a380x/src/systems/instruments/src/SD/styles.scss create mode 100644 fbw-a380x/src/systems/instruments/src/index.scss create mode 100644 fbw-a380x/src/systems/instruments/src/util.js create mode 100644 fbw-a380x/src/systems/instruments/template/rollup.js create mode 100644 fbw-a380x/src/systems/instruments/template/template.html create mode 100644 fbw-a380x/src/systems/instruments/template/template.js create mode 100644 fbw-a380x/src/systems/instruments/tsconfig.json create mode 100644 fbw-a380x/src/systems/shared/src/Coherent.d.ts create mode 100644 fbw-a380x/src/systems/shared/src/Constants.ts create mode 100644 fbw-a380x/src/systems/shared/src/FlowEventSync.ts create mode 100644 fbw-a380x/src/systems/shared/src/FmMessages.ts create mode 100644 fbw-a380x/src/systems/shared/src/MagVar.ts create mode 100644 fbw-a380x/src/systems/shared/src/MathUtils.spec.ts create mode 100644 fbw-a380x/src/systems/shared/src/MathUtils.ts create mode 100644 fbw-a380x/src/systems/shared/src/NXUnits.ts create mode 100644 fbw-a380x/src/systems/shared/src/NavigationDisplay.ts create mode 100644 fbw-a380x/src/systems/shared/src/UpdateThrottler.ts create mode 100644 fbw-a380x/src/systems/shared/src/arinc429.ts create mode 100644 fbw-a380x/src/systems/shared/src/ata.ts create mode 100644 fbw-a380x/src/systems/shared/src/autopilot.ts create mode 100644 fbw-a380x/src/systems/shared/src/electrical.ts create mode 100644 fbw-a380x/src/systems/shared/src/flightphase.ts create mode 100644 fbw-a380x/src/systems/shared/src/flightplan.ts create mode 100644 fbw-a380x/src/systems/shared/src/logic.ts create mode 100644 fbw-a380x/src/systems/shared/src/notification.ts create mode 100644 fbw-a380x/src/systems/shared/src/persistence.ts create mode 100644 fbw-a380x/src/systems/shared/src/popup.ts create mode 100644 fbw-a380x/src/systems/shared/src/simvar.ts create mode 100644 fbw-a380x/src/systems/shared/src/units.ts create mode 100644 fbw-a380x/src/systems/tcas/rollup.config.js create mode 100644 fbw-a380x/src/systems/tcas/src/components/TcasComputer.ts create mode 100644 fbw-a380x/src/systems/tcas/src/components/TcasSoundManager.ts create mode 100644 fbw-a380x/src/systems/tcas/src/index.ts create mode 100644 fbw-a380x/src/systems/tcas/src/lib/TcasComponent.ts create mode 100644 fbw-a380x/src/systems/tcas/src/lib/TcasConstants.ts create mode 100644 fbw-a380x/src/systems/tcas/src/loop.ts create mode 100644 fbw-a380x/src/systems/tcas/tsconfig.json create mode 100644 fbw-a380x/src/systems/tsconfig.json create mode 100644 scripts/build_a380x.js diff --git a/.gitignore b/.gitignore index 217c6fa9a00..afc7f4ff39b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ /fbw-a380x/* /fbw-a380x/.env.local !/fbw-a380x/README.md +!/fbw-a380x/mach.config.js !/fbw-a380x/docs/ !/fbw-a380x/docs/** !/fbw-a380x/src/** diff --git a/fbw-a380x/docs/a380-simvars.md b/fbw-a380x/docs/a380-simvars.md index a8c8f62ad9f..d5a2e1171fa 100644 --- a/fbw-a380x/docs/a380-simvars.md +++ b/fbw-a380x/docs/a380-simvars.md @@ -7,11 +7,24 @@ - [Air Conditioning / Pressurisation / Ventilation ATA21](#air-conditioning-pressurisation-ventilation-ata-21) - [Electrical ATA 24](#electrical-ata-24) - [Indicating/Recording ATA 31](#indicating-recording-ata-31) + - [ECAM Control Panel ATA 34](#ecam-control-panel-ata-34) + - [EFIS Control Panel ATA 34](#efis-control-panel-ata-34) - [Bleed Air ATA 36](#bleed-air-ata-36) - [Integrated Modular Avionics ATA 42](#integrated-modular-avionics-ata-42) + - [Hydraulics](#hydraulics) + - [Sound Variables](#sound-variables) ## Uncategorized +- A380X_OVHD_ANN_LT_POSITION + - Enum + - Represents the state of the ANN LT switch + - State | Value + -------- | ---- + TEST | 0 + BRT | 1 + DIM | 2 + - A32NX_OVHD_{name}_PB_IS_AVAILABLE - Bool - True when the push button's AVAIL light should illuminate @@ -464,6 +477,74 @@ - ArincWord852<> - Second CAN bus of the CDS on the first officer's side +## ECAM Control Panel ATA 34 + +- A380X_ECAM_CP_SELECTED_PAGE + - Enum + - Currently requested page on the ECAM CP + - State | Value + -------- | ---- + ENG | 0 + BLEED | 1 + PRESS | 2 + EL/AC | 3 + FUEL | 4 + HYD | 5 + C/B | 6 + APU | 7 + COND | 8 + DOOR | 9 + EL/DC | 10 + WHEEL | 11 + F/CTL | 12 + VIDEO | 13 + +## EFIS Control Panel ATA 34 + +- A380X_EFIS_{side}_LS_BUTTON_IS_ON + - Boolean + - Whether the LS button is activated + - {side} = L or R + +- A380X_EFIS_{side}_VV_BUTTON_IS_ON + - Boolean + - Whether the VV button is activated + - {side} = L or R + +- A380X_EFIS_{side}_CSTR_BUTTON_IS_ON + - Boolean + - Whether the CSTR button is activated + - {side} = L or R + +- A380X_EFIS_{side}_ACTIVE_FILTER + - Boolean + - Indicates which waypoint filter is selected + - {side} = L or R + - State | Value + -------- | ---- + WPT | 0 + VORD | 1 + NDB | 2 + +- A380X_EFIS_{side}_ACTIVE_OVERLAY + - Boolean + - Indicates which waypoint filter is selected + - {side} = L or R + - State | Value + -------- | ---- + WX | 0 + TERR | 1 + +- A380X_EFIS_{side}_ARPT_BUTTON_IS_ON + - Boolean + - Whether the ARPT button is activated + - {side} = L or R + +- A380X_EFIS_{side}_TRAF_BUTTON_IS_ON + - Boolean + - Whether the TRAF button is activated + - {side} = L or R + ## Bleed Air ATA 36 - A32NX_PNEU_ENG_{number}_INTERMEDIATE_TRANSDUCER_PRESSURE @@ -499,3 +580,26 @@ - A32NX_IOM__AVAIL - Bool - Indicates if a specific IOM system is available + +## Hydraulics + +- A32NX_OVHD_HYD_ENG_{ENG}AB_PUMP_DISC_PB_IS_AUTO + - Boolean + - Whether the pump disconnect pushbutton on engine {ENG} is in auto mode, i.e not disconnected + - {ENG} = 1, 2, 3, 4 + +- A32NX_OVHD_HYD_ENG_{ENG}AB_PUMP_DISC_PB_HAS_FAULT + - Boolean + - Whether the pump disconnect pushbutton on engine {ENG} has a fault + - {ENG} = 1, 2, 3, 4 + +- A32NX_HYD_ENG_{ENG}AB_PUMP_DISC + - Boolean + - Disconnected pump feedback signal + - {ENG} = 1, 2, 3, 4 + +## Sound Variables + +- A380X_SOUND_COCKPIT_WINDOW_RATIO + - Number + - Ratio between 0-1 of the cockpit windows being physically open diff --git a/fbw-a380x/mach.config.js b/fbw-a380x/mach.config.js new file mode 100644 index 00000000000..40f56dd3e6e --- /dev/null +++ b/fbw-a380x/mach.config.js @@ -0,0 +1,66 @@ +const imagePlugin = require('esbuild-plugin-inline-image'); +const postCssPlugin = require('esbuild-style-plugin'); +// const tailwind = require('tailwindcss'); +const postCssColorFunctionalNotation = require('postcss-color-functional-notation'); +const postCssInset = require('postcss-inset'); +const { typecheckingPlugin } = require("#build-utils"); + +/** @type { import('@synaptic-simulations/mach').MachConfig } */ +module.exports = { + packageName: 'A380X', + packageDir: 'out/flybywire-aircraft-a380-842', + plugins: [ + imagePlugin({ limit: -1 }), + postCssPlugin({ + extract: true, + postcss: { + plugins: [ + // tailwind('src/systems/instruments/src/EFB/tailwind.config.js'), + + // transform: hsl(x y z / alpha) -> hsl(x, y, z, alpha) + postCssColorFunctionalNotation(), + + // transform: inset: 0; -> top/right/left/bottom: 0; + postCssInset(), + ], + } + }), + typecheckingPlugin(), + ], + instruments: [ + msfsAvionicsInstrument('PFD'), + + reactInstrument('EWD'), + reactInstrument('MFD'), + reactInstrument('OIT'), + reactInstrument('RMP'), + reactInstrument('SD'), + ], +}; + +function msfsAvionicsInstrument(name, folder = name) { + return { + name, + index: `src/systems/instruments/src/${folder}/instrument.tsx`, + simulatorPackage: { + type: 'baseInstrument', + templateId: `A380X_${name}`, + mountElementId: `${name}_CONTENT`, + fileName: name.toLowerCase(), + imports: ['/JS/dataStorage.js'], + }, + }; +} + +function reactInstrument(name, additionalImports) { + return { + name, + index: `src/systems/instruments/src/${name}/index.tsx`, + simulatorPackage: { + type: 'react', + isInteractive: false, + fileName: name.toLowerCase(), + imports: ['/JS/dataStorage.js','/JS/fbw-a380x/A380X_Simvars.js', ...(additionalImports ?? [])], + }, + }; +} diff --git a/fbw-a380x/src/.eslintrc.js b/fbw-a380x/src/.eslintrc.js new file mode 100644 index 00000000000..1911b891787 --- /dev/null +++ b/fbw-a380x/src/.eslintrc.js @@ -0,0 +1,141 @@ +'use strict'; + +module.exports = { + root: true, + env: { browser: true }, + extends: [ + '@flybywiresim/eslint-config', + 'plugin:jest/recommended', + 'plugin:jest/style', + 'plugin:tailwindcss/recommended', + ], + plugins: [ + '@typescript-eslint', + 'tailwindcss', + ], + parser: '@typescript-eslint/parser', + ignorePatterns: [ + 'mcdu-server/client/build/**', + // disabled all for now as the A380 code is not up to standard + '**/*', + ], + parserOptions: { + ecmaVersion: 2021, + sourceType: 'script', + requireConfigFile: false, + }, + settings: { + 'tailwindcss': { groupByResponsive: true }, + 'import/resolver': { node: { extensions: ['.js', '.mjs', '.jsx', '.ts', '.tsx'] } }, + }, + overrides: [ + { + files: ['*.jsx', '*.tsx'], + parserOptions: { + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + }, + { + files: ['*.mjs', '*.ts', '*.d.ts'], + parserOptions: { sourceType: 'module' }, + }, + ], + // overrides airbnb, use sparingly + rules: { + 'tailwindcss/no-custom-classname': 'off', + 'no-bitwise': 'off', + 'no-mixed-operators': 'off', + 'arrow-parens': ['error', 'always'], + 'brace-style': ['error', '1tbs', { allowSingleLine: false }], + 'class-methods-use-this': 'off', + 'curly': ['error', 'multi-line'], + 'import/prefer-default-export': 'off', + 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], + 'indent': ['error', 4], + 'react/jsx-filename-extension': [2, { extensions: ['.jsx', '.tsx'] }], + 'react/jsx-indent': ['error', 4], + 'no-restricted-syntax': 'off', + 'quote-props': ['error', 'consistent-as-needed'], + 'strict': ['error', 'global'], + + 'no-case-declarations': 'off', + + 'no-plusplus': 'off', + 'no-shadow': 'off', + 'no-continue': 'off', + 'no-return-assign': 'off', + 'radix': 'off', + 'max-classes-per-file': 'off', + 'no-useless-constructor': 'off', + '@typescript-eslint/no-useless-constructor': ['error'], + 'no-empty-function': ['error', { allow: ['constructors', 'arrowFunctions'] }], + '@typescript-eslint/no-empty-function': 'off', + + // buggy + 'prefer-destructuring': 'off', + + // Avoid typescript-eslint conflicts + 'no-unused-vars': 'off', + 'import/no-unresolved': 'off', + '@typescript-eslint/no-unused-vars': ['error', { + vars: 'all', + varsIgnorePattern: '^_|^FSComponent$', + args: 'after-used', + argsIgnorePattern: '^_|^node$|^deltaTime$', + }], + + 'no-use-before-define': 'off', + + 'react/jsx-indent-props': 'off', + + // not relevant now + 'react/no-unused-state': 'off', + + // useless + 'react/prop-types': 'off', + 'react/require-default-props': 'off', + 'react/no-unused-prop-types': 'off', + 'react/destructuring-assignment': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/no-unescaped-entities': 'off', + + // Not needed with react 17+ + 'react/jsx-uses-react': 'off', + 'react/react-in-jsx-scope': 'off', + + 'import/extensions': 'off', + 'no-param-reassign': 'off', + 'no-undef-init': 'off', + 'no-undef': 'off', + 'max-len': ['error', { code: 192 }], + + // Irrelevant for our use + 'jsx-a11y/alt-text': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'object-curly-newline': ['error', { multiline: true }], + 'linebreak-style': 'off', + + // allow typescript overloads + 'no-redeclare': 'off', + '@typescript-eslint/no-redeclare': ['error'], + 'lines-between-class-members': 'off', + '@typescript-eslint/lines-between-class-members': ['error'], + 'no-dupe-class-members': 'off', + '@typescript-eslint/no-dupe-class-members': ['error'], + + // allow console logging + 'no-console': 'off', + }, + globals: { + Simplane: 'readonly', + SimVar: 'readonly', + Utils: 'readonly', + JSX: 'readonly', + Coherent: 'readonly', + ViewListener: 'readonly', + RegisterViewListener: 'readonly', + }, +}; diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/.keep b/fbw-a380x/src/base/flybywire-aircraft-a380-842/.keep deleted file mode 100644 index f2449a15e9d..00000000000 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/.keep +++ /dev/null @@ -1,2 +0,0 @@ -Keep this file to make git keep the folder. -Can be removed once the folder has content diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/A380X_FCU.ttf b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/A380X_FCU.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5afc0fb07f72a4e5cf6e04b6280c3132fd69f988 GIT binary patch literal 9048 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 literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-RMP-11.ttf b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-RMP-11.ttf new file mode 100644 index 0000000000000000000000000000000000000000..af71c0311f8afefcc9fe9a18cd3d5472569c2b95 GIT binary patch literal 5588 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 J^PcnZcL9Fh9J~Mk literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/Engine-Hyd-Bleed-Dithered.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/Engine-Hyd-Bleed-Dithered.png new file mode 100644 index 0000000000000000000000000000000000000000..7890ee61b089ed751c3a015ebc4668bf1c306a0b GIT binary patch literal 44592 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%0DIc&1EZX^ckyyg8^VB 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 N002ovPDHLkV1kjCVuJtx literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.css b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.css new file mode 100644 index 00000000000..caf2722cfb6 --- /dev/null +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.css @@ -0,0 +1,182 @@ +:root { + --bodyHeightScale: 1; +} + +@keyframes TemporaryShow { + 0%, + 100% { + visibility: visible; + } +} + +@keyframes TemporaryHide { + 0%, + 100% { + visibility: hidden; + } +} + +#highlight { + position: absolute; + height: 100%; + width: 100%; + z-index: 10; +} + +#Electricity { + width: 100%; + height: 100%; + transform: scale(0.5) translate(-50%, -50%); +} + +#Electricity[state="off"] { + display: none; +} + + +@font-face { + font-family: "Poppins-SemiBold"; + src: url("/Fonts/Poppins-SemiBold.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: "Digital"; + src: url("/Fonts/A380X_FCU.ttf") format("truetype"); + font-weight: 900; + font-style: normal; +} + +:root { + --main-display-colour-background: hsl(240, 100%, 55%); + --main-display-colour: hsl(0, 0%, 100%); + --main-display-colour-inactive: hsl(240, 100%, 50%); +} + +:root text.Common { + width: 100%; + height: 100%; +} + +:root text.Active { + font-family: "Poppins-SemiBold"; + font-size: 230px; + fill: var(--main-display-colour); +} +:root text.Active.BaroValue { + font-size: 210px; +} + +:root text.Inactive { + font-family: "Poppins-SemiBold"; + font-size: 230px; + fill: var(--main-display-colour-inactive); +} +:root text.Inactive.BaroValue { + font-size: 210px; +} + +:root text.Value { + font-family: Digital; + font-size:440px; + text-anchor: start; + fill: var(--main-display-colour); + letter-spacing: 0px; +} + +:root text.BaroValue { + font-size: 400px; +} + +:root line { + stroke: var(--main-display-colour); + stroke-width: 5; +} + +:root circle { + fill: var(--main-display-colour); +} + +a380-fcu-element { + width: 100%; + height: 100%; + background-color: var(--main-display-colour-background); + font-family: "Poppins-SemiBold"; + position: relative; + overflow: hidden; +} + +a380-fcu-element #Mainframe { + width: 100%; + height: 100%; + display: block; + position: relative; +} + +a380-fcu-element #Mainframe #LargeScreen { + width: 100%; + height: 16%; + display: block; + position: absolute; + background-color: var(--main-display-colour-background); +} + +a380-fcu-element #Mainframe #LargeScreen #Speed { + width: 30%; + height: 100%; + left: 5%; + /*top: 100%;*/ + position: absolute; +} + +a380-fcu-element #Mainframe #LargeScreen #Heading { + width: 30%; + height: 100%; + left: 50%; + position: absolute; +} + +a380-fcu-element #Mainframe #LargeScreen #Mode { + width: 20%; + height: 100%; + left: 80%; + position: absolute; +} + +a380-fcu-element #Mainframe #LargeScreen #AltVS { + width: 40%; + height: 100%; + left: 120%; + position: absolute; +} + +a380-fcu-element #Mainframe #LargeScreen #AltVS #Altitude { + width: 100%; + height: 100%; + left: 0%; + position: absolute; +} + +a380-fcu-element #Mainframe #LargeScreen #AltVS #VerticalSpeed { + width: 100%; + height: 100%; + left: 95%; + position: absolute; +} + +a380-fcu-element #Mainframe #SmallScreen { + width: 27%; + height: 17%; + display: block; + position: absolute; + top: 16%; + background-color: var(--main-display-colour-background); +} + +a380-fcu-element #Mainframe #SmallScreen #Selected, +a380-fcu-element #Mainframe #SmallScreen #Standard { + width: 100%; + height: 100%; + top: 0; +} diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.html b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.html new file mode 100644 index 00000000000..34d00ee5019 --- /dev/null +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.html @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.js b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.js new file mode 100644 index 00000000000..df51454fa42 --- /dev/null +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/A380_FCU.js @@ -0,0 +1,1376 @@ +class A320_Neo_FCU extends BaseAirliners { + constructor() { + super(); + this.initDuration = 3000; + this.electricity = document.querySelector('#Electricity'); + } + + get templateID() { + return 'A320_Neo_FCU'; + } + + connectedCallback() { + super.connectedCallback(); + RegisterViewListener('JS_LISTENER_KEYEVENT', this.onListenerRegistered.bind(this)); + this.maxUpdateBudget = 12; + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + onListenerRegistered() { + this.mainPage = new A320_Neo_FCU_MainPage(); + this.pageGroups = [ + new NavSystemPageGroup('Main', this, [ + this.mainPage, + ]), + ]; + } + + reboot() { + super.reboot(); + this.mainPage.reboot(); + } + + onUpdate(_deltaTime) { + super.onUpdate(_deltaTime); + + const newStyle = SimVar.GetSimVarValue('L:A32NX_ELEC_DC_ESS_BUS_IS_POWERED', 'Bool') + || SimVar.GetSimVarValue('L:A32NX_ELEC_DC_2_BUS_IS_POWERED', 'Bool') ? 'block' : 'none'; + if (newStyle === 'block' && newStyle !== this.electricity.style.display) { + if (!SimVar.GetSimVarValue('AUTOPILOT FLIGHT DIRECTOR ACTIVE:1', 'bool')) { + SimVar.SetSimVarValue('K:TOGGLE_FLIGHT_DIRECTOR', 'number', 1); + } + if (!SimVar.GetSimVarValue('AUTOPILOT FLIGHT DIRECTOR ACTIVE:2', 'bool')) { + SimVar.SetSimVarValue('K:TOGGLE_FLIGHT_DIRECTOR', 'number', 2); + } + } + this.electricity.style.display = newStyle; + } + + onEvent(_event) { + } +} + +class A320_Neo_FCU_MainElement extends NavSystemElement { + init(root) { + } + + onEnter() { + } + + onUpdate(_deltaTime) { + } + + onExit() { + } + + onEvent(_event) { + } +} + +class A320_Neo_FCU_MainPage extends NavSystemPage { + constructor() { + super('Main', 'Mainframe', new A320_Neo_FCU_MainElement()); + this.largeScreen = new A320_Neo_FCU_LargeScreen(); + this.smallScreen = new A320_Neo_FCU_SmallScreen(); + this.element = new NavSystemElementGroup([ + this.largeScreen, + this.smallScreen, + ]); + } + + init() { + super.init(); + } + + onEvent(_event) { + this.largeScreen.onEvent(_event); + } + + reboot() { + this.largeScreen.reboot(); + this.smallScreen.reboot(); + } +} + +class A320_Neo_FCU_Component { + getDivElement(_name) { + if (this.divRef != null) { + return this.divRef.querySelector(`#${_name}`); + } + } + + set textValueContent(_textContent) { + if (this.textValue != null) { + this.textValue.textContent = _textContent; + this.textValue.innerHTML = this.textValue.innerHTML.replace('{sp}', ' '); + } + } + + getElement(_type, _name) { + if (this.divRef != null) { + const allText = this.divRef.getElementsByTagName(_type); + if (allText != null) { + for (let i = 0; i < allText.length; ++i) { + if (allText[i].id == _name) { + return allText[i]; + } + } + } + } + return null; + } + + getTextElement(_name) { + return this.getElement('text', _name); + } + + setTextElementActive(_text, _active, _baro) { + if (_text != null) { + _text.setAttribute('class', `Common ${_active ? 'Active' : 'Inactive'} ${_baro ? 'BaroValue' : ''}`); + } + } + + setElementVisibility(_element, _show) { + if (_element != null) { + _element.style.display = _show ? 'block' : 'none'; + } + } + + constructor(_gps, _divName) { + this.gps = _gps; + this.divRef = _gps.getChildById(_divName); + this.textValue = this.getTextElement('Value'); + } + + reboot() { + this.init(); + } +} + +class A320_Neo_FCU_Speed extends A320_Neo_FCU_Component { + constructor() { + super(...arguments); + + this.backToIdleTimeout = 10000; + this.MIN_SPEED = 100; + this.MAX_SPEED = 399; + this.MIN_MACH = 0.10; + this.MAX_MACH = 0.99; + + this.isActive = false; + this.isManaged = false; + this.showSelectedSpeed = true; + this.currentValue = this.MIN_SPEED; + this.selectedValue = this.MIN_SPEED; + this.isMachActive = false; + this.inSelection = false; + this.isSelectedValueActive = false; + this.isValidV2 = false; + this.isVerticalModeSRS = false; + this.isTargetManaged = false; + + this._rotaryEncoderCurrentSpeed = 1; + this._rotaryEncoderMaximumSpeed = 10; + this._rotaryEncoderTimeout = 300; + this._rotaryEncoderIncrement = 0.15; + this._rotaryEncoderPreviousTimestamp = 0; + this.init(); + this.update(0); + } + + init() { + this.isValidV2 = false; + this.isVerticalModeSRS = false; + this.selectedValue = this.MIN_SPEED; + this.currentValue = this.MIN_SPEED; + this.targetSpeed = this.MIN_SPEED; + this.isTargetManaged = false; + this.isMachActive = false; + this.textSPD = this.getTextElement('SPD'); + this.textMACH = this.getTextElement('MACH'); + this.illuminator = this.getElement('circle', 'Illuminator'); + Coherent.call('AP_SPD_VAR_SET', 0, this.MIN_SPEED).catch(console.error); + SimVar.SetSimVarValue('L:A32NX_AUTOPILOT_SPEED_SELECTED', 'number', this.MIN_SPEED); + SimVar.SetSimVarValue('K:AP_MANAGED_SPEED_IN_MACH_OFF', 'number', 0); + this.onPull(); + } + + update(_deltaTime) { + const isManaged = Simplane.getAutoPilotAirspeedManaged() && this.isTargetManaged; + const showSelectedSpeed = this.inSelection || !isManaged; + const isMachActive = SimVar.GetSimVarValue('AUTOPILOT MANAGED SPEED IN MACH', 'bool'); + const isExpedModeOn = SimVar.GetSimVarValue('L:A32NX_FMA_EXPEDITE_MODE', 'number') === 1; + const isManagedSpeedAvailable = this.isManagedSpeedAvailable(); + + // detect if managed speed should engage due to V2 entry or SRS mode + if (this.shouldEngageManagedSpeed()) { + this.onPush(); + } + // detect if EXPED mode was engaged + if (!isManaged && isExpedModeOn && isManagedSpeedAvailable) { + this.onPush(); + } + // when both AP and FD off -> revert to selected + if (isManaged && !isManagedSpeedAvailable) { + this.onPull(); + } + + // update speed + if (!isManaged && this.selectedValue > 0) { + // mach mode was switched + if (isMachActive != this.isMachActive) { + if (isMachActive || this.selectedValue > 1) { + // KIAS -> Mach + this.selectedValue = this.clampMach( + Math.round(SimVar.GetGameVarValue('FROM KIAS TO MACH', 'number', this.selectedValue) * 100) / 100, + ); + } else { + // Mach -> KIAS + this.selectedValue = this.clampSpeed( + Math.round(SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', this.selectedValue)), + ); + } + } + // get current target speed + let targetSpeed = (isMachActive || this.selectedValue < 1) + ? SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', this.selectedValue) + : this.selectedValue; + // clamp speed into valid range + targetSpeed = this.clampSpeed(targetSpeed); + // set target speed + if (targetSpeed !== this.targetSpeed) { + Coherent.call('AP_SPD_VAR_SET', 0, targetSpeed).catch(console.error); + this.targetSpeed = targetSpeed; + } + // detect mismatch + if (Simplane.getAutoPilotAirspeedHoldValue() !== this.targetSpeed) { + Coherent.call('AP_SPD_VAR_SET', 0, targetSpeed).catch(console.error); + } + } else { + this.targetSpeed = -1; + } + + this.refresh( + true, + isManaged, + showSelectedSpeed, + isMachActive, + this.selectedValue, + SimVar.GetSimVarValue('L:A32NX_OVHD_INTLT_ANN', 'number') == 0, + ); + } + + shouldEngageManagedSpeed() { + const managedSpeedTarget = SimVar.GetSimVarValue('L:A32NX_SPEEDS_MANAGED_PFD', 'knots'); + const isValidV2 = SimVar.GetSimVarValue('L:AIRLINER_V2_SPEED', 'knots') >= 90; + const isVerticalModeSRS = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'enum') === 40; + + // V2 is entered into MCDU (was not set -> set) + // SRS mode engages (SRS no engaged -> engaged) + let shouldEngage = false; + if ((!this.isValidV2 && isValidV2) || (!this.isVerticalModeSRS && isVerticalModeSRS)) { + shouldEngage = true; + } + + // store state + if (!isValidV2 || managedSpeedTarget >= 90) { + // store V2 state only if managed speed target is valid (to debounce) + this.isValidV2 = isValidV2; + } + this.isVerticalModeSRS = isVerticalModeSRS; + + return shouldEngage; + } + + isManagedSpeedAvailable() { + // managed speed is available when flight director or autopilot is engaged, or in approach phase (FMGC flight phase) + return (Simplane.getAutoPilotFlightDirectorActive(1) + || Simplane.getAutoPilotFlightDirectorActive(2) + || SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_ACTIVE', 'number') === 1 + || SimVar.GetSimVarValue('L:A32NX_FMGC_FLIGHT_PHASE', 'number') === 5) + && SimVar.GetSimVarValue('L:A32NX_SPEEDS_MANAGED_PFD', 'knots') >= 90; + } + + refresh(_isActive, _isManaged, _showSelectedSpeed, _machActive, _value, _lightsTest, _force = false) { + if ((_isActive != this.isActive) + || (_isManaged != this.isManaged) + || (_showSelectedSpeed != this.showSelectedSpeed) + || (_machActive != this.isMachActive) + || (_value != this.currentValue) + || (_lightsTest !== this.lightsTest) + || _force) { + this.isActive = _isActive; + if (_isManaged !== this.isManaged && _isManaged) { + this.inSelection = false; + this.isSelectedValueActive = false; + this.selectedValue = -1; + console.warn('reset due to _isManaged == true'); + } + this.isManaged = _isManaged; + SimVar.SetSimVarValue('L:A32NX_FCU_SPD_MANAGED_DOT', 'boolean', this.isManaged); + if (_showSelectedSpeed !== this.showSelectedSpeed && !_showSelectedSpeed) { + this.inSelection = false; + this.isSelectedValueActive = false; + this.selectedValue = -1; + console.warn('reset due to _showSelectedSpeed == false'); + } + this.showSelectedSpeed = _showSelectedSpeed; + SimVar.SetSimVarValue('L:A32NX_FCU_SPD_MANAGED_DASHES', 'boolean', this.isManaged && !this.showSelectedSpeed); + if (this.currentValue != _value) { + SimVar.SetSimVarValue('L:A32NX_AUTOPILOT_SPEED_SELECTED', 'number', _value); + } + this.currentValue = _machActive ? _value * 100 : _value; + this.isMachActive = _machActive; + this.setTextElementActive(this.textSPD, !_machActive); + this.setTextElementActive(this.textMACH, _machActive); + this.lightsTest = _lightsTest; + if (this.lightsTest) { + this.setElementVisibility(this.illuminator, true); + this.textValueContent = '.8.8.8'; + this.setTextElementActive(this.textSPD, true); + this.setTextElementActive(this.textMACH, true); + return; + } + let value = _machActive ? Math.max(this.currentValue, 0) : Math.max(this.currentValue, 100); + value = Math.round(value).toString().padStart(3, '0'); + if (!_isManaged && this.currentValue > 0) { + if (_machActive) { + value = `${value.substring(0, 1)}.${value.substring(1)}`; + } + this.textValueContent = value; + this.setElementVisibility(this.illuminator, false); + } else if (_isManaged || this.currentValue < 0) { + if (_showSelectedSpeed) { + if (_machActive) { + value = `${value.substring(0, 1)}.${value.substring(1)}`; + } + this.textValueContent = value; + } else { + this.textValueContent = '---'; + } + } + this.setElementVisibility(this.illuminator, this.isManaged); + } + } + + clampSpeed(value) { + return Utils.Clamp(value, this.MIN_SPEED, this.MAX_SPEED); + } + + clampMach(value) { + return Utils.Clamp(value, this.MIN_MACH, this.MAX_MACH); + } + + getCurrentSpeed() { + return this.clampSpeed(Math.round(Simplane.getIndicatedSpeed())); + } + + getCurrentMach() { + return this.clampMach(Math.round(Simplane.getMachSpeed() * 100) / 100); + } + + onRotate() { + clearTimeout(this._resetSelectionTimeout); + if (!this.inSelection && this.isManaged) { + this.inSelection = true; + if (!this.isSelectedValueActive) { + if (this.isMachActive) { + this.selectedValue = this.getCurrentMach(); + } else { + this.selectedValue = this.getCurrentSpeed(); + } + } + } + this.isSelectedValueActive = true; + if (this.inSelection) { + this._resetSelectionTimeout = setTimeout(() => { + this.selectedValue = -1; + this.isSelectedValueActive = false; + this.inSelection = false; + }, this.backToIdleTimeout); + } + } + + onPush() { + if (!this.isManagedSpeedAvailable()) { + return; + } + clearTimeout(this._resetSelectionTimeout); + SimVar.SetSimVarValue('K:SPEED_SLOT_INDEX_SET', 'number', 2); + this.inSelection = false; + this.isSelectedValueActive = false; + this.isTargetManaged = true; + } + + onPull() { + clearTimeout(this._resetSelectionTimeout); + if (!this.isSelectedValueActive) { + if (this.isMachActive) { + this.selectedValue = this.getCurrentMach(); + } else { + this.selectedValue = this.getCurrentSpeed(); + } + } + SimVar.SetSimVarValue('K:SPEED_SLOT_INDEX_SET', 'number', 1); + this.inSelection = false; + this.isSelectedValueActive = false; + this.isTargetManaged = false; + } + + onSwitchSpeedMach() { + clearTimeout(this._resetSelectionTimeout); + this.inSelection = false; + this.isSelectedValueActive = false; + if (this.isMachActive) { + SimVar.SetSimVarValue('K:AP_MANAGED_SPEED_IN_MACH_OFF', 'number', 0); + } else { + SimVar.SetSimVarValue('K:AP_MANAGED_SPEED_IN_MACH_ON', 'number', 0); + } + } + + onPreSelSpeed(isMach) { + clearTimeout(this._resetSelectionTimeout); + SimVar.SetSimVarValue('K:SPEED_SLOT_INDEX_SET', 'number', 1); + this.inSelection = false; + this.isSelectedValueActive = false; + this.isTargetManaged = false; + this.isMachActive = isMach; + if (isMach) { + this.selectedValue = SimVar.GetSimVarValue('L:A32NX_MachPreselVal', 'mach'); + SimVar.SetSimVarValue('K:AP_MANAGED_SPEED_IN_MACH_ON', 'number', 1); + } else { + this.selectedValue = SimVar.GetSimVarValue('L:A32NX_SpeedPreselVal', 'knots'); + SimVar.SetSimVarValue('K:AP_MANAGED_SPEED_IN_MACH_OFF', 'number', 1); + } + } + + getRotationSpeed() { + if (this._rotaryEncoderCurrentSpeed < 1 + || (Date.now() - this._rotaryEncoderPreviousTimestamp) > this._rotaryEncoderTimeout) { + this._rotaryEncoderCurrentSpeed = 1; + } else { + this._rotaryEncoderCurrentSpeed += this._rotaryEncoderIncrement; + } + this._rotaryEncoderPreviousTimestamp = Date.now(); + return Math.min(this._rotaryEncoderMaximumSpeed, Math.floor(this._rotaryEncoderCurrentSpeed)); + } + + onEvent(_event) { + if (_event === 'SPEED_INC') { + // use rotary encoder to speed dialing up / down + if (this.isMachActive) { + this.selectedValue = this.clampMach(this.selectedValue + 0.01); + } else { + this.selectedValue = this.clampSpeed(this.selectedValue + this.getRotationSpeed()); + } + this.onRotate(); + } else if (_event === 'SPEED_DEC') { + // use rotary encoder to speed dialing up / down + if (this.isMachActive) { + this.selectedValue = this.clampMach(this.selectedValue - 0.01); + } else { + this.selectedValue = this.clampSpeed(this.selectedValue - this.getRotationSpeed()); + } + this.onRotate(); + } else if (_event === 'SPEED_PUSH') { + this.onPush(); + } else if (_event === 'SPEED_PULL') { + this.onPull(); + } else if (_event === 'SPEED_SET') { + const value = SimVar.GetSimVarValue('L:A320_Neo_FCU_SPEED_SET_DATA', 'number'); + if (this.isMachActive) { + this.selectedValue = this.clampMach(value / 100.0); + } else { + this.selectedValue = this.clampSpeed(value); + } + this.isSelectedValueActive = true; + this.onRotate(); + } else if (_event === 'SPEED_TOGGLE_SPEED_MACH') { + this.onSwitchSpeedMach(); + } else if (_event === 'USE_PRE_SEL_SPEED') { + this.onPreSelSpeed(false); + } else if (_event === 'USE_PRE_SEL_MACH') { + this.onPreSelSpeed(true); + } else if (_event === 'SPEED_TCAS') { + this.onPull(); + if (this.isMachActive) { + this.selectedValue = this.getCurrentMach(); + } else { + this.selectedValue = this.getCurrentSpeed(); + } + } + } +} + +class A320_Neo_FCU_Autopilot extends A320_Neo_FCU_Component { + constructor() { + super(...arguments); + this.init(); + this.update(0); + } + + init() { + } + + onEvent(_event) { + if (_event === 'AP_1_PUSH') { + SimVar.SetSimVarValue('K:A32NX.FCU_AP_1_PUSH', 'number', 0); + } else if (_event === 'AP_2_PUSH') { + SimVar.SetSimVarValue('K:A32NX.FCU_AP_2_PUSH', 'number', 0); + } else if (_event === 'LOC_PUSH') { + SimVar.SetSimVarValue('K:A32NX.FCU_LOC_PUSH', 'number', 0); + } else if (_event === 'APPR_PUSH') { + SimVar.SetSimVarValue('K:A32NX.FCU_APPR_PUSH', 'number', 0); + } else if (_event === 'EXPED_PUSH') { + SimVar.SetSimVarValue('K:A32NX.FCU_EXPED_PUSH', 'number', 0); + } + } + + update(_deltaTime) { + } +} + +class A320_Neo_FCU_Heading extends A320_Neo_FCU_Component { + constructor() { + super(...arguments); + this.backToIdleTimeout = 45000; + this.inSelection = false; + + this._rotaryEncoderCurrentSpeed = 1; + this._rotaryEncoderMaximumSpeed = 5; + this._rotaryEncoderTimeout = 350; + this._rotaryEncoderIncrement = 0.1; + this._rotaryEncoderPreviousTimestamp = 0; + this.init(); + this.update(0); + } + + init() { + this.textHDG = this.getTextElement('HDG'); + this.textTRK = this.getTextElement('TRK'); + this.textLAT = this.getTextElement('LAT'); + this.illuminator = this.getElement('circle', 'Illuminator'); + this.currentValue = -1; + this.selectedValue = Simplane.getAltitudeAboveGround() > 1000 ? this.getCurrentHeading() : 0; + this.isSelectedValueActive = true; + this.isPreselectionModeActive = false; + this.wasHeadingSync = false; + this.refresh(true, false, false, false, true, 0, false, true); + } + + onRotate() { + const lateralMode = SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_MODE', 'Number'); + const lateralArmed = SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_ARMED', 'Number'); + const isTRKMode = SimVar.GetSimVarValue('L:A32NX_TRK_FPA_MODE_ACTIVE', 'Bool'); + const radioHeight = SimVar.GetSimVarValue('RADIO HEIGHT', 'feet'); + + if (!this.inSelection + && (this.isManagedModeActive(lateralMode) + || this.isPreselectionAvailable(radioHeight, lateralMode))) { + this.inSelection = true; + if (!this.isSelectedValueActive) { + if (isTRKMode) { + this.selectedValue = this.getCurrentTrack(); + } else { + this.selectedValue = this.getCurrentHeading(); + } + } + } + + this.isSelectedValueActive = true; + + if (this.inSelection && !this.isPreselectionAvailable(radioHeight, lateralMode)) { + this.isPreselectionModeActive = false; + clearTimeout(this._resetSelectionTimeout); + this._resetSelectionTimeout = setTimeout(() => { + this.selectedValue = -1; + this.isSelectedValueActive = false; + this.inSelection = false; + }, this.backToIdleTimeout); + } else { + this.isPreselectionModeActive = true; + } + } + + getCurrentHeading() { + return ((Math.round(SimVar.GetSimVarValue('PLANE HEADING DEGREES MAGNETIC', 'degree')) % 360) + 360) % 360; + } + + getCurrentTrack() { + return ((Math.round(SimVar.GetSimVarValue('GPS GROUND MAGNETIC TRACK', 'degree')) % 360) + 360) % 360; + } + + onPush() { + clearTimeout(this._resetSelectionTimeout); + this.isPreselectionModeActive = false; + this.inSelection = false; + SimVar.SetSimVarValue('K:A32NX.FCU_TO_AP_HDG_PUSH', 'number', 0); + SimVar.SetSimVarValue('K:HEADING_SLOT_INDEX_SET', 'number', 2); + } + + onPull() { + clearTimeout(this._resetSelectionTimeout); + const isTRKMode = SimVar.GetSimVarValue('L:A32NX_TRK_FPA_MODE_ACTIVE', 'Bool'); + if (!this.isSelectedValueActive) { + if (isTRKMode) { + this.selectedValue = this.getCurrentTrack(); + } else { + this.selectedValue = this.getCurrentHeading(); + } + } + this.inSelection = false; + this.isSelectedValueActive = true; + this.isPreselectionModeActive = false; + SimVar.SetSimVarValue('K:A32NX.FCU_TO_AP_HDG_PULL', 'number', 0); + SimVar.SetSimVarValue('K:HEADING_SLOT_INDEX_SET', 'number', 1); + } + + update(_deltaTime) { + const lateralMode = SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_MODE', 'Number'); + const lateralArmed = SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_ARMED', 'Number'); + const isTRKMode = SimVar.GetSimVarValue('L:A32NX_TRK_FPA_MODE_ACTIVE', 'Bool'); + const lightsTest = SimVar.GetSimVarValue('L:A32NX_OVHD_INTLT_ANN', 'number') == 0; + const isManagedActive = this.isManagedModeActive(lateralMode); + const isManagedArmed = this.isManagedModeArmed(lateralArmed); + const showSelectedValue = (this.isSelectedValueActive || this.inSelection || this.isPreselectionModeActive); + + const isHeadingSync = SimVar.GetSimVarValue('L:A32NX_FCU_HEADING_SYNC', 'Number'); + if (!this.wasHeadingSync && isHeadingSync) { + if (isTRKMode) { + this.selectedValue = this.getCurrentTrack(); + } else { + this.selectedValue = this.getCurrentHeading(); + } + this.isSelectedValueActive = true; + this.onRotate(); + } + this.wasHeadingSync = isHeadingSync; + + this.refresh(true, isManagedArmed, isManagedActive, isTRKMode, showSelectedValue, this.selectedValue, lightsTest); + } + + refresh(_isActive, _isManagedArmed, _isManagedActive, _isTRKMode, _showSelectedHeading, _value, _lightsTest, _force = false) { + if ((_isActive != this.isActive) + || (_isManagedArmed != this.isManagedArmed) + || (_isManagedActive != this.isManagedActive) + || (_isTRKMode != this.isTRKMode) + || (_showSelectedHeading != this.showSelectedHeading) + || (_value != this.currentValue) + || (_lightsTest !== this.lightsTest) + || _force) { + if (_isTRKMode != this.isTRKMode) { + this.onTRKModeChanged(_isTRKMode); + } + if (_isManagedArmed + && _isManagedArmed !== this.isManagedArmed + && SimVar.GetSimVarValue('RADIO HEIGHT', 'feet') < 30) { + _value = -1; + _showSelectedHeading = false; + this.selectedValue = _value; + this.isSelectedValueActive = false; + this.isPreselectionModeActive = false; + SimVar.SetSimVarValue('K:HEADING_SLOT_INDEX_SET', 'number', 2); + } + if (_isManagedActive !== this.isManagedActive) { + if (_isManagedActive) { + _value = -1; + _showSelectedHeading = false; + this.selectedValue = _value; + this.isSelectedValueActive = false; + this.isPreselectionModeActive = false; + } else { + _showSelectedHeading = true; + if (!this.isSelectedValueActive) { + this.isSelectedValueActive = true; + if (_isTRKMode) { + _value = this.getCurrentTrack(); + this.selectedValue = _value; + } else { + _value = this.getCurrentHeading(); + this.selectedValue = _value; + } + } + } + } + SimVar.SetSimVarValue('L:A320_FCU_SHOW_SELECTED_HEADING', 'number', _showSelectedHeading == true ? 1 : 0); + if (_value !== this.currentValue) { + SimVar.SetSimVarValue('L:A32NX_AUTOPILOT_HEADING_SELECTED', 'Degrees', _value); + Coherent.call('HEADING_BUG_SET', 1, Math.max(0, _value)).catch(console.error); + } + this.isActive = _isActive; + this.isManagedActive = _isManagedActive; + this.isManagedArmed = _isManagedArmed; + this.isTRKMode = _isTRKMode; + this.showSelectedHeading = _showSelectedHeading; + this.currentValue = _value; + this.setTextElementActive(this.textHDG, !this.isTRKMode); + this.setTextElementActive(this.textTRK, this.isTRKMode); + this.lightsTest = _lightsTest; + if (this.lightsTest) { + this.setTextElementActive(this.textHDG, true); + this.setTextElementActive(this.textTRK, true); + this.setTextElementActive(this.textLAT, true); + this.textValueContent = '.8.8.8'; + this.setElementVisibility(this.illuminator, true); + return; + } + if ((this.isManagedArmed || this.isManagedActive) && !this.showSelectedHeading) { + this.textValueContent = '---'; + } else { + const value = Math.round(Math.max(this.currentValue, 0)) % 360; + this.textValueContent = value.toString().padStart(3, '0'); + } + + SimVar.SetSimVarValue( + 'L:A32NX_FCU_HDG_MANAGED_DASHES', + 'boolean', + (this.isManagedArmed || this.isManagedActive) && !this.showSelectedHeading, + ); + SimVar.SetSimVarValue( + 'L:A32NX_FCU_HDG_MANAGED_DOT', + 'boolean', + this.isManagedArmed || this.isManagedActive, + ); + + this.setElementVisibility(this.illuminator, this.isManagedArmed || this.isManagedActive); + } + } + + isManagedModeActive(_mode) { + return (_mode !== 0 && _mode !== 10 && _mode !== 11 && _mode !== 40 && _mode !== 41); + } + + isManagedModeArmed(_armed) { + return (_armed > 0); + } + + isPreselectionAvailable(_radioHeight, _mode) { + return ( + _radioHeight < 30 + || ((_mode >= 30 && _mode <= 34) || _mode === 50) + ); + } + + onTRKModeChanged(_newValue) { + if (_newValue) { + this.selectedValue = this.calculateTrackForHeading(this.selectedValue); + } else { + this.selectedValue = this.calculateHeadingForTrack(this.selectedValue); + } + } + + /** + * Calculates the corresponding track for a given heading, assuming it is flown in the current conditions (TAS + wind). + * @param {number} _heading The heading in degrees. + * @returns {number} The corresponding track in degrees. + */ + calculateTrackForHeading(_heading) { + const trueAirspeed = SimVar.GetSimVarValue('AIRSPEED TRUE', 'Knots'); + if (trueAirspeed < 50) { + return _heading; + } + + const heading = _heading * Math.PI / 180; + const windVelocity = SimVar.GetSimVarValue('AMBIENT WIND VELOCITY', 'Knots'); + const windDirection = SimVar.GetSimVarValue('AMBIENT WIND DIRECTION', 'Degrees') * Math.PI / 180; + // https://web.archive.org/web/20160302090326/http://williams.best.vwh.net/avform.htm#Wind + const wca = Math.atan2(windVelocity * Math.sin(heading - windDirection), trueAirspeed - windVelocity * Math.cos(heading - windDirection)); + const track = heading + wca % (2 * Math.PI); + return (((track * 180 / Math.PI) % 360) + 360) % 360; + } + + /** + * Calculates the heading needed to fly a given track in the current conditions (TAS + wind). + * @param {number} _track The track in degrees. + * @returns {number} The corresponding heading in degrees. + */ + calculateHeadingForTrack(_track) { + const trueAirspeed = SimVar.GetSimVarValue('AIRSPEED TRUE', 'Knots'); + if (trueAirspeed < 50) { + return _track; + } + + const track = _track * Math.PI / 180; + const windVelocity = SimVar.GetSimVarValue('AMBIENT WIND VELOCITY', 'Knots'); + const windDirection = SimVar.GetSimVarValue('AMBIENT WIND DIRECTION', 'Degrees') * Math.PI / 180; + // https://web.archive.org/web/20160302090326/http://williams.best.vwh.net/avform.htm#Wind + const swc = (windVelocity / trueAirspeed) * Math.sin(windDirection - track); + const heading = track + Math.asin(swc) % (2 * Math.PI); + const _heading = (((heading * 180 / Math.PI) % 360) + 360) % 360; + return _heading == NaN ? _track : _heading; + } + + getRotationSpeed() { + if (this._rotaryEncoderCurrentSpeed < 1 + || (Date.now() - this._rotaryEncoderPreviousTimestamp) > this._rotaryEncoderTimeout) { + this._rotaryEncoderCurrentSpeed = 1; + } else { + this._rotaryEncoderCurrentSpeed += this._rotaryEncoderIncrement; + } + this._rotaryEncoderPreviousTimestamp = Date.now(); + return Math.min(this._rotaryEncoderMaximumSpeed, Math.floor(this._rotaryEncoderCurrentSpeed)); + } + + onEvent(_event) { + if (_event === 'HDG_INC_HEADING') { + this.selectedValue = ((Math.round(this.selectedValue + this.getRotationSpeed()) % 360) + 360) % 360; + this.onRotate(); + } else if (_event === 'HDG_DEC_HEADING') { + this.selectedValue = ((Math.round(this.selectedValue - this.getRotationSpeed()) % 360) + 360) % 360; + this.onRotate(); + } else if (_event === 'HDG_INC_TRACK') { + this.selectedValue = ((Math.round(this.selectedValue + this.getRotationSpeed()) % 360) + 360) % 360; + this.onRotate(); + } else if (_event === 'HDG_DEC_TRACK') { + this.selectedValue = ((Math.round(this.selectedValue - this.getRotationSpeed()) % 360) + 360) % 360; + this.onRotate(); + } else if (_event === 'HDG_PUSH') { + this.onPush(); + } else if (_event === 'HDG_PULL') { + this.onPull(); + } else if (_event === 'HDG_SET') { + this.selectedValue = Math.round(SimVar.GetSimVarValue('L:A320_Neo_FCU_HDG_SET_DATA', 'number') % 360); + this.isSelectedValueActive = true; + this.onRotate(); + } + } +} + +class A320_Neo_FCU_Mode extends A320_Neo_FCU_Component { + constructor() { + super(...arguments); + this.init(); + this.update(0); + } + + init() { + this.textHDG = this.getTextElement('HDG'); + this.textVS = this.getTextElement('VS'); + this.textTRK = this.getTextElement('TRK'); + this.textFPA = this.getTextElement('FPA'); + this.refresh(false, 0, true); + } + + update(_deltaTime) { + if (SimVar.GetSimVarValue('L:A32NX_FCU_MODE_REVERSION_TRK_FPA_ACTIVE', 'Bool')) { + SimVar.SetSimVarValue('L:A32NX_TRK_FPA_MODE_ACTIVE', 'Bool', 0); + } + const _isTRKFPADisplayMode = SimVar.GetSimVarValue('L:A32NX_TRK_FPA_MODE_ACTIVE', 'Bool'); + this.refresh(_isTRKFPADisplayMode, SimVar.GetSimVarValue('L:A32NX_OVHD_INTLT_ANN', 'number') == 0); + } + + refresh(_isTRKFPADisplayMode, _lightsTest, _force = false) { + if ((_isTRKFPADisplayMode != this.isTRKFPADisplayMode) || (_lightsTest !== this.lightsTest) || _force) { + this.isTRKFPADisplayMode = _isTRKFPADisplayMode; + this.lightsTest = _lightsTest; + if (this.lightsTest) { + this.setTextElementActive(this.textHDG, true); + this.setTextElementActive(this.textVS, true); + this.setTextElementActive(this.textTRK, true); + this.setTextElementActive(this.textFPA, true); + return; + } + this.setTextElementActive(this.textHDG, !this.isTRKFPADisplayMode); + this.setTextElementActive(this.textVS, !this.isTRKFPADisplayMode); + this.setTextElementActive(this.textTRK, this.isTRKFPADisplayMode); + this.setTextElementActive(this.textFPA, this.isTRKFPADisplayMode); + } + } +} + +class A320_Neo_FCU_Altitude extends A320_Neo_FCU_Component { + constructor() { + super(...arguments); + this.init(); + this.update(0); + } + + init() { + this.illuminator = this.getElement('circle', 'Illuminator'); + this.isActive = false; + this.isManaged = false; + this.currentValue = 0; + let initValue = 100; + if (Simplane.getAltitudeAboveGround() > 1000) { + initValue = Math.min(49000, Math.max(100, Math.round(Simplane.getAltitude() / 100) * 100)); + } + Coherent.call('AP_ALT_VAR_SET_ENGLISH', 3, initValue, true).catch(console.error); + this.refresh(false, false, initValue, 0, true); + } + + reboot() { + this.init(); + } + + isManagedModeActiveOrArmed(_mode, _armed) { + return ( + (_mode >= 20 && _mode <= 34) + || (_armed >> 1 & 1 + || _armed >> 2 & 1 + || _armed >> 3 & 1 + || _armed >> 4 & 1 + ) + ); + } + + update(_deltaTime) { + const verticalMode = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Number'); + const verticalArmed = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_ARMED', 'Number'); + const isManaged = this.isManagedModeActiveOrArmed(verticalMode, verticalArmed); + + this.refresh(Simplane.getAutoPilotActive(), isManaged, Simplane.getAutoPilotDisplayedAltitudeLockValue(Simplane.getAutoPilotAltitudeLockUnits()), SimVar.GetSimVarValue('L:A32NX_OVHD_INTLT_ANN', 'number') == 0); + } + + refresh(_isActive, _isManaged, _value, _lightsTest, _force = false) { + if ((_isActive != this.isActive) || (_isManaged != this.isManaged) || (_value != this.currentValue) || (_lightsTest !== this.lightsTest) || _force) { + this.isActive = _isActive; + this.isManaged = _isManaged; + this.currentValue = _value; + this.lightsTest = _lightsTest; + if (this.lightsTest) { + this.textValueContent = '88888'; + this.setElementVisibility(this.illuminator, true); + return; + } + const value = Math.floor(Math.max(this.currentValue, 100)); + this.textValueContent = value.toString().padStart(5, '0'); + this.setElementVisibility(this.illuminator, this.isManaged); + SimVar.SetSimVarValue('L:A32NX_FCU_ALT_MANAGED', 'boolean', this.isManaged); + } + } + + onEvent(_event) { + if (_event === 'ALT_PUSH') { + SimVar.SetSimVarValue('K:A32NX.FCU_ALT_PUSH', 'number', 0); + SimVar.SetSimVarValue('K:ALTITUDE_SLOT_INDEX_SET', 'number', 2); + } else if (_event === 'ALT_PULL') { + SimVar.SetSimVarValue('K:A32NX.FCU_ALT_PULL', 'number', 0); + SimVar.SetSimVarValue('K:ALTITUDE_SLOT_INDEX_SET', 'number', 1); + } + } +} + +let A320_Neo_FCU_VSpeed_State; +(function (A320_Neo_FCU_VSpeed_State) { + A320_Neo_FCU_VSpeed_State[A320_Neo_FCU_VSpeed_State.Idle = 0] = 'Idle'; + A320_Neo_FCU_VSpeed_State[A320_Neo_FCU_VSpeed_State.Zeroing = 1] = 'Zeroing'; + A320_Neo_FCU_VSpeed_State[A320_Neo_FCU_VSpeed_State.Selecting = 2] = 'Selecting'; + A320_Neo_FCU_VSpeed_State[A320_Neo_FCU_VSpeed_State.Flying = 3] = 'Flying'; +}(A320_Neo_FCU_VSpeed_State || (A320_Neo_FCU_VSpeed_State = {}))); +class A320_Neo_FCU_VerticalSpeed extends A320_Neo_FCU_Component { + constructor() { + super(...arguments); + this.forceUpdate = true; + this.ABS_MINMAX_FPA = 9.9; + this.ABS_MINMAX_VS = 6000; + this.backToIdleTimeout = 45000; + this.previousVerticalMode = 0; + this.init(); + this.update(0); + } + + get currentState() { + return this._currentState; + } + + set currentState(v) { + this._currentState = v; + SimVar.SetSimVarValue('L:A320_NE0_FCU_STATE', 'number', this.currentState); + } + + init() { + this.textVS = this.getTextElement('VS'); + this.textFPA = this.getTextElement('FPA'); + this.isActive = false; + this.isFPAMode = false; + this._enterIdleState(); + this.selectedVs = 0; + this.selectedFpa = 0; + this.refresh(false, false, 0, 0, true); + } + + onPush() { + const mode = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Number'); + if (mode >= 32 && mode <= 34) { + return; + } + clearTimeout(this._resetSelectionTimeout); + this.forceUpdate = true; + + this.currentState = A320_Neo_FCU_VSpeed_State.Zeroing; + + this.selectedVs = 0; + this.selectedFpa = 0; + + SimVar.SetSimVarValue('K:A32NX.FCU_TO_AP_VS_PUSH', 'number', 0); + } + + onRotate() { + if (this.currentState === A320_Neo_FCU_VSpeed_State.Idle || this.currentState === A320_Neo_FCU_VSpeed_State.Selecting) { + clearTimeout(this._resetSelectionTimeout); + this.forceUpdate = true; + + if (this.currentState === A320_Neo_FCU_VSpeed_State.Idle) { + this.selectedVs = this.getCurrentVerticalSpeed(); + this.selectedFpa = this.getCurrentFlightPathAngle(); + } + + this.currentState = A320_Neo_FCU_VSpeed_State.Selecting; + + this._resetSelectionTimeout = setTimeout(() => { + this.selectedVs = 0; + this.selectedFpa = 0; + this.currentState = A320_Neo_FCU_VSpeed_State.Idle; + this.forceUpdate = true; + }, this.backToIdleTimeout); + } else if (this.currentState === A320_Neo_FCU_VSpeed_State.Zeroing) { + this.currentState = A320_Neo_FCU_VSpeed_State.Flying; + this.forceUpdate = true; + } + } + + onPull() { + clearTimeout(this._resetSelectionTimeout); + this.forceUpdate = true; + + if (this.currentState === A320_Neo_FCU_VSpeed_State.Idle) { + if (this.isFPAMode) { + this.selectedFpa = this.getCurrentFlightPathAngle(); + } else { + this.selectedVs = this.getCurrentVerticalSpeed(); + } + } + + SimVar.SetSimVarValue('K:A32NX.FCU_TO_AP_VS_PULL', 'number', 0); + } + + getCurrentFlightPathAngle() { + return this.calculateAngleForVerticalSpeed(Simplane.getVerticalSpeed()); + } + + getCurrentVerticalSpeed() { + return Utils.Clamp(Math.round(Simplane.getVerticalSpeed() / 100) * 100, -this.ABS_MINMAX_VS, this.ABS_MINMAX_VS); + } + + _enterIdleState(idleVSpeed) { + this.selectedVs = 0; + this.selectedFpa = 0; + this.currentState = A320_Neo_FCU_VSpeed_State.Idle; + this.forceUpdate = true; + } + + update(_deltaTime) { + const lightsTest = SimVar.GetSimVarValue('L:A32NX_OVHD_INTLT_ANN', 'number') == 0; + const isFPAMode = SimVar.GetSimVarValue('L:A32NX_TRK_FPA_MODE_ACTIVE', 'Bool'); + const verticalMode = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Number'); + + if ((this.previousVerticalMode != verticalMode) + && (verticalMode !== 14 && verticalMode !== 15)) { + clearTimeout(this._resetSelectionTimeout); + this._enterIdleState(); + } + + if (this.currentState !== A320_Neo_FCU_VSpeed_State.Flying + && this.currentState !== A320_Neo_FCU_VSpeed_State.Zeroing + && (verticalMode === 14 || verticalMode === 15)) { + clearTimeout(this._resetSelectionTimeout); + this.forceUpdate = true; + const isModeReversion = SimVar.GetSimVarValue('L:A32NX_FCU_MODE_REVERSION_ACTIVE', 'Number'); + const modeReversionTargetFpm = SimVar.GetSimVarValue('L:A32NX_FCU_MODE_REVERSION_TARGET_FPM', 'Number'); + if (isFPAMode) { + if (isModeReversion === 1) { + this.currentState = A320_Neo_FCU_VSpeed_State.Flying; + const modeReversionTargetFpa = this.calculateAngleForVerticalSpeed(modeReversionTargetFpm); + this.selectedFpa = Utils.Clamp(Math.round(modeReversionTargetFpa * 10) / 10, -this.ABS_MINMAX_FPA, this.ABS_MINMAX_FPA); + } else if (this.selectedFpa !== 0) { + this.currentState = A320_Neo_FCU_VSpeed_State.Flying; + } else { + this.currentState = A320_Neo_FCU_VSpeed_State.Zeroing; + } + } else if (isModeReversion === 1) { + this.currentState = A320_Neo_FCU_VSpeed_State.Flying; + this.selectedVs = Utils.Clamp(Math.round(modeReversionTargetFpm / 100) * 100, -this.ABS_MINMAX_VS, this.ABS_MINMAX_VS); + } else if (this.currentVs !== 0) { + this.currentState = A320_Neo_FCU_VSpeed_State.Flying; + } else { + this.currentState = A320_Neo_FCU_VSpeed_State.Zeroing; + } + } + + if (isFPAMode) { + this.refresh(true, true, this.selectedFpa, lightsTest, this.forceUpdate); + } else { + this.refresh(true, false, this.selectedVs, lightsTest, this.forceUpdate); + } + + this.forceUpdate = false; + this.previousVerticalMode = verticalMode; + } + + refresh(_isActive, _isFPAMode, _value, _lightsTest, _force = false) { + if ((_isActive != this.isActive) || (_isFPAMode != this.isFPAMode) || (_value != this.currentValue) || (_lightsTest !== this.lightsTest) || _force) { + if (this.isFPAMode != _isFPAMode) { + this.onFPAModeChanged(_isFPAMode); + } + if (this.currentValue !== _value) { + if (_isFPAMode) { + SimVar.SetSimVarValue('L:A32NX_AUTOPILOT_FPA_SELECTED', 'Degree', this.selectedFpa); + SimVar.SetSimVarValue('L:A32NX_AUTOPILOT_VS_SELECTED', 'feet per minute', 0); + } else { + SimVar.SetSimVarValue('L:A32NX_AUTOPILOT_FPA_SELECTED', 'Degree', 0); + SimVar.SetSimVarValue('L:A32NX_AUTOPILOT_VS_SELECTED', 'feet per minute', this.selectedVs); + } + } + this.isActive = _isActive; + this.isFPAMode = _isFPAMode; + this.currentValue = _value; + this.lightsTest = _lightsTest; + if (this.lightsTest) { + this.setTextElementActive(this.textVS, true); + this.setTextElementActive(this.textFPA, true); + this.textValueContent = '+8.888'; + return; + } + this.setTextElementActive(this.textVS, !this.isFPAMode); + this.setTextElementActive(this.textFPA, this.isFPAMode); + if (this.isActive && this.currentState != A320_Neo_FCU_VSpeed_State.Idle) { + const sign = (this.currentValue < 0) ? '~' : '+'; + if (this.isFPAMode) { + let value = Math.abs(this.currentValue); + value = Math.round(value * 10).toString().padStart(2, '0'); + value = `${value.substring(0, 1)}.${value.substring(1)}`; + this.textValueContent = sign + value; + } else if (this.currentState === A320_Neo_FCU_VSpeed_State.Zeroing) { + this.textValueContent = ('{sp}00oo'); + } else { + let value = Math.floor(this.currentValue); + value = Math.abs(value); + this.textValueContent = `${sign + (Math.floor(value * 0.01).toString().padStart(2, '0'))}oo`; + } + SimVar.SetSimVarValue('L:A32NX_FCU_VS_MANAGED', 'boolean', false); + } else { + this.textValueContent = '~----'; + SimVar.SetSimVarValue('L:A32NX_FCU_VS_MANAGED', 'boolean', true); + } + } + } + + onEvent(_event) { + if (_event === 'VS_INC_VS') { + this.selectedVs = Utils.Clamp(Math.round(this.selectedVs + 100), -this.ABS_MINMAX_VS, this.ABS_MINMAX_VS); + this.onRotate(); + } else if (_event === 'VS_DEC_VS') { + this.selectedVs = Utils.Clamp(Math.round(this.selectedVs - 100), -this.ABS_MINMAX_VS, this.ABS_MINMAX_VS); + this.onRotate(); + } else if (_event === 'VS_INC_FPA') { + this.selectedFpa = Utils.Clamp(Math.round((this.selectedFpa + 0.1) * 10) / 10, -this.ABS_MINMAX_FPA, this.ABS_MINMAX_FPA); + this.onRotate(); + } else if (_event === 'VS_DEC_FPA') { + this.selectedFpa = Utils.Clamp(Math.round((this.selectedFpa - 0.1) * 10) / 10, -this.ABS_MINMAX_FPA, this.ABS_MINMAX_FPA); + this.onRotate(); + } else if (_event === 'VS_PUSH') { + this.onPush(); + } else if (_event === 'VS_PULL') { + this.onPull(); + } else if (_event === 'VS_SET') { + const value = SimVar.GetSimVarValue('L:A320_Neo_FCU_VS_SET_DATA', 'number'); + if (this.isFPAMode) { + if (Math.abs(value) < 100 || value == 0) { + this.selectedFpa = Utils.Clamp(Math.round(value) / 10, -this.ABS_MINMAX_FPA, this.ABS_MINMAX_FPA); + this.currentState = A320_Neo_FCU_VSpeed_State.Selecting; + this.onRotate(); + } + } else if (Math.abs(value) >= 100 || value == 0) { + this.selectedVs = Utils.Clamp(Math.round(value), -this.ABS_MINMAX_VS, this.ABS_MINMAX_VS); + this.currentState = A320_Neo_FCU_VSpeed_State.Selecting; + this.onRotate(); + } + } + } + + onFPAModeChanged(_newValue) { + if (_newValue) { + this.selectedFpa = this.calculateAngleForVerticalSpeed(this.selectedVs); + } else { + this.selectedVs = this.calculateVerticalSpeedForAngle(this.selectedFpa); + } + } + + /** + * Calculates the vertical speed needed to fly a flight path angle at the current ground speed. + * @param {number} _angle The flight path angle in degrees. + * @returns {number} The corresponding vertical speed in feet per minute. + */ + calculateVerticalSpeedForAngle(_angle) { + if (_angle == 0) { + return 0; + } + const _groundSpeed = SimVar.GetSimVarValue('GPS GROUND SPEED', 'Meters per second'); + const groundSpeed = _groundSpeed * 3.28084 * 60; // Now in feet per minute. + const angle = _angle * Math.PI / 180; // Now in radians. + const verticalSpeed = Math.tan(angle) * groundSpeed; + return Utils.Clamp(Math.round(verticalSpeed / 100) * 100, -this.ABS_MINMAX_VS, this.ABS_MINMAX_VS); + } + + /** + * Calculates the flight path angle for a given vertical speed, assuming it is flown at the current ground speed. + * @param {number} verticalSpeed The flight path angle in feet per minute. + * @returns {number} The corresponding flight path angle in degrees. + */ + calculateAngleForVerticalSpeed(verticalSpeed) { + if (Math.abs(verticalSpeed) < 10) { + return 0; + } + const _groundSpeed = SimVar.GetSimVarValue('GPS GROUND SPEED', 'Meters per second'); + const groundSpeed = _groundSpeed * 3.28084 * 60; // Now in feet per minute. + const angle = Math.atan(verticalSpeed / groundSpeed); + const _angle = angle * 180 / Math.PI; + return Utils.Clamp(Math.round(_angle * 10) / 10, -this.ABS_MINMAX_FPA, this.ABS_MINMAX_FPA); + } +} + +class A320_Neo_FCU_LargeScreen extends NavSystemElement { + init(root) { + if (this.components == null) { + this.components = new Array(); + this.speedDisplay = new A320_Neo_FCU_Speed(this.gps, 'Speed'); + this.components.push(this.speedDisplay); + this.headingDisplay = new A320_Neo_FCU_Heading(this.gps, 'Heading'); + this.components.push(this.headingDisplay); + this.components.push(new A320_Neo_FCU_Mode(this.gps, 'Mode')); + this.altitudeDisplay = new A320_Neo_FCU_Altitude(this.gps, 'Altitude'); + this.components.push(this.altitudeDisplay); + this.verticalSpeedDisplay = new A320_Neo_FCU_VerticalSpeed(this.gps, 'VerticalSpeed'); + this.components.push(this.verticalSpeedDisplay); + this.autopilotInterface = new A320_Neo_FCU_Autopilot(this.gps, 'Autopilot'); + } + } + + onEnter() { + } + + reboot() { + if (this.components != null) { + for (let i = 0; i < this.components.length; ++i) { + if (this.components[i] != null) { + this.components[i].reboot(); + } + } + } + } + + onUpdate(_deltaTime) { + if (this.components != null) { + for (let i = 0; i < this.components.length; ++i) { + if (this.components[i] != null) { + this.components[i].update(_deltaTime); + } + } + } + } + + onExit() { + } + + onEvent(_event) { + this.autopilotInterface.onEvent(_event); + this.speedDisplay.onEvent(_event); + this.headingDisplay.onEvent(_event); + this.altitudeDisplay.onEvent(_event); + this.verticalSpeedDisplay.onEvent(_event); + } +} + +class A320_Neo_FCU_Pressure extends A320_Neo_FCU_Component { + constructor() { + super(...arguments); + this.init(); + this.update(0); + } + + init() { + this.selectedElem = this.getDivElement('Selected'); + this.standardElem = this.getDivElement('Standard'); + this.textQFE = this.getTextElement('QFE'); + this.textQNH = this.getTextElement('QNH'); + this.refresh('QFE', true, 0, 0, true); + } + + update(_deltaTime) { + const units = Simplane.getPressureSelectedUnits(); + const mode = Simplane.getPressureSelectedMode(Aircraft.A320_NEO); + this.refresh(mode, (units != 'millibar'), Simplane.getPressureValue(units), SimVar.GetSimVarValue('L:A32NX_OVHD_INTLT_ANN', 'number') == 0); + } + + refresh(_mode, _isHGUnit, _value, _lightsTest, _force = false) { + if ((_mode != this.currentMode) || (_isHGUnit != this.isHGUnit) || (_value != this.currentValue) || (_lightsTest !== this.lightsTest) || _force) { + const wasStd = this.currentMode == 'STD' && _mode != 'STD'; + this.currentMode = _mode; + this.isHGUnit = _isHGUnit; + this.currentValue = _value; + this.lightsTest = _lightsTest; + if (this.lightsTest) { + this.standardElem.style.display = 'none'; + this.selectedElem.style.display = 'block'; + this.setTextElementActive(this.textQFE, true, true); + this.setTextElementActive(this.textQNH, true, true); + this.textValueContent = '88.88'; + return; + } + if (this.currentMode == 'STD') { + this.standardElem.style.display = 'block'; + this.selectedElem.style.display = 'none'; + SimVar.SetSimVarValue('KOHLSMAN SETTING STD', 'Bool', 1); + } else { + this.standardElem.style.display = 'none'; + this.selectedElem.style.display = 'block'; + SimVar.SetSimVarValue('KOHLSMAN SETTING STD', 'Bool', 0); + const isQFE = (this.currentMode == 'QFE'); + this.setTextElementActive(this.textQFE, isQFE, true); + this.setTextElementActive(this.textQNH, !isQFE, true); + let value = Math.round(Math.max(this.isHGUnit ? (this.currentValue * 100) : this.currentValue, 0)); + if (!wasStd) { + value = value.toString().padStart(4, '0'); + if (this.isHGUnit) { + value = `${value.substring(0, 2)}.${value.substring(2)}`; + } + this.textValueContent = value; + } + } + } + } +} + +class A320_Neo_FCU_SmallScreen extends NavSystemElement { + init(root) { + if (this.pressure == null) { + this.pressure = new A320_Neo_FCU_Pressure(this.gps, 'SmallScreen'); + } + } + + onEnter() { + } + + onUpdate(_deltaTime) { + if (this.pressure != null) { + this.pressure.update(_deltaTime); + } + } + + onExit() { + } + + onEvent(_event) { + } + + reboot() { + if (this.pressure) { + this.pressure.reboot(); + } + } +} + +registerInstrument('a380-fcu-element', A320_Neo_FCU); diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/ARPT.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/ARPT.png new file mode 100644 index 0000000000000000000000000000000000000000..32d57ff022398fa1f17fea4efdad9c5b7a317ebf GIT binary patch literal 2850 zcmeAS@N?(olHy`uVBq!ia0y~yV7bG(0r%1aer?9eo`c7&i8E|4C$JVBp&B>Eakt!T9#Z!Op`D0t}8P4_a`$ zuGg8<<1Z*7Hp}tFs(ZgBw^-lUJOBC5dwFjx`p%#F`95{`#=o12?%8Y(dtQ0#_e1md zh0`kEoc8>ddb)Z}s@(6QNwqgW$)(RcZZkPf8fYcM1VNxf85k5)b}#}d2af_~Ak{G8 z0m$JDiU)Z?lv5K}X$y-vh{wU12ciUohNDK1&&UJ)8|(FN_r9Gz_4D(}ecPq2`yOvb ziiY1s(`qy0{Wj@Ge|xI)`_jHSDe>F(9FP3}*LGL_=FynIlG@0N1(2@Mn4oUN#KU-(Osr zc5}M!_ws4?r@yA8{+;^tPvyM#Z;$mo{sD@W zHCHR=eXIL%cHf-T{X6m}hwbOzzjOcQKl@D1gYw0{bDyfu)Xx4gnkzuzj@k+#J0=(q zxxyZi6`sx9YIXkdJ^uYSr*D4a4Q@1T(oerR{n^YdR^T}K_xrv0_uOmQpvDq|(a32Y c5tBJC$mh0e+^g?6uL=qrPgg&ebxsLQ0Mx8ZPXGV_ literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/Blank.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/Blank.png new file mode 100644 index 0000000000000000000000000000000000000000..e56ab363a2cd1d82d8897a7f86fb022b057cbd7e GIT binary patch literal 2742 zcmeAS@N?(olHy`uVBq!ia0y~yV7bG(0r%1aer?9eo`c7&i8E|4C$JVBqrhba4!+V0?SyAm?EN0oFkEqf59$ z*XzajS{X>R9i8$0+^(94k{Rdk{Vun;5%qe{=X>)azwOE9zkcVvPW79aPxj~RydU}P zhVjYzn??IKzuB06={wL81_q-hX%N-IVh*A>IP*Z1fDq8Z3=9e?I~akKgGT`~kZPFl z0OSD%#e=*c%4s-i1bK`+@W9lr`u+Wn3E68upR2A;yZL#YZA>vZpeOy`^t3MhX7YKJ z|EZ_{p4)VMp67i~cru*e7}p$M?tv}wpcI!Fo_ityWp5Ohg;4CDd z|DB%pdv0pK?c_hxb-!DmmaA5)=I{Sx`L|uOdcS9T z`ZU}3w+@>^0{W+Z^td{#czX@L0@b^_B8Yi|6&AhV563?Ve`*S+;{>!PC{xWt~$(69A1q0>J

(0r%1aer?9eo`c7&i8E|4C$JVBp&B>Eakt!T9#Z!Q2)H0S3ncgXydn zCm&TUR%23Ga*f0BYPG%c`*RQDzSsWOG2fPZ`}?V%_Y-$+{95pIPi0!{+~=9^+l#+F z)%ktNX7ajohb_OCPpdp*d~#pT&i$LuJpTXDvz{AhDZ>Oopi>zb6jXLF0x1WN0%jo9 zFyR5n=?sboc|nv@lQf8GVKE0$9GrO|N z^Ox`OKYw#N^4ro%wb;4s#qZv)*A|lIQ@Ob{mr9sfi1m}7Y`s^IO3w} zx6Pc*`sp|IpUq5(-*)GIWcc%s)n=#nJ?R;Z2R!+nCh<^x>;CV|-}h}c-~0FbjOG6C zoA2%We|LN2x75?$PwRZX@qh9^F_66q%A@5HC>)?=FNjGVkW2n6J54&8Km6SL-~S@> za&B*XKlSsonP0zET0H-E&glH+XO*|UvpwH)JTkw&|J`k!`_pyb7f!3qjQ9H$4YIMJ z2U`{$2~`4CKav*-AYBzbU&|x%a%^KP_rL$0zH?*l?S0|rW~X1fS~>4qoyBw8b4LBO zmcVMdvLBc=LUGmr0^1FR|Lef$1xug&YXzmM*#5r1EJ?e_h}_cuq5wx6)I5&tpD X$7}y6oilwsC{8?G{an^LB{Ts5apFkD literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/NDB.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/NDB.png new file mode 100644 index 0000000000000000000000000000000000000000..b2c25472be6ccd9a1d5213a3fe13f21e20b326bb GIT binary patch literal 2839 zcmeAS@N?(olHy`uVBq!ia0y~yV7bG(0r%1aer?9eo`c7&i8E|4C$JVBlKg>Eakt!T9#ZLC(Vl0t}AUN0)Gi zuh+Ze^N5S5%~c}x+1m4hua(N=llN?Ax9)pf{CrR4Go$lSp9|hr-;&F*ziav4?%db+ zpVySt@7Q+heeS%Sf96-l{}cY5s`vTEf8)JoKr0y(ly@)!DF=@NW+2rt;Q`3S42lPN zL6lRIG>B?pF$Yl`oOvKhKnUnHphlJ9s1f8d^1!r>{f75$H_IOXxU=}4P4Ue1wUs-* z+2-!42qw#<>t&tZ8AYJ(4AwE~` zcj~&`pVxfbTX?o^=IQD)`{tb7mveJ^@y+Sqp8Bi@C;Af{!#|mmkd?`fiH6iS$vxk{ zB^)oTc{j)We9W`RY`Jx{{W@Z06NpWi)> zDSsaMwmLdjZXGZyRQBKeZ+vsQ?(ZVK-$iCdp_Qi zdt=dee(vXIGf&%OpFO}OwVeL@+R!t?8%k)|;tTVZWZhQv R?~>rQp{J{#%Q~loCIDt8J#hd4 literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/POINTER1.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/POINTER1.png new file mode 100644 index 0000000000000000000000000000000000000000..644c47639022c9166ed9b95a47c7ba54e0b01e49 GIT binary patch literal 2819 zcmeAS@N?(olHy`uVBq!ia0y~yV7bG(0r%1aer?9eo`c7&i8E|4C$JVBnhJ>Eakt!T9#Z!Op`546K2cN0)Gi zuGhQdb4`?mr`h1%_j9{y7I7FKk9uvJmvipt&Z2ws_P(jSCRg`+-mzZU<=W5R-hBN^ z?)}rad#9)U-aXy++nXnQb9U~pzL|X9Z#^T>LWUF;a}dSBnFpc-gn*7_U{Fxm0g`m^ zC}0Ls4HF)KJi(xNkQYQbHA#c0mSL$8jRAu$IP0u5%f4r@h zGB2C6ZqE1mJFj2OTYh)@^0yDo-=B{Ac4PD8_pk{0A_cO~!EM9^HNNCTRuq6W*&N$= zt*T#oc5&UhIos3qtLJTPzrSyJVe;{prz-y+-}~lc+&^{yYW=QPXFHt$rYoK zfF(~;5(yt|d!?=Y!q45_Jl*#Et;6~LzxQ5`dS94aeD3_z&)?pBz5o5g?BBbuU$xzL z{M)D6llt%8gL1{?Y_Oe@BcPzcm-%GUvp3RvLY89QHm;UzQ_V<~0ZWQhUHJaAF`xy7`DZFh2Z!V2i3|J}#fnUtu WEj8}dcN_bI;=|L`&t;ucLK6UYFFP^- literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/POINTER2.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/POINTER2.png new file mode 100644 index 0000000000000000000000000000000000000000..f23d509527e10b8ffc9b4cb7ccffe4934ff98ada GIT binary patch literal 2801 zcmeAS@N?(olHy`uVBq!ia0y~yV7bG(0r%1aer?9eo`c7&i8E|4C$JVBl)=ba4!+V0?SyVCP{25!XP=qf5BM z*Xv#KIVQ@&Bc5pTe9hjKUn+FjfG zrz$uma7h{0;gvM<&l=y@n&f9<6v81jsD|(0r%1aer?9eo`c7&i8E|4C$JVBlKs>Eakt!T9#Z!Op`50uF(d-Jas> z?!_*dxkj5q!mhdSp7m=Z1>Q&b^Dpn(__pNfp31bD&m%v7nETuMw9T`bDe>Fx+>d-# zdClf{;ht-~Z-4)bOWAWg^4Ugx^}o76+ZYs-cQ67e2af_~Ak{G80m!ioiU)Z?lv9&5 zh-zUm2T>fHc_2za2^KEn2%_+a1 z7CX27JuuAo|C2jwe0pEb&FP!pi2wiZ8GfJtK8Wr8yXew=CXl5@BQCh{B`NZv0i^4J z;kL&Xaj%bktF4c>{J*8rZh2|)?U$!2zvo`NTKVH#^_;J--(R%Z{O0tuTFcXQAOFuU z`TF<6Tp)YjmeFj1HS3cf7x=Tqyl=of@%sAnjr#T7-z`qte0!?$d(%_fb4LBOmM`-` z1rvkhNG*!6<_+>A;)BHPLYeiY$-npQUq7e*mJO`F_-*$3l}*ia+jGCZe$SW}`7QPI zzBy0pGvoa>=||sttn>TQzBymEgRE`n8R2yUYJ`v<6BRvgE&8tS+E{!4e)u{2+(0r%1aer?9eo`c7&i8E|4C$JVBp&6>Eakt!T9#Z!Op`D0t}8Xn+nCc z;vy|1=PMcLm^4pVeebvA9&53^^?J8^-%g+U`Pt0WdFf{lm%n?j^VueQ-e;Tm+r6^K zKi)fG^Y;6;__UkTzdhCeD3?C-xXt8$W! zem`yPxx?l692{ zfOI`D{02+-ahCtLRN75{d+gikQ@7%Ai@9XdTpAO0*pZ^-2w)r<- z{ckSFY6rK`(g_p}(Ch+Yk_T*#)y;dJy?f(hi+kG7=bN2>zUl6bx=73S`_4wqZGXS# zc;vUGwU#IM<=DQpos~YVa-J#w{hxKf2)eHijHd0Nih@CMq*e`B%PR7s0i^4L#P7;^ z>)#&hdt9-8PW>&L`KQa0f2Tg(^V=-DRBqpAx%8X=-`xgQ5l{aCi>unq|L3D>?Q7CU q>kClWVQqqdB*+7!^##>ie1DlsBQ);q^1f*giV{y(KbLh*2~7YdjYBv9 literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/VORD.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/VORD.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb4c2cd9b0df0628fca925eaa873e747be2494c GIT binary patch literal 2854 zcmeAS@N?(olHy`uVBq!ia0y~yV7bG(0r%1aer?9eo`c7&i8E|4C$JVBp&A>Eakt!T9#Z#g)wtBCdh8N0)G~ z-rj98Ii0n!@vQ5asQaIdUHntt&p%z3{5$pOpUQdb-yZ9GT(N#m`TWS$-z#tZK3qQU ze&p)&>wlm5_4R-HzRz~&isY*;PsVNA)4%ym_dbJ{`OH9j8H}2wK~xKiIf&xm%mYyZ zLO?GtFes?(U<6VQ9t9w!4HF)Kyu_e*kQYQb4M&Y2kC6u+7=AmSx_jf_O-27~&R;J} zK3=#d{M_#IYp(vbdHeUFb+zT`xLc<^zimp@PrvC6j)yl-b$&mpoc(1xD2y2-hjFNq zo^r^G1CXu{62G5sx_hH8(z1Tu^X%OlA6wkhettLo+N;`m-~N4=yLV2i{@bT2)i*ay z-~49j-Z?4y(cg-u?cFkc@;@_>b_L~;mgMn94k$>-19Hj#W41}(*4KW1{&P*Pd6~@d z>Zf~tpNZOg{_;KfeVgH-@Be!*Fm~48f1dgKJgCIlr(YZI_v<#u(uSVlU*O;kebmyJ zym$cVs_6M!d^0a+|C%}W=Ra?~d!tb1{nXFzjI+Pm`B&HfI2U(r)AVmellJD^JRQ0J zpWUthzzXD{_4~&<_ox3mul6?=WQ2p;h^YgJi4stdkOypz)gj6y`{{3weLH=sa{u`? z@&9XoocnB@{q<+*{K)*FFDUr~2h+O@W#npp6sqVgaNJSW_Tc zP4%~G?Vyoy{x-1E{xG+8=GI;M?~Gqx|33e{&Hl}Ge}5nS|9Q>T@9pnv_MiD=oAzIQ fG-qIII{sq+kfr@&n#WZgP#}A{`njxgN@xNA+67FJ literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/WPT.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/WPT.png new file mode 100644 index 0000000000000000000000000000000000000000..dd36f24299fb95904d37e6002a44ab0a3a81be02 GIT binary patch literal 2835 zcmeAS@N?(olHy`uVBq!ia0y~yV7bG(0r%1aer?9eo`c7&i8E|4C$JVBlKe>Eakt!T9#Z!IdorA`XFnkNSv* z&DUEt^Jka>$8?2{b4tznRfOK#9s7~PUnX<>`?;#mXHw^FefIFy`z^LN>UM3uS3BqR z+s|vt>UV6rRX00*-S+d3Yk%s!dmQu3@VrmGG|)|g{^4ju)}K&oNF z1CYZR6c6%(D5oZ{(iRqT5RZd14@3zF4M&Y2pOFVn-#9KY|LwuP$3N~x=Fi)CX7e?h znBQ}^vETMDzqY%-cyH~ztMB-)*XTe0wD-&2IVZnwdTMv>>$l%8_MH8?9TfBok|Qp# z@g*qoA_1gpLr-nb$D49*Ec)Kh{rYU?X`Ae`hqvC(tlsdqBz^C1i|eta17%TA73Gay*eZ> z4uHBEQr{%^od1?^ys+l|8uRlp&myzs*8TOn-TZdB_3Lo!{ojB!^n3p6caHDebNr0) zlXm-(PtnZan})Wbx?3fa-XkRbV6;_+tN8-+6WtzVxto?dxvR(tm9 zx8Gr{rhjL?e*61y)@SSK-)B^&ecGF2xxcuw-!k9$Z!XAE2e%R4NJ5L8U+heh8u$3# Rac%{Lj;E`i%Q~loCIG{9JzM|) literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/WX.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/Images/WX.png new file mode 100644 index 0000000000000000000000000000000000000000..f7bcd676c80beb11eaaf6f2c29f77908082f81c4 GIT binary patch literal 2829 zcmeAS@N?(olHy`uVBq!ia0y~yV7bG(0r%1aer?9eo`c7&i8E|4C$JVBlKh>Eakt!T9#ZY2HHyA`Ayxl3pCT zx|@4eV~Bv}rBhvp&fJ`?UeB@nyygAdy}gEZ;n!xT>wdP$mOEF=f4lc>yY}n5$G85g zowxk1{JBc~>!0GP^us^JZM$=P>zU+vQ{$w8HZn{Q1iF)fK|y5)Bam|NC}0Ls4HF)K z+|8hPkQYQbHG!43u$Y5*9GrO|N#u#Pz4__yrfHRDHebpHrF;gXk(1Vm$j_hD&C_*X-Z^xiueHLDXA3|FiCC?M8c0 zH2kdJGMXK5RBz-(0Z12-+2P&esC~!xOxLc|j|MWI8J_oA53Yeva17%*mxyu*twBO| zJTx4?@qEtvx5ti2%-4Q>cJs8#d8Yi&=WV-x|8QCI?}w+Ves8|E|2_ZnIrq2L^jH25 x-&F<78g}baL(fQU7-5g3FU%9A)dQc%ZY=?Yj;E`i%Q~loCIA~1Fzo;U literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/base/manifest-base.json b/fbw-a380x/src/base/manifest-base.json new file mode 100644 index 00000000000..e9a532fafd0 --- /dev/null +++ b/fbw-a380x/src/base/manifest-base.json @@ -0,0 +1,23 @@ +{ + "creator": "FlyByWire Simulations", + "release_notes": { + "neutral": { + "LastUpdate": "", + "OlderHistory": "" + } + }, + "title": "A380X Instruments", + "dependencies": [ + { + "package_version": "0.1.129", + "name": "asobo-vcockpits-instruments-airliners" + }, + { + "package_version": "0.1.125", + "name": "fs-base-aircraft-common" + } + ], + "content_type": "AIRCRAFT", + "minimum_game_version": "1.26.5", + "manufacturer": "Airbus" +} diff --git a/fbw-a380x/src/systems/atsu/rollup.config.js b/fbw-a380x/src/systems/atsu/rollup.config.js new file mode 100644 index 00000000000..a596444d6b7 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/rollup.config.js @@ -0,0 +1,71 @@ +/* + * MIT License + * + * Copyright (c) 2022 FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +'use strict'; + +const { join } = require('path'); +const babel = require('@rollup/plugin-babel').default; +const { typescriptPaths } = require('rollup-plugin-typescript-paths'); +const commonjs = require('@rollup/plugin-commonjs'); +const nodeResolve = require('@rollup/plugin-node-resolve').default; +const json = require('@rollup/plugin-json'); + +const replace = require('@rollup/plugin-replace'); + +const extensions = ['.js', '.ts']; + +const src = join(__dirname, '..'); +const root = join(__dirname, '..', '..'); + +process.chdir(src); + +module.exports = { + input: join(__dirname, 'src/index.ts'), + plugins: [ + nodeResolve({ extensions, browser: true }), + commonjs(), + json(), + babel({ + presets: ['@babel/preset-typescript', ['@babel/preset-env', { targets: { browsers: ['safari 11'] } }]], + plugins: [ + '@babel/plugin-proposal-class-properties', + ], + extensions, + }), + typescriptPaths({ + tsConfigPath: join(src, 'tsconfig.json'), + preserveExtensions: true, + }), + replace({ + 'DEBUG': 'false', + 'process.env.NODE_ENV': '"production"', + 'preventAssignment': true, + }), + ], + output: { + file: join(root, 'flybywire-aircraft-a320-neo/html_ui/JS/atsu/atsu.js'), + format: 'umd', + name: 'Atsu', + }, +}; diff --git a/fbw-a380x/src/systems/atsu/src/AOC.ts b/fbw-a380x/src/systems/atsu/src/AOC.ts new file mode 100644 index 00000000000..6927443bd4f --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/AOC.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuStatusCodes } from './AtsuStatusCodes'; +import { AtsuMessageDirection, AtsuMessage, AtsuMessageType } from './messages/AtsuMessage'; +import { WeatherMessage } from './messages/WeatherMessage'; +import { AtisType } from './messages/AtisMessage'; +import { Datalink } from './com/Datalink'; + +/** + * Defines the AOC + */ +export class Aoc { + private datalink: Datalink = null; + + private messageQueue: AtsuMessage[] = []; + + constructor(datalink: Datalink) { + this.datalink = datalink; + } + + public static isRelevantMessage(message: AtsuMessage): boolean { + return message.Type < AtsuMessageType.AOC; + } + + public async sendMessage(message: AtsuMessage): Promise { + if (Aoc.isRelevantMessage(message)) { + return this.datalink.sendMessage(message, false); + } + return AtsuStatusCodes.UnknownMessage; + } + + public removeMessage(uid: number): boolean { + const index = this.messageQueue.findIndex((element) => element.UniqueMessageID === uid); + if (index !== -1) { + this.messageQueue.splice(index, 1); + } + return index !== -1; + } + + public async receiveWeather(requestMetar: boolean, icaos: string[], sentCallback: () => void): Promise<[AtsuStatusCodes, WeatherMessage]> { + return this.datalink.receiveWeather(requestMetar, icaos, sentCallback); + } + + public async receiveAtis(icao: string, type: AtisType, sentCallback: () => void): Promise<[AtsuStatusCodes, WeatherMessage]> { + return this.datalink.receiveAtis(icao, type, sentCallback); + } + + public messageRead(uid: number): boolean { + const index = this.messageQueue.findIndex((element) => element.UniqueMessageID === uid); + if (index !== -1 && this.messageQueue[index].Direction === AtsuMessageDirection.Uplink) { + if (this.messageQueue[index].Confirmed === false) { + const cMsgCnt = SimVar.GetSimVarValue('L:A32NX_COMPANY_MSG_COUNT', 'Number'); + SimVar.SetSimVarValue('L:A32NX_COMPANY_MSG_COUNT', 'Number', cMsgCnt <= 1 ? 0 : cMsgCnt - 1); + } + + this.messageQueue[index].Confirmed = true; + } + + return index !== -1; + } + + public messages(): AtsuMessage[] { + return this.messageQueue; + } + + public outputMessages(): AtsuMessage[] { + return this.messageQueue.filter((entry) => entry.Direction === AtsuMessageDirection.Downlink); + } + + public inputMessages(): AtsuMessage[] { + return this.messageQueue.filter((entry) => entry.Direction === AtsuMessageDirection.Uplink); + } + + public uidRegistered(uid: number): boolean { + return this.messageQueue.findIndex((element) => uid === element.UniqueMessageID) !== -1; + } + + public insertMessages(messages: AtsuMessage[]): void { + messages.forEach((message) => { + this.messageQueue.unshift(message); + + if (message.Direction === AtsuMessageDirection.Uplink) { + // increase the company message counter + const cMsgCnt = SimVar.GetSimVarValue('L:A32NX_COMPANY_MSG_COUNT', 'Number'); + SimVar.SetSimVarValue('L:A32NX_COMPANY_MSG_COUNT', 'Number', cMsgCnt + 1); + } + }); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/ATC.ts b/fbw-a380x/src/systems/atsu/src/ATC.ts new file mode 100644 index 00000000000..0df24a33d47 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/ATC.ts @@ -0,0 +1,643 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { InputValidation } from './InputValidation'; +import { AtsuStatusCodes } from './AtsuStatusCodes'; +import { AtisMessage, AtisType } from './messages/AtisMessage'; +import { AtsuTimestamp } from './messages/AtsuTimestamp'; +import { AtsuMessageComStatus, AtsuMessage, AtsuMessageType, AtsuMessageDirection } from './messages/AtsuMessage'; +import { CpdlcMessagesDownlink, CpdlcMessageExpectedResponseType } from './messages/CpdlcMessageElements'; +import { CpdlcMessage } from './messages/CpdlcMessage'; +import { Datalink } from './com/Datalink'; +import { Atsu } from './ATSU'; +import { DcduStatusMessage, DcduLink } from './components/DcduLink'; +import { FansMode, FutureAirNavigationSystem } from './com/FutureAirNavigationSystem'; +import { UplinkMessageStateMachine } from './components/UplinkMessageStateMachine'; +import { UplinkMessageMonitoring } from './components/UplinkMessageMonitoring'; + +/* + * Defines the ATC system for CPDLC communication + */ +export class Atc { + private parent: Atsu = null; + + private datalink: Datalink = null; + + private dcduLink: DcduLink = null; + + private handoverInterval: number = 0; + + private handoverOngoing = false; + + private currentAtc = ''; + + private nextAtc = ''; + + private notificationTime = 0; + + private cpdlcMessageId = 0; + + private messageQueue: CpdlcMessage[] = []; + + private printAtisReport = false; + + private atisAutoUpdateIcaos: [string, AtisType, number][] = []; + + private atisMessages: Map = new Map(); + + public maxUplinkDelay: number = -1; + + private currentFansMode: FansMode = FansMode.FansNone; + + private automaticPositionReport: boolean = false; + + public messageMonitoring: UplinkMessageMonitoring = null; + + constructor(parent: Atsu, datalink: Datalink) { + this.parent = parent; + this.datalink = datalink; + this.dcduLink = new DcduLink(parent, this); + this.messageMonitoring = new UplinkMessageMonitoring(parent); + + setInterval(() => { + const ids = this.messageMonitoring.checkMessageConditions(); + ids.forEach((id) => { + const message = this.messageQueue.find((element) => id === element.UniqueMessageID); + if (message) { + UplinkMessageStateMachine.update(this.parent, message, false, true); + this.dcduLink.update(message, true); + } + }); + }, 5000); + } + + public async disconnect(): Promise { + if (this.currentAtc !== '') { + await this.logoff(); + } + if (this.nextAtc !== '') { + this.resetLogon(); + } + } + + public currentStation(): string { + return this.currentAtc; + } + + public nextStation(): string { + return this.nextAtc; + } + + public nextStationNotificationTime(): number { + return this.notificationTime; + } + + public logonInProgress(): boolean { + return this.nextAtc !== ''; + } + + public resetLogon(): void { + this.currentAtc = ''; + this.nextAtc = ''; + this.notificationTime = 0; + this.dcduLink.setAtcLogonMessage(''); + } + + public async logon(station: string): Promise { + if (this.nextAtc !== '' && station !== this.nextAtc) { + return AtsuStatusCodes.SystemBusy; + } + + if (!this.handoverOngoing && this.currentAtc !== '') { + const retval = await this.logoff(); + if (retval !== AtsuStatusCodes.Ok) { + return retval; + } + } + this.handoverOngoing = false; + + const message = new CpdlcMessage(); + message.Station = station; + message.CurrentTransmissionId = ++this.cpdlcMessageId; + message.Direction = AtsuMessageDirection.Downlink; + message.Content.push(CpdlcMessagesDownlink.DM9998[1]); + message.ComStatus = AtsuMessageComStatus.Sending; + message.Message = 'REQUEST LOGON'; + message.DcduRelevantMessage = false; + + this.nextAtc = station; + this.parent.registerMessages([message]); + this.dcduLink.setAtcLogonMessage(`NEXT ATC: ${station}`); + this.notificationTime = SimVar.GetGlobalVarValue('ZULU TIME', 'seconds'); + + // check if the logon was successful within five minutes + setTimeout(() => { + // check if we have to timeout the logon request + if (this.logonInProgress()) { + const currentTime = SimVar.GetGlobalVarValue('ZULU TIME', 'seconds'); + const delta = currentTime - this.notificationTime; + + // validate that no second notification is triggered + if (delta >= 300) { + this.resetLogon(); + } + } + }, 300000); + + return this.datalink.sendMessage(message, false); + } + + private async handover(station: string): Promise { + if (this.nextAtc !== '' && station !== this.nextAtc) { + return AtsuStatusCodes.SystemBusy; + } + + return new Promise((resolve, _reject) => { + // add an interval to check if all messages are answered or sent to ATC + this.handoverInterval = setInterval(() => { + if (!this.dcduLink.openMessagesForStation(this.currentAtc)) { + clearInterval(this.handoverInterval); + this.handoverInterval = null; + + // add a timer to ensure that the last transmission is already received to avoid ATC software warnings + setTimeout(() => { + if (this.currentAtc !== '') { + this.logoffWithoutReset().then((code) => { + if (code !== AtsuStatusCodes.Ok) { + resolve(code); + } + + this.handoverOngoing = true; + this.logon(station).then((code) => resolve(code)); + }); + } else { + this.handoverOngoing = true; + this.logon(station).then((code) => resolve(code)); + } + }, 15000); + } + }, 1000); + }); + } + + private async logoffWithoutReset(): Promise { + if (this.currentAtc === '') { + return AtsuStatusCodes.NoAtc; + } + + const message = new CpdlcMessage(); + message.Station = this.currentAtc; + message.CurrentTransmissionId = ++this.cpdlcMessageId; + message.Direction = AtsuMessageDirection.Downlink; + message.Content.push(CpdlcMessagesDownlink.DM9999[1]); + message.ComStatus = AtsuMessageComStatus.Sending; + message.DcduRelevantMessage = false; + + this.maxUplinkDelay = -1; + this.parent.registerMessages([message]); + + return this.datalink.sendMessage(message, true).then((error) => error); + } + + public async logoff(): Promise { + // abort a handover run + if (this.handoverInterval !== undefined) { + clearInterval(this.handoverInterval); + this.handoverInterval = undefined; + } + + return this.logoffWithoutReset().then((error) => { + this.dcduLink.setAtcLogonMessage(''); + this.currentFansMode = FansMode.FansNone; + this.currentAtc = ''; + this.nextAtc = ''; + return error; + }); + } + + private createCpdlcResponse(request: CpdlcMessage, response: number): CpdlcMessage { + const downlinkId = `DM${response}`; + if (!(downlinkId in CpdlcMessagesDownlink)) { + return null; + } + + // create the meta information of the response + const responseMessage = new CpdlcMessage(); + responseMessage.Direction = AtsuMessageDirection.Downlink; + responseMessage.CurrentTransmissionId = ++this.cpdlcMessageId; + responseMessage.PreviousTransmissionId = request.CurrentTransmissionId; + responseMessage.Station = request.Station; + responseMessage.Content.push(CpdlcMessagesDownlink[downlinkId][1]); + + return responseMessage; + } + + public sendResponse(uid: number, response: number): void { + const message = this.messageQueue.find((element) => element.UniqueMessageID === uid); + if (message !== undefined) { + const responseMsg = this.createCpdlcResponse(message, response); + + // avoid double-sends + if (message.Response?.Content[0]?.TypeId === responseMsg.Content[0]?.TypeId + && (message.Response?.ComStatus === AtsuMessageComStatus.Sending || message.Response?.ComStatus === AtsuMessageComStatus.Sent)) { + return; + } + + message.Response = responseMsg; + message.Response.ComStatus = AtsuMessageComStatus.Sending; + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.Sending); + this.dcduLink.update(message); + + if (this.parent.modificationMessage?.UniqueMessageID === uid) { + this.parent.modificationMessage = null; + } + + if (message.Response !== undefined) { + this.datalink.sendMessage(message.Response, false).then((code) => { + if (code === AtsuStatusCodes.Ok) { + message.Response.ComStatus = AtsuMessageComStatus.Sent; + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.Sent); + setTimeout(() => { + if (this.dcduLink.currentDcduStatusMessage(message.UniqueMessageID) === DcduStatusMessage.Sent) { + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.NoMessage); + } + }, 5000); + } else { + message.Response.ComStatus = AtsuMessageComStatus.Failed; + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.SendFailed); + } + this.dcduLink.update(message); + }); + } + } + } + + public sendExistingResponse(uid: number): void { + const message = this.messageQueue.find((element) => element.UniqueMessageID === uid); + if (message !== undefined && message.Response !== undefined) { + // avoid double-sends + if (message.Response.ComStatus === AtsuMessageComStatus.Sending || message.Response.ComStatus === AtsuMessageComStatus.Sent) { + return; + } + + if (message.Response.CurrentTransmissionId < 0) { + message.Response.CurrentTransmissionId = ++this.cpdlcMessageId; + } + message.Response.ComStatus = AtsuMessageComStatus.Sending; + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.Sending); + this.dcduLink.update(message); + + this.datalink.sendMessage(message.Response, false).then((code) => { + if (code === AtsuStatusCodes.Ok) { + message.Response.ComStatus = AtsuMessageComStatus.Sent; + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.Sent); + setTimeout(() => { + if (this.dcduLink.currentDcduStatusMessage(message.UniqueMessageID) === DcduStatusMessage.Sent) { + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.NoMessage); + } + }, 5000); + } else { + message.Response.ComStatus = AtsuMessageComStatus.Failed; + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.SendFailed); + } + this.dcduLink.update(message); + }); + + if (this.parent.modificationMessage?.UniqueMessageID === uid) { + this.parent.modificationMessage = null; + } + } + } + + public async sendMessage(message: AtsuMessage): Promise { + if (message.ComStatus === AtsuMessageComStatus.Sending || message.ComStatus === AtsuMessageComStatus.Sent) { + return AtsuStatusCodes.Ok; + } + + if (message.Station === '') { + if (this.currentAtc === '') { + return AtsuStatusCodes.NoAtc; + } + message.Station = this.currentAtc; + } + + message.ComStatus = AtsuMessageComStatus.Sending; + if ((message as CpdlcMessage).DcduRelevantMessage) { + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.Sending); + this.dcduLink.update(message as CpdlcMessage); + } + + if (this.parent.modificationMessage?.UniqueMessageID === message.UniqueMessageID) { + this.parent.modificationMessage = null; + } + + return this.datalink.sendMessage(message, false).then((code) => { + if (code === AtsuStatusCodes.Ok) { + message.ComStatus = AtsuMessageComStatus.Sent; + } else { + message.ComStatus = AtsuMessageComStatus.Failed; + } + + if ((message as CpdlcMessage).DcduRelevantMessage) { + this.dcduLink.update(message as CpdlcMessage); + + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, code === AtsuStatusCodes.Ok ? DcduStatusMessage.Sent : DcduStatusMessage.SendFailed); + if (code === AtsuStatusCodes.Ok) { + setTimeout(() => { + if (this.dcduLink.currentDcduStatusMessage(message.UniqueMessageID) === DcduStatusMessage.Sent) { + this.dcduLink.updateDcduStatusMessage(message.UniqueMessageID, DcduStatusMessage.NoMessage); + } + }, 5000); + } + } + + return code; + }); + } + + public messages(): AtsuMessage[] { + return this.messageQueue; + } + + public monitoredMessages(): AtsuMessage[] { + const retval: AtsuMessage[] = []; + + this.messageMonitoring.monitoredMessageIds().forEach((id) => { + const message = this.messageQueue.find((elem) => elem.UniqueMessageID === id); + if (message) { + retval.push(message); + } + }); + + return retval; + } + + public static isRelevantMessage(message: AtsuMessage): boolean { + return message.Type > AtsuMessageType.AOC && message.Type < AtsuMessageType.ATC; + } + + public removeMessage(uid: number): boolean { + const index = this.messageQueue.findIndex((element) => element.UniqueMessageID === uid); + if (index !== -1) { + this.messageQueue.splice(index, 1); + this.dcduLink.dequeue(uid); + } + return index !== -1; + } + + public cleanupMessages(): void { + this.messageQueue = []; + this.dcduLink.reset(); + this.atisMessages = new Map(); + } + + private analyzeMessage(request: CpdlcMessage, response: CpdlcMessage): boolean { + if (request.Content[0]?.ExpectedResponse === CpdlcMessageExpectedResponseType.NotRequired && response === undefined) { + // received the station message for the DCDU + if (request.Content[0]?.TypeId === 'UM9999') { + request.DcduRelevantMessage = false; + if (this.currentAtc !== '') { + this.dcduLink.setAtcLogonMessage(request.Message); + } + return true; + } + + // received a logoff message + if (request.Content[0]?.TypeId === 'UM9995') { + request.DcduRelevantMessage = false; + this.dcduLink.setAtcLogonMessage(''); + this.currentAtc = ''; + return true; + } + + // received a service terminated message + if (request.Message.includes('TERMINATED')) { + request.DcduRelevantMessage = false; + this.dcduLink.setAtcLogonMessage(''); + this.currentAtc = ''; + return true; + } + + // process the handover message + if (request.Content[0]?.TypeId === 'UM9998') { + const entries = request.Message.split(' '); + if (entries.length >= 2) { + request.DcduRelevantMessage = false; + const station = entries[1].replace(/@/gi, ''); + this.handover(station); + return true; + } + } + } + + // expecting a LOGON or denied message + if (this.nextAtc !== '' && request !== undefined && response !== undefined) { + if (request.Content[0]?.TypeId === 'DM9998') { + // logon accepted by ATC + if (response.Content[0]?.TypeId === 'UM9997') { + response.DcduRelevantMessage = false; + this.dcduLink.setAtcLogonMessage(`CURRENT ATC UNIT @${this.nextAtc}@`); + this.currentFansMode = FutureAirNavigationSystem.currentFansMode(this.nextAtc); + InputValidation.FANS = this.currentFansMode; + this.currentAtc = this.nextAtc; + this.nextAtc = ''; + return true; + } + + // logon rejected + if (response.Content[0]?.TypeId === 'UM9996' || response.Content[0]?.TypeId === 'UM0') { + response.DcduRelevantMessage = false; + this.dcduLink.setAtcLogonMessage(''); + this.currentAtc = ''; + this.nextAtc = ''; + return true; + } + } + } + + // TODO later analyze requests by ATC + return false; + } + + public insertMessages(messages: AtsuMessage[]): void { + messages.forEach((message) => { + const cpdlcMessage = message as CpdlcMessage; + + let concatMessages = true; + if (cpdlcMessage.Direction === AtsuMessageDirection.Uplink && cpdlcMessage.Content !== undefined) { + // filter all standard messages and LOGON-related messages + concatMessages = cpdlcMessage.Content[0]?.TypeId === 'UM0' || cpdlcMessage.Content[0]?.TypeId === 'UM1' || cpdlcMessage.Content[0]?.TypeId === 'UM3' + || cpdlcMessage.Content[0]?.TypeId === 'UM4' || cpdlcMessage.Content[0]?.TypeId === 'UM5' || cpdlcMessage.Content[0]?.TypeId === 'UM9995' + || cpdlcMessage.Content[0]?.TypeId === 'UM9996' || cpdlcMessage.Content[0]?.TypeId === 'UM9997'; + } + + if (cpdlcMessage.Direction === AtsuMessageDirection.Downlink && cpdlcMessage.CurrentTransmissionId === -1) { + cpdlcMessage.CurrentTransmissionId = ++this.cpdlcMessageId; + } + + // initialize the uplink message + if (cpdlcMessage.Direction === AtsuMessageDirection.Uplink) { + UplinkMessageStateMachine.initialize(this.parent, cpdlcMessage); + } + + // search corresponding request, if previous ID is set + if (concatMessages && cpdlcMessage.PreviousTransmissionId !== -1) { + this.messageQueue.forEach((element) => { + // ensure that the sending and receiving stations are the same to avoid CPDLC ID overlaps + if (element.Station === cpdlcMessage.Station) { + while (element !== null) { + if (element.CurrentTransmissionId === cpdlcMessage.PreviousTransmissionId) { + element.Response = cpdlcMessage; + this.analyzeMessage(element, cpdlcMessage); + break; + } + element = element.Response; + } + } + }); + } else { + this.messageQueue.unshift(cpdlcMessage); + this.analyzeMessage(cpdlcMessage, undefined); + } + }); + + if (messages.length !== 0 && (messages[0] as CpdlcMessage).DcduRelevantMessage) { + this.dcduLink.enqueue(messages); + } + } + + public updateMessage(message: CpdlcMessage): void { + const index = this.messageQueue.findIndex((element) => element.UniqueMessageID === message.UniqueMessageID); + if (index !== -1) { + if (this.parent.modificationMessage?.UniqueMessageID === message.UniqueMessageID) { + this.parent.modificationMessage = undefined; + } + + this.messageQueue[index] = message; + this.dcduLink.update(message); + } + } + + public messageRead(uid: number): boolean { + const index = this.messageQueue.findIndex((element) => element.UniqueMessageID === uid); + if (index !== -1 && this.messageQueue[index].Direction === AtsuMessageDirection.Uplink) { + this.messageQueue[index].Confirmed = true; + } + + return index !== -1; + } + + private async updateAtis(icao: string, type: AtisType, overwrite: boolean): Promise { + return this.datalink.receiveAtis(icao, type, () => { }).then((retval) => { + if (retval[0] === AtsuStatusCodes.Ok) { + let code = AtsuStatusCodes.Ok; + const atis = retval[1] as AtisMessage; + atis.Timestamp = new AtsuTimestamp(); + atis.parseInformation(); + let printable = false; + + if (atis.Information === '') { + return AtsuStatusCodes.NoAtisReceived; + } + + if (this.atisMessages.get(icao) !== undefined) { + if (this.atisMessages.get(icao)[1][0].Information !== atis.Information) { + this.atisMessages.get(icao)[1].unshift(atis); + code = AtsuStatusCodes.NewAtisReceived; + printable = true; + } else if (overwrite) { + this.atisMessages.get(icao)[1][0] = atis; + code = AtsuStatusCodes.NewAtisReceived; + } + } else { + this.atisMessages.set(icao, [atis.Timestamp.Seconds, [atis]]); + code = AtsuStatusCodes.NewAtisReceived; + printable = true; + } + + this.atisMessages.get(icao)[0] = atis.Timestamp.Seconds; + + if (this.printAtisReport && printable) { + this.parent.printMessage(atis); + } + + return code; + } + + return retval[0]; + }); + } + + public togglePrintAtisReports() { + this.printAtisReport = !this.printAtisReport; + } + + public printAtisReportsPrint(): boolean { + return this.printAtisReport; + } + + public async receiveAtis(icao: string, type: AtisType): Promise { + return this.updateAtis(icao, type, true); + } + + public atisReports(icao: string): AtisMessage[] { + if (this.atisMessages.has(icao)) { + return this.atisMessages.get(icao)[1]; + } + return []; + } + + public resetAtisAutoUpdate() { + this.atisAutoUpdateIcaos.forEach((elem) => clearInterval(elem[2])); + this.atisAutoUpdateIcaos = []; + } + + public atisAutoUpdateActive(icao: string): boolean { + return this.atisAutoUpdateIcaos.findIndex((elem) => icao === elem[0]) !== -1; + } + + private automaticAtisUpdater(icao: string, type: AtisType) { + if (this.atisMessages.has(icao)) { + this.updateAtis(icao, type, false).then((code) => { + if (code === AtsuStatusCodes.Ok) { + this.atisMessages.get(icao)[0] = new AtsuTimestamp().Seconds; + } else { + this.parent.publishAtsuStatusCode(code); + } + }); + } else { + this.updateAtis(icao, type, false).then((code) => { + if (code !== AtsuStatusCodes.Ok) { + this.parent.publishAtsuStatusCode(code); + } + }); + } + } + + public activateAtisAutoUpdate(icao: string, type: AtisType): void { + if (this.atisAutoUpdateIcaos.find((elem) => elem[0] === icao) === undefined) { + const updater = setInterval(() => this.automaticAtisUpdater(icao, type), 60000); + this.atisAutoUpdateIcaos.push([icao, type, updater]); + } + } + + public deactivateAtisAutoUpdate(icao: string): void { + const idx = this.atisAutoUpdateIcaos.findIndex((elem) => icao === elem[0]); + if (idx >= 0) { + clearInterval(this.atisAutoUpdateIcaos[idx][2]); + this.atisAutoUpdateIcaos.splice(idx, 1); + } + } + + public fansMode(): FansMode { + return this.currentFansMode; + } + + public automaticPositionReportActive(): boolean { + return this.automaticPositionReport; + } + + public toggleAutomaticPositionReportActive(): void { + this.automaticPositionReport = !this.automaticPositionReport; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/ATSU.ts b/fbw-a380x/src/systems/atsu/src/ATSU.ts new file mode 100644 index 00000000000..d3a9aa39fe5 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/ATSU.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { FmgcFlightPhase } from '@shared/flightphase'; +import { CpdlcMessage } from './messages/CpdlcMessage'; +import { Datalink } from './com/Datalink'; +import { AtsuStatusCodes } from './AtsuStatusCodes'; +import { Atc } from './ATC'; +import { Aoc } from './AOC'; +import { AtsuMessage, AtsuMessageSerializationFormat } from './messages/AtsuMessage'; +import { AtsuTimestamp } from './messages/AtsuTimestamp'; +import { FlightStateObserver } from './components/FlightStateObserver'; +import { CpdlcMessagesDownlink } from './messages/CpdlcMessageElements'; +import { coordinateToString, timestampToString } from './Common'; +import { InputValidation } from './InputValidation'; +import { ATS623 } from './components/ATS623'; + +/** + * Defines the ATSU + */ +export class Atsu { + private flightStateObserver: FlightStateObserver = null; + + private datalink = new Datalink(this); + + private fltNo: string = ''; + + private messageCounter = 0; + + private ats623 = new ATS623(this); + + public aoc = new Aoc(this.datalink); + + public atc = new Atc(this, this.datalink); + + public modificationMessage: CpdlcMessage = null; + + private listener = RegisterViewListener('JS_LISTENER_SIMVARS', null, true); + + private mcdu = undefined; + + public static createAutomatedPositionReport(atsu: Atsu): CpdlcMessage { + const message = new CpdlcMessage(); + message.Station = atsu.atc.currentStation(); + message.Content.push(CpdlcMessagesDownlink.DM48[1].deepCopy()); + + let targetAltitude: string = ''; + let passedAltitude: string = ''; + let currentAltitude: string = ''; + if (Simplane.getPressureSelectedMode(Aircraft.A320_NEO) === 'STD') { + if (atsu.flightStateObserver.LastWaypoint) { + passedAltitude = InputValidation.formatScratchpadAltitude(`FL${Math.round(atsu.flightStateObserver.LastWaypoint.altitude / 100)}`); + } else { + passedAltitude = InputValidation.formatScratchpadAltitude(`FL${Math.round(atsu.flightStateObserver.PresentPosition.altitude / 100)}`); + } + currentAltitude = InputValidation.formatScratchpadAltitude(`FL${Math.round(atsu.flightStateObserver.PresentPosition.altitude / 100)}`); + if (atsu.flightStateObserver.FcuSettings.altitude) { + targetAltitude = InputValidation.formatScratchpadAltitude(`FL${Math.round(atsu.flightStateObserver.FcuSettings.altitude / 100)}`); + } else { + targetAltitude = currentAltitude; + } + } else { + if (atsu.flightStateObserver.LastWaypoint) { + passedAltitude = InputValidation.formatScratchpadAltitude(atsu.flightStateObserver.LastWaypoint.altitude.toString()); + } else { + passedAltitude = InputValidation.formatScratchpadAltitude(atsu.flightStateObserver.PresentPosition.altitude.toString()); + } + currentAltitude = InputValidation.formatScratchpadAltitude(atsu.flightStateObserver.PresentPosition.altitude.toString()); + if (atsu.flightStateObserver.FcuSettings.altitude) { + targetAltitude = InputValidation.formatScratchpadAltitude(atsu.flightStateObserver.FcuSettings.altitude.toString()); + } else { + targetAltitude = currentAltitude; + } + } + + let extension = null; + if (atsu.flightStateObserver.LastWaypoint) { + // define the overhead + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `OVHD:${atsu.flightStateObserver.LastWaypoint.ident}`; + message.Content.push(extension); + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `AT ${timestampToString(atsu.flightStateObserver.LastWaypoint.utc)}Z/${passedAltitude}`; + message.Content.push(extension); + } + + // define the present position + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `PPOS:${coordinateToString({ lat: atsu.flightStateObserver.PresentPosition.lat, lon: atsu.flightStateObserver.PresentPosition.lon }, false)}`; + message.Content.push(extension); + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `AT ${timestampToString(SimVar.GetSimVarValue('E:ZULU TIME', 'seconds'))}Z/${currentAltitude}`; + message.Content.push(extension); + + if (atsu.flightStateObserver.ActiveWaypoint) { + // define the active position + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `NEXT:${atsu.flightStateObserver.ActiveWaypoint.ident}`; + message.Content.push(extension); + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `AT ${timestampToString(atsu.flightStateObserver.ActiveWaypoint.utc)}Z`; + message.Content.push(extension); + } + + if (atsu.flightStateObserver.NextWaypoint) { + // define the next position + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `ENSUING:${atsu.flightStateObserver.NextWaypoint.ident}`; + message.Content.push(extension); + } + + if (atsu.destinationWaypoint()) { + // define ETA + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `DEST ETA:${timestampToString(atsu.destinationWaypoint().utc)}Z`; + message.Content.push(extension); + } + + // TODO define deviating + + // define descending/climbing and VS + if (Math.abs(atsu.flightStateObserver.FcuSettings.altitude - atsu.flightStateObserver.PresentPosition.altitude) >= 500) { + if (atsu.flightStateObserver.FcuSettings.altitude > atsu.flightStateObserver.PresentPosition.altitude) { + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `CLIMBING TO ${targetAltitude}`; + message.Content.push(extension); + } else { + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `DESCENDING TO ${targetAltitude}`; + message.Content.push(extension); + } + + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `VS:${InputValidation.formatScratchpadVerticalSpeed(`${atsu.flightStateObserver.PresentPosition.verticalSpeed}FTM`)}`; + message.Content.push(extension); + } + + // define speed + const ias = InputValidation.formatScratchpadSpeed(atsu.flightStateObserver.PresentPosition.indicatedAirspeed.toString()); + const gs = InputValidation.formatScratchpadSpeed(atsu.flightStateObserver.PresentPosition.groundSpeed.toString()); + extension = CpdlcMessagesDownlink.DM67[1].deepCopy(); + extension.Content[0].Value = `SPD: ${ias} GS: ${gs}`; + message.Content.push(extension); + + return message; + } + + private static waypointPassedCallback(atsu: Atsu): void { + if (atsu.atc.automaticPositionReportActive() && atsu.atc.currentStation() !== '' && atsu.flightStateObserver.LastWaypoint + && atsu.flightStateObserver.ActiveWaypoint && atsu.flightStateObserver.NextWaypoint) { + const message = Atsu.createAutomatedPositionReport(atsu); + + // skip the DCDU + message.DcduRelevantMessage = false; + + atsu.sendMessage(message); + } + } + + constructor(mcdu) { + this.flightStateObserver = new FlightStateObserver(mcdu, Atsu.waypointPassedCallback); + this.mcdu = mcdu; + } + + public async connectToNetworks(flightNo: string): Promise { + await this.disconnectFromNetworks(); + + if (flightNo.length === 0) { + return AtsuStatusCodes.Ok; + } + + const code = await Datalink.connect(flightNo); + if (code === AtsuStatusCodes.Ok) { + console.log(`ATSU: Callsign switch from ${this.fltNo} to ${flightNo}`); + this.fltNo = flightNo; + } + + return code; + } + + public flightPhase(): FmgcFlightPhase { + if (this.mcdu !== undefined && this.mcdu.flightPhaseManager) { + return this.mcdu.flightPhaseManager.phase; + } + return FmgcFlightPhase.Preflight; + } + + public async disconnectFromNetworks(): Promise { + await this.atc.disconnect(); + + console.log('ATSU: Reset of callsign'); + this.fltNo = ''; + + return Datalink.disconnect(); + } + + public flightNumber(): string { + return this.fltNo; + } + + public async sendMessage(message: AtsuMessage): Promise { + let retval = AtsuStatusCodes.UnknownMessage; + + if (Aoc.isRelevantMessage(message)) { + retval = await this.aoc.sendMessage(message); + if (retval === AtsuStatusCodes.Ok) { + this.registerMessages([message]); + } + } else if (Atc.isRelevantMessage(message)) { + retval = await this.atc.sendMessage(message); + if (retval === AtsuStatusCodes.Ok) { + this.registerMessages([message]); + } + } + + return retval; + } + + public removeMessage(uid: number): void { + if (this.atc.removeMessage(uid) === true) { + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG_DELETE_UID', uid); + } else { + this.aoc.removeMessage(uid); + } + } + + public registerMessages(messages: AtsuMessage[]): void { + if (messages.length === 0) return; + + messages.forEach((message) => { + message.UniqueMessageID = ++this.messageCounter; + message.Timestamp = new AtsuTimestamp(); + }); + + if (this.ats623.isRelevantMessage(messages[0])) { + this.ats623.insertMessages(messages); + } else if (Aoc.isRelevantMessage(messages[0])) { + this.aoc.insertMessages(messages); + } else if (Atc.isRelevantMessage(messages[0])) { + this.atc.insertMessages(messages); + } + } + + public messageRead(uid: number): void { + this.aoc.messageRead(uid); + this.atc.messageRead(uid); + } + + public publishAtsuStatusCode(code: AtsuStatusCodes): void { + this.mcdu.addNewAtsuMessage(code); + } + + public modifyDcduMessage(message: CpdlcMessage): void { + this.modificationMessage = message; + this.mcdu.tryToShowAtcModifyPage(); + } + + public async isRemoteStationAvailable(callsign: string): Promise { + return this.datalink.isStationAvailable(callsign); + } + + public findMessage(uid: number): AtsuMessage { + let message = this.aoc.messages().find((element) => element.UniqueMessageID === uid); + if (message !== undefined) { + return message; + } + + message = this.atc.messages().find((element) => element.UniqueMessageID === uid); + if (message !== undefined) { + return message; + } + + return undefined; + } + + public printMessage(message: AtsuMessage): void { + const text = message.serialize(AtsuMessageSerializationFormat.Printer); + this.mcdu.printPage(text.split('\n')); + } + + public lastWaypoint() { + return this.flightStateObserver.LastWaypoint; + } + + public activeWaypoint() { + return this.flightStateObserver.ActiveWaypoint; + } + + public nextWaypoint() { + return this.flightStateObserver.NextWaypoint; + } + + public destinationWaypoint() { + return this.flightStateObserver.Destination; + } + + public currentFlightState() { + return this.flightStateObserver.PresentPosition; + } + + public targetFlightState() { + return this.flightStateObserver.FcuSettings; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/AtsuStatusCodes.ts b/fbw-a380x/src/systems/atsu/src/AtsuStatusCodes.ts new file mode 100644 index 00000000000..b507bfa6b94 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/AtsuStatusCodes.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +export enum AtsuStatusCodes { + Ok, + CallsignInUse, + OwnCallsign, + NoHoppieConnection, + NoTelexConnection, + TelexDisabled, + ComFailed, + NoAtc, + DcduFull, + UnknownMessage, + ProxyError, + NewAtisReceived, + NoAtisReceived, + SystemBusy, + EntryOutOfRange, + FormatError, + NotInDatabase +} diff --git a/fbw-a380x/src/systems/atsu/src/Common.ts b/fbw-a380x/src/systems/atsu/src/Common.ts new file mode 100644 index 00000000000..598e2b5cf5b --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/Common.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +export function wordWrap(text: string, maxLength: number) { + const result = []; + let line = []; + let length = 0; + + const words = text.match(/[-@_A-Z0-9]+|\[\s+\]/g); + for (const word of words) { + if ((length + word.length) >= maxLength) { + result.push(line.join(' ').toUpperCase()); + line = []; length = 0; + } + length += word.length + 1; + line.push(word); + } + + if (line.length > 0) { + result.push(line.join(' ').toUpperCase()); + } + + return result; +} + +export function timestampToString(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor(seconds / 60) % 60; + return `${hours.toString().padStart(2, '0')}${minutes.toString().padStart(2, '0')}`; +} + +function decimalToDms(deg: number, lng: boolean) { + // converts decimal degrees to degrees minutes seconds + const M = 0 | (deg % 1) * 60e7; + let degree; + if (lng) { + degree = (0 | (deg < 0 ? -deg : deg)).toString().padStart(3, '0'); + } else { + degree = 0 | (deg < 0 ? -deg : deg); + } + + let dir = ''; + if (deg < 0) { + dir = lng ? 'W' : 'S'; + } else { + dir = lng ? 'E' : 'N'; + } + + return { + dir, + deg: degree, + min: Math.abs(0 | M / 1e7), + sec: Math.abs((0 | M / 1e6 % 1 * 6e4) / 100), + }; +} + +export function coordinateToString(coordinate: { lat: number, lon: number }, shortVersion: boolean): string { + const dmsLat = decimalToDms(coordinate.lat, false); + const dmsLon = decimalToDms(coordinate.lon, true); + + dmsLon.deg = Number(dmsLon.deg); + dmsLat.sec = Math.ceil(Number(dmsLat.sec / 100)); + dmsLon.sec = Math.ceil(Number(dmsLon.sec / 100)); + + if (shortVersion) { + if (dmsLat.dir === 'N') { + if (dmsLon.dir === 'E') { + return `${dmsLat.deg}N${dmsLon.deg}`; + } + return `${dmsLat.deg}${dmsLon.deg}N`; + } if (dmsLon.dir === 'E') { + return `${dmsLat.deg}${dmsLon.deg}S`; + } + return `${dmsLat.deg}W${dmsLon.deg}`; + } + + const lat = `${dmsLat.deg}°${dmsLat.min}.${dmsLat.sec}${dmsLat.dir}`; + const lon = `${dmsLon.deg}°${dmsLon.min}.${dmsLon.sec}${dmsLon.dir}`; + return `${lat}/${lon}`; +} diff --git a/fbw-a380x/src/systems/atsu/src/InputValidation.ts b/fbw-a380x/src/systems/atsu/src/InputValidation.ts new file mode 100644 index 00000000000..d2a3b78817a --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/InputValidation.ts @@ -0,0 +1,650 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuStatusCodes } from './AtsuStatusCodes'; +import { FansMode } from './com/FutureAirNavigationSystem'; +import { InputValidationFansA } from './components/InputValidationFansA'; +import { InputValidationFansB } from './components/InputValidationFansB'; + +export enum InputWaypointType { + Invalid, + GeoCoordinate, + Timepoint, + Place +} + +export class InputValidation { + public static FANS: FansMode = FansMode.FansNone; + + /** + * Checks if the value fits to a waypoint format + * @param value The entered waypoint candidate + * @returns AtsuStatusCodes.Ok if the format is valid + */ + public static validateScratchpadWaypoint(value: string): AtsuStatusCodes { + if (value.match(/^(N|S)?([0-9]{2,4}\.[0-9])(N|S)?\/(E|W)?([0-9]{2,5}\.[0-9])(E|W)?$/) !== null) { + return AtsuStatusCodes.Ok; + } + if (/^[A-Z0-9]{1,5}$/.test(value)) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.FormatError; + } + + /** + * Checks if the value fits to a position format + * @param value The entered position candidate + * @returns AtsuStatusCodes.Ok if the format is valid + */ + public static validateScratchpadPosition(value: string): AtsuStatusCodes { + if (/^[A-Z0-9]{1,10}$/.test(value)) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.FormatError; + } + + /** + * Checks if the value fits to a procedure format + * @param value The entered procedure candidate + * @returns AtsuStatusCodes.Ok if the format is valid + */ + public static validateScratchpadProcedure(value: string): AtsuStatusCodes { + if (/^[A-Z0-9]{1,7}$/.test(value)) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.FormatError; + } + + /** + * Checks if the value fits to the time format + * @param value The entered time candidate + * @returns AtsuStatusCodes.Ok if the format is valid + */ + public static validateScratchpadTime(value: string, expectZulu: boolean = true): AtsuStatusCodes { + if ((expectZulu && /^[0-9]{4}Z$/.test(value)) || (!expectZulu && /^[0-9]{4}$/.test(value))) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.FormatError; + } + + /** + * Checks if the value fits to the ATIS format + * @param value The entered ATIS candidate + * @returns AtsuStatusCodes.Ok if the format is valid + */ + public static validateScratchpadAtis(value: string): AtsuStatusCodes { + if (/^[A-Z]{1}$/.test(value)) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.FormatError; + } + + /** + * Checks if the value fits to the degree format + * @param value The entered degree candidate + * @returns AtsuStatusCodes.Ok if the format is valid + */ + public static validateScratchpadDegree(value: string): AtsuStatusCodes { + if (/^[0-9]{1,3}$/.test(value)) { + const heading = parseInt(value); + if (heading >= 0 && heading <= 360) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + return AtsuStatusCodes.FormatError; + } + + /** + * Checks if the value fits to the squawk format + * @param value The entered squawk candidate + * @returns AtsuStatusCodes.Ok if the format is valid + */ + public static validateScratchpadSquawk(value: string): AtsuStatusCodes { + if (/^[0-9]{4}$/.test(value)) { + const squawk = parseInt(value); + if (squawk >= 0 && squawk < 7777) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.FormatError; + } + + /** + * Classifies a possible waypoint type of the scratchpad + * Types: + * - 0 = lat-lon coordinate + * - 1 = time + * - 2 = place + * - -1 = unknonw + * @param {FMCMainDisplay} mcdu The current MCDU instance + * @param {string} waypoint The entered waypoint + * @param {boolean} allowTime Indicates if time entries are allowed + * @returns A tuple with the type and null or a NXSystemMessage-entry in case of a failure + */ + public static async classifyScratchpadWaypointType(mcdu: any, waypoint: string, allowTime: boolean): Promise<[InputWaypointType, AtsuStatusCodes]> { + if (mcdu.isLatLonFormat(waypoint)) { + return [InputWaypointType.GeoCoordinate, AtsuStatusCodes.Ok]; + } + + // time formatted + if (allowTime && /^([0-2][0-4][0-5][0-9]Z?)$/.test(waypoint)) { + return [InputWaypointType.Timepoint, AtsuStatusCodes.Ok]; + } + + // place formatted + if (/^[A-Z0-9]{2,7}/.test(waypoint)) { + return mcdu.dataManager.GetWaypointsByIdent.bind(mcdu.dataManager)(waypoint).then((waypoints) => { + if (waypoints.length !== 0) { + return [InputWaypointType.Place, AtsuStatusCodes.Ok]; + } + return [InputWaypointType.Invalid, AtsuStatusCodes.NotInDatabase]; + }); + } + + return [InputWaypointType.Invalid, AtsuStatusCodes.FormatError]; + } + + /** + * Validate a given VHF frequency that it fits to the 8.33 kHz-spacing + * @param {string} value Frequency candidate + * @returns null or a NXSystemMessages-entry in case of a failure + */ + public static validateVhfFrequency(value: string): AtsuStatusCodes { + // valid frequency range: 118.000 - 136.975 + if (!/^1[1-3][0-9].[0-9]{2}[0|5]$/.test(value)) { + return AtsuStatusCodes.FormatError; + } + + const elements = value.split('.'); + const before = parseInt(elements[0]); + if (before < 118 || before > 136) { + return AtsuStatusCodes.EntryOutOfRange; + } + + // TODO replace by REGEX + // valid 8.33 kHz spacings + const frequencySpacingOther = ['00', '05', '10', '15', '25', '30', '35', '40', '50', '55', '60', '65', '75', '80', '85', '90']; + const frequencySpacingEnd = ['00', '05', '10', '15', '25', '30', '35', '40', '50', '55', '60', '65', '75']; + + // validate the correct frequency fraction + const twoDigitFraction = elements[1].substring(1, elements[1].length); + if (before === 136) { + if (frequencySpacingEnd.findIndex((entry) => entry === twoDigitFraction) === -1) { + return AtsuStatusCodes.EntryOutOfRange; + } + } else if (frequencySpacingOther.findIndex((entry) => entry === twoDigitFraction) === -1) { + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.Ok; + } + + /** + * Validates a value that it is compatible with the FCOM format for altitudes and flight levels + * @param {string} value The entered scratchpad altitude + * @returns An AtsuStatusCodes-value + */ + public static validateScratchpadAltitude(value: string): AtsuStatusCodes { + if (/^((FL)*[0-9]{1,3})$/.test(value)) { + const flightlevel = parseInt(value.match(/([0-9]+)/)[0]); + if (flightlevel >= 30 && flightlevel <= 410) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + + if (InputValidation.FANS === FansMode.FansB) { + return InputValidationFansB.validateScratchpadAltitude(value); + } + return InputValidationFansA.validateScratchpadAltitude(value); + } + + /** + * Checks if a string fits to the distance definition + * @param distance The distance candidate + * @returns AtsuStatusCodes.Ok if the format is valid + */ + public static validateScratchpadDistance(distance: string): AtsuStatusCodes { + if (/^[0-9]{1,3}(NM|KM)$/.test(distance) || /^[0-9]{1,3}$/.test(distance)) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.FormatError; + } + + /** + * Validates a value that it is compatible with the FCOM format for lateral offsets + * @param {string} value The entered scratchpad offset + * @returns An AtsuStatusCodes-value + */ + public static validateScratchpadOffset(offset: string): AtsuStatusCodes { + let nmUnit = true; + let distance = 0; + + if (/^[LR][0-9]{1,3}(NM|KM)$/.test(offset) || /^[LR][0-9]{1,3}$/.test(offset)) { + // format: DNNNKM, DNNNNM, DNNN + distance = parseInt(offset.match(/([0-9]+)/)[0]); + nmUnit = !offset.endsWith('KM'); + } else if (/^[0-9]{1,3}(NM|KM)[LR]$/.test(offset) || /^[0-9]{1,3}[LR]$/.test(offset)) { + // format: NNNKMD, NNNNMD, NNND + distance = parseInt(offset.match(/([0-9]+)/)[0]); + nmUnit = !(offset.endsWith('KML') || offset.endsWith('KMR')); + } else { + return AtsuStatusCodes.FormatError; + } + + // validate the ranges + if (nmUnit) { + if (distance >= 1 && distance <= 128) { + return AtsuStatusCodes.Ok; + } + } else if (distance >= 1 && distance <= 256) { + return AtsuStatusCodes.Ok; + } + + return AtsuStatusCodes.EntryOutOfRange; + } + + /** + * Validates a value that it is compatible with the FCOM format for speeds + * @param {string} value The entered scratchpad speed + * @returns An AtsuStatusCodes-value + */ + public static validateScratchpadSpeed(value: string): AtsuStatusCodes { + if (InputValidation.FANS === FansMode.FansB) { + return InputValidationFansB.validateScratchpadSpeed(value); + } + return InputValidationFansA.validateScratchpadSpeed(value); + } + + /** + * Validates a value that it is compatible with the FCOM format for vertical speeds + * @param {string} value The entered scratchpad vertical speed + * @returns An AtsuStatusCodes-value + */ + public static validateScratchpadVerticalSpeed(value: string): AtsuStatusCodes { + if (/^(\+|-|M)?[0-9]{1,4}(FT\/MIN|FT|FTM|M\/MIN|MM|M){1}$/.test(value)) { + let verticalSpeed = parseInt(value.match(/([0-9]+)/)[0]); + if (value.startsWith('-') || value.startsWith('M')) { + verticalSpeed *= -1; + } + + if (!/(FT){1}/.test(value)) { + if (verticalSpeed >= -2000 && verticalSpeed <= 2000) { + return AtsuStatusCodes.Ok; + } + } else if (verticalSpeed >= -6000 && verticalSpeed <= 6000) { + return AtsuStatusCodes.Ok; + } + + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.FormatError; + } + + /** + * Validates that two speed entries describe the same (knots or mach) + * @param {string} lower Lower speed value + * @param {string} higher Higher speed value + * @returns True if both are same type else false + */ + private static sameSpeedType(lower: string, higher: string): boolean { + if (lower[0] === 'M' && higher[0] === 'M') { + return true; + } + if (lower[0] === 'M' || higher[0] === 'M') { + return false; + } + return true; + } + + /** + * Validates that a scratchpad entry follows the FCOM definition for speed ranges + * @param {string} Given speed range candidate + * @returns An array of AtsuStatusCodes-value and the speed ranges + */ + public static validateScratchpadSpeedRanges(value: string): [AtsuStatusCodes, string[]] { + const entries = value.split('/'); + if (entries.length !== 2) { + return [AtsuStatusCodes.FormatError, []]; + } + if (InputValidation.validateScratchpadSpeed(entries[0]) || InputValidation.validateScratchpadSpeed(entries[1])) { + let error = InputValidation.validateScratchpadSpeed(entries[0]); + if (error) { + return [error, []]; + } + error = this.validateScratchpadSpeed(entries[1]); + return [error, []]; + } + + const lower = InputValidation.formatScratchpadSpeed(entries[0]); + const higher = InputValidation.formatScratchpadSpeed(entries[1]); + + if (!InputValidation.sameSpeedType(lower, higher)) { + return [AtsuStatusCodes.FormatError, []]; + } + if (parseInt(lower.match(/([0-9]+)/)[0]) >= parseInt(higher.match(/([0-9]+)/)[0])) { + return [AtsuStatusCodes.EntryOutOfRange, []]; + } + return [AtsuStatusCodes.Ok, [lower, higher]]; + } + + /** + * Formats a scratchpad to a standard altitude string + * @param {string} value The entered valid altitude + * @returns Formatted string or empty string in case of a failure + */ + public static formatScratchpadAltitude(value: string): string { + if (value.startsWith('FL') || value.endsWith('M') || value.endsWith('FT')) { + return value; + } + + const altitude = parseInt(value); + if (altitude >= 30 && altitude <= 410) { + return `FL${value}`; + } + + return `${value}FT`; + } + + /** + * Formats a scratchpad entry to the standard speed description + * @param {string} value Valid speed entry + * @returns The formatted speed string + */ + public static formatScratchpadSpeed(value: string): string { + if (value[0] === 'M' || value[0] === '.') { + return `M.${value.match(/([0-9]+)/)[0]}`; + } + return value.replace('KT', ''); + } + + /** + * Validates a value that it is compatible with the FCOM format for vertical speeds + * @param {string} value The entered scratchpad vertical speed + * @returns An AtsuStatusCodes-value + */ + public static formatScratchpadVerticalSpeed(value: string): string { + let verticalSpeed = parseInt(value.match(/([0-9]+)/)[0]); + if (value.startsWith('-') || value.startsWith('M')) { + verticalSpeed *= -1; + } + + if (!/(FT){1}/.test(value)) { + return `${verticalSpeed}MM`; + } + + return `${verticalSpeed}FTM`; + } + + /** + * Validates that two altitude entries describe the same (FL, feet or meters) + * @param {string} lower Lower altitude value + * @param {string} higher Higher altitude value + * @returns True if both are same type else false + */ + private static sameAltitudeType(lower: string, higher: string): boolean { + if (lower.startsWith('FL') && higher.startsWith('FL')) { + return true; + } + if (lower.startsWith('FL') || higher.startsWith('FL')) { + return false; + } + if ((lower[lower.length - 1] === 'M' && higher[higher.length - 1] === 'M') || (lower[lower.length - 1] !== 'M' && higher[higher.length - 1] !== 'M')) { + return true; + } + return false; + } + + /** + * Converts a given altitude into foot + * @param value The altitude that needs to be converted + * @returns The altitude in feet + */ + private static convertToFeet(value: string): number { + const height = parseInt(value.match(/([0-9]+)/)[0]); + + if (value.startsWith('FL')) { + return height * 100; + } + if (value[value.length - 1] === 'M') { + return height * 3.28; + } + if (value.endsWith('FT')) { + return height; + } + + if (height < 1000) return height * 100; + + return height; + } + + /** + * Validates that lower is smaller than higher + * @param {string} lower Lower altitude value + * @param {string} higher Higher altitude value + * @returns True if lower is smaller than higher, else false + */ + public static validateAltitudeRange(lower: string, higher: string): AtsuStatusCodes { + if (!InputValidation.sameAltitudeType(lower, higher)) return AtsuStatusCodes.FormatError; + + const errorLower = InputValidation.validateScratchpadAltitude(lower); + if (errorLower !== AtsuStatusCodes.Ok) return errorLower; + const errorHigher = InputValidation.validateScratchpadAltitude(higher); + if (errorHigher !== AtsuStatusCodes.Ok) return errorHigher; + + const lowerFt = InputValidation.convertToFeet(lower); + const higherFt = InputValidation.convertToFeet(higher); + + if (lowerFt >= higherFt) return AtsuStatusCodes.EntryOutOfRange; + + return AtsuStatusCodes.Ok; + } + + /** + * Validates the persons on board + * @param {string} value The persons on board + * @returns AtsuStatusCodes.Ok if the value is valid + */ + public static validateScratchpadPersonsOnBoard(value: string): AtsuStatusCodes { + if (/^[0-9]{1,4}$/.test(value)) { + const pob = parseInt(value.match(/([0-9]+)/)[0]); + if (pob >= 1 && pob <= 1024) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.FormatError; + } + + /** + * Validates the endurance + * @param {string} value The entered endurance + * @returns AtsuStatusCodes.Ok if the value is valid + */ + public static validateScratchpadEndurance(value: string): AtsuStatusCodes { + if (/^([0-9]{1}H|[0-9]{2}(H)*)[0-9]{2}(M|MIN|MN)*$/.test(value)) { + const matches = value.match(/[0-9]{1,2}/g); + + const hours = parseInt(matches[0]); + if (hours < 0 || hours >= 24) { + return AtsuStatusCodes.EntryOutOfRange; + } + + const minutes = parseInt(matches[1]); + if (minutes < 0 || minutes >= 60) { + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.Ok; + } + + return AtsuStatusCodes.FormatError; + } + + /** + * Validates the temparture + * @param {string} value The entered temperature + * @returns AtsuStatusCodes.Ok if the value is valid + */ + public static validateScratchpadTemperature(value: string): AtsuStatusCodes { + if (/^[-+M]?[0-9]{1,3}[CF]?$/.test(value)) { + const negative = value.startsWith('-') || value.startsWith('M'); + const fahrenheit = value.endsWith('F'); + + let temperature = parseInt(value.match(/([0-9]+)/)[0]); + if (negative) { + temperature *= -1; + } + + if (fahrenheit && temperature >= -105 && temperature <= 150) { + return AtsuStatusCodes.Ok; + } + if (!fahrenheit && (temperature >= 80 || temperature < 47)) { + return AtsuStatusCodes.Ok; + } + + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.FormatError; + } + + /** + * Validates the wind data + * @param {string} value The entered wind data + * @returns AtsuStatusCodes.Ok if the value is valid + */ + public static validateScratchpadWind(value: string): AtsuStatusCodes { + if (/^[0-9]{1,3}\/[0-9]{1,3}(KT|KM)?$/.test(value)) { + const numbers = value.match(/([0-9]+)/g); + const direction = parseInt(numbers[0]); + const speed = parseInt(numbers[1]); + + if (direction < 1 || direction > 360 || speed < 0 || speed > 255) { + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.Ok; + } + + return AtsuStatusCodes.FormatError; + } + + /** + * Converts an FCOM valid encoded offset string to a list of offset entries + * @param {string} offset Valid encoded offset + * @returns The decoded offset entries + */ + private static decodeOffsetString(offset: string): string[] | null { + let nmUnit = true; + let left = false; + let distance; + + if (/^[LR][0-9]{1,3}(NM|KM)$/.test(offset) || /^[LR][0-9]{1,3}$/.test(offset)) { + // format: DNNNKM, DNNNNM, DNNN + + // contains not only numbers + distance = offset.replace(/NM|KM/, '').replace(/L|R/, ''); + if (/(?!^\d+$)^.+$/.test(distance)) { + return []; + } + + distance = parseInt(distance); + nmUnit = !offset.endsWith('KM'); + left = offset[0] === 'L'; + } else if (/[0-9]{1,3}(NM|KM)[LR]/.test(offset) || /[0-9]{1,3}[LR]/.test(offset)) { + // format: NNNKMD, NNNNMD, NNND + + // contains not only numbers + distance = offset.replace(/NM|KM/, '').replace(/L|R/, ''); + if (/(?!^\d+$)^.+$/.test(distance)) { + return null; + } + + distance = parseInt(distance); + nmUnit = !(offset.endsWith('KML') || offset.endsWith('KMR')); + left = offset[offset.length - 1] === 'L'; + } + + return [distance.toString(), nmUnit ? 'NM' : 'KM', left ? 'L' : 'R']; + } + + /** + * Formats a valid scratchpad offset to a normalized temperature entry + * @param {string} value The entered temperature + * @returns The formatted temperature + */ + public static formatScratchpadTemperature(value: string): string { + const negative = value.startsWith('-') || value.startsWith('M'); + const fahrenheit = value.endsWith('F'); + + let temperature = parseInt(value.match(/([0-9]+)/)[0]); + if (negative) { + temperature *= -1; + } + + return `${temperature}${fahrenheit ? 'F' : 'C'}`; + } + + /** + * Normalizes the wind data + * @param {string} value The entered wind data + * @returns The normalized wind data + */ + public static formatScratchpadWind(value: string): string { + const numbers = value.match(/([0-9]+)/g); + const direction = parseInt(numbers[0]); + const speed = parseInt(numbers[1]); + const kilometers = value.endsWith('M'); + return `${direction.toString().padStart(3, '0')}/${speed.toString().padStart(3, '0')}${kilometers ? 'KM' : 'KT'}`; + } + + /** + * Formats a valid scratchpad offset to a normalized offset entry + * @param {string} value The scratchpad entry + * @returns The normalized offset entry + */ + public static formatScratchpadOffset(value: string): string { + const entries = InputValidation.decodeOffsetString(value); + return `${entries[0]}${entries[1]}${entries[2]}`; + } + + /** + * Formats a valid scratchpad endurance entry to a normalized offset entry + * @param {string} value The scratchpad entry + * @returns The normalized offset entry + */ + public static formatScratchpadEndurance(value: string): string { + const matches = value.match(/[0-9]{1,2}/g); + const hours = parseInt(matches[0]); + const minutes = parseInt(matches[1]); + return `${hours}H${minutes}`; + } + + /** + * Expands a lateral offset encoded string into an expanded version + * @param {string} offset The valid offset value + * @returns The expanded lateral offset + */ + public static expandLateralOffset(offset: string): string { + const entries = InputValidation.decodeOffsetString(offset); + return `${entries[0]}${entries[1]} ${entries[2] === 'L' ? 'LEFT' : 'RIGHT'}`; + } + + /** + * Formats a valid scratchpad distance entry to a normalized distance entry + * @param {string} value The scratchpad entry + * @returns The normalized distance entry + */ + public static formatScratchpadDistance(distance: string): string { + if (distance.endsWith('NM') || distance.endsWith('KM')) { + return distance; + } + return `${distance}NM`; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/com/Datalink.ts b/fbw-a380x/src/systems/atsu/src/com/Datalink.ts new file mode 100644 index 00000000000..39f2ee2fdb5 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/com/Datalink.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuStatusCodes } from '../AtsuStatusCodes'; +import { Atsu } from '../ATSU'; +import { CpdlcMessage } from '../messages/CpdlcMessage'; +import { AtsuMessage, AtsuMessageNetwork, AtsuMessageType } from '../messages/AtsuMessage'; +import { AtisMessage, AtisType } from '../messages/AtisMessage'; +import { MetarMessage } from '../messages/MetarMessage'; +import { TafMessage } from '../messages/TafMessage'; +import { Vdl } from './vhf/VDL'; +import { WeatherMessage } from '../messages/WeatherMessage'; +import { HoppieConnector } from './webinterfaces/HoppieConnector'; +import { NXApiConnector } from './webinterfaces/NXApiConnector'; + +export class Datalink { + private vdl: Vdl = new Vdl(); + + private waitedComUpdate = 0; + + private waitedTimeHoppie = 0; + + private waitedTimeNXApi = 0; + + private firstPollHoppie = true; + + private enqueueReceivedMessages(parent: Atsu, messages: AtsuMessage[]): void { + messages.forEach((message) => { + // ignore empty messages (happens sometimes in CPDLC with buggy ATC software) + if (message.Message.length !== 0) { + const transmissionTime = this.vdl.enqueueInboundMessage(message); + setTimeout(() => { + this.vdl.dequeueInboundMessage(transmissionTime); + parent.registerMessages([message]); + }, transmissionTime); + } + }); + } + + constructor(parent: Atsu) { + HoppieConnector.activateHoppie(); + + setInterval(() => { + if (this.waitedComUpdate <= 30000) { + this.vdl.simulateTransmissionTimes(parent.flightPhase()); + this.waitedComUpdate = 0; + } else { + this.waitedComUpdate += 5000; + } + + if (HoppieConnector.pollInterval() <= this.waitedTimeHoppie) { + HoppieConnector.poll().then((retval) => { + if (retval[0] === AtsuStatusCodes.Ok) { + // delete all data in the first call (Hoppie stores old data) + if (!this.firstPollHoppie) { + this.enqueueReceivedMessages(parent, retval[1]); + } + this.firstPollHoppie = false; + } + }); + this.waitedTimeHoppie = 0; + } else { + this.waitedTimeHoppie += 5000; + } + + if (NXApiConnector.pollInterval() <= this.waitedTimeNXApi) { + NXApiConnector.poll().then((retval) => { + if (retval[0] === AtsuStatusCodes.Ok) { + this.enqueueReceivedMessages(parent, retval[1]); + } + }); + this.waitedTimeNXApi = 0; + } else { + this.waitedTimeNXApi += 5000; + } + }, 5000); + } + + public static async connect(flightNo: string): Promise { + return NXApiConnector.connect(flightNo).then((code) => { + if (code === AtsuStatusCodes.TelexDisabled) code = AtsuStatusCodes.Ok; + + if (code === AtsuStatusCodes.Ok) { + return HoppieConnector.connect(flightNo).then((code) => { + if (code === AtsuStatusCodes.NoHoppieConnection) code = AtsuStatusCodes.Ok; + return code; + }); + } + + return code; + }); + } + + public static async disconnect(): Promise { + let retvalNXApi = await NXApiConnector.disconnect(); + if (retvalNXApi === AtsuStatusCodes.TelexDisabled) retvalNXApi = AtsuStatusCodes.Ok; + + let retvalHoppie = HoppieConnector.disconnect(); + if (retvalHoppie === AtsuStatusCodes.NoHoppieConnection) retvalHoppie = AtsuStatusCodes.Ok; + + if (retvalNXApi !== AtsuStatusCodes.Ok) return retvalNXApi; + return retvalHoppie; + } + + private async receiveWeatherData(requestMetar: boolean, icaos: string[], index: number, message: WeatherMessage): Promise { + let retval = AtsuStatusCodes.Ok; + + if (index < icaos.length) { + if (requestMetar === true) { + retval = await NXApiConnector.receiveMetar(icaos[index], message).then(() => this.receiveWeatherData(requestMetar, icaos, index + 1, message)); + } else { + retval = await NXApiConnector.receiveTaf(icaos[index], message).then(() => this.receiveWeatherData(requestMetar, icaos, index + 1, message)); + } + } + + return retval; + } + + private async simulateWeatherRequestResponse(data: [AtsuStatusCodes, WeatherMessage], sentCallback: () => void): Promise<[AtsuStatusCodes, WeatherMessage]> { + return new Promise((resolve, _reject) => { + // simulate the request transmission + const requestTimeout = this.vdl.enqueueOutboundPacket(); + setTimeout(() => { + this.vdl.dequeueOutboundMessage(requestTimeout); + sentCallback(); + + const processingTimeout = 300 + Math.floor(Math.random() * 500); + + // simulate some remote processing time + setTimeout(() => { + // simulate the response transmission + const responseTimeout = this.vdl.enqueueInboundMessage(data[1]); + setTimeout(() => { + this.vdl.dequeueInboundMessage(responseTimeout); + resolve(data); + }, responseTimeout); + }, processingTimeout); + }, requestTimeout); + }); + } + + public async receiveWeather(requestMetar: boolean, icaos: string[], sentCallback: () => void): Promise<[AtsuStatusCodes, WeatherMessage]> { + let message = undefined; + if (requestMetar === true) { + message = new MetarMessage(); + } else { + message = new TafMessage(); + } + + return this.receiveWeatherData(requestMetar, icaos, 0, message).then((code) => this.simulateWeatherRequestResponse([code, message], sentCallback)); + } + + public async isStationAvailable(callsign: string): Promise { + return HoppieConnector.isStationAvailable(callsign); + } + + public async receiveAtis(icao: string, type: AtisType, sentCallback: () => void): Promise<[AtsuStatusCodes, WeatherMessage]> { + const message = new AtisMessage(); + return NXApiConnector.receiveAtis(icao, type, message).then(() => this.simulateWeatherRequestResponse([AtsuStatusCodes.Ok, message], sentCallback)); + } + + public async sendMessage(message: AtsuMessage, force: boolean): Promise { + return new Promise((resolve, _reject) => { + const timeout = this.vdl.enqueueOutboundMessage(message); + setTimeout(() => { + this.vdl.dequeueOutboundMessage(timeout); + + if (message.Type < AtsuMessageType.AOC) { + if (message.Network === AtsuMessageNetwork.FBW) { + NXApiConnector.sendTelexMessage(message).then((code) => resolve(code)); + } else { + HoppieConnector.sendTelexMessage(message, force).then((code) => resolve(code)); + } + } else if (message.Type === AtsuMessageType.DCL) { + HoppieConnector.sendTelexMessage(message, force).then((code) => resolve(code)); + } else if (message.Type < AtsuMessageType.ATC) { + HoppieConnector.sendCpdlcMessage(message as CpdlcMessage, force).then((code) => resolve(code)); + } else { + resolve(AtsuStatusCodes.UnknownMessage); + } + }, timeout); + }); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/com/FutureAirNavigationSystem.ts b/fbw-a380x/src/systems/atsu/src/com/FutureAirNavigationSystem.ts new file mode 100644 index 00000000000..21ff0709c85 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/com/FutureAirNavigationSystem.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +export enum FansMode { + FansNone, + FansA, + FansB +} + +// Sources for FANS-B areas: +// https://www.icao.int/WACAF/Documents/Meetings/2016/Lisbon-2016/SAT-FI11/SAT-FIT-11_IP%2004%20-attachment_Boeing.pdf +// Station logons are taken from VATSIM vACCs and sector file data of controllers (IVAO and VATSIM use the same callsigns) +export class FutureAirNavigationSystem { + // contains all CPDLC callsigns that use FANS-B + // FANS-A is assumed to be the fallback + private static areasFansB = [ + // Eurocontrol + 'EURW', 'EURM', 'EURS', 'EURE', 'EURN', + // Austria + 'LOVV', 'LOVB', 'LOVN', 'LOVF', 'LOVE', 'LOVS', 'LOVW', 'LOVL', 'LOVU', 'LOVC', 'LOVR', + // Benelux + 'EHAW', 'EHAE', 'EHAS', 'EBBU', 'EBBW', 'EBBE', + // Germany + 'EDMM', 'EDMR', 'EDMZ', 'EDMU', 'EDMG', 'EDMS', 'EDML', 'EDMB', 'EDGC', 'EDGE', 'EDGK', 'EDGG', 'EDGP', 'EDGR', 'EDGT', 'EDGZ', + 'EDWW', 'EDWA', 'EDWB', 'EDWM', 'EDWE', 'EDWD', 'EDUH', 'EDUP', 'EDUO', 'EDUA', 'EDUD', 'EDUL', 'EDUR', 'EDUF', 'EDUN', 'EDUS', + 'EDUT', 'EDUU', 'EDUW', 'EDYC', 'EDYH', 'EDYJ', 'EDYS', 'EDYM', 'EDYR', + // Estonia + 'EETT', 'EEEE', 'EENN', 'EESS', + // Latvia + 'BALT', 'EVRR', 'EVRW', 'EVRE', 'EVRN', 'EVRS', + // Lithuania + 'EYVL', 'EYVU', + // Greece + 'LGGG', 'LGGU', 'LGGE', 'LGGW', 'LGGS', 'LGGP', 'LGGK', 'LGGH', + // Croatia, Slovenia, Zagreb, Sarajevo, Belgrade, Skopje, Tirana, Kosovo (currently no CPDLC) + // Romania + 'LRBL', 'LRBA', 'LRBC', + // Bulgary (currently no CPDLC) + // Cyprus + 'LCCW', 'LCCS', 'LCCE', 'LCCC', + // Hungary (currently no CPDLC) + // Italy + 'LMMM', 'LMME', 'LIRD', 'LIRI', 'LIRM', 'LIRN', 'LIRS', 'LIPM', 'LIPN', 'LIPS', 'LIMS', 'LIMN', 'LIBI', 'LIBN', 'LIBS', + // Romania (currently no CPDLC) + // Switzerland + 'LSAS', 'LSAA', 'LSAB', 'LSAC', 'LSAD', 'LSAF', 'LSAG', 'LSAH', 'LSAJ', 'LSAU', 'LSAV', + // Slovakia (currently no CPDLC) + // France + 'LFXX', + // Scandinavia + 'EKDK', 'EKDB', 'EKDC', 'EKDD', 'EKDS', 'EKDN', 'EKDV', 'EKCH', 'ESOS', 'ESM2', 'ESM3', 'ESM4', 'ESM5', 'ESM6', 'ESM7', 'ESMW', + 'ENO1', 'ENO2', 'ENO3', 'ENO4', 'ENO5', 'ENO6', 'ENO7', 'ENO8', 'ENS9', 'ENS1', 'ENS2', 'ENS3', 'ENS4', 'ENS5', 'ENS7', 'ENB8', + 'ENB9', 'ENB4', 'ENB5', 'ENB6', 'ENOR', 'ENNS', 'ENOB', 'EFES', 'EFEF', 'EFEG', 'EFEH', 'EFEJ', 'EFEM', + // Spain + 'CBRA', 'CBRN', 'CBRS', 'CBRW', 'CBRC', 'CBRE', 'CBRD', 'CMRA', 'CMRM', 'CMRN', 'CMRC', 'CMRW', 'CMRE', 'CSRA', 'CSRW', + 'CCRA', 'CCRI', 'CCRL', 'CCRW', 'CCRE', 'CCRO', + // Portugal + 'LPPC', 'LPZC', 'LPZD', 'LPZE', 'LPZI', 'LPZN', 'LPZS', 'LPZV', 'LPZW', 'LPZO', 'LPZL', + // Poland (currently no CPDLC) + // Czech + 'LKAA', 'LKAW', 'LKAN', 'LKAU', 'LKAI', + ]; + + public static currentFansMode(identifier: string): FansMode { + if (/^[0-9A-Z]{4}$/.test(identifier)) { + if (FutureAirNavigationSystem.areasFansB.findIndex((entry) => entry === identifier) > -1) { + return FansMode.FansB; + } + return FansMode.FansA; + } + return FansMode.FansNone; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/com/vhf/Common.ts b/fbw-a380x/src/systems/atsu/src/com/vhf/Common.ts new file mode 100644 index 00000000000..fc452dd2605 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/com/vhf/Common.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +export enum DatalinkProviders { + ARINC = 0, + SITA = 1, + ProviderCount = 2 +} + +export class Aircraft { + public Latitude = 0.0; + + public Longitude = 0.0; + + public Altitude = 0.0; +} + +export class OwnAircraft extends Aircraft { + public AltitudeAboveGround = 0.0; + + public PressureAltitude = 0.0; +} + +// maximum search range in NM +export const MaxSearchRange = 400; +// maximum datarate under optimal conditions: 31.5 kb/s +export const VdlMaxDatarate = 31500; + +// dataprovider configuration +export const DatalinkConfiguration: number[] = [ + 137.275, // ARINC + 137.975, // SITA +]; diff --git a/fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts b/fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts new file mode 100644 index 00000000000..a9eecc3689c --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { FmgcFlightPhase } from '@shared/flightphase'; +import { MathUtils } from '@shared/MathUtils'; +import { AtsuMessage, AtsuMessageSerializationFormat } from '../../messages/AtsuMessage'; +import { DatalinkProviders, OwnAircraft, MaxSearchRange } from './Common'; +import { Vhf } from './VHF'; + +interface NPCPlane { + name: string, + uId: number, + lat: number, + lon: number, + alt: number, + heading: number +} + +const UpperSectorAltitude = 24000; +// according to 1V3D in 120 ms +const MessageChunksPerSecond = 32; +const DataslotsPerSecond = 24; +const BitsOfChunksPerSecond = MessageChunksPerSecond * 496; +// standard size per data block +const BytesPerSlot = 62; + +/* + * Vdl simulates VDL3 to calculate the datarate for messages + * - general idea is that Datalink is range based on not sector based + * - 1V3D is used and it is assumed that one block is used for uplink messages + * - traffic split up into upper and lower + * - estimate relevant traffic based on own level and traffic of the lower sectors + * - own datarate is simulated by VDL3 specification and sharing between relevant traffic + */ +export class Vdl { + public static TransmissionTimePerPacket = 40; + + private recListener: ViewListener.ViewListener = RegisterViewListener('JS_LISTENER_MAPS', () => { + this.recListener.trigger('JS_BIND_BINGMAP', 'nxMap', true); + }); + + private inboundDelay = { updateTime: 0, messages: 0, delay: 0 }; + + private outboundDelay = { updateTime: 0, messages: 0, delay: 0 }; + + private vhf3: Vhf = new Vhf(); + + private presentPosition: OwnAircraft = new OwnAircraft(); + + private upperAirspaceTraffic: number = 0; + + private lowerAirspaceTraffic: number = 0; + + private perPacketDelay: number[] = Array(DatalinkProviders.ProviderCount).fill(500); + + private updatePresentPosition() { + this.presentPosition.Latitude = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + this.presentPosition.Longitude = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + this.presentPosition.Altitude = SimVar.GetSimVarValue('PLANE ALTITUDE', 'feet'); + this.presentPosition.AltitudeAboveGround = SimVar.GetSimVarValue('PLANE ALT ABOVE GROUND', 'feet'); + this.presentPosition.PressureAltitude = SimVar.GetSimVarValue('INDICATED ALTITUDE:3', 'feet'); + } + + private async updateRemoteAircrafts(): Promise { + this.lowerAirspaceTraffic = 0; + this.upperAirspaceTraffic = 0; + + return Coherent.call('GET_AIR_TRAFFIC').then((obj: NPCPlane[]) => { + obj.forEach((traffic) => { + // skip invalid aircraft + if (!traffic.lat || !traffic.lon || !traffic.alt || !traffic.uId) { + return; + } + + const distance = MathUtils.computeDistance3D(traffic.lat, traffic.lon, traffic.alt, + this.presentPosition.Latitude, this.presentPosition.Longitude, this.presentPosition.PressureAltitude); + + if (distance <= MaxSearchRange) { + if (traffic.alt < UpperSectorAltitude) { + this.lowerAirspaceTraffic += 1; + } else { + this.upperAirspaceTraffic += 1; + } + } + }); + }).catch(console.error); + } + + public simulateTransmissionTimes(flightPhase: FmgcFlightPhase) { + this.updatePresentPosition(); + this.vhf3.simulateDatarates(flightPhase).then(() => this.updateRemoteAircrafts().then(() => { + // check if now VHF connection is available + let connectionAvailable = false; + for (let i = 0; i < DatalinkProviders.ProviderCount; ++i) { + if (this.vhf3.datarates[0] !== 0.0) { + connectionAvailable = true; + break; + } + } + if (!connectionAvailable) { + this.perPacketDelay = Array(DatalinkProviders.ProviderCount).fill(10000); + return; + } + + let relevantStations = 0; + + // calculate the relevant aircrafts based on the own level + if (this.presentPosition.PressureAltitude < UpperSectorAltitude) { + // calculate the ratio between relevant stations and upper sector stations + // this ratio is used to add a fraction of the upper level aircrafts to the own relevant stations + let ratio = 1.0; + if (this.vhf3.stationsUpperAirspace !== 0) { + ratio = this.vhf3.relevantAirports.length / this.vhf3.stationsUpperAirspace; + } + + relevantStations = this.lowerAirspaceTraffic + ratio * this.upperAirspaceTraffic; + } else { + // calculate the ratio between relevant stations and lower stations + // it is assumed that one station is responsible for the lower sectors + let ratio = 1.0; + if (this.vhf3.stationsUpperAirspace !== 0) { + ratio = 1 / this.vhf3.relevantAirports.length; + } + + relevantStations = this.upperAirspaceTraffic + ratio * this.lowerAirspaceTraffic; + } + // add the A32NX and the ground stations into the list of relevant aircrafts + relevantStations += 1 + this.vhf3.relevantAirports.length; + + this.perPacketDelay = Array(DatalinkProviders.ProviderCount).fill(0); + for (let i = 0; i < DatalinkProviders.ProviderCount; ++i) { + // calculate the number of available slots based on data rate and floor due to broken slots + let messageCount = Math.floor(DataslotsPerSecond * Math.min(1.0, this.vhf3.datarates[i] / BitsOfChunksPerSecond)); + + // get all available message slots + messageCount *= this.vhf3.relevantAirports.length; + + // calculate the number of slots for the remote traffic based on non-rounded messages + const messageCountPerStation = messageCount / relevantStations; + + // calculate the data rates and the time between two own packets + this.perPacketDelay[i] = Math.round(1000 / messageCountPerStation + 0.5); + } + })); + } + + // calculates the required transmission time in milliseconds + private calculateTransmissionTime(message: AtsuMessage): number { + // calculate the number of occupied datablocks + const messageLength = message.serialize(AtsuMessageSerializationFormat.Network).length; + const occupiedDatablocks = Math.round(messageLength / BytesPerSlot + 0.5); + const blocksTransmissionTime = occupiedDatablocks * Vdl.TransmissionTimePerPacket; + + // calculate the transmission times based on the data rates and choose the fastest + return blocksTransmissionTime + (occupiedDatablocks - 1) * Math.min(...this.perPacketDelay); + } + + /** + * enqueues an inbound message and returns the required transmission time + * @param message The enqueued message + * @returns The overall transmission time + */ + public enqueueInboundMessage(message: AtsuMessage): number { + const currentTime = Date.now(); + + let transmissionTime = this.calculateTransmissionTime(message); + if (this.inboundDelay.messages !== 0) { + transmissionTime += Math.min(...this.perPacketDelay); + } else { + this.inboundDelay.updateTime = currentTime; + } + + this.inboundDelay.messages += 1; + this.inboundDelay.delay = transmissionTime; + + return transmissionTime - (currentTime - this.inboundDelay.updateTime); + } + + /** + * Decreases the inbound system delay and resets the system if no message is enqueued + * @param delay The passed delay + */ + public dequeueInboundMessage(delay: number): void { + this.inboundDelay.delay = Math.max(this.inboundDelay.delay - delay, 0); + this.inboundDelay.updateTime = Date.now(); + this.inboundDelay.messages -= 1; + + // reset the timer + if (this.inboundDelay.messages <= 0) { + this.inboundDelay.messages = 0; + this.inboundDelay.delay = 0; + } + } + + /** + * Enqueues a message into the outbound queue. It is simulated that all ground stations communicate first, followed by the A32NX + * @param message The enqueued outbound message + * @returns The overall transmission time + */ + public enqueueOutboundMessage(message: AtsuMessage): number { + const currentTime = Date.now(); + + let transmissionTime = this.calculateTransmissionTime(message); + if (this.outboundDelay.messages !== 0) { + transmissionTime += Math.min(...this.perPacketDelay); + } else { + // simulate that first packets are the ground stations, thereafter the A32NX packet for an initial offset + transmissionTime += Vdl.TransmissionTimePerPacket * this.vhf3.relevantAirports.length; + this.outboundDelay.updateTime = currentTime; + } + + this.outboundDelay.messages += 1; + this.outboundDelay.delay = transmissionTime; + + return transmissionTime - (currentTime - this.outboundDelay.updateTime); + } + + /** + * Enqueues a message of one packet length into the queue. It is simulated that all ground stations communicate first, followed by the A32NX + * @returns The overall transmission time + */ + public enqueueOutboundPacket(): number { + const currentTime = Date.now(); + + let transmissionTime = Vdl.TransmissionTimePerPacket; + if (this.outboundDelay.messages !== 0) { + transmissionTime += Math.min(...this.perPacketDelay); + } else { + // simulate that first packets are the ground stations, thereafter the A32NX packet for an initial offset + transmissionTime += Vdl.TransmissionTimePerPacket * this.vhf3.relevantAirports.length; + this.outboundDelay.updateTime = currentTime; + } + + this.outboundDelay.messages += 1; + this.outboundDelay.delay = transmissionTime; + + return transmissionTime - (currentTime - this.outboundDelay.updateTime); + } + + /** + * Dequeues an outbound message from the queue and decreases the overall delay + * @param delay The passed delay + */ + public dequeueOutboundMessage(delay: number): void { + this.outboundDelay.delay = Math.max(this.outboundDelay.delay - delay, 0); + this.outboundDelay.updateTime = Date.now(); + this.outboundDelay.messages -= 1; + + // reset the timer + if (this.outboundDelay.messages <= 0) { + this.outboundDelay.messages = 0; + this.outboundDelay.delay = 0; + } + } +} diff --git a/fbw-a380x/src/systems/atsu/src/com/vhf/VHF.ts b/fbw-a380x/src/systems/atsu/src/com/vhf/VHF.ts new file mode 100644 index 00000000000..c5f47cb4191 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/com/vhf/VHF.ts @@ -0,0 +1,308 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { ATC } from '@flybywiresim/api-client'; +import { FmgcFlightPhase } from '@shared/flightphase'; +import { NXDataStore } from '@shared/persistence'; +import { DatalinkConfiguration, DatalinkProviders, MaxSearchRange, OwnAircraft, VdlMaxDatarate } from './Common'; + +// worldwide international airports +// assumptions: international airports provide VHDL communication (i.e. USA) +// not perfectly realistic, but realistic enough for a frequency occupancy calculation +const VhfDatalinkAirports: string[] = [ + 'DAUA', 'DAAG', 'DABB', 'DABT', 'DAAE', 'DAUB', 'DAOI', 'DABC', 'DAUH', 'DAAV', 'DAOO', 'DAAS', 'DAAT', 'DAON', 'HEBA', 'HEAT', 'HESN', 'HECA', + 'HEAR', 'HEAL', 'HEGN', 'HELX', 'HEMA', 'HEMM', 'HESC', 'HESH', 'HEMK', 'HETB', 'HLLB', 'HLLS', 'HLLT', 'HLLM', 'GMAD', 'GMMN', 'GMFF', 'GMMX', + 'GMMW', 'GMFO', 'GMME', 'GMTT', 'GMTN', 'GMMH', 'GMML', 'HSSS', 'HSPN', 'DTTJ', 'DTNH', 'DTMB', 'DTTX', 'DTKA', 'DTTZ', 'DTTA', 'HBBA', 'FMCH', + 'HFFF', 'HHAS', 'HAAB', 'HADR', 'HKED', 'HKMO', 'HKKI', 'HKJK', 'FMMI', 'FMNA', 'FMNM', 'FMNN', 'FMMT', 'FMSD', 'FMST', 'FWCL', 'FWLI', 'FIMP', + 'FMCZ', 'FQMA', 'FQBR', 'FQIN', 'FQNP', 'FQPB', 'FQTT', 'FQVL', 'FMEE', 'HRYR', 'FSIA', 'HCMF', 'HCMH', 'HCMK', 'HCMM', 'HSSJ', 'HTAR', 'HTDA', + 'HTKJ', 'HTMW', 'HTZA', 'HUAR', 'HUEN', 'HUGU', 'FLLI', 'FLLS', 'FLND', 'FVHA', 'FVFA', 'FVBU', 'FNLU', 'FNUB', 'FKKD', 'FKYS', 'FEFF', 'FTTJ', + 'FZNA', 'FZAA', 'FZIC', 'FZQA', 'FCBB', 'FCPP', 'FGSL', 'FOON', 'FOOL', 'FOOG', 'FPST', 'DBBB', 'RKND', 'FXMM', 'FYWH', 'FYWB', 'FACT', 'FADN', + 'FAJS', 'FAKN', 'FABL', 'FAEL', 'FBSK', 'FBMN', 'FBFT', 'FBKE', 'DFOO', 'DFFD', 'GVBA', 'GVAC', 'GVFM', 'GVSV', 'DIAP', 'GBYD', 'DGAA', 'DGSI', + 'DGTK', 'DGSN', 'DGLW', 'DGLE', 'GUCY', 'GGOV', 'GGBU', 'GLRB', 'GABS', 'GQNN', 'GQPP', 'DRRN', 'DNAA', 'DNCA', 'DNAS', 'DNKN', 'DNMM', 'DNPO', + 'DNEN', 'DNSO', 'FHSH', 'GOBD', 'GFLL', 'DXXX', 'TQPF', 'TAPA', 'TNCA', 'MYNN', 'MYBC', 'MYEF', 'MYGF', 'MYER', 'TBPB', 'TUPJ', 'TNCB', 'TNCE', + 'TNCS', 'MWCB', 'MWCR', 'MUCM', 'MUOC', 'MUCL', 'MUCF', 'MUHA', 'MUHG', 'MUSC', 'MUCU', 'MUVR', 'TNCC', 'TDPD', 'MDBH', 'MDLR', 'MDPC', 'MDCY', + 'MDPP', 'MDST', 'MDSD', 'TGPY', 'TFFR', 'MTCH', 'MTPP', 'MKJP', 'MKJS', 'TFFF', 'TRPG', 'TJBQ', 'TJSJ', 'TFFJ', 'TKPK', 'TLPL', 'TVSV', 'TVSC', + 'TNCM', 'TTPP', 'TTCP', 'MBPV', 'TIST', 'TISX', 'MZBZ', 'MRLB', 'MROC', 'MSLP', 'MGTK', 'MGGT', 'MHLC', 'MHRO', 'MHLM', 'MHTG', 'MNMG', 'MNBL', + 'MNCI', 'MPBO', 'MPDA', 'MPTO', 'TXKF', 'CYXX', 'CYYC', 'CYEG', 'CYFC', 'CYQX', 'CYHZ', 'CYHM', 'CYLW', 'CYXU', 'CYQM', 'CYUL', 'CYOW', 'CYQB', + 'CYQR', 'CYXE', 'CYYT', 'CYQT', 'CYYZ', 'CYVR', 'CYYJ', 'CYXY', 'CYHA', 'CYWG', 'BGSF', 'BGGH', 'BGJN', 'BGBW', 'MMAA', 'MMAS', 'MMUN', 'MMCU', + 'MMCE', 'MMCZ', 'MMCL', 'MMDO', 'MMGL', 'MMHO', 'MMBT', 'MMZH', 'MMLO', 'MMLT', 'MMSD', 'MMZO', 'MMMZ', 'MMMD', 'MMMX', 'MMMY', 'MMMM', 'MMOX', + 'MMPB', 'MMPR', 'MMQT', 'MMRX', 'MMIO', 'MMSP', 'MMTM', 'MMTJ', 'MMTO', 'MMTC', 'MMTG', 'MMPN', 'MMVR', 'MMVA', 'MMZC', 'LFVP', 'KAKR', 'KALB', + 'KABQ', 'PANC', 'KATW', 'KATL', 'KACY', 'KAUS', 'KBWI', 'KBGR', 'KBLI', 'KBHM', 'KBOI', 'KBOS', 'KBUF', 'KCLT', 'KCHS', 'KMDW', 'KCVG', 'KCLE', + 'KCMH', 'KDFW', 'KDAY', 'KDEN', 'KDSM', 'KDTW', 'KELP', 'PAFA', 'KFLL', 'KRSW', 'KFAT', 'KGRR', 'KGRB', 'KGSO', 'KMDT', 'KBDL', 'PHTO', 'PHNL', + 'KIAH', 'KHSV', 'KIND', 'KJAN', 'KJAX', 'PAJN', 'KMCI', 'PAKT', 'KEYW', 'PHKO', 'KTYS', 'KLAL', 'KLAN', 'KLAS', 'KLIT', 'KLAX', 'KSDF', 'KMLB', + 'KMEM', 'KMIA', 'KMAF', 'KMKE', 'KMSP', 'KMYR', 'KBNA', 'KMSY', 'KJFK', 'KEWR', 'KSWF', 'KORF', 'KOAK', 'KOKC', 'KOMA', 'KONT', 'KSNA', 'KMCO', + 'KSFB', 'KPSP', 'KECP', 'KPNS', 'KPHL', 'KPHX', 'KIWA', 'KPIT', 'KPWM', 'KPDX', 'KPVD', 'KRAC', 'KRDU', 'KRNO', 'KRIC', 'KRST', 'KROC', 'KRFD', + 'KSMF', 'KSLC', 'KSAT', 'KSBD', 'KSAN', 'KSFO', 'KSJC', 'KSRQ', 'KSAV', 'KSBM', 'KPAE', 'KGEG', 'KSTL', 'KPIE', 'KSYR', 'KTLH', 'KTPA', 'KTUS', + 'KTUL', 'KDCA', 'KPBI', 'KAVP', 'KILM', 'SAEZ', 'SAZS', 'SACO', 'SAME', 'SARI', 'SARE', 'SAWG', 'SAWH', 'SLLP', 'SLVR', 'SLCB', 'SBAR', 'SBBE', + 'SBCF', 'SBBV', 'SBBR', 'SBKP', 'SBCG', 'SBCY', 'SBCT', 'SBFL', 'SBFZ', 'SBFI', 'SBGO', 'SBJP', 'SBMO', 'SBEG', 'SBNT', 'SBPL', 'SBPA', 'SBPV', + 'SBRF', 'SBRB', 'SBGL', 'SBSV', 'SBSL', 'SBSP', 'SBTE', 'SBUL', 'SBVT', 'SCFA', 'SCIE', 'SCTE', 'SCCI', 'SCEL', 'SKAR', 'SKBQ', 'SKBO', 'SKBG', + 'SKBU', 'SKCL', 'SKCG', 'SKCC', 'SKIB', 'SKIP', 'SKFL', 'SKLT', 'SKAO', 'SKMZ', 'SKRG', 'SKMU', 'SKMR', 'SKNV', 'SKPS', 'SKPE', 'SKPP', 'SKPV', + 'SKUI', 'SKRH', 'SKSP', 'SKTL', 'SKCO', 'SKSM', 'SKCZ', 'SKVP', 'SKVV', 'SKYP', 'SECU', 'SETN', 'SEGU', 'SERO', 'SEMT', 'SEQU', 'SETU', 'EGYP', + 'SOCA', 'SYCJ', 'SGAS', 'SGES', 'SPQU', 'SPZO', 'SPIM', 'SMJP', 'SUMU', 'SULS', 'SURV', 'SVMI', 'SVMC', 'SVVA', 'UATE', 'UATT', 'UAAA', 'UATG', + 'UAKK', 'UACK', 'UAUU', 'UAOO', 'UACC', 'UARR', 'UASK', 'UASP', 'UACP', 'UASS', 'UAII', 'UADD', 'UAFM', 'UCFL', 'UAFO', 'UTDT', 'UTDD', 'UTDL', + 'UTDK', 'UTAA', 'UTAT', 'UTAM', 'UTAK', 'UTAV', 'UTFA', 'UTSB', 'UTKF', 'UTSL', 'UTFN', 'UTSA', 'UTNN', 'UTSS', 'UTTT', 'UTST', 'UTNU', 'ZKPY', + 'RJSK', 'RJSA', 'RJFF', 'RJCH', 'RJFK', 'RJNK', 'RJOA', 'RJFR', 'RJFU', 'ROAH', 'RJGG', 'RJSN', 'RJFO', 'RJOB', 'RJBB', 'RJCC', 'RJSS', 'RJNS', + 'RJTT', 'RJAA', 'ZMUB', 'ZBOW', 'ZGBH', 'ZBAA', 'ZYCC', 'ZGHA', 'ZSCG', 'ZUUU', 'ZUCK', 'ZYTL', 'ZYDD', 'ZBDT', 'ZLDH', 'ZHES', 'ZSFZ', 'ZSGZ', + 'ZGGG', 'ZGKL', 'ZUGY', 'ZJHK', 'ZSHC', 'ZYHB', 'ZSOF', 'ZYHE', 'ZBHH', 'ZSSH', 'ZSTX', 'ZBLA', 'ZYJM', 'ZGOW', 'ZSJN', 'ZPPP', 'ZLAN', 'ZULS', + 'ZSLG', 'ZPLJ', 'ZSLY', 'ZHLY', 'ZPMS', 'ZBMZ', 'ZGMX', 'ZYMD', 'ZSCN', 'ZSNJ', 'ZGNN', 'ZSNT', 'ZSNB', 'ZBDS', 'ZSQD', 'ZBDH', 'ZYQQ', 'ZSQZ', + 'ZGSY', 'ZSSS', 'ZYTX', 'ZGSZ', 'ZBSJ', 'ZBYN', 'ZBTJ', 'ZWWW', 'ZUWX', 'ZSWH', 'ZSWZ', 'ZHHH', 'ZSWX', 'ZSWY', 'ZSAM', 'ZLXY', 'ZLXN', 'ZBXZ', + 'ZPJH', 'ZSXZ', 'ZSYN', 'ZSYA', 'ZYYJ', 'ZSYT', 'ZHYC', 'ZLIC', 'ZSYW', 'ZBYC', 'ZGDY', 'ZGZJ', 'ZHCC', 'ZGSD', 'ZUZY', 'VHHH', 'VMMC', 'RCYU', + 'RCKH', 'RCMQ', 'RCNN', 'RCSS', 'RCTP', 'RKPK', 'RKTN', 'RKPC', 'RKSS', 'RKSI', 'RKTU', 'RKJB', 'RKNY', 'VGEG', 'VGHS', 'VGSY', 'VQPR', 'VEAT', + 'VAAH', 'VIAR', 'VOBG', 'VEBS', 'VOMM', 'VOCB', 'VIDP', 'VAGO', 'VEGY', 'VEGT', 'VOHY', 'VEIM', 'VAID', 'VIJP', 'UELL', 'VOCI', 'VECC', 'VOCL', + 'VILK', 'VOMD', 'VOML', 'VABB', 'VANP', 'VAPO', 'VEBD', 'VISR', 'VASU', 'VOTV', 'VOTR', 'VABO', 'VIBN', 'VOBZ', 'VEVZ', 'VRMM', 'VRMG', 'VRMH', + 'VNKT', 'OPBW', 'OPFA', 'OPGD', 'OPRN', 'OPKC', 'OPLA', 'OPMT', 'OPPS', 'OPQT', 'OPRK', 'OPST', 'OPTU', 'VCBI', 'VCRI', 'VCCJ', 'WBSB', 'VDPP', + 'VDSR', 'VDSV', 'WPDL', 'WALL', 'WITT', 'WIIT', 'WIIB', 'WRBB', 'WADY', 'WIKB', 'WABB', 'WADD', 'WIIH', 'WRKK', 'WAAA', 'WAMM', 'WADL', 'WIMM', + 'WIPT', 'WIPP', 'WIBB', 'WIOO', 'WIIS', 'WIMN', 'WRSJ', 'WRSQ', 'WIKD', 'WRLR', 'WAHI', 'VLLB', 'VLPS', 'VLSK', 'VLVT', 'WMKA', 'WMKI', 'WMKJ', + 'WMKC', 'WBKK', 'WMKK', 'WMKN', 'WMKD', 'WBGG', 'WMKL', 'WMKP', 'WMSA', 'VYCZ', 'VYYY', 'VYNT', 'RPUO', 'RPLH', 'RPVM', 'RPLC', 'RPMD', 'RPMR', + 'RPVI', 'RPVK', 'RPLI', 'RPLL', 'RPVT', 'RPVP', 'RPLB', 'RPMZ', 'WSSS', 'VTBD', 'VTBD', 'VTCC', 'VTCT', 'VTUD', 'VTSS', 'VTSG', 'VTSP', 'VTSB', + 'VTSM', 'VTUD', 'VVDN', 'VVNB', 'VVTS', 'VVCT', 'VVCI', 'VVPB', 'VVPQ', 'VVCR', 'VVCA', 'OAKB', 'OAHR', 'OAKN', 'OAMS', 'OBBI', 'OIAA', 'OIAW', + 'OIHR', 'OITL', 'OIBP', 'OIKB', 'OIMB', 'OIBB', 'OING', 'OIHH', 'OICI', 'OIFM', 'OIKK', 'OICC', 'OIBK', 'OIZC', 'OISR', 'OISL', 'OIMM', 'OIKQ', + 'OIGG', 'OINZ', 'OISS', 'OITT', 'OIIE', 'OITR', 'OIYY', 'OIZH', 'ORNI', 'ORBI', 'ORMM', 'ORER', 'ORBM', 'ORTL', 'ORSU', 'LLER', 'LLHA', 'LLBG', + 'OJAQ', 'OJAI', 'OKBK', 'OLBA', 'OOMS', 'OOSA', 'OOSH', 'OTBD', 'OEAB', 'OEAH', 'OESK', 'OEGS', 'OEDF', 'OEHL', 'OEJN', 'OEGN', 'OEMA', 'OENG', + 'OERK', 'OETB', 'OETF', 'OEYN', 'OSAP', 'OSDI', 'OSLK', 'OSKL', 'OMAA', 'OMAL', 'OMDW', 'OMRK', 'OMSJ', 'OYAA', 'OYSN', 'OYSY', 'EBAW', 'EBBR', + 'EBCI', 'EBLG', 'EBOS', 'LFKJ', 'LFKB', 'LFOB', 'LFBE', 'LFMU', 'LFBZ', 'LFBD', 'LFRB', 'LFMK', 'LFOK', 'LFLB', 'LFRD', 'LFKF', 'LFLS', 'LFBH', + 'LFQQ', 'LFBL', 'LFLL', 'LFML', 'LFSB', 'LFRS', 'LFMN', 'LFTW', 'LFPG', 'LFBP', 'LFMP', 'LFBI', 'LFCR', 'LFMH', 'LFST', 'LFTH', 'LFBO', 'LFOT', + 'LXGB', 'EGJA', 'EGJB', 'EGJJ', 'EICK', 'EIDW', 'EIKY', 'EIKN', 'EINN', 'EGNS', 'ELLX', 'EHAM', 'EHEH', 'EHGG', 'EHBK', 'EHRD', 'LOWG', 'LOWK', + 'LOWI', 'LOWL', 'LOWS', 'LOWW', 'LKTB', 'LKKV', 'LKMT', 'LKPR', 'LKPD', 'EDSB', 'EDDB', 'EDDW', 'EDDK', 'EDLW', 'EDDL', 'EDDF', 'EDNY', 'EDDH', + 'EDDV', 'EDDP', 'EDHL', 'EDJA', 'EDDM', 'EDDN', 'EDDS', 'EDLV', 'LHBP', 'LHDC', 'LHSM', 'LHPR', 'LZIB', 'LZKZ', 'LZPP', 'LZTT', 'LZSL', 'LZZI', + 'LFSB', 'LSZB', 'LSGG', 'LSZA', 'LSZR', 'LSZH', 'EPBY', 'EPGD', 'EPKT', 'EPKK', 'EPLB', 'EPLL', 'EPPO', 'EPRZ', 'EPSC', 'EPWA', 'EPWR', 'LDSB', + 'LDDU', 'LDLO', 'LDOS', 'LDPL', 'LDRI', 'LDSP', 'LDZD', 'LDZA', 'LCLK', 'LCPH', 'LCEN', 'LGAV', 'LGKF', 'LGSA', 'LGHI', 'LGKR', 'LGIR', 'LGKL', + 'LGKP', 'LGKV', 'LGKO', 'LGMK', 'LGMT', 'LGPZ', 'LGRP', 'LGSM', 'LGSR', 'LGSK', 'LGSY', 'LGTS', 'LGBL', 'LGZA', 'LIEA', 'LIPY', 'LIBD', 'LIME', + 'LIPE', 'LIPO', 'LIBR', 'LIEE', 'LICC', 'LIMZ', 'LIRQ', 'LIMJ', 'LICA', 'LIML', 'LIRN', 'LIEO', 'LICJ', 'LIMP', 'LIRZ', 'LIBP', 'LIRP', 'LIPR', + 'LIRF', 'LICT', 'LIPQ', 'LIMF', 'LIPZ', 'LIPX', 'LMML', 'LPBJ', 'LPFR', 'LPMA', 'LPPS', 'LPPT', 'LPPR', 'LPPD', 'LPLA', 'LJLJ', 'LJMB', 'LJPZ', + 'LECO', 'LEAL', 'LEAM', 'LEAS', 'LEBL', 'LEBB', 'LECS', 'GCFV', 'LEGE', 'GCLP', 'LEGR', 'LEHC', 'LEIB', 'LEJR', 'GCLA', 'GCRR', 'LEDA', 'LEMD', + 'LEMG', 'LEMH', 'LEMI', 'LEPA', 'LEPP', 'LERS', 'LEXJ', 'LEST', 'LEZL', 'GCXO', 'LEVC', 'LEVD', 'LEVX', 'LEVT', 'LEZG', 'LATI', 'LAKU', 'UGEE', + 'UDSG', 'UBBB', 'UBBG', 'UBBN', 'UBBQ', 'UBBL', 'UBBY', 'UMMG', 'UMGG', 'UMMS', 'LQBK', 'LQSA', 'LQTZ', 'LQMO', 'LBBG', 'LBPD', 'LBSF', 'LBWN', + 'EETN', 'EETU', 'UGSB', 'UGKO', 'UGSS', 'UGGG', 'LYPR', 'EVRA', 'EVVA', 'EYKA', 'EYPA', 'EYSA', 'EYVI', 'LUKK', 'LRAR', 'LRBC', 'LRBM', 'LROP', + 'LRCL', 'LRCK', 'LRCV', 'LRIA', 'LROD', 'LRSM', 'LRSB', 'LRSV', 'LRTM', 'LRTR', 'LYPG', 'LYTV', 'LWOH', 'LWSK', 'UNAA', 'UHMA', 'URKA', 'ULAA', + 'URWA', 'UNBB', 'UUOB', 'UHBB', 'UIBB', 'UUBP', 'UWKS', 'USCC', 'ULWC', 'UIAA', 'URWI', 'UUII', 'URMG', 'UMKK', 'UWKD', 'UHHH', 'UHKK', 'URKK', + 'UNKL', 'UUOK', 'UHMM', 'USCM', 'URML', 'URMM', 'UUDD', 'ULMM', 'URMN', 'USNN', 'UWKE', 'UWGG', 'UNWW', 'UNNT', 'UNOO', 'UWOO', 'UWOR', 'USPP', + 'ULPB', 'UHMD', 'UHPP', 'ULOO', 'URRR', 'ULLI', 'UWWW', 'URSS', 'URMT', 'USRR', 'UUYY', 'UNTT', 'USTR', 'UIUU', 'UWLL', 'UWUU', 'UHWW', 'URMO', + 'URWW', 'UUOO', 'UEEE', 'UUDL', 'USSS', 'UHSS', 'LYBE', 'LYNI', 'LYKV', 'LTAF', 'LTFG', 'LTAC', 'LTAI', 'LTFE', 'LTBR', 'LTBS', 'LTAY', 'LTCC', + 'LTCA', 'LTAJ', 'LTBA', 'LTBJ', 'LTAU', 'LTAN', 'LTBZ', 'LTAT', 'LTAZ', 'LTFH', 'LTCG', 'LTAS', 'UKLN', 'UKDD', 'UKLI', 'UKHH', 'UKDR', 'UKBB', + 'UKLL', 'UKON', 'UKOO', 'UKHP', 'UKFF', 'UKLU', 'UKDE', 'EKYT', 'EKAH', 'EKBI', 'EKCH', 'EKVG', 'EFMA', 'EFHK', 'EFKT', 'EFKU', 'EFKS', 'EFLP', + 'EFOU', 'EFRO', 'EFTP', 'EFTU', 'EFVA', 'BIAR', 'BIKF', 'ENAL', 'ENBR', 'ENBO', 'ENHD', 'ENCN', 'ENGM', 'ENZV', 'ENTC', 'ENVA', 'ESGG', 'ESPA', + 'ESMS', 'ESSP', 'ESPC', 'ESSA', 'ESNN', 'ESNU', 'ESMX', 'ESSV', 'EGBB', 'EGHH', 'EGGD', 'EGFF', 'EGCN', 'EGNV', 'EGNX', 'EGTE', 'EGNM', 'EGGP', + 'EGLC', 'EGCC', 'EGNT', 'EGDQ', 'EGSH', 'EGHI', 'EGPD', 'EGPH', 'EGPF', 'EGPE', 'EGAA', 'EGAE', 'NSTU', 'YPAD', 'YBBN', 'YBRM', 'YBCS', 'YSCB', + 'YPDN', 'YAVV', 'YBCG', 'YMHB', 'YMML', 'YWLM', 'YPPH', 'YPPD', 'YBMC', 'YSSY', 'YBTL', 'YPXM', 'YPCC', 'NCRG', 'SCIP', 'NFFN', 'NFNA', 'NTAA', + 'PGUM', 'PLCH', 'NGTA', 'PKWA', 'PKMJ', 'PTKK', 'PTSA', 'PTPN', 'PTYA', 'ANAU', 'NWWW', 'NZAA', 'NZCH', 'NZQN', 'NZWN', 'YSNF', 'PGRO', 'PGSN', + 'PGWT', 'NIUE', 'PTRO', 'AYPY', 'AYMH', 'NSFA', 'AGGH', 'NFTF', 'NFTV', 'NGFU', 'NVSS', 'NVVV', 'NLWF', 'NLWW']; + +// filter parameters to find preselect conditional relevant stations +const MaxAirportsInRange = 50; + +// physical parameters to simulate the signal quality +const AdditiveNoiseOverlapDB = 1.4; +const MaximumDampingDB = -75.0; +const ReceiverAntennaGainDBI = 25.0; +// is equal to 50W emitter power +const SignalStrengthDBW = 39.1202; + +class Airport { + public Icao = ''; + + public Elevation = 0.0; + + public Distance = 0.0; + + public Datarates: [ boolean, number ][] = Array(DatalinkProviders.ProviderCount).fill([false, 0]); +} + +/* + * Simulates the physical effects of the VHF communication + * - All international airports in a LoS range are taken into account + * - The SNR is simulated and the resulting datarate is defined per airport + */ +export class Vhf { + public stationsUpperAirspace: number = 0; + + public datarates: number[] = []; + + private presentPosition: OwnAircraft = new OwnAircraft(); + + private frequencyOverlap: number[] = []; + + public relevantAirports: Airport[] = []; + + private updatePresentPosition() { + this.presentPosition.Latitude = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + this.presentPosition.Longitude = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + this.presentPosition.Altitude = SimVar.GetSimVarValue('PLANE ALTITUDE', 'feet'); + this.presentPosition.AltitudeAboveGround = SimVar.GetSimVarValue('PLANE ALT ABOVE GROUND', 'feet'); + this.presentPosition.PressureAltitude = SimVar.GetSimVarValue('INDICATED ALTITUDE:3', 'feet'); + } + + // calculates the freespace path loss for a certain distance + // reference: https://en.wikipedia.org/wiki/Free-space_path_loss + private freespacePathLoss(frequency: number, distance: number): number { + // convert to meters + const meters = distance * 1852; + return 10.0 * Math.log10((4.0 * Math.PI * meters * (frequency * 1000000) / 299792458) ** 2.0); + } + + private estimateDatarate(type: DatalinkProviders, distance: number, flightPhase: FmgcFlightPhase, airport: Airport): void { + const maximumFreespaceLoss = SignalStrengthDBW + ReceiverAntennaGainDBI - AdditiveNoiseOverlapDB * (this.frequencyOverlap[type]) - MaximumDampingDB; + let freespaceLoss = this.freespacePathLoss(DatalinkConfiguration[type], distance); + + // simulate the influence of buildings + if (flightPhase === FmgcFlightPhase.Preflight || flightPhase === FmgcFlightPhase.Done) { + // assume that buildings are close the aircraft -> add a loss of 30 dB to simulate the influence of buildings + freespaceLoss += 30; + } else if (flightPhase === FmgcFlightPhase.Takeoff || flightPhase === FmgcFlightPhase.GoAround || flightPhase === FmgcFlightPhase.Approach) { + // assume that high buildings are in the vicinity of the aircraft -> add a loss of 15 dB to simulate the influence of buildings + freespaceLoss += 15; + } + + if (maximumFreespaceLoss >= freespaceLoss) { + const lossDelta = maximumFreespaceLoss - freespaceLoss; + + // get the quality ratio normalized by the simulated signal power range + const qualityRatio = Math.min(1.0, lossDelta / Math.abs(MaximumDampingDB)); + + // use a sigmoid function to estimate the scaling of the datarate + // parametrized to jump from 1.0 to 0.02 (y) between 0.0 and 1.0 (x) + // minimum scaling is 10% of the optimal datarate + // inverse of quality ratio is needed to estimate the quality loss + const scaling = Math.max(0.1, 1.0 / (Math.exp(9.0 * (1.0 - qualityRatio) - 5.0) + 1.0)); + + airport.Datarates[type][0] = true; + airport.Datarates[type][1] = VdlMaxDatarate * scaling; + } + } + + private async updateRelevantAirports(flightPhase: FmgcFlightPhase): Promise { + // use a simple line of sight algorithm to calculate the maximum distance + // it ignores the topolography, but simulates the earth curvature + // reference: https://audio.vatsim.net/storage/AFV%20User%20Guide.pdf + const maximumDistanceLoS = (altitude0: number, altitude1: number): number => 1.23 * Math.sqrt(Math.abs(altitude0 - altitude1)); + + this.stationsUpperAirspace = 0; + this.relevantAirports = []; + + // prepare the request with the information + const requestBatch = new SimVar.SimVarBatch('C:fs9gps:NearestAirportItemsNumber', 'C:fs9gps:NearestAirportCurrentLine'); + requestBatch.add('C:fs9gps:NearestAirportCurrentICAO', 'string', 'string'); + requestBatch.add('C:fs9gps:NearestAirportSelectedLatitude', 'degree latitude'); + requestBatch.add('C:fs9gps:NearestAirportSelectedLongitude', 'degree longitude'); + requestBatch.add('C:fs9gps:WaypointAirportElevation', 'feet'); + requestBatch.add('C:fs9gps:NearestAirportCurrentDistance', 'meters'); + + SimVar.SetSimVarValue('C:fs9gps:NearestAirportCurrentLatitude', 'degree latitude', this.presentPosition.Latitude); + SimVar.SetSimVarValue('C:fs9gps:NearestAirportCurrentLongitude', 'degree longitude', this.presentPosition.Longitude); + SimVar.SetSimVarValue('C:fs9gps:NearestAirportMaximumItems', 'number', MaxAirportsInRange); + SimVar.SetSimVarValue('C:fs9gps:NearestAirportMaximumDistance', 'nautical miles', 100000); + + // get all airports + return new Promise((resolve) => { + SimVar.GetSimVarArrayValues(requestBatch, (airports) => { + airports.forEach((fetched) => { + // format: 'TYPE(one char) ICAO ' + const icao = fetched[0].substr(2).trim(); + + // found an international airport + if (VhfDatalinkAirports.findIndex((elem) => elem === icao) !== -1) { + const maxDistance = maximumDistanceLoS(this.presentPosition.PressureAltitude, fetched[3]); + const distanceNM = fetched[4] * 0.000539957; + + if (distanceNM <= maxDistance) { + const airport = new Airport(); + airport.Icao = icao; + airport.Elevation = fetched[3]; + airport.Distance = distanceNM; + + let validAirport = false; + for (let i = 0; i < DatalinkProviders.ProviderCount; ++i) { + this.estimateDatarate(i as DatalinkProviders, distanceNM, flightPhase, airport); + validAirport = validAirport || airport.Datarates[i as DatalinkProviders][0]; + } + + if (validAirport) { + this.relevantAirports.push(airport); + } + } + + // assume that all upper stations are reachable within the maximum range + if (distanceNM <= MaxSearchRange) { + this.stationsUpperAirspace += 1; + } + } + }); + + resolve(); + }); + }); + } + + private greatCircleDistance(latitude: number, longitude: number): number { + const deg2rad = (deg) => deg * (Math.PI / 180); + + const R = 6371; // Radius of the earth in km + const dLat = deg2rad(this.presentPosition.Latitude - latitude); // deg2rad below + const dLon = deg2rad(this.presentPosition.Longitude - longitude); + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(deg2rad(latitude)) * Math.cos(deg2rad(this.presentPosition.Latitude)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const d = R * c * 0.5399568; // Distance in nm + + return d; + } + + private async updateUsedVoiceFrequencies(): Promise { + const storedAtisSrc = NXDataStore.get('CONFIG_ATIS_SRC', 'FAA').toLowerCase(); + this.frequencyOverlap = Array(DatalinkProviders.ProviderCount).fill(0); + + if (storedAtisSrc === 'vatsim' || storedAtisSrc === 'ivao') { + await ATC.get(storedAtisSrc).then((res) => { + if (!res) return; + + res = res.filter((a) => a.callsign.indexOf('_OBS') === -1 && parseFloat(a.frequency) <= 136.975 && this.greatCircleDistance(a.latitude, a.longitude) <= MaxSearchRange); + res.forEach((controller) => { + const frequency = parseFloat(controller.frequency); + + for (const key in DatalinkConfiguration) { + if ({}.hasOwnProperty.call(DatalinkConfiguration, key)) { + const datalinkFrequency = DatalinkConfiguration[key]; + + if (frequency >= datalinkFrequency - 0.009 && frequency <= datalinkFrequency + 0.009) { + // check 8.33 kHz spacing + this.frequencyOverlap[key] += 1; + } else if (frequency >= datalinkFrequency - 0.025 && frequency <= datalinkFrequency + 0.025) { + // check the direct 25 kHz neighbors for SITA + this.frequencyOverlap[key] += 1; + } + } + } + }); + }); + } + } + + /** + * Simulates the data rates for the different datalink providers + * @param flightPhase Actual flight phase to simulate the building based interferences + * @returns A promise to provide the possibilty to run it in sequence + */ + public async simulateDatarates(flightPhase: FmgcFlightPhase): Promise { + this.updatePresentPosition(); + + return this.updateUsedVoiceFrequencies().then(() => this.updateRelevantAirports(flightPhase).then(() => { + // use the average over all reachable stations to estimate the datarate + this.datarates = Array(DatalinkProviders.ProviderCount).fill(0.0); + const stationCount = Array(DatalinkProviders.ProviderCount).fill(0); + + this.relevantAirports.forEach((airport) => { + for (let i = 0; i < DatalinkProviders.ProviderCount; ++i) { + if (airport.Datarates[0]) { + this.datarates[i] += airport.Datarates[i][1]; + stationCount[i] += 1; + } + } + }); + + for (let i = 0; i < DatalinkProviders.ProviderCount; ++i) { + if (stationCount[i] !== 0) this.datarates[i] /= stationCount[i]; + } + })); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/com/webinterfaces/HoppieConnector.ts b/fbw-a380x/src/systems/atsu/src/com/webinterfaces/HoppieConnector.ts new file mode 100644 index 00000000000..04b944afc5d --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/com/webinterfaces/HoppieConnector.ts @@ -0,0 +1,385 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { NXDataStore } from '@shared/persistence'; +import { Hoppie } from '@flybywiresim/api-client'; +import { AtsuStatusCodes } from '../../AtsuStatusCodes'; +import { AtsuMessage, AtsuMessageNetwork, AtsuMessageDirection, AtsuMessageComStatus, AtsuMessageSerializationFormat } from '../../messages/AtsuMessage'; +import { CpdlcMessage } from '../../messages/CpdlcMessage'; +import { CpdlcMessagesUplink, CpdlcMessageElement, CpdlcMessageContent, CpdlcMessageExpectedResponseType } from '../../messages/CpdlcMessageElements'; +import { FreetextMessage } from '../../messages/FreetextMessage'; +import { FansMode } from '../FutureAirNavigationSystem'; + +/** + * Defines the connector to the hoppies network + */ +export class HoppieConnector { + private static flightNumber: string = ''; + + public static fansMode: FansMode = FansMode.FansNone; + + public static async activateHoppie() { + SimVar.SetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number', 0); + + if (NXDataStore.get('CONFIG_HOPPIE_ENABLED', 'DISABLED') === 'DISABLED') { + console.log('Hoppie deactivated in EFB'); + return; + } + + if (NXDataStore.get('CONFIG_HOPPIE_USERID', '') === '') { + console.log('No Hoppie-ID set'); + return; + } + + const metarSrc = NXDataStore.get('CONFIG_METAR_SRC', 'MSFS'); + if (metarSrc !== 'VATSIM' && metarSrc !== 'IVAO') { + console.log('Invalid METAR source'); + return; + } + + const atisSrc = NXDataStore.get('CONFIG_ATIS_SRC', 'FAA'); + if (atisSrc !== 'VATSIM' && atisSrc !== 'IVAO') { + console.log('Invalid ATIS source'); + return; + } + + const body = { + logon: NXDataStore.get('CONFIG_HOPPIE_USERID', ''), + from: 'FBWA32NX', + to: 'ALL-CALLSIGNS', + type: 'ping', + packet: '', + }; + + Hoppie.sendRequest(body).then((resp) => { + if (resp.response !== 'error {illegal logon code}') { + SimVar.SetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number', 1); + console.log('Activated Hoppie ID'); + } else { + console.log('Invalid Hoppie-ID set'); + } + }); + } + + public static deactivateHoppie(): void { + SimVar.SetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number', 0); + } + + public static async connect(flightNo: string): Promise { + if (SimVar.GetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number') !== 1) { + HoppieConnector.flightNumber = flightNo; + return AtsuStatusCodes.NoHoppieConnection; + } + + return HoppieConnector.isCallsignInUse(flightNo).then((code) => { + if (code === AtsuStatusCodes.Ok) { + HoppieConnector.flightNumber = flightNo; + return HoppieConnector.poll().then(() => code); + } + return code; + }); + } + + public static disconnect(): AtsuStatusCodes { + HoppieConnector.flightNumber = ''; + return AtsuStatusCodes.Ok; + } + + public static async isCallsignInUse(station: string): Promise { + if (SimVar.GetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number') !== 1) { + return AtsuStatusCodes.NoHoppieConnection; + } + + const body = { + logon: NXDataStore.get('CONFIG_HOPPIE_USERID', ''), + from: station, + to: 'ALL-CALLSIGNS', + type: 'ping', + packet: station, + }; + const text = await Hoppie.sendRequest(body).then((resp) => resp.response); + + if (text === 'error {callsign already in use}') { + return AtsuStatusCodes.CallsignInUse; + } + if (text.includes('error')) { + return AtsuStatusCodes.ProxyError; + } + if (text.startsWith('ok') !== true) { + return AtsuStatusCodes.ComFailed; + } + + return AtsuStatusCodes.Ok; + } + + public static async isStationAvailable(station: string): Promise { + if (SimVar.GetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number') !== 1 || HoppieConnector.flightNumber === '') { + return AtsuStatusCodes.NoHoppieConnection; + } + + if (station === HoppieConnector.flightNumber) { + return AtsuStatusCodes.OwnCallsign; + } + + const body = { + logon: NXDataStore.get('CONFIG_HOPPIE_USERID', ''), + from: HoppieConnector.flightNumber, + to: 'ALL-CALLSIGNS', + type: 'ping', + packet: station, + }; + const text = await Hoppie.sendRequest(body).then((resp) => resp.response); + + if (text.includes('error')) { + return AtsuStatusCodes.ProxyError; + } + if (text.startsWith('ok') !== true) { + return AtsuStatusCodes.ComFailed; + } + if (text !== `ok {${station}}`) { + return AtsuStatusCodes.NoAtc; + } + + return AtsuStatusCodes.Ok; + } + + private static async sendMessage(message: AtsuMessage, type: string): Promise { + if (SimVar.GetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number') !== 1 || HoppieConnector.flightNumber === '') { + return AtsuStatusCodes.NoHoppieConnection; + } + + const body = { + logon: NXDataStore.get('CONFIG_HOPPIE_USERID', ''), + from: HoppieConnector.flightNumber, + to: message.Station, + type, + packet: message.serialize(AtsuMessageSerializationFormat.Network), + }; + const text = await Hoppie.sendRequest(body).then((resp) => resp.response).catch(() => 'proxy'); + + if (text === 'proxy') { + return AtsuStatusCodes.ProxyError; + } + + if (text !== 'ok') { + return AtsuStatusCodes.ComFailed; + } + + return AtsuStatusCodes.Ok; + } + + public static async sendTelexMessage(message: AtsuMessage, force: boolean): Promise { + if (HoppieConnector.flightNumber !== '' && (force || SimVar.GetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number') === 1)) { + return HoppieConnector.sendMessage(message, 'telex'); + } + return AtsuStatusCodes.NoHoppieConnection; + } + + public static async sendCpdlcMessage(message: CpdlcMessage, force: boolean): Promise { + if (HoppieConnector.flightNumber !== '' && (force || SimVar.GetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number') === 1)) { + return HoppieConnector.sendMessage(message, 'cpdlc'); + } + return AtsuStatusCodes.NoHoppieConnection; + } + + private static levenshteinDistance(template: string, message: string, content: CpdlcMessageContent[]): number { + let elements = message.replace(/\n/g, ' ').split(' '); + let validContent = true; + + // try to match the content + content.forEach((entry) => { + const result = entry.validateAndReplaceContent(elements); + if (!result.matched) { + validContent = false; + } else { + elements = result.remaining; + } + }); + if (!validContent) return 100000; + const correctedMessage = elements.join(' '); + + // initialize the track matrix + const track = Array(correctedMessage.length + 1).fill(null).map(() => Array(template.length + 1).fill(null)); + for (let i = 0; i <= template.length; ++i) track[0][i] = i; + for (let i = 0; i <= correctedMessage.length; ++i) track[i][0] = i; + + for (let j = 1; j <= correctedMessage.length; ++j) { + for (let i = 1; i <= template.length; ++i) { + const indicator = template[i - 1] === correctedMessage[j - 1] ? 0 : 1; + track[j][i] = Math.min( + track[j][i - 1] + 1, // delete + track[j - 1][i] + 1, // insert + track[j - 1][i - 1] + indicator, // substitude + ); + } + } + + return track[correctedMessage.length][template.length]; + } + + private static cpdlcMessageClassification(message: string): CpdlcMessageElement | undefined { + const scores: [number, string][] = []; + let minScore = 100000; + + // clear the message from marker, etc. + const clearedMessage = message.replace('@', '').replace('_', ' '); + + // test all uplink messages + for (const ident in CpdlcMessagesUplink) { + if ({}.hasOwnProperty.call(CpdlcMessagesUplink, ident)) { + const data = CpdlcMessagesUplink[ident]; + + if (HoppieConnector.fansMode === FansMode.FansNone || data[1].FansModes.includes(HoppieConnector.fansMode)) { + let minDistance = 100000; + + data[0].forEach((template) => { + const distance = HoppieConnector.levenshteinDistance(template, clearedMessage, data[1].Content); + if (minDistance > distance) minDistance = distance; + }); + + scores.push([minDistance, ident]); + if (minScore > minDistance) minScore = minDistance; + } + } + } + + // get all entries with the minimal score + let matches: string[] = []; + scores.forEach((elem) => { + if (elem[0] === minScore) matches.push(elem[1]); + }); + + console.log(`Found matches: ${matches}, score: ${minScore}`); + if (matches.length === 0) return undefined; + + // check if message without parameters are in, but the minScore not empty + if (matches.length > 1 && minScore !== 0) { + const nonEmpty = matches.filter((match) => CpdlcMessagesUplink[match][1].Content.length !== 0); + if (nonEmpty.length !== 0 && matches.length !== nonEmpty.length) { + console.log(`Ignoring ${matches.length - nonEmpty.length} messages without arguments. Remaining ${nonEmpty}`); + matches = nonEmpty; + } + } + + // check if more than the freetext-entry is valid + if (matches.length > 1) { + const nonFreetext = matches.filter((match) => match !== 'UM169' && match !== 'UM183'); + if (nonFreetext.length !== 0 && matches.length !== nonFreetext.length) { + console.log(`Ignoring ${matches.length - nonFreetext.length} freetext messages. Remaining: ${nonFreetext}`); + matches = nonFreetext; + } + } + + // check if the FANS mode is invalid + if (matches.length > 1 && this.fansMode !== FansMode.FansNone) { + const validFans = matches.filter((match) => CpdlcMessagesUplink[match][1].FansModes.findIndex((elem) => elem === this.fansMode) !== -1); + if (validFans.length !== 0 && matches.length !== validFans.length) { + console.log(`Ignoring ${matches.length - validFans.length} invalid FANS messages. Remaining: ${validFans}`); + matches = validFans; + } + } + + // TODO add some more heuristic about messages + + // create a deep-copy of the message + const retval: CpdlcMessageElement = CpdlcMessagesUplink[matches[0]][1].deepCopy(); + let elements = message.split(' '); + console.log(`Selected UM-ID: ${matches[0]}`); + + // parse the content and store it in the deep copy + retval.Content.forEach((entry) => { + const result = entry.validateAndReplaceContent(elements); + elements = result.remaining; + }); + + return retval; + } + + public static async poll(): Promise<[AtsuStatusCodes, AtsuMessage[]]> { + const retval: AtsuMessage[] = []; + + if (SimVar.GetSimVarValue('L:A32NX_HOPPIE_ACTIVE', 'number') !== 1 || HoppieConnector.flightNumber === '') { + return [AtsuStatusCodes.NoHoppieConnection, retval]; + } + + try { + const body = { + logon: NXDataStore.get('CONFIG_HOPPIE_USERID', ''), + from: HoppieConnector.flightNumber, + to: HoppieConnector.flightNumber, + type: 'poll', + }; + const text = await Hoppie.sendRequest(body).then((resp) => resp.response).catch(() => 'proxy'); + + // proxy error during request + if (text === 'proxy') { + return [AtsuStatusCodes.ProxyError, retval]; + } + + // something went wrong + if (!text.startsWith('ok')) { + return [AtsuStatusCodes.ComFailed, retval]; + } + + // split up the received data into multiple messages + let messages = text.split(/({.*?})/gm); + messages = messages.filter((elem) => elem !== 'ok' && elem !== 'ok ' && elem !== '} ' && elem !== '}' && elem !== ''); + + // create the messages + messages.forEach((element) => { + // get the single entries of the message + // example: [CALLSIGN telex, {Hello world!}] + const entries = element.substring(1).split(/({.*?})/gm); + + // get all relevant information + const metadata = entries[0].split(' '); + const sender = metadata[0].toUpperCase(); + const type = metadata[1].toLowerCase(); + const content = entries[1].replace(/{/, '').replace(/}/, '').toUpperCase(); + + switch (type) { + case 'telex': + const freetext = new FreetextMessage(); + freetext.Network = AtsuMessageNetwork.Hoppie; + freetext.Station = sender; + freetext.Direction = AtsuMessageDirection.Uplink; + freetext.ComStatus = AtsuMessageComStatus.Received; + freetext.Message = content.replace(/\n/i, ' '); + retval.push(freetext); + break; + case 'cpdlc': + const cpdlc = new CpdlcMessage(); + cpdlc.Station = sender; + cpdlc.Direction = AtsuMessageDirection.Uplink; + cpdlc.ComStatus = AtsuMessageComStatus.Received; + + // split up the data + const elements = content.split('/'); + cpdlc.CurrentTransmissionId = parseInt(elements[2]); + if (elements[3] !== '') { + cpdlc.PreviousTransmissionId = parseInt(elements[3]); + } + cpdlc.Message = elements[5].replace(/@/g, '').replace(/_/g, '\n'); + cpdlc.Content.push(HoppieConnector.cpdlcMessageClassification(cpdlc.Message)); + if ((elements[4] as CpdlcMessageExpectedResponseType) !== cpdlc.Content[0]?.ExpectedResponse) { + cpdlc.Content[0].ExpectedResponse = (elements[4] as CpdlcMessageExpectedResponseType); + } + + retval.push(cpdlc); + break; + default: + break; + } + }); + + return [AtsuStatusCodes.Ok, retval]; + } catch (_err) { + console.log('ERROR IN POLL'); + return [AtsuStatusCodes.NoHoppieConnection, []]; + } + } + + public static pollInterval(): number { + return 5000; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/com/webinterfaces/NXApiConnector.ts b/fbw-a380x/src/systems/atsu/src/com/webinterfaces/NXApiConnector.ts new file mode 100644 index 00000000000..0baff87768f --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/com/webinterfaces/NXApiConnector.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { Atis, Metar, Taf, Telex, AircraftStatus } from '@flybywiresim/api-client'; +import { NXDataStore } from '@shared/persistence'; +import { AtsuStatusCodes } from '../../AtsuStatusCodes'; +import { AtsuMessage, AtsuMessageComStatus, AtsuMessageNetwork, AtsuMessageDirection } from '../../messages/AtsuMessage'; +import { FreetextMessage } from '../../messages/FreetextMessage'; +import { WeatherMessage } from '../../messages/WeatherMessage'; +import { AtisMessage, AtisType } from '../../messages/AtisMessage'; + +const WeatherMap = { + FAA: 'faa', + IVAO: 'ivao', + MSFS: 'ms', + NOAA: 'aviationweather', + PILOTEDGE: 'pilotedge', + VATSIM: 'vatsim', +}; + +/** + * Defines the NXApi connector for the AOC system + */ +export class NXApiConnector { + private static flightNumber: string = ''; + + private static connected: boolean = false; + + private static updateCounter: number = 0; + + private static createAircraftStatus(): AircraftStatus | undefined { + const lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + const long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + const alt = SimVar.GetSimVarValue('PLANE ALTITUDE', 'feet'); + const heading = SimVar.GetSimVarValue('PLANE HEADING DEGREES TRUE', 'degree'); + const acType = SimVar.GetSimVarValue('TITLE', 'string'); + const origin = NXDataStore.get('PLAN_ORIGIN', ''); + const destination = NXDataStore.get('PLAN_DESTINATION', ''); + const freetext = NXDataStore.get('CONFIG_ONLINE_FEATURES_STATUS', 'DISABLED') === 'ENABLED'; + + return { + location: { + long, + lat, + }, + trueAltitude: alt, + heading, + origin, + destination, + freetextEnabled: freetext, + flight: NXApiConnector.flightNumber, + aircraftType: acType, + }; + } + + public static async connect(flightNo: string): Promise { + if (NXDataStore.get('CONFIG_ONLINE_FEATURES_STATUS', 'DISABLED') !== 'ENABLED') { + return AtsuStatusCodes.TelexDisabled; + } + + // deactivate old connection + await NXApiConnector.disconnect(); + + NXApiConnector.flightNumber = flightNo; + const status = NXApiConnector.createAircraftStatus(); + if (status !== undefined) { + return Telex.connect(status).then((res) => { + if (res.accessToken !== '') { + NXApiConnector.connected = true; + NXApiConnector.updateCounter = 0; + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.NoTelexConnection; + }).catch(() => AtsuStatusCodes.CallsignInUse); + } + + return AtsuStatusCodes.Ok; + } + + public static async disconnect(): Promise { + if (NXDataStore.get('CONFIG_ONLINE_FEATURES_STATUS', 'DISABLED') !== 'ENABLED') { + return AtsuStatusCodes.TelexDisabled; + } + + if (NXApiConnector.connected) { + return Telex.disconnect().then(() => { + NXApiConnector.connected = false; + NXApiConnector.flightNumber = ''; + return AtsuStatusCodes.Ok; + }).catch(() => AtsuStatusCodes.ProxyError); + } + + return AtsuStatusCodes.NoTelexConnection; + } + + public static isConnected(): boolean { + return NXApiConnector.connected; + } + + public static async sendTelexMessage(message: FreetextMessage): Promise { + if (NXApiConnector.connected) { + const content = message.Message.replace('\n', ';'); + return Telex.sendMessage(message.Station, content).then(() => { + message.ComStatus = AtsuMessageComStatus.Sent; + return AtsuStatusCodes.Ok; + }).catch(() => { + message.ComStatus = AtsuMessageComStatus.Failed; + return AtsuStatusCodes.ComFailed; + }); + } + return AtsuStatusCodes.NoTelexConnection; + } + + public static async receiveMetar(icao: string, message: WeatherMessage): Promise { + const storedMetarSrc = NXDataStore.get('CONFIG_METAR_SRC', 'MSFS'); + + return Metar.get(icao, WeatherMap[storedMetarSrc]) + .then((data) => { + let metar = data.metar; + if (!metar || metar === undefined || metar === '') { + metar = 'NO METAR AVAILABLE'; + } + + message.Reports.push({ airport: icao, report: metar }); + return AtsuStatusCodes.Ok; + }).catch(() => { + message.Reports.push({ airport: icao, report: 'NO METAR AVAILABLE' }); + return AtsuStatusCodes.Ok; + }); + } + + public static async receiveTaf(icao: string, message: WeatherMessage): Promise { + const storedTafSrc = NXDataStore.get('CONFIG_TAF_SRC', 'NOAA'); + + return Taf.get(icao, WeatherMap[storedTafSrc]) + .then((data) => { + let taf = data.taf; + if (!taf || taf === undefined || taf === '') { + taf = 'NO TAF AVAILABLE'; + } + + message.Reports.push({ airport: icao, report: taf }); + return AtsuStatusCodes.Ok; + }).catch(() => { + message.Reports.push({ airport: icao, report: 'NO TAF AVAILABLE' }); + return AtsuStatusCodes.Ok; + }); + } + + public static async receiveAtis(icao: string, type: AtisType, message: AtisMessage): Promise { + const storedAtisSrc = NXDataStore.get('CONFIG_ATIS_SRC', 'FAA'); + + await Atis.get(icao, WeatherMap[storedAtisSrc]) + .then((data) => { + let atis = undefined; + + if (type === AtisType.Arrival) { + if ('arr' in data) { + atis = data.arr; + } else { + atis = data.combined; + } + } else if (type === AtisType.Departure) { + if ('dep' in data) { + atis = data.dep; + } else { + atis = data.combined; + } + } else if (type === AtisType.Enroute) { + if ('combined' in data) { + atis = data.combined; + } else if ('arr' in data) { + atis = data.arr; + } + } + + if (!atis || atis === undefined) { + atis = 'D-ATIS NOT AVAILABLE'; + } + + message.Reports.push({ airport: icao, report: atis }); + }).catch(() => { + message.Reports.push({ airport: icao, report: 'D-ATIS NOT AVAILABLE' }); + }); + + return AtsuStatusCodes.Ok; + } + + public static async poll(): Promise<[AtsuStatusCodes, AtsuMessage[]]> { + const retval: AtsuMessage[] = []; + + if (NXApiConnector.connected) { + if (NXApiConnector.updateCounter++ % 4 === 0) { + const status = NXApiConnector.createAircraftStatus(); + if (status !== undefined) { + const code = await Telex.update(status).then(() => AtsuStatusCodes.Ok).catch(() => AtsuStatusCodes.ProxyError); + if (code !== AtsuStatusCodes.Ok) { + return [AtsuStatusCodes.ComFailed, retval]; + } + } + } + + // Fetch new messages + try { + const data = await Telex.fetchMessages(); + for (const msg of data) { + const message = new FreetextMessage(); + message.Network = AtsuMessageNetwork.FBW; + message.Direction = AtsuMessageDirection.Uplink; + message.Station = msg.from.flight; + message.Message = msg.message.replace(/;/i, ' '); + + retval.push(message); + } + } catch (_e) { + return [AtsuStatusCodes.ComFailed, retval]; + } + } + + return [AtsuStatusCodes.Ok, retval]; + } + + public static pollInterval(): number { + return 15000; + } +} + +NXDataStore.set('PLAN_ORIGIN', ''); +NXDataStore.set('PLAN_DESTINATION', ''); diff --git a/fbw-a380x/src/systems/atsu/src/components/ATS623.ts b/fbw-a380x/src/systems/atsu/src/components/ATS623.ts new file mode 100644 index 00000000000..ecfbfe51f46 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/components/ATS623.ts @@ -0,0 +1,101 @@ +import { FansMode } from '../com/FutureAirNavigationSystem'; +import { Atsu } from '../ATSU'; +import { AtsuMessage } from '../messages/AtsuMessage'; +import { CpdlcMessage } from '../messages/CpdlcMessage'; +import { CpdlcMessageExpectedResponseType, CpdlcMessagesUplink } from '../messages/CpdlcMessageElements'; +import { DclMessage } from '../messages/DclMessage'; +import { OclMessage } from '../messages/OclMessage'; + +// TODO reset internal states if flight state changes + +export class ATS623 { + private atsu: Atsu = null; + + private clearanceRequest: CpdlcMessage = null; + + constructor(atsu: Atsu) { + this.atsu = atsu; + } + + public isRelevantMessage(message: AtsuMessage): boolean { + if (message instanceof DclMessage || message instanceof OclMessage) { + return true; + } + + if (message.Station !== this.clearanceRequest?.Station) { + return false; + } + + if (message instanceof CpdlcMessage) { + const cpdlc = message as CpdlcMessage; + // allow only freetext messages + return cpdlc.Content.TypeId === 'UM183' || cpdlc.Content.TypeId === 'UM169'; + } + + return true; + } + + public insertMessages(messages: AtsuMessage[]): void { + const handledMessages: CpdlcMessage[] = []; + + messages.forEach((message) => { + let processedMessage: AtsuMessage | CpdlcMessage = message; + + if (!(message instanceof CpdlcMessage)) { + processedMessage = new CpdlcMessage(); + + processedMessage.UniqueMessageID = message.UniqueMessageID; + processedMessage.ComStatus = message.ComStatus; + processedMessage.Confirmed = message.Confirmed; + processedMessage.Direction = message.Direction; + processedMessage.Timestamp = message.Timestamp; + processedMessage.Network = message.Network; + processedMessage.Station = message.Station; + processedMessage.Message = message.Message; + (processedMessage as CpdlcMessage).DcduRelevantMessage = true; + (processedMessage as CpdlcMessage).PreviousTransmissionId = this.clearanceRequest.CurrentTransmissionId; + if (this.atsu.atc.fansMode() === FansMode.FansA) { + (processedMessage as CpdlcMessage).Content = CpdlcMessagesUplink.UM169[1].deepCopy(); + } else { + (processedMessage as CpdlcMessage).Content = CpdlcMessagesUplink.UM183[1].deepCopy(); + } + (processedMessage as CpdlcMessage).Content.Content[0].Value = message.Message; + (processedMessage as CpdlcMessage).Content.ExpectedResponse = CpdlcMessageExpectedResponseType.No; + } + + if (message instanceof DclMessage || message instanceof OclMessage) { + // new clearance request sent + this.clearanceRequest = message as CpdlcMessage; + } else if (this.clearanceRequest instanceof DclMessage && this.atsu.destinationWaypoint()) { + (processedMessage as CpdlcMessage).CloseAutomatically = false; + + // expect some clearance with TO DEST or SQWK/SQUAWK/SQK XXXX -> stop ATS run + const regex = new RegExp(`.*TO @?(${this.atsu.destinationWaypoint().ident}){1}@?.*(SQWK|SQUAWK){1}.*`); + if (regex.test(processedMessage.Message)) { + if ((processedMessage as CpdlcMessage).Content.ExpectedResponse === CpdlcMessageExpectedResponseType.No) { + (processedMessage as CpdlcMessage).Content.ExpectedResponse = CpdlcMessageExpectedResponseType.Roger; + } + this.clearanceRequest = null; + } else if (/.*VIA TELEX.*/.test(processedMessage.Message)) { + // ignore "CLEARANCE DELIVERED VIA TELEX" in the DCDU + (processedMessage as CpdlcMessage).DcduRelevantMessage = false; + } + } else if (this.atsu.destinationWaypoint()) { + (processedMessage as CpdlcMessage).CloseAutomatically = false; + + // oceanic clearance with CLRD TO -> stop ATS run + const regex = new RegExp(`.*TO @?(${this.atsu.destinationWaypoint().ident}){1}@?`); + if (regex.test(processedMessage.Message)) { + if ((processedMessage as CpdlcMessage).Content.ExpectedResponse === CpdlcMessageExpectedResponseType.No) { + (processedMessage as CpdlcMessage).Content.ExpectedResponse = CpdlcMessageExpectedResponseType.Roger; + } + this.clearanceRequest = null; + } + } + + handledMessages.push(processedMessage as CpdlcMessage); + }); + + this.atsu.atc.insertMessages(handledMessages); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/components/DcduLink.ts b/fbw-a380x/src/systems/atsu/src/components/DcduLink.ts new file mode 100644 index 00000000000..82f4f9599da --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/components/DcduLink.ts @@ -0,0 +1,522 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { Atc } from '../ATC'; +import { Atsu } from '../ATSU'; +import { AtsuMessage, AtsuMessageDirection } from '../messages/AtsuMessage'; +import { AtsuStatusCodes } from '../AtsuStatusCodes'; +import { CpdlcMessage } from '../messages/CpdlcMessage'; +import { UplinkMessageStateMachine } from './UplinkMessageStateMachine'; + +export enum DcduStatusMessage { + NoMessage = -1, + AnswerRequired = 0, + CommunicationFault, + CommunicationNotAvailable, + CommunicationNotInitialized, + MaximumDownlinkMessages, + LinkLost, + FlightplanLoadFailed, + FlightplanLoadPartial, + FlightplanLoadingUnavailable, + MonitoringFailed, + MonitoringLost, + MonitoringUnavailable, + NoAtcReply, + OverflowClosed, + PrintFailed, + PriorityMessage, + SendFailed = 16, + FlightplanLoadSecondary, + FlightplanLoadingSecondary, + McduForText, + McduForModification, + MonitoringCancelled, + Monitoring, + NoFmData, + NoMoreMessages, + NoMorePages, + PartialFmgsData, + Printing, + RecallMode, + RecallEmpty, + Reminder, + Sending, + Sent, + WaitFmData +} + +class DcduMessage { + public MessageId: number = 0; + + public Station: string = ''; + + public MessageSent = false; + + public MessageRead = false; + + public PriorityMessage = false; + + public Status: DcduStatusMessage = DcduStatusMessage.NoMessage; + + public Direction: AtsuMessageDirection = null; +} + +export class DcduLink { + private static MaxDcduFileSize = 5; + + private listener = RegisterViewListener('JS_LISTENER_SIMVARS', null, true); + + private atsu: Atsu = null; + + private atc: Atc = null; + + private downlinkMessages: (DcduMessage[])[] = []; + + private uplinkMessages: (DcduMessage[])[] = []; + + private bufferedDownlinkMessages: (DcduMessage[])[] = []; + + private bufferedUplinkMessages: (DcduMessage[])[] = []; + + private lastClosedMessage: [DcduMessage[], number] = null; + + private atcMsgWatchdogInterval: number = null; + + private atcRingInterval: number = null; + + private closeMessage(messages: (DcduMessage[])[], backlog: (DcduMessage[])[], uid: number, uplink: boolean): boolean { + const idx = messages.findIndex((elem) => elem[0].MessageId === uid); + if (idx !== -1) { + // validate that message exists in the queue + const message = this.atc.messages().find((elem) => elem.UniqueMessageID === uid); + + if ((!this.lastClosedMessage || this.lastClosedMessage[0][0].MessageId !== uid) && message !== undefined) { + this.lastClosedMessage = [messages[idx], new Date().getTime()]; + } + + messages.splice(idx, 1); + if (uplink) { + this.validateNotificationCondition(); + } + + // add buffered messages + while (backlog.length !== 0 && messages.length !== DcduLink.MaxDcduFileSize) { + const bufferedBlock = backlog.shift(); + const dcduMessages = []; + messages.push([]); + + bufferedBlock.forEach((data) => { + const message = this.atc.messages().find((elem) => elem.UniqueMessageID === data.MessageId); + if (message !== undefined) { + messages[messages.length - 1].push(data); + + // pushed a new inbound message + if (!data.MessageRead) { + this.setupIntervals(); + } + + if ((message as CpdlcMessage).DcduRelevantMessage) { + dcduMessages.push(message); + } + } + }); + + if (dcduMessages.length !== 0) { + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG', dcduMessages); + } + } + } + + return idx !== -1; + } + + constructor(atsu: Atsu, atc: Atc) { + this.atsu = atsu; + this.atc = atc; + + Coherent.on('A32NX_ATSU_DELETE_MESSAGE', (uid: number) => { + let idx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx > -1) { + this.uplinkMessages[idx].forEach((message) => { + this.atc.removeMessage(message.MessageId); + }); + } else { + idx = this.downlinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx > -1) { + this.downlinkMessages[idx].forEach((message) => { + this.atc.removeMessage(message.MessageId); + }); + } + } + }); + + Coherent.on('A32NX_ATSU_SEND_RESPONSE', (uid: number, response: number) => { + const idx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx > -1) { + // iterate in reverse order to ensure that the "identification" message is the last message in the queue + // ensures that the DCDU-status change to SENT is done after every message is sent + this.uplinkMessages[idx].slice().reverse().forEach((message) => { + this.atc.sendResponse(message.MessageId, response); + }); + } + }); + + Coherent.on('A32NX_ATSU_SEND_MESSAGE', (uid: number) => { + let idx = this.downlinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx > -1) { + // iterate in reverse order to ensure that the "identification" message is the last message in the queue + // ensures that the DCDU-status change to SENT is done after every message is sent + this.downlinkMessages[idx].slice().reverse().forEach((entry) => { + const message = this.atc.messages().find((element) => element.UniqueMessageID === entry.MessageId); + if (message !== undefined) { + if (message.Direction === AtsuMessageDirection.Downlink) { + this.atc.sendMessage(message).then((code) => { + if (code !== AtsuStatusCodes.Ok) { + this.atsu.publishAtsuStatusCode(code); + } + }); + } + } + }); + + return; + } + + idx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx > -1) { + const message = this.atc.messages().find((element) => element.UniqueMessageID === uid); + if (message !== undefined) { + const cpdlcMessage = message as CpdlcMessage; + if (cpdlcMessage.Response && cpdlcMessage.SemanticResponseRequired) { + this.atc.sendExistingResponse(uid); + } + } + } + }); + + Coherent.on('A32NX_ATSU_DCDU_MESSAGE_MODIFY_RESPONSE', (uid: number) => { + const idx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx > -1) { + const message = this.atc.messages().find((element) => element.UniqueMessageID === uid); + if (message !== undefined) { + this.atsu.modifyDcduMessage(message as CpdlcMessage); + } + } + }); + + Coherent.on('A32NX_ATSU_PRINT_MESSAGE', (uid: number) => { + const message = this.atc.messages().find((element) => element.UniqueMessageID === uid); + if (message !== undefined) { + this.updateDcduStatusMessage(uid, DcduStatusMessage.Printing); + this.atsu.printMessage(message); + setTimeout(() => { + if (this.currentDcduStatusMessage(uid) === DcduStatusMessage.Printing) { + this.updateDcduStatusMessage(uid, DcduStatusMessage.NoMessage); + } + }, 4500); + } + }); + + Coherent.on('A32NX_ATSU_DCDU_MESSAGE_CLOSED', (uid: number) => { + if (!this.closeMessage(this.uplinkMessages, this.bufferedUplinkMessages, uid, true)) { + this.closeMessage(this.downlinkMessages, this.bufferedDownlinkMessages, uid, false); + } + }); + + Coherent.on('A32NX_ATSU_DCDU_MESSAGE_MONITORING', (uid: number) => { + const message = this.atc.messages().find((element) => element.UniqueMessageID === uid); + UplinkMessageStateMachine.update(this.atsu, message as CpdlcMessage, true, true); + this.update(message as CpdlcMessage); + }); + + Coherent.on('A32NX_ATSU_DCDU_MESSAGE_STOP_MONITORING', (uid: number) => { + const message = this.atc.messages().find((element) => element.UniqueMessageID === uid); + UplinkMessageStateMachine.update(this.atsu, message as CpdlcMessage, true, false); + this.update(message as CpdlcMessage); + }); + + Coherent.on('A32NX_ATSU_DCDU_MESSAGE_RECALL', () => { + if (!this.lastClosedMessage) { + this.listener.triggerToAllSubscribers('A32NX_DCDU_SYSTEM_ATSU_STATUS', DcduStatusMessage.RecallEmpty); + } else { + const currentStamp = new Date().getTime(); + // timed out after five minutes + if (currentStamp - this.lastClosedMessage[1] > 300000) { + this.listener.triggerToAllSubscribers('A32NX_DCDU_SYSTEM_ATSU_STATUS', DcduStatusMessage.RecallEmpty); + this.lastClosedMessage = undefined; + } else { + const messages : CpdlcMessage[] = []; + + this.lastClosedMessage[0].forEach((dcduMessage) => { + const msg = this.atc.messages().find((elem) => elem.UniqueMessageID === dcduMessage.MessageId); + if (msg !== undefined) { + messages.push(msg as CpdlcMessage); + } + }); + + messages[0].CloseAutomatically = false; + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG', messages); + if (this.lastClosedMessage[0][0].Direction === AtsuMessageDirection.Downlink) { + this.downlinkMessages.push(this.lastClosedMessage[0]); + } else { + this.uplinkMessages.push(this.lastClosedMessage[0]); + } + this.updateDcduStatusMessage(messages[0].UniqueMessageID, DcduStatusMessage.RecallMode); + } + } + }); + + Coherent.on('A32NX_ATSU_DCDU_MESSAGE_READ', (uid: number) => { + const idx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx !== -1) { + this.uplinkMessages[idx][0].MessageRead = true; + this.validateNotificationCondition(); + } + }); + + Coherent.on('A32NX_ATSU_DCDU_MESSAGE_INVERT_SEMANTIC_RESPONSE', (uid: number) => { + const message = this.atc.messages().find((element) => element.UniqueMessageID === uid); + if (message !== undefined) { + UplinkMessageStateMachine.update(this.atsu, message as CpdlcMessage, true, false); + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG', [message]); + } + }); + } + + private validateNotificationCondition() { + // check if the ring tone is still needed + let unreadMessages = false; + this.uplinkMessages.forEach((elem) => { + if (!elem[0].MessageRead) { + unreadMessages = true; + } + }); + + if (!unreadMessages) { + this.cleanupNotifications(); + } + } + + private estimateRingInterval() { + let interval = 15000; + + this.uplinkMessages.forEach((elem) => { + if (!elem[0].MessageRead) { + if (elem[0].PriorityMessage) { + interval = Math.min(interval, 5000); + } else { + interval = Math.min(interval, 15000); + } + } + }); + + return interval; + } + + private atcRingTone() { + Coherent.call('PLAY_INSTRUMENT_SOUND', 'cpdlc_ring'); + // ensure that the timeout is longer than the sound + setTimeout(() => SimVar.SetSimVarValue('W:cpdlc_ring', 'boolean', 0), 2000); + } + + private cleanupNotifications() { + SimVar.SetSimVarValue('L:A32NX_DCDU_ATC_MSG_WAITING', 'boolean', 0); + SimVar.SetSimVarValue('L:A32NX_DCDU_ATC_MSG_ACK', 'number', 0); + + if (this.atcMsgWatchdogInterval) { + clearInterval(this.atcMsgWatchdogInterval); + this.atcMsgWatchdogInterval = null; + } + + if (this.atcRingInterval) { + clearInterval(this.atcRingInterval); + this.atcRingInterval = null; + } + } + + private setupIntervals() { + if (!this.atcMsgWatchdogInterval) { + // start the watchdog to check the the ATC MSG button + this.atcMsgWatchdogInterval = setInterval(() => { + if (SimVar.GetSimVarValue('L:A32NX_DCDU_ATC_MSG_ACK', 'number') === 1) { + this.cleanupNotifications(); + } + }, 100); + } + + if (this.atcRingInterval) { + clearInterval(this.atcRingInterval); + } + + // call the first ring tone + this.atcRingTone(); + + // start the ring tone interval + this.atcRingInterval = setInterval(() => this.atcRingTone(), this.estimateRingInterval()); + } + + public reset() { + this.listener.triggerToAllSubscribers('A32NX_DCDU_RESET'); + } + + public setAtcLogonMessage(message: string) { + this.listener.triggerToAllSubscribers('A32NX_DCDU_ATC_LOGON_MSG', message); + } + + public enqueue(messages: AtsuMessage[]) { + if (messages.length === 0) { + return; + } + + const dcduBlocks: DcduMessage[] = []; + messages.forEach((message) => { + const block = new DcduMessage(); + block.MessageId = message.UniqueMessageID; + block.MessageRead = message.Direction === AtsuMessageDirection.Downlink; + block.Station = message.Station; + block.Direction = message.Direction; + block.PriorityMessage = (message as CpdlcMessage).Content[0]?.Urgent; + dcduBlocks.push(block); + }); + + if (dcduBlocks[0].Direction === AtsuMessageDirection.Downlink && this.downlinkMessages.length < DcduLink.MaxDcduFileSize) { + this.downlinkMessages.push(dcduBlocks); + } else if (dcduBlocks[0].Direction === AtsuMessageDirection.Uplink && this.uplinkMessages.length < DcduLink.MaxDcduFileSize) { + this.uplinkMessages.push(dcduBlocks); + SimVar.SetSimVarValue('L:A32NX_DCDU_ATC_MSG_WAITING', 'boolean', 1); + SimVar.SetSimVarValue('L:A32NX_DCDU_ATC_MSG_ACK', 'number', 0); + this.setupIntervals(); + } else { + if (dcduBlocks[0].Direction === AtsuMessageDirection.Downlink) { + this.bufferedDownlinkMessages.push(dcduBlocks); + this.listener.triggerToAllSubscribers('A32NX_DCDU_SYSTEM_ATSU_STATUS', DcduStatusMessage.MaximumDownlinkMessages); + this.atsu.publishAtsuStatusCode(AtsuStatusCodes.DcduFull); + } else { + this.bufferedUplinkMessages.push(dcduBlocks); + this.listener.triggerToAllSubscribers('A32NX_DCDU_SYSTEM_ATSU_STATUS', DcduStatusMessage.AnswerRequired); + } + return; + } + + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG', messages); + } + + public update(message: CpdlcMessage, insertIfNeeded: boolean = false) { + // the assumption is that the first message in the block is the UID for the complete block + + const uplinkIdx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === message.UniqueMessageID); + if (uplinkIdx !== -1) { + const messages = []; + + // create all messages and overwrite the first because this is the updated + this.uplinkMessages[uplinkIdx].forEach((dcduMessage) => { + const msg = this.atc.messages().find((elem) => elem.UniqueMessageID === dcduMessage.MessageId); + if (msg !== undefined) { + if (message.UniqueMessageID !== msg.UniqueMessageID) { + messages.push(msg as CpdlcMessage); + } else { + messages.push(message); + } + } + }); + + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG', messages); + return; + } + + const downlinkIdx = this.downlinkMessages.findIndex((elem) => elem[0].MessageId === message.UniqueMessageID); + if (downlinkIdx !== -1) { + const messages = []; + + // create all messages and overwrite the first because this is the updated + this.downlinkMessages[downlinkIdx].forEach((dcduMessage) => { + const msg = this.atc.messages().find((elem) => elem.UniqueMessageID === dcduMessage.MessageId); + if (message.UniqueMessageID !== msg.UniqueMessageID) { + messages.push(msg as CpdlcMessage); + } else { + messages.push(message); + } + }); + messages[0] = message; + + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG', messages); + return; + } + + if (insertIfNeeded) { + this.enqueue([message]); + } + } + + public dequeue(uid: number) { + // the assumption is that the first message in the block is the UID for the complete block + let idx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx !== -1) { + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG_DELETE_UID', uid); + } else { + idx = this.downlinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx !== -1) { + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG_DELETE_UID', uid); + } + } + } + + public updateDcduStatusMessage(uid: number, status: DcduStatusMessage): void { + // the assumption is that the first message in the block is the UID for the complete block + const uplinkIdx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (uplinkIdx !== -1) { + this.uplinkMessages[uplinkIdx][0].Status = status; + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG_ATSU_STATUS', uid, status); + return; + } + + const downlinkIdx = this.downlinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (downlinkIdx !== -1) { + this.downlinkMessages[downlinkIdx][0].Status = status; + this.listener.triggerToAllSubscribers('A32NX_DCDU_MSG_ATSU_STATUS', uid, status); + } + } + + public currentDcduStatusMessage(uid: number): DcduStatusMessage { + let idx = this.uplinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx !== -1) { + return this.uplinkMessages[idx][0].Status; + } + + idx = this.downlinkMessages.findIndex((elem) => elem[0].MessageId === uid); + if (idx !== -1) { + return this.downlinkMessages[idx][0].Status; + } + + return DcduStatusMessage.NoMessage; + } + + public openMessagesForStation(station: string): boolean { + let retval = false; + + this.uplinkMessages.forEach((block) => { + if (!block[0].MessageSent && block[0].Station === station) retval = true; + }); + + if (!retval) { + this.downlinkMessages.forEach((block) => { + if (!block[0].MessageSent && block[0].Station === station) retval = true; + }); + } + + if (!retval) { + this.bufferedUplinkMessages.forEach((block) => { + if (!block[0].MessageSent && block[0].Station === station) retval = true; + }); + } + + if (!retval) { + this.bufferedDownlinkMessages.forEach((block) => { + if (!block[0].MessageSent && block[0].Station === station) retval = true; + }); + } + + return retval; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/components/FlightStateObserver.ts b/fbw-a380x/src/systems/atsu/src/components/FlightStateObserver.ts new file mode 100644 index 00000000000..bfd127de528 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/components/FlightStateObserver.ts @@ -0,0 +1,130 @@ +import { FlightPlanManager } from '@shared/flightplan'; +import { Atsu } from '../ATSU'; + +export class Waypoint { + public ident: string = ''; + + public altitude: number = 0; + + public utc: number = 0; + + constructor(ident: string) { + this.ident = ident; + } +} + +export class FlightStateObserver { + public LastWaypoint: Waypoint | undefined = undefined; + + public PresentPosition = { lat: null, lon: null, altitude: null, heading: null, track: null, indicatedAirspeed: null, groundSpeed: null, verticalSpeed: null }; + + public FcuSettings = { apActive: false, speed: null, machMode: false, altitude: null } + + public ActiveWaypoint: Waypoint | undefined = undefined; + + public NextWaypoint: Waypoint | undefined = undefined; + + public Destination: Waypoint | undefined = undefined; + + private static findLastWaypoint(fp) { + if (fp) { + let idx = fp.activeWaypointIndex; + while (idx >= 0) { + const wp = fp.getWaypoint(idx); + if (wp?.waypointReachedAt !== 0) { + return wp; + } + + idx -= 1; + } + } + + return null; + } + + private updatePresentPosition() { + this.PresentPosition.lat = SimVar.GetSimVarValue('GPS POSITION LAT', 'degree latitude'); + this.PresentPosition.lon = SimVar.GetSimVarValue('GPS POSITION LON', 'degree longitude'); + this.PresentPosition.altitude = Math.round(SimVar.GetSimVarValue('PLANE ALTITUDE', 'feet')); + this.PresentPosition.heading = Math.round(SimVar.GetSimVarValue('GPS GROUND TRUE HEADING', 'degree')); + this.PresentPosition.track = Math.round(SimVar.GetSimVarValue('GPS GROUND TRUE TRACK', 'degree')); + this.PresentPosition.indicatedAirspeed = Math.round(SimVar.GetSimVarValue('AIRSPEED INDICATED', 'knots')); + this.PresentPosition.groundSpeed = Math.round(SimVar.GetSimVarValue('GROUND VELOCITY', 'knots')); + this.PresentPosition.verticalSpeed = Math.round(SimVar.GetSimVarValue('VERTICAL SPEED', 'feet per second') * 60.0); + } + + private updateFcu() { + this.FcuSettings.apActive = SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_ACTIVE', 'bool'); + const thrustMode = SimVar.GetSimVarValue('L:A32NX_AUTOTHRUST_MODE', 'number'); + + if (this.FcuSettings.apActive) { + this.FcuSettings.altitude = Math.round(Simplane.getAutoPilotDisplayedAltitudeLockValue()); + this.FcuSettings.machMode = thrustMode === 8; + if (thrustMode === 0) { + if (this.FcuSettings.machMode) { + this.FcuSettings.speed = SimVar.GetSimVarValue('L:A32NX_MachPreselVal', 'number'); + } else { + this.FcuSettings.speed = SimVar.GetSimVarValue('L:A32NX_SpeedPreselVal', 'number'); + } + } else if (this.FcuSettings.machMode) { + this.FcuSettings.speed = SimVar.GetSimVarValue('AIRSPEED INDICATED', 'knots'); + } else { + this.FcuSettings.speed = SimVar.GetSimVarValue('AIRSPEED MACH', 'mach'); + } + } else { + this.FcuSettings.altitude = null; + this.FcuSettings.machMode = false; + this.FcuSettings.speed = null; + } + } + + constructor(mcdu: any, callback: (atsu: Atsu) => void) { + setInterval(() => { + const fp = (mcdu.flightPlanManager as FlightPlanManager).activeFlightPlan; + const last = FlightStateObserver.findLastWaypoint(fp); + const active = fp?.getWaypoint(fp.activeWaypointIndex); + const next = fp?.getWaypoint(fp.activeWaypointIndex + 1); + const destination = fp?.getWaypoint(fp.waypoints.length - 1); + let waypointPassed = false; + + this.updatePresentPosition(); + this.updateFcu(); + + if (last) { + if (!this.LastWaypoint || last.ident !== this.LastWaypoint.ident) { + this.LastWaypoint = new Waypoint(last.ident); + this.LastWaypoint.utc = last.waypointReachedAt; + this.LastWaypoint.altitude = this.PresentPosition.altitude; + waypointPassed = true; + } + } + + if (active && next) { + const ppos = { + lat: this.PresentPosition.lat, + long: this.PresentPosition.lon, + }; + const stats = fp.computeWaypointStatistics(ppos); + + if (!this.ActiveWaypoint || this.ActiveWaypoint.ident !== active.ident) { + this.ActiveWaypoint = new Waypoint(active.ident); + } + this.ActiveWaypoint.utc = Math.round(stats.get(fp.activeWaypointIndex).etaFromPpos); + + if (!this.NextWaypoint || this.NextWaypoint.ident !== next.ident) { + this.NextWaypoint = new Waypoint(next.ident); + } + this.ActiveWaypoint.utc = Math.round(stats.get(fp.activeWaypointIndex + 1).etaFromPpos); + + if (!this.Destination || this.Destination.ident !== destination.ident) { + this.Destination = new Waypoint(destination.ident); + } + this.Destination.utc = Math.round(stats.get(fp.waypoints.length - 1).etaFromPpos); + } + + if (waypointPassed) { + callback(mcdu.atsu); + } + }, 1000); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/components/InputValidationFansA.ts b/fbw-a380x/src/systems/atsu/src/components/InputValidationFansA.ts new file mode 100644 index 00000000000..e5763893578 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/components/InputValidationFansA.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuStatusCodes } from '../AtsuStatusCodes'; + +export class InputValidationFansA { + public static validateScratchpadAltitude(value: string): AtsuStatusCodes { + if (!/^[0-9]{1,5}(FT|M)*$/.test(value)) { + return AtsuStatusCodes.FormatError; + } + + const feet = !value.endsWith('M'); + const altitude = parseInt(value.match(/([0-9]+)/)[0]); + + if (feet) { + if (altitude >= 0 && altitude <= 1000 && !value.endsWith('FT')) { + return AtsuStatusCodes.FormatError; + } + + if (altitude >= 0 && altitude <= 25000) { + return AtsuStatusCodes.Ok; + } + + return AtsuStatusCodes.EntryOutOfRange; + } + + if (altitude >= 0 && altitude <= 12500) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + + public static validateScratchpadSpeed(value: string): AtsuStatusCodes { + if (/^((M*)\.[0-9]{1,2})$/.test(value)) { + // MACH number + let mach = parseInt(value.match(/([0-9]+)/)[0]); + if (mach < 10) mach *= 10; + + if (mach >= 61 && mach <= 92) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } if (/^([0-9]{1,3}(KT)*)$/.test(value)) { + // knots + const knots = parseInt(value.match(/([0-9]+)/)[0]); + if (knots >= 70 && knots <= 350) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.FormatError; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/components/InputValidationFansB.ts b/fbw-a380x/src/systems/atsu/src/components/InputValidationFansB.ts new file mode 100644 index 00000000000..35e77a7c840 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/components/InputValidationFansB.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuStatusCodes } from '../AtsuStatusCodes'; + +export class InputValidationFansB { + public static validateScratchpadAltitude(value: string): AtsuStatusCodes { + if (!/^-*[0-9]{1,5}(FT|M)*$/.test(value)) { + return AtsuStatusCodes.FormatError; + } + + const feet = !value.endsWith('M'); + const altitude = parseInt(value.match(/(-*[0-9]+)/)[0]); + + if (feet) { + if (altitude >= 0 && altitude <= 410 && !value.endsWith('FT')) { + return AtsuStatusCodes.FormatError; + } + if (altitude >= -600 && altitude <= 41000) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + + if (altitude >= -30 && altitude <= 12500) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + + public static validateScratchpadSpeed(value: string): AtsuStatusCodes { + if (/^((M*)\.[0-9]{1,2})$/.test(value)) { + // MACH number + let mach = parseInt(value.match(/([0-9]+)/)[0]); + if (mach < 10) mach *= 10; + + if (mach >= 50 && mach <= 92) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } if (/^([0-9]{1,3}(KT)*)$/.test(value)) { + // knots + const knots = parseInt(value.match(/([0-9]+)/)[0]); + + if (knots >= 0 && knots <= 350) { + return AtsuStatusCodes.Ok; + } + return AtsuStatusCodes.EntryOutOfRange; + } + + return AtsuStatusCodes.FormatError; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/components/UplinkMessageInterpretation.ts b/fbw-a380x/src/systems/atsu/src/components/UplinkMessageInterpretation.ts new file mode 100644 index 00000000000..3352a98cab7 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/components/UplinkMessageInterpretation.ts @@ -0,0 +1,253 @@ +import { coordinateToString, timestampToString } from '../Common'; +import { InputValidation } from '../InputValidation'; +import { CpdlcMessagesDownlink } from '../messages/CpdlcMessageElements'; +import { Atsu } from '../ATSU'; +import { CpdlcMessage } from '../messages/CpdlcMessage'; + +export class UplinkMessageInterpretation { + private static NonAutomaticClosingMessage: string[] = [ + 'UM127', 'UM128', 'UM129', 'UM130', 'UM131', 'UM132', 'UM133', 'UM134', 'UM135', 'UM136', + 'UM137', 'UM138', 'UM139', 'UM140', 'UM141', 'UM142', 'UM143', 'UM144', 'UM145', 'UM146', + 'UM147', 'UM148', 'UM151', 'UM152', 'UM180', 'UM181', 'UM182', 'UM228', 'UM231', 'UM232', + ]; + + private static RequestMessages: string[] = [ + 'DM15', 'DM16', 'DM17', 'DM18', 'DM19', 'DM20', 'DM21', 'DM22', 'DM23', 'DM24', 'DM25', 'DM26', 'DM27', + 'DM51', 'DM52', 'DM53', 'DM54', + 'DM69', 'DM70', 'DM71', 'DM72', 'DM73', 'DM74', + ]; + + private static SemanticAnswerTable = { + UM128: { positiveOrNegative: false, modifiable: false, messages: ['DM28'] }, + UM129: { positiveOrNegative: false, modifiable: false, messages: ['DM37'] }, + UM130: { positiveOrNegative: false, modifiable: false, messages: ['DM31'] }, + UM131: { positiveOrNegative: false, modifiable: true, messages: ['DM57'] }, + UM132: { positiveOrNegative: false, modifiable: true, messages: ['DM33'] }, + UM133: { positiveOrNegative: false, modifiable: true, messages: ['DM32'] }, + UM134: { positiveOrNegative: false, modifiable: true, messages: ['DM34'] }, + UM135: { positiveOrNegative: false, modifiable: true, messages: ['DM38'] }, + UM136: { positiveOrNegative: false, modifiable: true, messages: ['DM39'] }, + UM137: { positiveOrNegative: false, modifiable: true, messages: ['DM40'] }, + UM138: { positiveOrNegative: false, modifiable: true, messages: ['DM46'] }, + UM139: { positiveOrNegative: false, modifiable: true, messages: ['DM45'] }, + UM140: { positiveOrNegative: false, modifiable: true, messages: ['DM42'] }, + UM141: { positiveOrNegative: false, modifiable: true, messages: ['DM43'] }, + UM142: { positiveOrNegative: false, modifiable: true, messages: ['DM44'] }, + UM144: { positiveOrNegative: false, modifiable: true, messages: ['DM47'] }, + UM145: { positiveOrNegative: false, modifiable: true, messages: ['DM35'] }, + UM146: { positiveOrNegative: false, modifiable: true, messages: ['DM36'] }, + UM147: { positiveOrNegative: false, modifiable: true, messages: ['DM48'] }, + UM148: { positiveOrNegative: true, modifiable: true, messages: ['DM81', 'DM82'] }, + UM151: { positiveOrNegative: false, modifiable: true, messages: ['DM83'] }, + UM152: { positiveOrNegative: true, modifiable: true, messages: ['DM85', 'DM86'] }, + UM175: { positiveOrNegative: false, modifiable: false, messages: ['DM72'] }, + UM180: { positiveOrNegative: false, modifiable: false, messages: ['DM76'] }, + UM181: { positiveOrNegative: false, modifiable: true, messages: ['DM67'] }, + UM182: { positiveOrNegative: false, modifiable: true, messages: ['DM79'] }, + UM184: { positiveOrNegative: false, modifiable: true, messages: ['DM67'] }, + UM228: { positiveOrNegative: false, modifiable: true, messages: ['DM104'] }, + UM231: { positiveOrNegative: false, modifiable: true, messages: ['DM106'] }, + UM232: { positiveOrNegative: false, modifiable: true, messages: ['DM109'] }, + }; + + public static MessageRemainsOnDcdu(message: CpdlcMessage): boolean { + return UplinkMessageInterpretation.NonAutomaticClosingMessage.findIndex((elem) => message.Content[0].TypeId === elem) !== -1; + } + + public static SemanticAnswerRequired(message: CpdlcMessage): boolean { + return message.Content[0].TypeId === 'UM143' || message.Content[0].TypeId in UplinkMessageInterpretation.SemanticAnswerTable; + } + + private static getDigitsFromBco16(code: number): number[] { + let codeCopy = code; + const digits: number[] = []; + while (codeCopy > 0) { + digits.push(codeCopy % 16); + codeCopy = Math.floor(codeCopy / 16); + } + if (digits.length < 4) { + const digitsToAdd = 4 - digits.length; + for (let i = 0; i < digitsToAdd; i++) { + digits.push(0); + } + } + digits.reverse(); + return digits; + } + + private static FillPresentData(atsu: Atsu, message: CpdlcMessage): boolean { + switch (message.Content[0]?.TypeId) { + case 'UM132': + message.Response.Content[0].Content[0].Value = coordinateToString({ lat: atsu.currentFlightState().lat, lon: atsu.currentFlightState().lon }, false); + return true; + case 'UM133': + message.Response.Content[0].Content[0].Value = InputValidation.formatScratchpadAltitude(Math.round(atsu.currentFlightState().altitude / 100).toString()); + return true; + case 'UM134': + message.Response.Content[0].Content[0].Value = InputValidation.formatScratchpadSpeed(atsu.currentFlightState().indicatedAirspeed.toString()); + return true; + case 'UM144': + const squawk = UplinkMessageInterpretation.getDigitsFromBco16(SimVar.GetSimVarValue('TRANSPONDER CODE:1', 'Bco16')); + message.Response.Content[0].Content[0].Value = `${squawk[0]}${squawk[1]}${squawk[2]}${squawk[3]}`; + return true; + case 'UM145': + message.Response.Content[0].Content[0].Value = atsu.currentFlightState().heading.toString(); + return true; + case 'UM146': + message.Response.Content[0].Content[0].Value = atsu.currentFlightState().track.toString(); + return true; + case 'UM228': + message.Response.Content[0].Content[0].Value = `${timestampToString(atsu.destinationWaypoint().utc)}Z`; + return true; + default: + return false; + } + } + + private static FillAssignedData(atsu: Atsu, message: CpdlcMessage): boolean { + switch (message.Content[0]?.TypeId) { + case 'UM135': + message.Response.Content[0].Content[0].Value = InputValidation.formatScratchpadAltitude(Math.round(atsu.targetFlightState().altitude / 100).toString()); + return true; + case 'UM136': + message.Response.Content[0].Content[0].Value = InputValidation.formatScratchpadAltitude(atsu.targetFlightState().speed.toString()); + return true; + default: + return false; + } + } + + private static FillPositionReportRelatedData(atsu: Atsu, message: CpdlcMessage): boolean { + switch (message.Content[0]?.TypeId) { + case 'UM138': + if (atsu.lastWaypoint()) message.Response.Content[0].Content[0].Value = `${timestampToString(atsu.lastWaypoint().utc)}Z`; + return true; + case 'UM139': + if (atsu.lastWaypoint()) message.Response.Content[0].Content[0].Value = atsu.lastWaypoint().ident; + return true; + case 'UM140': + if (atsu.activeWaypoint()) message.Response.Content[0].Content[0].Value = atsu.activeWaypoint().ident; + return true; + case 'UM141': + if (atsu.activeWaypoint()) message.Response.Content[0].Content[0].Value = `${timestampToString(atsu.activeWaypoint().utc)}Z`; + return true; + case 'UM142': + if (atsu.nextWaypoint()) message.Response.Content[0].Content[0].Value = atsu.nextWaypoint().ident; + return true; + case 'UM147': + message.Response = Atsu.createAutomatedPositionReport(atsu); + return true; + case 'UM148': + case 'UM151': + message.Response.Content[0].Content[0].Value = message.Content[0].Content[0].Value; + return true; + case 'UM152': + message.Response.Content[0].Content[0].Value = message.Content[0].Content[0].Value; + message.Response.Content[0].Content[1].Value = message.Content[0].Content[1].Value; + return true; + case 'UM228': + if (atsu.destinationWaypoint()) { + message.Response.Content[0].Content[0].Value = atsu.destinationWaypoint().ident; + message.Response.Content[0].Content[1].Value = `${timestampToString(atsu.destinationWaypoint().utc)}Z`; + } + return true; + default: + return false; + } + } + + private static FillReportingRelatedData(message: CpdlcMessage): boolean { + switch (message.Content[0]?.TypeId) { + case 'UM128': + case 'UM129': + case 'UM130': + case 'UM175': + message.Response.Content[0].Content[0].Value = message.Content[0].Content[0].Value; + return true; + case 'UM180': + for (let i = 0; i < message.Response.Content[0].Content.length; ++i) { + message.Response.Content[0].Content[i].Value = message.Content[0].Content[i].Value; + } + return true; + default: + return false; + } + } + + public static AppendSemanticAnswer(atsu: Atsu, positiveAnswer: boolean, message: CpdlcMessage): boolean { + if (message.Content[0]?.TypeId === 'UM143') { + // find last request and create a deep copy + for (const atcMessage of atsu.atc.messages()) { + const cpdlc = atcMessage as CpdlcMessage; + + if (UplinkMessageInterpretation.RequestMessages.findIndex((elem) => elem === cpdlc.Content[0].TypeId) !== -1) { + const response = new CpdlcMessage(); + + response.Station = atcMessage.Station; + response.PreviousTransmissionId = message.CurrentTransmissionId; + for (const entry of cpdlc.Content) { + response.Content.push(entry.deepCopy()); + } + + message.Response = response; + return true; + } + } + + if (!message.Response) { + const response = new CpdlcMessage(); + response.Station = message.Station; + response.PreviousTransmissionId = message.CurrentTransmissionId; + response.Content.push(CpdlcMessagesDownlink.DM67[1].deepCopy()); + response.Content[0].Content[0].Value = 'NO REQUEST TRANSMITTED'; + message.Response = response; + } + } else if (message.Content[0]?.TypeId in UplinkMessageInterpretation.SemanticAnswerTable) { + const lutEntry = UplinkMessageInterpretation.SemanticAnswerTable[message.Content[0].TypeId]; + if (lutEntry.positiveOrNegative) { + const response = new CpdlcMessage(); + response.Station = message.Station; + response.PreviousTransmissionId = message.CurrentTransmissionId; + + if (positiveAnswer) { + response.Content.push(CpdlcMessagesDownlink[lutEntry.messages[0]][1].deepCopy()); + } else { + response.Content.push(CpdlcMessagesDownlink[lutEntry.messages[1]][1].deepCopy()); + } + + message.Response = response; + } else if (lutEntry.messages[0] in CpdlcMessagesDownlink) { + const response = new CpdlcMessage(); + response.Station = message.Station; + response.PreviousTransmissionId = message.CurrentTransmissionId; + response.Content.push(CpdlcMessagesDownlink[lutEntry.messages[0]][1].deepCopy()); + message.Response = response; + } + } + + if (!UplinkMessageInterpretation.FillPresentData(atsu, message) && !UplinkMessageInterpretation.FillAssignedData(atsu, message)) { + if (!UplinkMessageInterpretation.FillPositionReportRelatedData(atsu, message)) { + UplinkMessageInterpretation.FillReportingRelatedData(message); + } + } + return false; + } + + public static HasNegativeResponse(message: CpdlcMessage): boolean { + if (message.Content[0]?.TypeId in UplinkMessageInterpretation.SemanticAnswerTable) { + const lutEntry = UplinkMessageInterpretation.SemanticAnswerTable[message.Content[0]?.TypeId]; + if (lutEntry.positiveOrNegative) { + return message.Response.Content[0].TypeId !== lutEntry.messages[1]; + } + } + return false; + } + + public static IsModifiable(message: CpdlcMessage): boolean { + if (message.Content[0]?.TypeId in UplinkMessageInterpretation.SemanticAnswerTable) { + const lutEntry = UplinkMessageInterpretation.SemanticAnswerTable[message.Content[0]?.TypeId]; + return lutEntry.modifiable; + } + return false; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/components/UplinkMessageMonitoring.ts b/fbw-a380x/src/systems/atsu/src/components/UplinkMessageMonitoring.ts new file mode 100644 index 00000000000..45a2659e886 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/components/UplinkMessageMonitoring.ts @@ -0,0 +1,217 @@ +import { Atsu } from '../ATSU'; +import { CpdlcMessage } from '../messages/CpdlcMessage'; +import { AtsuMessageComStatus } from '../messages/AtsuMessage'; + +export abstract class UplinkMonitor { + private static positionMonitoringMessageIds = ['UM22', 'UM25', 'UM65', 'UM77', 'UM83', 'UM84', 'UM97', 'UM118', 'UM121', 'UM130']; + + private static timeMonitoringMessageIds = ['UM21', 'UM24', 'UM66', 'UM76', 'UM119', 'UM122', 'UM184']; + + private static levelMonitoringMessageIds = ['UM78', 'UM128', 'UM129', 'UM130', 'UM175', 'UM180']; + + protected atsu: Atsu = null; + + public messageId = -1; + + constructor(atsu: Atsu, message: CpdlcMessage) { + this.atsu = atsu; + this.messageId = message.UniqueMessageID; + } + + public static relevantMessage(message: CpdlcMessage): boolean { + if (UplinkMonitor.positionMonitoringMessageIds.findIndex((id) => id === message.Content[0]?.TypeId) === -1 + && UplinkMonitor.timeMonitoringMessageIds.findIndex((id) => id === message.Content[0]?.TypeId) === -1 + && UplinkMonitor.levelMonitoringMessageIds.findIndex((id) => id === message.Content[0]?.TypeId) === -1) { + return false; + } + + return true; + } + + public static createMessageMonitor(atsu: Atsu, message: CpdlcMessage): UplinkMonitor { + if (UplinkMonitor.positionMonitoringMessageIds.findIndex((id) => id === message.Content[0]?.TypeId) !== -1) { + return new PositionMonitor(atsu, message); + } + if (UplinkMonitor.timeMonitoringMessageIds.findIndex((id) => id === message.Content[0]?.TypeId) !== -1) { + return new TimeMonitor(atsu, message); + } + if (UplinkMonitor.levelMonitoringMessageIds.findIndex((id) => id === message.Content[0]?.TypeId) !== -1) { + return new LevelMonitor(atsu, message); + } + + return null; + } + + abstract conditionsMet(): boolean; +} + +class PositionMonitor extends UplinkMonitor { + private positionMonitor = ''; + + constructor(atsu: Atsu, message: CpdlcMessage) { + super(atsu, message); + this.positionMonitor = message.Content[0]?.Content[0]?.Value; + } + + public conditionsMet(): boolean { + if (this.atsu.lastWaypoint()) { + const lastPosition = this.atsu.lastWaypoint().ident; + return this.positionMonitor === lastPosition; + } + + return false; + } +} + +class TimeMonitor extends UplinkMonitor { + private static deferredMessageIDs = ['UM66', 'UM69', 'UM119', 'UM122']; + + private timeOffset = 0; + + private timeMonitor = -1; + + private static extractSeconds(value: string): number { + const matches = value.match(/[0-9]{2}/g); + const hours = parseInt(matches[0]); + const minutes = parseInt(matches[1]); + return (hours * 60 + minutes) * 60; + } + + constructor(atsu: Atsu, message: CpdlcMessage) { + super(atsu, message); + if (TimeMonitor.deferredMessageIDs.findIndex((id) => id === message.Content[0]?.TypeId) !== -1) { + this.timeOffset = 30; + } + this.timeMonitor = TimeMonitor.extractSeconds(message.Content[0]?.Content[0]?.Value); + } + + public conditionsMet(): boolean { + const currentTime = SimVar.GetSimVarValue('E:ZULU TIME', 'seconds'); + + if ((currentTime + this.timeOffset) >= this.timeMonitor) { + // avoid errors due to day change (2359 to 0001) + return (currentTime - this.timeMonitor) < 30; + } + + return false; + } +} + +class LevelMonitor extends UplinkMonitor { + private lowerLevel = -1; + + private upperLevel = -1; + + private reachingLevel = false; + + private leavingLevel = false; + + private reachedLevel = false; + + private static extractAltitude(value: string): number { + let altitude = parseInt(value.match(/[0-9]+/)[0]); + if (value.startsWith('FL')) { + altitude *= 100; + } else if (value.endsWith('M')) { + altitude *= 3.28084; + } + return altitude; + } + + constructor(atsu: Atsu, message: CpdlcMessage) { + super(atsu, message); + + this.lowerLevel = LevelMonitor.extractAltitude(message.Content[0]?.Content[0]?.Value); + if (message.Content[0]?.TypeId === 'UM180') { + this.upperLevel = LevelMonitor.extractAltitude(message.Content[0]?.Content[1].Value); + this.reachingLevel = true; + } else if (message.Content[0]?.TypeId === 'UM78' || message.Content[0]?.TypeId === 'UM129' || message.Content[0]?.TypeId === 'UM175') { + this.reachingLevel = true; + } else if (message.Content[0]?.TypeId === 'UM128') { + this.reachingLevel = false; + } else if (message.Content[0]?.TypeId === 'UM130') { + this.reachingLevel = true; + this.leavingLevel = true; + } + } + + public conditionsMet(): boolean { + const currentAltitude = this.atsu.currentFlightState().altitude; + + if (this.reachingLevel && this.leavingLevel) { + if (!this.reachedLevel) { + this.reachedLevel = Math.abs(currentAltitude - this.lowerLevel) <= 100; + } else { + return Math.abs(currentAltitude - this.lowerLevel) > 100; + } + } + if (!this.reachingLevel) { + return Math.abs(currentAltitude - this.lowerLevel) > 100; + } + if (this.upperLevel > -1) { + return this.lowerLevel <= currentAltitude && this.upperLevel >= currentAltitude; + } + + return Math.abs(currentAltitude - this.lowerLevel) <= 100; + } +} + +export class UplinkMessageMonitoring { + private monitoredMessages: UplinkMonitor[] = []; + + private atsu: Atsu = null; + + constructor(atsu: Atsu) { + this.atsu = atsu; + } + + public + + public monitorMessage(message: CpdlcMessage): boolean { + if (UplinkMonitor.relevantMessage(message)) { + this.monitoredMessages.push(UplinkMonitor.createMessageMonitor(this.atsu, message)); + return true; + } + return false; + } + + public removeMessage(uid: number): void { + const idx = this.monitoredMessages.findIndex((message) => message.messageId === uid); + if (idx > -1) { + this.monitoredMessages.splice(idx, 1); + } + } + + public monitoredMessageIds(): number[] { + const ids = []; + this.monitoredMessages.forEach((monitor) => ids.push(monitor.messageId)); + return ids; + } + + private findAtcMessage(uid: number): CpdlcMessage | undefined { + for (const message of this.atsu.atc.messages()) { + if (message.UniqueMessageID === uid) { + return message as CpdlcMessage; + } + } + return undefined; + } + + public checkMessageConditions(): number[] { + const ids = []; + + let idx = this.monitoredMessages.length - 1; + while (idx >= 0) { + if (this.monitoredMessages[idx].conditionsMet()) { + const message = this.findAtcMessage(this.monitoredMessages[idx].messageId); + if (message !== undefined && message.Response?.ComStatus === AtsuMessageComStatus.Sent) { + ids.push(this.monitoredMessages[idx].messageId); + this.monitoredMessages.splice(idx, 1); + } + } + idx -= 1; + } + + return ids; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/components/UplinkMessageStateMachine.ts b/fbw-a380x/src/systems/atsu/src/components/UplinkMessageStateMachine.ts new file mode 100644 index 00000000000..c170af3cadb --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/components/UplinkMessageStateMachine.ts @@ -0,0 +1,45 @@ +import { Atsu } from '../ATSU'; +import { CpdlcMessage, CpdlcMessageMonitoringState } from '../messages/CpdlcMessage'; +import { UplinkMonitor } from './UplinkMessageMonitoring'; +import { UplinkMessageInterpretation } from './UplinkMessageInterpretation'; +import { AtsuMessageComStatus } from '../messages/AtsuMessage'; + +export class UplinkMessageStateMachine { + public static initialize(atsu: Atsu, message: CpdlcMessage): void { + message.CloseAutomatically = !UplinkMessageInterpretation.MessageRemainsOnDcdu(message); + + if (UplinkMonitor.relevantMessage(message)) { + message.MessageMonitoring = CpdlcMessageMonitoringState.Required; + message.SemanticResponseRequired = false; + } else { + message.MessageMonitoring = CpdlcMessageMonitoringState.Ignored; + message.SemanticResponseRequired = UplinkMessageInterpretation.SemanticAnswerRequired(message); + if (message.SemanticResponseRequired) { + UplinkMessageInterpretation.AppendSemanticAnswer(atsu, true, message); + } + } + } + + public static update(atsu: Atsu, message: CpdlcMessage, uiEvent: boolean, positive: boolean): void { + if (positive) { + if (message.MessageMonitoring === CpdlcMessageMonitoringState.Required) { + message.MessageMonitoring = CpdlcMessageMonitoringState.Monitoring; + atsu.atc.messageMonitoring.monitorMessage(message); + } else if (!uiEvent && message.MessageMonitoring === CpdlcMessageMonitoringState.Monitoring) { + message.MessageMonitoring = CpdlcMessageMonitoringState.Finished; + message.SemanticResponseRequired = UplinkMessageInterpretation.SemanticAnswerRequired(message); + } + } else if (message.MessageMonitoring === CpdlcMessageMonitoringState.Monitoring) { + if (message.Response?.ComStatus === AtsuMessageComStatus.Sending || message.Response?.ComStatus === AtsuMessageComStatus.Sent) { + message.MessageMonitoring = CpdlcMessageMonitoringState.Cancelled; + } else { + message.MessageMonitoring = CpdlcMessageMonitoringState.Required; + } + atsu.atc.messageMonitoring.removeMessage(message.UniqueMessageID); + } + + if (message.SemanticResponseRequired) { + UplinkMessageInterpretation.AppendSemanticAnswer(atsu, positive, message); + } + } +} diff --git a/fbw-a380x/src/systems/atsu/src/index.ts b/fbw-a380x/src/systems/atsu/src/index.ts new file mode 100644 index 00000000000..7e70d0a2f8c --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/index.ts @@ -0,0 +1,60 @@ +import { AtsuStatusCodes } from './AtsuStatusCodes'; +import { Atsu } from './ATSU'; +import { FreetextMessage } from './messages/FreetextMessage'; +import { AtsuMessage, AtsuMessageComStatus, AtsuMessageDirection, AtsuMessageSerializationFormat, AtsuMessageNetwork, AtsuMessageType } from './messages/AtsuMessage'; +import { AtsuTimestamp } from './messages/AtsuTimestamp'; +import { CpdlcMessageExpectedResponseType, CpdlcMessageContentType, CpdlcMessageContent, CpdlcMessageElement, CpdlcMessagesDownlink } from './messages/CpdlcMessageElements'; +import { CpdlcMessage } from './messages/CpdlcMessage'; +import { WeatherMessage } from './messages/WeatherMessage'; +import { MetarMessage } from './messages/MetarMessage'; +import { TafMessage } from './messages/TafMessage'; +import { AtisMessage, AtisType } from './messages/AtisMessage'; +import { Aoc } from './AOC'; +import { Atc } from './ATC'; +import { DclMessage } from './messages/DclMessage'; +import { OclMessage } from './messages/OclMessage'; +import { InputValidation, InputWaypointType } from './InputValidation'; +import { FansMode } from './com/FutureAirNavigationSystem'; +import { HoppieConnector } from './com/webinterfaces/HoppieConnector'; +import { Waypoint } from './components/FlightStateObserver'; +import { coordinateToString } from './Common'; +import { UplinkMessageInterpretation } from './components/UplinkMessageInterpretation'; +import { UplinkMessageStateMachine } from './components/UplinkMessageStateMachine'; +import { UplinkMonitor } from './components/UplinkMessageMonitoring'; + +export { + AtsuStatusCodes, + AtsuMessage, + AtsuMessageComStatus, + AtsuMessageDirection, + AtsuMessageNetwork, + AtsuMessageSerializationFormat, + AtsuMessageType, + Atsu, + AtsuTimestamp, + CpdlcMessage, + CpdlcMessageExpectedResponseType, + CpdlcMessageContent, + CpdlcMessageElement, + CpdlcMessageContentType, + CpdlcMessagesDownlink, + FreetextMessage, + WeatherMessage, + MetarMessage, + TafMessage, + AtisMessage, + AtisType, + Aoc, + Atc, + DclMessage, + OclMessage, + FansMode, + InputValidation, + InputWaypointType, + HoppieConnector, + Waypoint, + coordinateToString, + UplinkMessageInterpretation, + UplinkMessageStateMachine, + UplinkMonitor, +}; diff --git a/fbw-a380x/src/systems/atsu/src/messages/AtisMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/AtisMessage.ts new file mode 100644 index 00000000000..01ac713c524 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/AtisMessage.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { NXDataStore } from '@shared/persistence'; +import { AtsuMessageType } from './AtsuMessage'; +import { WeatherMessage } from './WeatherMessage'; + +export enum AtisType { + Departure, + Arrival, + Enroute +} + +/** + * Defines the general ATIS message format + */ +export class AtisMessage extends WeatherMessage { + public Information = ''; + + constructor() { + super(); + this.Type = AtsuMessageType.ATIS; + this.Station = NXDataStore.get('CONFIG_ATIS_SRC', 'MSFS'); + } + + public parseInformation(): void { + let foundInfo = false; + + // this function is only relevant for the ATC updater + this.Reports.forEach((report) => { + report.report.split(' ').forEach((word) => { + // expect 'INFORMATION H' or 'INFORMATION HOTEL' + if (foundInfo === false) { + if (word === 'INFORMATION' || word === 'INFO') { + foundInfo = true; + } + } else { + this.Information = word; + // fix 'INFORMATION HOTEL' + if (this.Information.length > 1) { + this.Information = this.Information[0]; + } + foundInfo = false; + } + }); + }); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/messages/AtsuMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/AtsuMessage.ts new file mode 100644 index 00000000000..df2193784ee --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/AtsuMessage.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuTimestamp } from './AtsuTimestamp'; + +export enum AtsuMessageNetwork { + Hoppie, + FBW +} + +export enum AtsuMessageDirection { + Uplink, + Downlink +} + +export enum AtsuMessageType { + Freetext = 0, + METAR = 1, + TAF = 2, + ATIS = 3, + AOC = 4, + CPDLC = 5, + DCL = 6, + OCL = 7, + ATC = 8 +} + +export enum AtsuMessageComStatus { + Open, + Sending, + Sent, + Received, + Failed +} + +export enum AtsuMessageSerializationFormat { + MCDU, + MCDUMonitored, + DCDU, + Printer, + Network +} + +/** + * Defines the generic ATC message + */ +export class AtsuMessage { + public Network = AtsuMessageNetwork.Hoppie; + + public UniqueMessageID: number = -1; + + public Timestamp: AtsuTimestamp = new AtsuTimestamp(); + + public Station = ''; + + public ComStatus: AtsuMessageComStatus = AtsuMessageComStatus.Open; + + public Type: AtsuMessageType = null; + + public Direction: AtsuMessageDirection = null; + + public Confirmed = false; + + public Message = ''; + + public serialize(_format: AtsuMessageSerializationFormat) : string { + throw new Error('No valid implementation'); + } + + // used to deserialize event data + public deserialize(jsonData: Record) { + this.Network = jsonData.Network; + this.UniqueMessageID = jsonData.UniqueMessageID; + if (jsonData.Timestamp) { + this.Timestamp = new AtsuTimestamp(); + this.Timestamp.deserialize(jsonData.Timestamp); + } + this.Station = jsonData.Station; + this.ComStatus = jsonData.ComStatus; + this.Type = jsonData.Type; + this.Direction = jsonData.Direction; + this.Confirmed = jsonData.Confirmed; + this.Message = jsonData.Message; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/messages/AtsuTimestamp.ts b/fbw-a380x/src/systems/atsu/src/messages/AtsuTimestamp.ts new file mode 100644 index 00000000000..34156d41dad --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/AtsuTimestamp.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { timestampToString } from '../Common'; + +/** + * Defines the decoded UTC timestamp + */ +export class AtsuTimestamp { + public Year: number = SimVar.GetSimVarValue('E:ZULU YEAR', 'number'); + + public Month: number = SimVar.GetSimVarValue('E:ZULU MONTH OF YEAR', 'number'); + + public Day: number = SimVar.GetSimVarValue('E:ZULU DAY OF MONTH', 'number'); + + public Seconds: number = SimVar.GetSimVarValue('E:ZULU TIME', 'seconds'); + + public deserialize(jsonData) { + this.Year = jsonData.Year; + this.Month = jsonData.Month; + this.Day = jsonData.Day; + this.Seconds = jsonData.Seconds; + } + + public dcduTimestamp(): string { + return `${timestampToString(this.Seconds)}Z`; + } + + public mcduTimestamp(): string { + return timestampToString(this.Seconds); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/messages/CpdlcMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/CpdlcMessage.ts new file mode 100644 index 00000000000..6bbd462cc74 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/CpdlcMessage.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuMessageNetwork, AtsuMessageType, AtsuMessageDirection, AtsuMessageSerializationFormat, AtsuMessage } from './AtsuMessage'; +import { CpdlcMessageElement, CpdlcMessagesDownlink, CpdlcMessagesUplink } from './CpdlcMessageElements'; +import { wordWrap } from '../Common'; + +export enum CpdlcMessageMonitoringState { + Ignored = 0, + Required = 1, + Monitoring = 2, + Cancelled = 3, + Finished = 4 +} + +/** + * Defines the general freetext message format + */ +export class CpdlcMessage extends AtsuMessage { + public Content: CpdlcMessageElement[] = []; + + public Response: CpdlcMessage = null; + + public CurrentTransmissionId = -1; + + public PreviousTransmissionId = -1; + + public DcduRelevantMessage = true; + + public CloseAutomatically = true; + + public MessageMonitoring = CpdlcMessageMonitoringState.Ignored; + + public SemanticResponseRequired = false; + + constructor() { + super(); + this.Type = AtsuMessageType.CPDLC; + this.Network = AtsuMessageNetwork.Hoppie; + this.Direction = AtsuMessageDirection.Downlink; + } + + public deserialize(jsonData: any): void { + super.deserialize(jsonData); + + jsonData.Content.forEach((element) => { + const entry = new CpdlcMessageElement(''); + entry.deserialize(element); + this.Content.push(entry); + }); + if (jsonData.Response) { + this.Response = new CpdlcMessage(); + this.Response.deserialize(jsonData.Response); + } + this.CurrentTransmissionId = jsonData.CurrentTransmissionId; + this.PreviousTransmissionId = jsonData.PreviousTransmissionId; + this.DcduRelevantMessage = jsonData.DcduRelevantMessage; + this.CloseAutomatically = jsonData.CloseAutomatically; + this.MessageMonitoring = jsonData.MessageMonitoring; + this.SemanticResponseRequired = jsonData.SemanticResponseRequired; + } + + protected serializeContent(format: AtsuMessageSerializationFormat, template: string, element: CpdlcMessageElement): string { + let content: string = ''; + + content = template; + element.Content.forEach((entry) => { + const idx = content.indexOf('%s'); + if (format === AtsuMessageSerializationFormat.Network) { + content = `${content.substring(0, idx)}${entry.Value}${content.substring(idx + 2)}`; + } else if (entry.Value !== '') { + if (this.MessageMonitoring === CpdlcMessageMonitoringState.Monitoring && format === AtsuMessageSerializationFormat.MCDUMonitored) { + content = `${content.substring(0, idx)}{magenta}${entry.Value}{end}${content.substring(idx + 2)}`; + } else { + content = `${content.substring(0, idx)}@${entry.Value}@${content.substring(idx + 2)}`; + } + } else { + content = `${content.substring(0, idx)}[ ]${content.substring(idx + 2)}`; + } + }); + + return content; + } + + protected extendSerializationWithResponse(): boolean { + if (!this.Response || this.Response.Content.length === 0) { + return false; + } + + // ignore the standard responses + return this.Response.Content[0]?.TypeId !== 'DM0' && this.Response.Content[0]?.TypeId !== 'DM1' && this.Response.Content[0]?.TypeId !== 'DM2' + && this.Response.Content[0]?.TypeId !== 'DM3' && this.Response.Content[0]?.TypeId !== 'DM4' && this.Response.Content[0]?.TypeId !== 'DM5' + && this.Response.Content[0]?.TypeId !== 'UM0' && this.Response.Content[0]?.TypeId !== 'UM1' && this.Response.Content[0]?.TypeId !== 'UM3' + && this.Response.Content[0]?.TypeId !== 'UM4' && this.Response.Content[0]?.TypeId !== 'UM5'; + } + + public serialize(format: AtsuMessageSerializationFormat) { + const lineLength = format === AtsuMessageSerializationFormat.DCDU ? 30 : 25; + const lines: string[] = []; + let message: string = ''; + + if (this.Content.length !== 0) { + for (const element of this.Content) { + if (this.Direction === AtsuMessageDirection.Downlink) { + lines.push(...wordWrap(this.serializeContent(format, CpdlcMessagesDownlink[element.TypeId][0][0], element), lineLength)); + } else { + lines.push(...wordWrap(this.serializeContent(format, CpdlcMessagesUplink[element.TypeId][0][0], element), lineLength)); + } + } + } else { + this.Message.split('_').forEach((entry) => { + lines.push(...wordWrap(entry, lineLength)); + }); + } + + if (format === AtsuMessageSerializationFormat.Network) { + message = `/data2/${this.CurrentTransmissionId}/${this.PreviousTransmissionId !== -1 ? this.PreviousTransmissionId : ''}/${this.Content[0]?.ExpectedResponse}/${lines.join(' ')}`; + } else if (format === AtsuMessageSerializationFormat.DCDU) { + message = lines.join('\n'); + } else if (format === AtsuMessageSerializationFormat.MCDU || format === AtsuMessageSerializationFormat.MCDUMonitored) { + if (this.Direction === AtsuMessageDirection.Uplink) { + message += `{cyan}${this.Timestamp.dcduTimestamp()} FROM ${this.Station}{end}\n`; + } else { + message += `{cyan}${this.Timestamp.dcduTimestamp()} TO ${this.Station}{end}\n`; + } + + lines.forEach((line) => { + line = line.replace(/@/gi, ''); + if (format === AtsuMessageSerializationFormat.MCDUMonitored) { + message += line; + } else { + message += `{green}${line}{end}\n`; + } + }); + + message += '{white}------------------------{end}\n'; + + if (this.extendSerializationWithResponse()) { + message += this.Response.serialize(format); + } + } else if (format === AtsuMessageSerializationFormat.Printer) { + message += `${this.Timestamp.dcduTimestamp()} ${this.Direction === AtsuMessageDirection.Uplink ? 'FROM' : 'TO'} ${this.Station}}\n`; + + lines.forEach((line) => { + line = line.replace(/@/gi, ''); + message += `${line}\n`; + }); + + message += '------------------------\n'; + + if (this.extendSerializationWithResponse()) { + message += this.Response.serialize(format); + } + } else { + message = this.Message; + } + + return message; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/messages/CpdlcMessageElements.ts b/fbw-a380x/src/systems/atsu/src/messages/CpdlcMessageElements.ts new file mode 100644 index 00000000000..c6b94c99153 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/CpdlcMessageElements.ts @@ -0,0 +1,953 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuStatusCodes } from '../AtsuStatusCodes'; +import { InputValidation } from '../InputValidation'; +import { FansMode } from '../com/FutureAirNavigationSystem'; + +export enum CpdlcMessageExpectedResponseType { + NotRequired = 'NE', + WilcoUnable = 'WU', + AffirmNegative = 'AN', + Roger = 'R', + No = 'N', + Yes = 'Y' +} + +export enum CpdlcMessageContentType { + Unknown, + Level, + Position, + Time, + Direction, + Distance, + Speed, + Frequency, + Procedure, + Degree, + VerticalRate, + LegType, + LegTypeDistance, + LegTypeTime, + AtcUnit, + Squawk, + Altimeter, + Atis, + Fuel, + PersonsOnBoard, + Freetext +} + +export abstract class CpdlcMessageContent { + public Type: CpdlcMessageContentType = CpdlcMessageContentType.Unknown; + + public IndexStart: number = -1; + + public IndexEnd: number = -1; + + public Monitoring: boolean = false; + + public Value: string = ''; + + public constructor(type: CpdlcMessageContentType, ...args: any[]) { + this.Type = type; + + args.forEach((arg) => { + if (typeof arg === 'number') { + if (this.IndexStart === -1) { + this.IndexStart = arg as number; + } else { + this.IndexEnd = arg as number; + } + } else if (typeof arg === 'boolean') { + this.Monitoring = arg as boolean; + } else if (typeof arg === 'string') { + this.Value = arg as string; + } + }); + } + + abstract validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] }; + + public static createInstance(type: CpdlcMessageContentType): CpdlcMessageContent { + switch (type) { + case CpdlcMessageContentType.Level: + return new CpdlcMessageContentLevel(0); + case CpdlcMessageContentType.Position: + return new CpdlcMessageContentPosition(0); + case CpdlcMessageContentType.Time: + return new CpdlcMessageContentTime(0); + case CpdlcMessageContentType.Direction: + return new CpdlcMessageContentDirection(0); + case CpdlcMessageContentType.Distance: + return new CpdlcMessageContentDistance(0); + case CpdlcMessageContentType.Speed: + return new CpdlcMessageContentSpeed(0); + case CpdlcMessageContentType.Frequency: + return new CpdlcMessageContentFrequency(0); + case CpdlcMessageContentType.Procedure: + return new CpdlcMessageContentProcedure(0); + case CpdlcMessageContentType.Degree: + return new CpdlcMessageContentDegree(0); + case CpdlcMessageContentType.VerticalRate: + return new CpdlcMessageContentVerticalRate(0); + case CpdlcMessageContentType.LegType: + return new CpdlcMessageContentLegType(0); + case CpdlcMessageContentType.LegTypeDistance: + return new CpdlcMessageContentLegTypeDistance(0); + case CpdlcMessageContentType.LegTypeTime: + return new CpdlcMessageContentLegTypeTime(0); + case CpdlcMessageContentType.AtcUnit: + return new CpdlcMessageContentAtcUnit(0); + case CpdlcMessageContentType.Squawk: + return new CpdlcMessageContentSquawk(0); + case CpdlcMessageContentType.Altimeter: + return new CpdlcMessageContentAltimeter(0); + case CpdlcMessageContentType.Atis: + return new CpdlcMessageContentAtis(0); + case CpdlcMessageContentType.Fuel: + return new CpdlcMessageContentFuel(0); + case CpdlcMessageContentType.PersonsOnBoard: + return new CpdlcMessageContentPersonsOnBoard(0); + case CpdlcMessageContentType.Freetext: + return new CpdlcMessageContentFreetext(0, 0); + default: + return null; + } + } + + public deserialize(jsonData: any): void { + this.Type = jsonData.Type; + this.IndexStart = jsonData.IndexStart; + this.IndexEnd = jsonData.IndexEnd; + this.Value = jsonData.Value; + this.Monitoring = jsonData.Monitoring; + } +} + +export class CpdlcMessageContentLevel extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Level, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + retval = InputValidation.validateScratchpadAltitude(value[this.IndexStart]) === AtsuStatusCodes.Ok; + if (retval) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + } + } + if (!retval && this.IndexEnd < value.length && this.IndexEnd > -1) { + retval = InputValidation.validateScratchpadAltitude(value[this.IndexEnd]) === AtsuStatusCodes.Ok; + if (retval) { + this.Value = value[this.IndexEnd]; + value[this.IndexEnd] = '%s'; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentPosition extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Position, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (InputValidation.validateScratchpadWaypoint(value[this.IndexStart]) === AtsuStatusCodes.Ok + && InputValidation.validateScratchpadTime(value[this.IndexStart], true) !== AtsuStatusCodes.Ok + && InputValidation.validateScratchpadTime(value[this.IndexStart], false) !== AtsuStatusCodes.Ok) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentTime extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Time, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (InputValidation.validateScratchpadTime(value[this.IndexStart], true) === AtsuStatusCodes.Ok) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } else if (InputValidation.validateScratchpadTime(value[this.IndexStart], false) === AtsuStatusCodes.Ok) { + this.Value = `${value[this.IndexStart]}Z`; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentDirection extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Direction, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (value[this.IndexStart] === 'LEFT' || value[this.IndexStart] === 'RIGHT') { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentDistance extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Distance, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (InputValidation.validateScratchpadDistance(value[this.IndexStart]) === AtsuStatusCodes.Ok) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentSpeed extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Speed, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (InputValidation.validateScratchpadSpeed(value[this.IndexStart]) === AtsuStatusCodes.Ok) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentFrequency extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Frequency, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (InputValidation.validateVhfFrequency(value[this.IndexStart]) === AtsuStatusCodes.Ok) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentProcedure extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Procedure, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (InputValidation.validateScratchpadProcedure(value[this.IndexStart]) === AtsuStatusCodes.Ok) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentDegree extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Degree, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (InputValidation.validateScratchpadDegree(value[this.IndexStart]) === AtsuStatusCodes.Ok) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentVerticalRate extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.VerticalRate, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart + 3 < value.length && this.IndexStart > -1) { + if (value[this.IndexStart + 1] === 'FEET' && value[this.IndexStart + 2] === 'PER' && value[this.IndexStart + 3] === 'MINUTE') { + this.Value = `${value[this.IndexStart]} FEET PER MINUTE`; + value[this.IndexStart] = '%s'; + value.slice(this.IndexStart + 1, 3); + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentAtcUnit extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.AtcUnit, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (this.IndexStart + 1 < value.length && value[this.IndexStart + 1] === 'CTR') { + this.Value = `${value[this.IndexStart]} ${value[this.IndexStart + 1]}`; + value.splice(this.IndexStart + 1, 1); + } else { + this.Value = value[this.IndexStart]; + } + value[this.IndexStart] = '%s'; + retval = true; + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentSquawk extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Squawk, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1 && /^[0-9]{4}$/.test(value[this.IndexStart])) { + const squawk = parseInt(value[this.IndexStart]); + if (squawk >= 0 && squawk < 7777) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentFreetext extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Freetext, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + this.Value = value.slice(this.IndexStart, this.IndexEnd === -1 ? value.length : this.IndexEnd + 1).join(' '); + value = value.slice(0, this.IndexStart); + value.push('%s'); + retval = true; + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentLegTypeDistance extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.LegTypeDistance, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (/^[0-9]{1,2}$/.test(value[this.IndexStart])) { + const distance = parseInt(value[this.IndexStart]); + if (distance >= 1 && distance < 100) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentLegTypeTime extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.LegTypeTime, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart + 1 < value.length && this.IndexStart > -1 && /^[0-9]{1}$/.test(value[this.IndexStart])) { + if (value[this.IndexStart + 1] === 'MIN' || value[this.IndexStart + 1] === 'MINS' || value[this.IndexStart + 1] === 'MINUTES') { + const minutes = parseInt(value[this.IndexStart]); + if (minutes >= 1 && minutes < 10) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentLegType extends CpdlcMessageContent { + private legDistance: CpdlcMessageContentLegTypeDistance; + + private legTime: CpdlcMessageContentLegTypeTime; + + public constructor(...args: any[]) { + super(CpdlcMessageContentType.LegType, ...args); + this.legDistance = new CpdlcMessageContentLegTypeDistance(...args); + this.legTime = new CpdlcMessageContentLegTypeTime(...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + const legTimeRetval = this.legTime.validateAndReplaceContent(value); + if (legTimeRetval.matched === true) { + return legTimeRetval; + } + return this.legDistance.validateAndReplaceContent(value); + } +} + +export class CpdlcMessageContentAltimeter extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Altimeter, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart >= 1 && this.IndexStart < value.length && this.IndexStart > -1) { + if (value[this.IndexStart - 1] === 'ALTIMETER' && /^[0-9]{2}\.[0-9]{2}$/.test(value[this.IndexStart])) { + retval = true; + } else if (value[this.IndexStart - 1] === 'QNH' && /^[0-9]{3,4}$/.test(value[this.IndexStart])) { + retval = true; + } + + if (retval === true) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentAtis extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Atis, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (/^[A-Z]{1}$/.test(value[this.IndexStart])) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentFuel extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.Fuel, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (/^[0-9]{1,6}$/.test(value[this.IndexStart])) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageContentPersonsOnBoard extends CpdlcMessageContent { + public constructor(...args: any[]) { + super(CpdlcMessageContentType.PersonsOnBoard, ...args); + } + + public validateAndReplaceContent(value: string[]): { matched: boolean, remaining: string[] } { + let retval = false; + if (this.IndexStart < value.length && this.IndexStart > -1) { + if (/^[0-9]{1,3}$/.test(value[this.IndexStart])) { + this.Value = value[this.IndexStart]; + value[this.IndexStart] = '%s'; + retval = true; + } + } + return { matched: retval, remaining: value }; + } +} + +export class CpdlcMessageElement { + public TypeId: string = ''; + + public FansModes: FansMode[] = []; + + public Urgent: boolean = false; + + public Content: CpdlcMessageContent[] = []; + + public ExpectedResponse: CpdlcMessageExpectedResponseType = CpdlcMessageExpectedResponseType.No; + + public constructor(typeId: string, ...args: any[]) { + this.TypeId = typeId; + args.forEach((arg) => { + if (arg instanceof Array && arg[0] instanceof CpdlcMessageContent) this.Content = arg as CpdlcMessageContent[]; + else if (typeof arg === 'boolean') this.Urgent = arg as boolean; + else if (arg instanceof Array) this.FansModes = arg as FansMode[]; + else if (typeof arg === 'string') this.ExpectedResponse = arg as CpdlcMessageExpectedResponseType; + else console.log(`Unknown arg: ${arg}, type: ${typeof arg}`); + }); + } + + public deepCopy(): CpdlcMessageElement { + const instance = new CpdlcMessageElement(this.TypeId, this.FansModes, this.Urgent, this.ExpectedResponse); + + this.Content.forEach((entry) => { + instance.Content.push(CpdlcMessageContent.createInstance(entry.Type)); + instance.Content[instance.Content.length - 1].IndexStart = entry.IndexStart; + instance.Content[instance.Content.length - 1].IndexEnd = entry.IndexEnd; + instance.Content[instance.Content.length - 1].Value = entry.Value; + instance.Content[instance.Content.length - 1].Monitoring = entry.Monitoring; + }); + + return instance; + } + + public deserialize(jsonData: any): void { + this.TypeId = jsonData.TypeId; + this.FansModes = jsonData.FansModes; + this.Urgent = jsonData.Urgent; + + jsonData.Content.forEach((entry) => { + this.Content.push(CpdlcMessageContent.createInstance(entry.Type)); + this.Content[this.Content.length - 1].deserialize(entry); + }); + + this.ExpectedResponse = jsonData.ExpectedResponse; + } +} + +export const CpdlcMessagesDownlink: { [identification: string]: [string[], CpdlcMessageElement] } = { + DM0: [['WILCO'], new CpdlcMessageElement('DM0', [FansMode.FansA, FansMode.FansB])], + DM1: [['UNABLE'], new CpdlcMessageElement('DM1', [FansMode.FansA, FansMode.FansB])], + DM2: [['STANDBY'], new CpdlcMessageElement('DM2', [FansMode.FansA, FansMode.FansB])], + DM3: [['ROGER'], new CpdlcMessageElement('DM3', [FansMode.FansA, FansMode.FansB])], + DM4: [['AFFIRM'], new CpdlcMessageElement('DM4', [FansMode.FansA, FansMode.FansB])], + DM5: [['NEGATIVE'], new CpdlcMessageElement('DM5', [FansMode.FansA, FansMode.FansB])], + DM6: [['REQUEST %s'], new CpdlcMessageElement('DM6', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(1)], CpdlcMessageExpectedResponseType.Yes)], + DM7: [['REQUEST BLOCK %s TO %s'], new CpdlcMessageElement('DM7', [FansMode.FansA], [new CpdlcMessageContentLevel(2), new CpdlcMessageContentLevel(4)], + CpdlcMessageExpectedResponseType.Yes)], + DM8: [['REQUEST CRUISE CLIMB TO %s'], new CpdlcMessageElement('DM8', [FansMode.FansA], [new CpdlcMessageContentLevel(4)], CpdlcMessageExpectedResponseType.Yes)], + DM9: [['REQUEST CLIMB TO %s'], new CpdlcMessageElement('DM9', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(3)], CpdlcMessageExpectedResponseType.Yes)], + DM10: [['REQUEST DESCEND TO %s'], new CpdlcMessageElement('DM10', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(3)], CpdlcMessageExpectedResponseType.Yes)], + DM11: [['AT %s REQUEST CLIMB TO %s'], new CpdlcMessageElement('DM11', [FansMode.FansA], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentLevel(5)], + CpdlcMessageExpectedResponseType.Yes)], + DM12: [['AT %s REQUEST DESCEND TO %s'], new CpdlcMessageElement('DM12', [FansMode.FansA], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentLevel(5)], + CpdlcMessageExpectedResponseType.Yes)], + DM13: [['AT %s REQUEST CLIMB TO %s'], new CpdlcMessageElement('DM13', [FansMode.FansA], [new CpdlcMessageContentTime(1), new CpdlcMessageContentLevel(5)], + CpdlcMessageExpectedResponseType.Yes)], + DM14: [['AT %s REQUEST DESCEND TO %s'], new CpdlcMessageElement('DM14', [FansMode.FansA], [new CpdlcMessageContentTime(1), new CpdlcMessageContentLevel(5)], + CpdlcMessageExpectedResponseType.Yes)], + DM15: [['REQUEST OFFSET %s %s OF ROUTE'], new CpdlcMessageElement('DM15', [FansMode.FansA], [new CpdlcMessageContentDistance(2), new CpdlcMessageContentDirection(3)], + CpdlcMessageExpectedResponseType.Yes)], + DM16: [['AT %s REQUEST OFFSET %s %s OF ROUTE'], new CpdlcMessageElement('DM16', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentDistance(4), new CpdlcMessageContentDirection(5)], CpdlcMessageExpectedResponseType.Yes)], + DM17: [['AT %s REQUEST OFFSET %s %s OF ROUTE'], new CpdlcMessageElement('DM17', [FansMode.FansA], + [new CpdlcMessageContentTime(1), new CpdlcMessageContentDistance(4), new CpdlcMessageContentDirection(5)], CpdlcMessageExpectedResponseType.Yes)], + DM18: [['REQUEST %s'], new CpdlcMessageElement('DM18', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentSpeed(1)], CpdlcMessageExpectedResponseType.Yes)], + DM19: [['REQUEST %s TO %s'], new CpdlcMessageElement('DM19', [FansMode.FansA], [new CpdlcMessageContentSpeed(1), new CpdlcMessageContentSpeed(3)], CpdlcMessageExpectedResponseType.Yes)], + DM20: [['REQUEST VOICE CONTACT'], new CpdlcMessageElement('DM20', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + DM21: [['REQUEST VOICE CONTACT %s'], new CpdlcMessageElement('DM21', [FansMode.FansA], [new CpdlcMessageContentFrequency(3)], CpdlcMessageExpectedResponseType.Yes)], + DM22: [['REQUEST DIRECT TO %s'], new CpdlcMessageElement('DM22', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(3)], CpdlcMessageExpectedResponseType.Yes)], + DM23: [['REQUEST %s'], new CpdlcMessageElement('DM23', [FansMode.FansA], [new CpdlcMessageContentProcedure(1)], CpdlcMessageExpectedResponseType.Yes)], + DM25: [['REQUEST %s CLEARANCE'], new CpdlcMessageElement('DM25', [FansMode.FansA], [new CpdlcMessageContentFreetext(1, 2)], CpdlcMessageExpectedResponseType.Yes)], + DM26: [['REQUEST WEATHER DEVIATION TO %s VIA %s'], new CpdlcMessageElement('DM26', [FansMode.FansA], + [new CpdlcMessageContentPosition(4), new CpdlcMessageContentFreetext(6, -1)], CpdlcMessageExpectedResponseType.Yes)], + DM27: [['REQUEST WEATHER DEVIATION UP TO %s %s OF ROUTE'], new CpdlcMessageElement('DM27', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentDistance(5), new CpdlcMessageContentDirection(6)], CpdlcMessageExpectedResponseType.Yes)], + DM28: [['LEAVING %s'], new CpdlcMessageElement('DM28', [FansMode.FansA], [new CpdlcMessageContentLevel(1)], CpdlcMessageExpectedResponseType.No)], + DM29: [['CLIMBING TO %s'], new CpdlcMessageElement('DM29', [FansMode.FansA], [new CpdlcMessageContentLevel(2)], CpdlcMessageExpectedResponseType.No)], + DM30: [['DESCENDING TO %s'], new CpdlcMessageElement('DM30', [FansMode.FansA], [new CpdlcMessageContentLevel(2)], CpdlcMessageExpectedResponseType.No)], + DM31: [['PASSING %s'], new CpdlcMessageElement('DM31', [FansMode.FansA], [new CpdlcMessageContentPosition(1)], CpdlcMessageExpectedResponseType.No)], + DM32: [['PRESENT LEVEL %s'], new CpdlcMessageElement('DM32', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(2)], + CpdlcMessageExpectedResponseType.No)], + DM33: [['PRESENT POSITION %s'], new CpdlcMessageElement('DM33', [FansMode.FansA], [new CpdlcMessageContentPosition(2)], CpdlcMessageExpectedResponseType.No)], + DM34: [['PRESENT SPEED %s'], new CpdlcMessageElement('DM34', [FansMode.FansA], [new CpdlcMessageContentSpeed(2)], CpdlcMessageExpectedResponseType.No)], + DM35: [['PRESENT HEADING %s'], new CpdlcMessageElement('DM35', [FansMode.FansA], [new CpdlcMessageContentDegree(2)], CpdlcMessageExpectedResponseType.No)], + DM36: [['PRESENT GROUND TRACK %s'], new CpdlcMessageElement('DM36', [FansMode.FansA], [new CpdlcMessageContentDegree(3)], CpdlcMessageExpectedResponseType.No)], + DM37: [['MAINTAINING %s', 'LEVEL %s'], new CpdlcMessageElement('DM37', [FansMode.FansA], [new CpdlcMessageContentLevel(1)], CpdlcMessageExpectedResponseType.No)], + DM38: [['ASSIGNED LEVEL %s'], new CpdlcMessageElement('DM38', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(2)], + CpdlcMessageExpectedResponseType.No)], + DM39: [['ASSIGNED SPEED %s'], new CpdlcMessageElement('DM39', [FansMode.FansA], [new CpdlcMessageContentSpeed(2)], CpdlcMessageExpectedResponseType.No)], + DM40: [['ASSIGNED ROUTE %s'], new CpdlcMessageElement('DM40', [FansMode.FansA], [new CpdlcMessageContentFreetext(2, -1)], CpdlcMessageExpectedResponseType.No)], + DM41: [['BACK ON ROUTE'], new CpdlcMessageElement('DM41', [FansMode.FansA], CpdlcMessageExpectedResponseType.No)], + DM42: [['NEXT WAYPOINT %s'], new CpdlcMessageElement('DM42', [FansMode.FansA], [new CpdlcMessageContentPosition(2)], CpdlcMessageExpectedResponseType.No)], + DM43: [['NEXT WAYPOINT ETA %s'], new CpdlcMessageElement('DM43', [FansMode.FansA], [new CpdlcMessageContentTime(3)], CpdlcMessageExpectedResponseType.No)], + DM44: [['ENSUING WAYPOINT %s'], new CpdlcMessageElement('DM44', [FansMode.FansA], [new CpdlcMessageContentPosition(2)], CpdlcMessageExpectedResponseType.No)], + DM45: [['REPORTED WAYPOINT %s'], new CpdlcMessageElement('DM45', [FansMode.FansA], [new CpdlcMessageContentPosition(2)], CpdlcMessageExpectedResponseType.No)], + DM46: [['REPORTED WAYPOINT %s'], new CpdlcMessageElement('DM46', [FansMode.FansA], [new CpdlcMessageContentTime(2)], CpdlcMessageExpectedResponseType.No)], + DM47: [['SQUAWKING %s'], new CpdlcMessageElement('DM47', [FansMode.FansA], [new CpdlcMessageContentSquawk(1)], CpdlcMessageExpectedResponseType.No)], + DM48: [['POSITION REPORT'], new CpdlcMessageElement('DM48', [FansMode.FansA], CpdlcMessageExpectedResponseType.Roger)], + DM49: [['WHEN CAN WE EXPECT %s'], new CpdlcMessageElement('DM49', [FansMode.FansA], [new CpdlcMessageContentSpeed(4)], CpdlcMessageExpectedResponseType.Yes)], + DM50: [['WHEN CAN WE EXPECT %s TO %s'], new CpdlcMessageElement('DM50', [FansMode.FansA], [new CpdlcMessageContentSpeed(4), new CpdlcMessageContentSpeed(6)], + CpdlcMessageExpectedResponseType.Yes)], + DM51: [['WHEN CAN WE EXPECT BACK ON ROUTE'], new CpdlcMessageElement('DM51', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + DM52: [['WHEN CAN WE EXPECT LOWER LEVEL'], new CpdlcMessageElement('DM52', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + DM53: [['WHEN CAN WE EXPECT HIGHER LEVEL'], new CpdlcMessageElement('DM53', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + DM54: [['WHEN CAN WE EXPECT CRUISE CLIMB TO %s'], new CpdlcMessageElement('DM54', [FansMode.FansA], [new CpdlcMessageContentLevel(7)], CpdlcMessageExpectedResponseType.Yes)], + DM55: [['PAN PAN PAN'], new CpdlcMessageElement('DM55', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.Yes, true)], + DM56: [['MAYDAY MAYDAY MAYDAY'], new CpdlcMessageElement('DM56', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.Yes, true)], + DM57: [['%s FUEL REMAINING AND %s PERSONS ON BOARD'], new CpdlcMessageElement('DM57', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentFuel(0), new CpdlcMessageContentPersonsOnBoard(4)], CpdlcMessageExpectedResponseType.Yes, true)], + DM58: [['CANCEL EMERGENCY'], new CpdlcMessageElement('DM58', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.Yes, true)], + DM59: [['DIVERTING TO %s VIA %s'], new CpdlcMessageElement('DM59', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentPosition(2), new CpdlcMessageContentFreetext(4, -1)], CpdlcMessageExpectedResponseType.Yes, true)], + DM60: [['OFFSETTING %s %s OF ROUTE'], new CpdlcMessageElement('DM60', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentDistance(1), new CpdlcMessageContentDirection(2)], CpdlcMessageExpectedResponseType.Yes, true)], + DM61: [['DESCENDING TO %s'], new CpdlcMessageElement('DM61', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(2)], CpdlcMessageExpectedResponseType.Yes, true)], + DM62: [['ERROR %s'], new CpdlcMessageElement('DM62', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentFreetext(1, -1)], CpdlcMessageExpectedResponseType.Yes, true)], + DM63: [['NOT CURRENT DATA AUTHORITY'], new CpdlcMessageElement('DM63', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.No)], + DM65: [['DUE TO WEATHER'], new CpdlcMessageElement('DM65', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.No)], + DM66: [['DUE TO AIRCRAFT PERFORMANCE'], new CpdlcMessageElement('DM66', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.No)], + DM67: [['%s'], new CpdlcMessageElement('DM67', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentFreetext(0, -1)], CpdlcMessageExpectedResponseType.No)], + DM68: [['%s'], new CpdlcMessageElement('DM68', [FansMode.FansA], true, [new CpdlcMessageContentFreetext(0, -1)], CpdlcMessageExpectedResponseType.Yes)], + DM69: [['REQUEST VMC DESCEND'], new CpdlcMessageElement('DM69', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + DM70: [['REQUEST HEADING %s'], new CpdlcMessageElement('DM70', [FansMode.FansA], [new CpdlcMessageContentDegree(2)], CpdlcMessageExpectedResponseType.Yes)], + DM71: [['REQUEST GROUND TRACK %s'], new CpdlcMessageElement('DM71', [FansMode.FansA], [new CpdlcMessageContentDegree(3)], CpdlcMessageExpectedResponseType.Yes)], + DM72: [['REACHING %s'], new CpdlcMessageElement('DM72', [FansMode.FansA], [new CpdlcMessageContentLevel(1)], CpdlcMessageExpectedResponseType.Yes)], + DM74: [['REQUEST TO MAINTAIN OWN SEPARATION AND VMC'], new CpdlcMessageElement('DM74', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + DM75: [['AT PILOTS DISCRETION'], new CpdlcMessageElement('DM75', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + DM76: [['REACHING BLOCK %s TO %s'], new CpdlcMessageElement('DM76', [FansMode.FansA], [new CpdlcMessageContentLevel(2), new CpdlcMessageContentLevel(4)], + CpdlcMessageExpectedResponseType.No)], + DM78: [['AT %s %s TO %s', 'AT %s %s FROM %s'], new CpdlcMessageElement('DM78', [FansMode.FansA], + [new CpdlcMessageContentTime(1), new CpdlcMessageContentDistance(2), new CpdlcMessageContentPosition(4)], CpdlcMessageExpectedResponseType.No)], + DM79: [['ATIS %s'], new CpdlcMessageElement('DM79', [FansMode.FansA], [new CpdlcMessageContentAtis(1)], CpdlcMessageExpectedResponseType.No)], + DM80: [['DEVIATING UP TO %s %s OF ROUTE'], new CpdlcMessageElement('DM80', [FansMode.FansA], + [new CpdlcMessageContentDistance(3), new CpdlcMessageContentDirection(4)], CpdlcMessageExpectedResponseType.Yes, true)], + DM81: [['WE CAN ACCEPT %s AT %s'], new CpdlcMessageElement('DM81', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(3), new CpdlcMessageContentTime(5)], + CpdlcMessageExpectedResponseType.No)], + DM82: [['WE CANNOT ACCEPT %s'], new CpdlcMessageElement('DM82', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(3)], CpdlcMessageExpectedResponseType.No)], + DM83: [['WE CAN ACCEPT %s AT %s'], new CpdlcMessageElement('DM83', [FansMode.FansA], [new CpdlcMessageContentSpeed(3), new CpdlcMessageContentTime(5)], + CpdlcMessageExpectedResponseType.No)], + DM84: [['WE CANNOT ACCEPT %s'], new CpdlcMessageElement('DM84', [FansMode.FansA], [new CpdlcMessageContentSpeed(3)], CpdlcMessageExpectedResponseType.No)], + DM85: [['WE CAN ACCEPT %s %s AT %s'], new CpdlcMessageElement('DM85', [FansMode.FansA], + [new CpdlcMessageContentDistance(3), new CpdlcMessageContentDirection(4), new CpdlcMessageContentTime(6)], CpdlcMessageExpectedResponseType.No)], + DM86: [['WE CANNOT ACCEPT %s %s'], new CpdlcMessageElement('DM86', [FansMode.FansA], [new CpdlcMessageContentDistance(3), new CpdlcMessageContentDirection(4)], + CpdlcMessageExpectedResponseType.No)], + DM87: [['WHEN CAN WE EXPECT CLIMB TO %s'], new CpdlcMessageElement('DM87', [FansMode.FansA], [new CpdlcMessageContentLevel(6)], CpdlcMessageExpectedResponseType.Yes)], + DM88: [['WHEN CAN WE EXPECT DESCEND TO %s'], new CpdlcMessageElement('DM88', [FansMode.FansA], [new CpdlcMessageContentLevel(6)], CpdlcMessageExpectedResponseType.Yes)], + DM89: [['MONITORING %s %s'], new CpdlcMessageElement('DM89', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentAtcUnit(1), new CpdlcMessageContentFrequency(2)], + CpdlcMessageExpectedResponseType.No)], + DM98: [['%s'], new CpdlcMessageElement('DM98', [FansMode.FansB], [new CpdlcMessageContentFreetext(0, -1)], CpdlcMessageExpectedResponseType.No)], + DM99: [['CURRENT DATA AUTHORITY'], new CpdlcMessageElement('DM99', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.No, true)], + DM100: [['LOGICAL ACKNOWLEDGEMENT'], new CpdlcMessageElement('DM100', [FansMode.FansB], CpdlcMessageExpectedResponseType.No)], + DM104: [['ETA %s %s'], new CpdlcMessageElement('DM104', [FansMode.FansA], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentTime(2)], CpdlcMessageExpectedResponseType.No)], + DM106: [['PREFERRED LEVEL %s'], new CpdlcMessageElement('DM106', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(2)], CpdlcMessageExpectedResponseType.No)], + DM107: [['NOT AUTHORIZED NEXT DATA AUTHORITY'], new CpdlcMessageElement('DM107', [FansMode.FansB], CpdlcMessageExpectedResponseType.No)], + DM109: [['TOP OF DESCENT %s'], new CpdlcMessageElement('DM109', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentTime(3)], CpdlcMessageExpectedResponseType.No)], + DM113: [['SPEED %s'], new CpdlcMessageElement('DM113', [FansMode.FansA], [new CpdlcMessageContentSpeed(1)], CpdlcMessageExpectedResponseType.No)], + DM9998: [['REQUEST LOGON'], new CpdlcMessageElement('DM9998', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.Yes)], + DM9999: [['LOGOFF'], new CpdlcMessageElement('DM9999', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.No)], +}; + +export const CpdlcMessagesUplink: { [identification: string]: [string[], CpdlcMessageElement] } = { + UM0: [['UNABLE'], new CpdlcMessageElement('UM0', [FansMode.FansA, FansMode.FansB])], + UM1: [['STANDBY'], new CpdlcMessageElement('UM1', [FansMode.FansA, FansMode.FansB])], + UM3: [['ROGER'], new CpdlcMessageElement('UM3', [FansMode.FansA, FansMode.FansB])], + UM4: [['AFFIRM'], new CpdlcMessageElement('UM4', [FansMode.FansA, FansMode.FansB])], + UM5: [['NEGATIVE'], new CpdlcMessageElement('UM5', [FansMode.FansA, FansMode.FansB])], + UM6: [['EXPECT %s'], new CpdlcMessageElement('UM6', [FansMode.FansA], [new CpdlcMessageContentLevel(1)], CpdlcMessageExpectedResponseType.Roger)], + UM7: [['EXPECT CLIMB AT %s'], new CpdlcMessageElement('UM7', [FansMode.FansA], [new CpdlcMessageContentTime(3)], CpdlcMessageExpectedResponseType.Roger)], + UM8: [['EXPECT CLIMB AT %s'], new CpdlcMessageElement('UM8', [FansMode.FansA], [new CpdlcMessageContentPosition(3)], CpdlcMessageExpectedResponseType.Roger)], + UM9: [['EXPECT DESCENT AT %s'], new CpdlcMessageElement('UM9', [FansMode.FansA], [new CpdlcMessageContentTime(3)], CpdlcMessageExpectedResponseType.Roger)], + UM10: [['EXPECT DESCENT AT %s'], new CpdlcMessageElement('UM10', [FansMode.FansA], [new CpdlcMessageContentPosition(3)], CpdlcMessageExpectedResponseType.Roger)], + UM11: [['EXPECT CRUISE CLIMB AT %s'], new CpdlcMessageElement('UM11', [FansMode.FansA], [new CpdlcMessageContentTime(4)], CpdlcMessageExpectedResponseType.Roger)], + UM12: [['EXPECT CRUISE CLIMB AT %s'], new CpdlcMessageElement('UM12', [FansMode.FansA], [new CpdlcMessageContentPosition(4)], CpdlcMessageExpectedResponseType.Roger)], + UM19: [['MAINTAIN %s'], new CpdlcMessageElement('UM19', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(1)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM20: [['CLIMB TO %s', 'CLIMB TO AND MAINTAIN %s'], new CpdlcMessageElement('UM20', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(2, 4)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM21: [['AT %s CLIMB TO %s', 'AT %s CLIMB TO AND MAINTAIN %s'], new CpdlcMessageElement('UM21', [FansMode.FansA], + [new CpdlcMessageContentTime(1, true), new CpdlcMessageContentLevel(4, 6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM22: [['AT %s CLIMB TO %s', 'AT %s CLIMB TO AND MAINTAIN %s'], new CpdlcMessageElement('UM22', [FansMode.FansA], + [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentLevel(4, 6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM23: [['DESCEND TO %s', 'DESCEND TO AND MAINTAIN %s'], new CpdlcMessageElement('UM23', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(2, 4)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM24: [['AT %s DESCEND TO %s', 'AT %s DESCEND TO AND MAINTAIN %s'], new CpdlcMessageElement('UM24', [FansMode.FansA], + [new CpdlcMessageContentTime(1, true), new CpdlcMessageContentLevel(4, 6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM25: [['AT %s DESCEND TO %s', 'AT %s DESCEND TO AND MAINTAIN %s'], new CpdlcMessageElement('UM25', [FansMode.FansA], + [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentLevel(4, 6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM26: [['CLIMB TO REACH %s BY %s'], new CpdlcMessageElement('UM26', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(3), new CpdlcMessageContentTime(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM27: [['CLIMB TO REACH %s BY %s'], new CpdlcMessageElement('UM27', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(3), new CpdlcMessageContentPosition(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM28: [['DESCEND TO REACH %s BY %s'], new CpdlcMessageElement('UM28', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(3), new CpdlcMessageContentTime(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM29: [['DESCEND TO REACH %s BY %s'], new CpdlcMessageElement('UM29', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentLevel(3), new CpdlcMessageContentPosition(5)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM30: [['MAINTAIN BLOCK %s TO %s'], new CpdlcMessageElement('UM30', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentLevel(2), new CpdlcMessageContentLevel(4)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM31: [['CLIMB TO MAINTAIN BLOCK %s TO %s'], new CpdlcMessageElement('UM31', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentLevel(4), new CpdlcMessageContentLevel(6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM32: [['DESCEND TO MAINTAIN BLOCK %s TO %s'], new CpdlcMessageElement('UM32', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentLevel(4), new CpdlcMessageContentLevel(6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM34: [['CRUISE CLIMB TO %s'], new CpdlcMessageElement('UM34', [FansMode.FansA], [new CpdlcMessageContentLevel(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM35: [['WHEN ABOVE %s COMMENCE CRUISE CLIMB', 'CRUISE CLIMB ABOVE %s'], new CpdlcMessageElement('UM35', [FansMode.FansA], + [new CpdlcMessageContentLevel(2, 3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM36: [['EXPEDITE CLIMB TO %s'], new CpdlcMessageElement('UM36', [FansMode.FansA], [new CpdlcMessageContentLevel(3)], CpdlcMessageExpectedResponseType.WilcoUnable, true)], + UM37: [['EXPEDITE DESCENT TO %s'], new CpdlcMessageElement('UM37', [FansMode.FansA], [new CpdlcMessageContentLevel(3)], CpdlcMessageExpectedResponseType.WilcoUnable, true)], + UM38: [['IMMEDIATELY CLIMB TO %s'], new CpdlcMessageElement('UM38', [FansMode.FansA], [new CpdlcMessageContentLevel(3)], CpdlcMessageExpectedResponseType.WilcoUnable, true)], + UM39: [['IMMEDIATELY DESCEND TO %s'], new CpdlcMessageElement('UM39', [FansMode.FansA], [new CpdlcMessageContentLevel(3)], CpdlcMessageExpectedResponseType.WilcoUnable, true)], + UM46: [['CROSS %s AT %s'], new CpdlcMessageElement('UM46', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentLevel(3)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM47: [['CROSS %s AT OR ABOVE %s'], new CpdlcMessageElement('UM47', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentLevel(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM48: [['CROSS %s AT OR BELOW %s'], new CpdlcMessageElement('UM48', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentLevel(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM49: [['CROSS %s AT AND MAINTAIN %s'], new CpdlcMessageElement('UM49', [FansMode.FansA], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentLevel(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM50: [['CROSS %s BETWEEN %s AND %s'], new CpdlcMessageElement('UM50', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentLevel(3), new CpdlcMessageContentLevel(5)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM51: [['CROSS %s AT %s'], new CpdlcMessageElement('UM51', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentTime(3)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM52: [['CROSS %s AT OR BEFORE %s'], new CpdlcMessageElement('UM52', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentTime(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM53: [['CROSS %s AT OR AFTER %s'], new CpdlcMessageElement('UM53', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentTime(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM54: [['CROSS %s BETWEEN %s AND %s'], new CpdlcMessageElement('UM54', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentTime(3), new CpdlcMessageContentTime(5)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM55: [['CROSS %s AT %s'], new CpdlcMessageElement('UM55', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentSpeed(3)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM56: [['CROSS %s AT OR LESS THAN %s'], new CpdlcMessageElement('UM56', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentSpeed(6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM57: [['CROSS %s AT OR GREATER THAN %s'], new CpdlcMessageElement('UM57', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentSpeed(6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM58: [['CROSS %s AT %s AT %s'], new CpdlcMessageElement('UM58', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentTime(3), new CpdlcMessageContentLevel(5)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM59: [['CROSS %s AT OR BEFORE %s AT %s'], new CpdlcMessageElement('UM59', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentTime(5), new CpdlcMessageContentLevel(7)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM60: [['CROSS %s AT OR AFTER %s AT %s'], new CpdlcMessageElement('UM60', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentTime(5), new CpdlcMessageContentLevel(7)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM61: [['CROSS %s AT AND MAINTAIN %s AT %s'], new CpdlcMessageElement('UM61', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentLevel(5), new CpdlcMessageContentSpeed(7)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM62: [['AT %s CROSS %s AT AND MAINTAIN %s'], new CpdlcMessageElement('UM62', [FansMode.FansA], + [new CpdlcMessageContentTime(1), new CpdlcMessageContentPosition(3), new CpdlcMessageContentLevel(7)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM63: [['AT %s CROSS %s AT AND MAINTAIN %s AT %s'], new CpdlcMessageElement('UM63', [FansMode.FansA], + [new CpdlcMessageContentTime(1), new CpdlcMessageContentPosition(3), new CpdlcMessageContentLevel(7), new CpdlcMessageContentSpeed(9)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM64: [['OFFSET %s %s OF ROUTE'], new CpdlcMessageElement('UM64', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentDistance(1), new CpdlcMessageContentDirection(2)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM65: [['AT %s OFFSET %s %s OF ROUTE'], new CpdlcMessageElement('UM65', [FansMode.FansA], + [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentDistance(3), new CpdlcMessageContentDirection(4)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM66: [['AT %s OFFSET %s %s OF ROUTE'], new CpdlcMessageElement('UM66', [FansMode.FansA], + [new CpdlcMessageContentTime(1, true), new CpdlcMessageContentDistance(3), new CpdlcMessageContentDirection(4)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM67: [['PROCEED BACK ON ROUTE'], new CpdlcMessageElement('UM67', [FansMode.FansA], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM68: [['REJOIN ROUTE BY %s'], new CpdlcMessageElement('UM68', [FansMode.FansA], [new CpdlcMessageContentPosition(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM69: [['REJOIN ROUTE BY %s'], new CpdlcMessageElement('UM69', [FansMode.FansA], [new CpdlcMessageContentTime(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM70: [['EXPECT BACK ON ROUTE BY %s'], new CpdlcMessageElement('UM70', [FansMode.FansA], [new CpdlcMessageContentPosition(5)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM71: [['EXPECT BACK ON ROUTE BY %s'], new CpdlcMessageElement('UM71', [FansMode.FansA], [new CpdlcMessageContentTime(5)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM72: [['RESUME OWN NAVIGATION'], new CpdlcMessageElement('UM72', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.WilcoUnable)], + // UM73 for clearance skipped -> needs to be handled in DCL manager + UM74: [['PROCEED DIRECT TO %s'], new CpdlcMessageElement('UM74', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM75: [['WHEN ABLE PROCEED DIRECT TO %s'], new CpdlcMessageElement('UM75', [FansMode.FansA], [new CpdlcMessageContentPosition(5)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM76: [['AT %s PROCEED DIRECT TO %s'], new CpdlcMessageElement('UM76', [FansMode.FansA], [new CpdlcMessageContentTime(1, true), new CpdlcMessageContentPosition(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM77: [['AT %s PROCEED DIRECT TO %s'], new CpdlcMessageElement('UM77', [FansMode.FansA], [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentPosition(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM78: [['AT %s PROCEED DIRECT TO %s'], new CpdlcMessageElement('UM78', [FansMode.FansA], [new CpdlcMessageContentLevel(1, true), new CpdlcMessageContentPosition(5)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM79: [['CLEARED TO %s VIA %s'], new CpdlcMessageElement('UM79', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentPosition(2), new CpdlcMessageContentFreetext(4, -1)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM80: [['CLEARED %s'], new CpdlcMessageElement('UM80', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentFreetext(1, -1)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM81: [['CLEARED %s'], new CpdlcMessageElement('UM81', [FansMode.FansA], [new CpdlcMessageContentProcedure(1)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM82: [['CLEARED TO DEVIATE UP TO %s %s OF ROUTE'], new CpdlcMessageElement('UM82', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentDirection(5), new CpdlcMessageContentDistance(6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM83: [['AT %s CLEARED %s'], new CpdlcMessageElement('UM83', [FansMode.FansA], + [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentFreetext(3, -1)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM84: [['AT %s CLEARED %s'], new CpdlcMessageElement('UM84', [FansMode.FansA], + [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentProcedure(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM85: [['EXPECT %s'], new CpdlcMessageElement('UM85', [FansMode.FansA], [new CpdlcMessageContentFreetext(1, -1)], CpdlcMessageExpectedResponseType.Roger)], + UM86: [['AT %s EXPECT %s'], new CpdlcMessageElement('UM86', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentFreetext(3, -1)], CpdlcMessageExpectedResponseType.Roger)], + UM87: [['EXPECT DIRECT TO %s'], new CpdlcMessageElement('UM87', [FansMode.FansA], [new CpdlcMessageContentPosition(3)], CpdlcMessageExpectedResponseType.Roger)], + UM88: [['AT %s EXPECT DIRECT TO %s'], new CpdlcMessageElement('UM88', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentPosition(5)], CpdlcMessageExpectedResponseType.Roger)], + UM89: [['AT %s EXPECT DIRECT TO %s'], new CpdlcMessageElement('UM89', [FansMode.FansA], + [new CpdlcMessageContentTime(1), new CpdlcMessageContentPosition(5)], CpdlcMessageExpectedResponseType.Roger)], + UM90: [['AT %s EXPECT DIRECT TO %s'], new CpdlcMessageElement('UM90', [FansMode.FansA], + [new CpdlcMessageContentLevel(1), new CpdlcMessageContentPosition(5)], CpdlcMessageExpectedResponseType.Roger)], + UM91: [['HOLD AT %s MAINTAIN %s INBOUND TRACK %s %s TURNS %s', 'HOLD AT %s MAINTAIN %s INBOUND TRACK %s %s TURN LEG TIME %s'], new CpdlcMessageElement('UM91', [FansMode.FansA], + [new CpdlcMessageContentPosition(2), new CpdlcMessageContentLevel(4), new CpdlcMessageContentDegree(7), new CpdlcMessageContentDirection(8), new CpdlcMessageContentLegType(12)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM92: [['HOLD AT %s AS PUBLISHED MAINTAIN %s'], new CpdlcMessageElement('UM92', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentPosition(2), new CpdlcMessageContentLevel(6)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM93: [['EXPECT FURTHER CLEARANCE AT %s'], new CpdlcMessageElement('UM93', [FansMode.FansA], [new CpdlcMessageContentTime(4)], CpdlcMessageExpectedResponseType.Roger)], + UM94: [['TURN %s HEADING %s'], new CpdlcMessageElement('UM94', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentDirection(1), new CpdlcMessageContentDegree(3)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM95: [['TURN %s GROUND TRACK %s'], new CpdlcMessageElement('UM95', [FansMode.FansA], [new CpdlcMessageContentDirection(1), new CpdlcMessageContentDegree(4)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM96: [['CONTINUE PRESENT HEADING', 'FLY PRESENT HEADING'], new CpdlcMessageElement('UM96', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM97: [['AT %s FLY HEADING %s'], new CpdlcMessageElement('UM97', [FansMode.FansA], [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentDegree(4)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM98: [['IMMEDIATELY TURN %s HEADING %s'], new CpdlcMessageElement('UM98', [FansMode.FansA], [new CpdlcMessageContentDirection(2), new CpdlcMessageContentDegree(4)], + CpdlcMessageExpectedResponseType.WilcoUnable, true)], + UM99: [['EXPECT %s'], new CpdlcMessageElement('UM99', [FansMode.FansA], [new CpdlcMessageContentProcedure(1)], CpdlcMessageExpectedResponseType.Roger)], + UM100: [['AT %s EXPECT %s'], new CpdlcMessageElement('UM100', [FansMode.FansA], [new CpdlcMessageContentTime(1), new CpdlcMessageContentSpeed(3)], CpdlcMessageExpectedResponseType.Roger)], + UM101: [['AT %s EXPECT %s'], new CpdlcMessageElement('UM101', [FansMode.FansA], [new CpdlcMessageContentPosition(1), new CpdlcMessageContentSpeed(3)], + CpdlcMessageExpectedResponseType.Roger)], + UM102: [['AT %s EXPECT %s'], new CpdlcMessageElement('UM102', [FansMode.FansA], [new CpdlcMessageContentLevel(1), new CpdlcMessageContentSpeed(3)], + CpdlcMessageExpectedResponseType.Roger)], + UM103: [['AT %s EXPECT %s TO %s'], new CpdlcMessageElement('UM103', [FansMode.FansA], [new CpdlcMessageContentTime(1), new CpdlcMessageContentSpeed(3), new CpdlcMessageContentSpeed(5)], + CpdlcMessageExpectedResponseType.Roger)], + UM104: [['AT %s EXPECT %s TO %s'], new CpdlcMessageElement('UM104', [FansMode.FansA], + [new CpdlcMessageContentPosition(1), new CpdlcMessageContentSpeed(3), new CpdlcMessageContentSpeed(5)], CpdlcMessageExpectedResponseType.Roger)], + UM105: [['AT %s EXPECT %s TO %s'], new CpdlcMessageElement('UM105', [FansMode.FansA], [new CpdlcMessageContentLevel(1), new CpdlcMessageContentSpeed(3), new CpdlcMessageContentSpeed(5)], + CpdlcMessageExpectedResponseType.Roger)], + UM106: [['MAINTAIN %s'], new CpdlcMessageElement('UM106', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentSpeed(1)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM107: [['MAINTAIN PRESENT SPEED'], new CpdlcMessageElement('UM107', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM108: [['MAINTAIN %s OR GREATER'], new CpdlcMessageElement('UM108', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentSpeed(1)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM109: [['MAINTAIN %s OR LESS'], new CpdlcMessageElement('UM109', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentSpeed(1)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM110: [['MAINTAIN %s TO %s'], new CpdlcMessageElement('UM110', [FansMode.FansA], [new CpdlcMessageContentSpeed(1), new CpdlcMessageContentSpeed(3)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM111: [['INCREASE SPEED TO %s'], new CpdlcMessageElement('UM111', [FansMode.FansA], [new CpdlcMessageContentSpeed(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM112: [['INCREASE SPEED TO %s OR GREATER'], new CpdlcMessageElement('UM112', [FansMode.FansA], [new CpdlcMessageContentSpeed(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM113: [['REDUCE SPEED TO %s'], new CpdlcMessageElement('UM113', [FansMode.FansA], [new CpdlcMessageContentSpeed(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM114: [['REDUCE SPEED TO %s OR LESS'], new CpdlcMessageElement('UM114', [FansMode.FansA], [new CpdlcMessageContentSpeed(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM115: [['DO NOT EXCEED %s'], new CpdlcMessageElement('UM115', [FansMode.FansA], [new CpdlcMessageContentSpeed(3)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM116: [['RESUME NORMAL SPEED'], new CpdlcMessageElement('UM116', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM117: [['CONTACT %s %s'], new CpdlcMessageElement('UM117', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentAtcUnit(1), new CpdlcMessageContentFrequency(2)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM118: [['AT %s CONTACT %s %s'], new CpdlcMessageElement('UM118', [FansMode.FansA], + [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentAtcUnit(3), new CpdlcMessageContentFrequency(4)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM119: [['AT %s CONTACT %s %s'], new CpdlcMessageElement('UM119', [FansMode.FansA], + [new CpdlcMessageContentTime(1, true), new CpdlcMessageContentAtcUnit(3), new CpdlcMessageContentFrequency(4)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM120: [['MONITOR %s %s'], new CpdlcMessageElement('UM120', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentAtcUnit(1), new CpdlcMessageContentFrequency(2)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM121: [['AT %s MONITOR %s %s'], new CpdlcMessageElement('UM121', [FansMode.FansA], + [new CpdlcMessageContentPosition(1, true), new CpdlcMessageContentAtcUnit(3), new CpdlcMessageContentFrequency(4)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM122: [['AT %s MONITOR %s %s'], new CpdlcMessageElement('UM122', [FansMode.FansA], + [new CpdlcMessageContentTime(1, true), new CpdlcMessageContentAtcUnit(3), new CpdlcMessageContentFrequency(4)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM123: [['SQUAWK %s'], new CpdlcMessageElement('UM123', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentSquawk(1)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM124: [['STOP SQUAWK'], new CpdlcMessageElement('UM124', [FansMode.FansA], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM125: [['SQUAWK MODE CHARLIE', 'SQUAWK ALTITUDE'], new CpdlcMessageElement('UM125', [FansMode.FansA], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM126: [['STOP SQUAWK MODE CHARLIE', 'STOP SQUAWK ALTITUDE'], new CpdlcMessageElement('UM126', [FansMode.FansA], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM127: [['REPORT BACK ON ROUTE'], new CpdlcMessageElement('UM127', [FansMode.FansA], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM128: [['REPORT LEAVING %s'], new CpdlcMessageElement('UM128', [FansMode.FansA], [new CpdlcMessageContentLevel(2, true)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM129: [['REPORT MAINTAINING %s', 'REPORT LEVEL %s'], new CpdlcMessageElement('UM129', [FansMode.FansA], [new CpdlcMessageContentLevel(2, true)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM130: [['REPORT PASSING %s'], new CpdlcMessageElement('UM130', [FansMode.FansA], [new CpdlcMessageContentPosition(2, true)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM131: [['REPORT REMAINING FUEL AND PERSONS ON BOARD', 'REPORT REMAINING FUEL AND SOULS ON BOARD'], new CpdlcMessageElement('UM131', [FansMode.FansA], + CpdlcMessageExpectedResponseType.Yes, true)], + UM132: [['REPORT POSITION', 'CONFIRM POSITION'], new CpdlcMessageElement('UM132', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM133: [['REPORT PRESENT LEVEL'], new CpdlcMessageElement('UM133', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM134: [['REPORT SPEED', 'CONFIRM SPEED'], new CpdlcMessageElement('UM134', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM135: [['CONFIRM ASSIGNED LEVEL'], new CpdlcMessageElement('UM135', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.Yes)], + UM136: [['CONFIRM ASSIGNED SPEED'], new CpdlcMessageElement('UM136', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM137: [['CONFIRM ASSIGNED ROUTE'], new CpdlcMessageElement('UM137', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM138: [['CONFIRM TIME OVER REPORTED WAYPOINT'], new CpdlcMessageElement('UM138', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM139: [['CONFIRM REPORTED WAYPOINT'], new CpdlcMessageElement('UM139', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM140: [['CONFIRM NEXT WAYPOINT'], new CpdlcMessageElement('UM140', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM141: [['CONFIRM NEXT WAYPOINT ETA'], new CpdlcMessageElement('UM141', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM142: [['CONFIRM ENSUING WAYPOINT'], new CpdlcMessageElement('UM142', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM143: [['CONFIRM REQUEST'], new CpdlcMessageElement('UM143', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM144: [['CONFIRM SQUAWK'], new CpdlcMessageElement('UM144', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM145: [['REPORT HEADING', 'CONFIRM HEADING'], new CpdlcMessageElement('UM145', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM146: [['REPORT GROUND TRACK', 'CONFIRM GROUND TRACK'], new CpdlcMessageElement('UM146', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM147: [['REQUEST POSITION REPORT'], new CpdlcMessageElement('UM147', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM148: [['WHEN CAN YOU ACCEPT %s'], new CpdlcMessageElement('UM148', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentLevel(4)], CpdlcMessageExpectedResponseType.Yes)], + UM149: [['CAN YOU ACCEPT %s AT %s'], new CpdlcMessageElement('UM149', [FansMode.FansA], [new CpdlcMessageContentLevel(3), new CpdlcMessageContentPosition(5)], + CpdlcMessageExpectedResponseType.AffirmNegative)], + UM150: [['CAN YOU ACCEPT %s AT %s'], new CpdlcMessageElement('UM150', [FansMode.FansA], [new CpdlcMessageContentLevel(3), new CpdlcMessageContentTime(5)], + CpdlcMessageExpectedResponseType.AffirmNegative)], + UM151: [['WHEN CAN YOU ACCEPT %s'], new CpdlcMessageElement('UM151', [FansMode.FansA], [new CpdlcMessageContentSpeed(4)], CpdlcMessageExpectedResponseType.Yes)], + UM152: [['WHEN CAN YOU ACCEPT %s %s OFFSET'], new CpdlcMessageElement('UM152', [FansMode.FansA], [new CpdlcMessageContentDistance(4), new CpdlcMessageContentDirection(5)], + CpdlcMessageExpectedResponseType.Yes)], + UM153: [['ALTIMETER %s', 'QNH %s'], new CpdlcMessageElement('UM153', [FansMode.FansA], [new CpdlcMessageContentAltimeter(1)], CpdlcMessageExpectedResponseType.Roger)], + UM154: [['RADAR SERVICE TERMINATED', 'RADAR SERVICES TERMINATED'], new CpdlcMessageElement('UM154', [FansMode.FansA], CpdlcMessageExpectedResponseType.Roger)], + UM155: [['RADAR CONTACT %s'], new CpdlcMessageElement('UM155', [FansMode.FansA], [new CpdlcMessageContentPosition(2)], CpdlcMessageExpectedResponseType.Roger)], + UM156: [['RADAR CONTACT LOST'], new CpdlcMessageElement('UM156', [FansMode.FansA], CpdlcMessageExpectedResponseType.Roger)], + UM157: [['CHECK STUCK MICROPHONE %s'], new CpdlcMessageElement('UM157', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentFrequency(3)], + CpdlcMessageExpectedResponseType.Roger, true)], + UM158: [['ATIS %s'], new CpdlcMessageElement('UM158', [FansMode.FansA], [new CpdlcMessageContentAtis(1)], CpdlcMessageExpectedResponseType.Roger)], + UM159: [['ERROR %s'], new CpdlcMessageElement('UM159', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentFreetext(1, -1)], CpdlcMessageExpectedResponseType.NotRequired)], + UM160: [['NEXT DATA AUTHORITY %s'], new CpdlcMessageElement('UM160', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentAtcUnit(3)], CpdlcMessageExpectedResponseType.NotRequired)], + UM161: [['END SERVICE'], new CpdlcMessageElement('UM161', [FansMode.FansA], CpdlcMessageExpectedResponseType.NotRequired)], + UM162: [['MESSAGE NOT SUPPORTED BY THIS ATS UNIT', 'SERVICE UNAVAILABLE'], new CpdlcMessageElement('UM162', [FansMode.FansA, FansMode.FansB], + CpdlcMessageExpectedResponseType.NotRequired)], + UM168: [['DISREGARD'], new CpdlcMessageElement('UM168', [FansMode.FansA], CpdlcMessageExpectedResponseType.Roger)], + UM169: [['%s'], new CpdlcMessageElement('UM169', [FansMode.FansA], [new CpdlcMessageContentFreetext(0, -1)], CpdlcMessageExpectedResponseType.Roger)], + UM171: [['CLIMB AT %s MINIMUM'], new CpdlcMessageElement('UM171', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentVerticalRate(2)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM172: [['CLIMB AT %s MAXIMUM'], new CpdlcMessageElement('UM172', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentVerticalRate(2)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM173: [['DESCEND AT %s MINIMUM'], new CpdlcMessageElement('UM173', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentVerticalRate(2)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM174: [['DESCEND AT %s MAXIMUM'], new CpdlcMessageElement('UM174', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentVerticalRate(2)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM175: [['REPORT REACHING %s'], new CpdlcMessageElement('UM175', [FansMode.FansA], [new CpdlcMessageContentLevel(2, true)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM176: [['MAINTAIN OWN SEPARATION AND VMC'], new CpdlcMessageElement('UM176', [FansMode.FansA], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM177: [['AT PILOTS DISCRETION'], new CpdlcMessageElement('UM177', [FansMode.FansA], CpdlcMessageExpectedResponseType.No)], + UM179: [['SQUAWK IDENT'], new CpdlcMessageElement('UM179', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM180: [['REPORT REACHING BLOCK %s TO %s'], new CpdlcMessageElement('UM180', [FansMode.FansA], [new CpdlcMessageContentLevel(3, true), new CpdlcMessageContentLevel(5, true)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM181: [['REPORT DISTANCE TO %s', 'REPORT DISTANCE FROM %s'], new CpdlcMessageElement('UM181', [FansMode.FansA], [new CpdlcMessageContentPosition(3)], + CpdlcMessageExpectedResponseType.Yes)], + UM182: [['CONFIRM ATIS CODE'], new CpdlcMessageElement('UM182', [FansMode.FansA], CpdlcMessageExpectedResponseType.Yes)], + UM183: [['%s'], new CpdlcMessageElement('UM183', [FansMode.FansB], [new CpdlcMessageContentFreetext(0, -1)], CpdlcMessageExpectedResponseType.Roger)], + UM184: [['AT TIME %s REPORT DISTANCE TO %s', 'AT TIME %s REPORT DISTANCE FROM %s'], new CpdlcMessageElement('UM184', [FansMode.FansA], + [new CpdlcMessageContentTime(2, true), new CpdlcMessageContentPosition(6)], CpdlcMessageExpectedResponseType.Yes)], + UM190: [['FLY HEADING %s'], new CpdlcMessageElement('UM190', [FansMode.FansB], [new CpdlcMessageContentDegree(2)], CpdlcMessageExpectedResponseType.WilcoUnable)], + UM213: [['%s ALTIMETER %s', '%s QNH %s'], new CpdlcMessageElement('UM213', [FansMode.FansA, FansMode.FansB], + [new CpdlcMessageContentPosition(0), new CpdlcMessageContentAltimeter(2)], CpdlcMessageExpectedResponseType.Roger)], + UM215: [['TURN %s %s DEGREES'], new CpdlcMessageElement('UM215', [FansMode.FansB], [new CpdlcMessageContentDirection(1), new CpdlcMessageContentDegree(2)], + CpdlcMessageExpectedResponseType.WilcoUnable)], + UM222: [['NO SPEED RESTRICTION'], new CpdlcMessageElement('UM222', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.Roger)], + UM227: [['LOGICAL ACKNOWLEDGEMENT'], new CpdlcMessageElement('UM227', [FansMode.FansB], CpdlcMessageExpectedResponseType.Roger)], + UM228: [['REPORT ETA %s'], new CpdlcMessageElement('UM228', [FansMode.FansA], [new CpdlcMessageContentPosition(2)], CpdlcMessageExpectedResponseType.Yes)], + UM231: [['STATE PREFERRED LEVEL'], new CpdlcMessageElement('UM231', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.Yes)], + UM232: [['STATE TOP OF DESCENT'], new CpdlcMessageElement('UM232', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.Yes)], + UM242: [['TRANSMIT ADS-B IDENT'], new CpdlcMessageElement('UM242', [FansMode.FansA], CpdlcMessageExpectedResponseType.Roger)], + UM244: [['IDENTIFICATION TERMINATED'], new CpdlcMessageElement('UM244', [FansMode.FansA], CpdlcMessageExpectedResponseType.Roger)], + UM9995: [['LOGOFF'], new CpdlcMessageElement('UM9995', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.NotRequired)], + UM9996: [['UNABLE %s'], new CpdlcMessageElement('UM9996', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentFreetext(1, -1)], CpdlcMessageExpectedResponseType.NotRequired)], + UM9997: [['LOGON ACCEPTED'], new CpdlcMessageElement('UM9997', [FansMode.FansA, FansMode.FansB], CpdlcMessageExpectedResponseType.NotRequired)], + UM9998: [['HANDOVER %s'], new CpdlcMessageElement('UM9998', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentAtcUnit(1)], CpdlcMessageExpectedResponseType.NotRequired)], + UM9999: [['CURRENT ATC %s'], new CpdlcMessageElement('UM9999', [FansMode.FansA, FansMode.FansB], [new CpdlcMessageContentFreetext(2, -1)], CpdlcMessageExpectedResponseType.NotRequired)], +}; diff --git a/fbw-a380x/src/systems/atsu/src/messages/DclMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/DclMessage.ts new file mode 100644 index 00000000000..35c5513f96d --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/DclMessage.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { CpdlcMessage } from './CpdlcMessage'; +import { AtsuMessageType, AtsuMessageDirection, AtsuMessageSerializationFormat, AtsuMessage } from './AtsuMessage'; + +/** + * Defines the general DCL message format + */ +export class DclMessage extends CpdlcMessage { + public Callsign = ''; + + public Origin = ''; + + public Destination = ''; + + public AcType = ''; + + public Atis = ''; + + public Gate = ''; + + public Freetext: string[] = []; + + constructor() { + super(); + this.Type = AtsuMessageType.DCL; + this.Direction = AtsuMessageDirection.Downlink; + this.CloseAutomatically = false; + } + + public serialize(format: AtsuMessageSerializationFormat) { + let dclMessage = ''; + if (format === AtsuMessageSerializationFormat.Network) { + dclMessage = 'REQUEST PREDEP CLEARANCE \n'; + dclMessage += `${this.Callsign} ${this.AcType} TO ${this.Destination} \n`; + dclMessage += `AT ${this.Origin}${this.Gate !== '' ? ` STAND ${this.Gate}` : ''} \n`; + dclMessage += `ATIS ${this.Atis}`; + } else { + if (format !== AtsuMessageSerializationFormat.DCDU) { + dclMessage = `${this.Timestamp.dcduTimestamp()} TO ${this.Station}\n`; + } + + dclMessage += `DEPART REQUEST\n${this.Callsign}\n`; + dclMessage += `FROM:${this.Origin}${this.Gate.length !== 0 ? ` GATE:${this.Gate}` : ''}\n`; + dclMessage += `TO:${this.Destination} ATIS:${this.Atis}\n`; + dclMessage += `A/C TYPE:${this.AcType}`; + + const freetext = this.Freetext.join('\n').replace(/^\s*\n/gm, ''); + if (freetext.length !== 0) { + dclMessage += `\n${freetext}`; + } + } + + return dclMessage; + } + + // used to deserialize event data + public deserialize(jsonData: any): void { + super.deserialize(jsonData); + + this.Callsign = jsonData.Callsign; + this.Origin = jsonData.Origin; + this.Destination = jsonData.Destination; + this.AcType = jsonData.AcType; + this.Gate = jsonData.Gate; + this.Atis = jsonData.Atis; + this.Freetext = jsonData.Freetext; + } +} + +export { AtsuMessageType, AtsuMessageDirection, AtsuMessageSerializationFormat, AtsuMessage }; diff --git a/fbw-a380x/src/systems/atsu/src/messages/FreetextMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/FreetextMessage.ts new file mode 100644 index 00000000000..df91f01d1ad --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/FreetextMessage.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuMessageType, AtsuMessageDirection, AtsuMessageSerializationFormat, AtsuMessage } from './AtsuMessage'; +import { wordWrap } from '../Common'; + +/** + * Defines the general freetext message format + */ +export class FreetextMessage extends AtsuMessage { + constructor() { + super(); + this.Type = AtsuMessageType.Freetext; + this.Direction = AtsuMessageDirection.Downlink; + } + + public serialize(format: AtsuMessageSerializationFormat) { + let message = ''; + + if (format === AtsuMessageSerializationFormat.MCDU || format === AtsuMessageSerializationFormat.MCDUMonitored) { + wordWrap(this.Message, 25).forEach((line) => { + message += `{green}${line}{end}\n`; + }); + message += '{white}------------------------{end}\n'; + } else { + message = this.Message; + } + + return message; + } +} diff --git a/fbw-a380x/src/systems/atsu/src/messages/MetarMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/MetarMessage.ts new file mode 100644 index 00000000000..9238a11b4ee --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/MetarMessage.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { NXDataStore } from '@shared/persistence'; +import { AtsuMessageType } from './AtsuMessage'; +import { WeatherMessage } from './WeatherMessage'; + +/** + * Defines the general METAR message format + */ +export class MetarMessage extends WeatherMessage { + constructor() { + super(); + this.Type = AtsuMessageType.METAR; + this.Station = NXDataStore.get('CONFIG_METAR_SRC', 'MSFS'); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/messages/OclMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/OclMessage.ts new file mode 100644 index 00000000000..7bb5e4a2909 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/OclMessage.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { CpdlcMessage } from './CpdlcMessage'; +import { AtsuMessageType, AtsuMessageDirection, AtsuMessageSerializationFormat, AtsuMessage } from './AtsuMessage'; + +/** + * Defines the general OCL message format + */ +export class OclMessage extends CpdlcMessage { + public Callsign = ''; + + public Destination = ''; + + public EntryPoint = ''; + + public EntryTime = ''; + + public RequestedMach = ''; + + public RequestedFlightlevel = ''; + + public Freetext: string[] = []; + + constructor() { + super(); + this.Type = AtsuMessageType.OCL; + this.Direction = AtsuMessageDirection.Downlink; + this.CloseAutomatically = false; + } + + public serialize(format: AtsuMessageSerializationFormat) { + let oclMessage = `OCEANIC REQUEST\n${this.Callsign} \n`; + oclMessage += `ENTRY POINT:${this.EntryPoint}\nAT:${this.EntryTime} \n`; + oclMessage += `REQ:${this.RequestedMach} ${this.RequestedFlightlevel}`; + + const freetext = this.Freetext.join('\n').replace(/^\s*\n/gm, ''); + if (freetext.length !== 0) { + oclMessage += `\n${freetext}`; + } + + // convert to the Hoppie-format + if (format === AtsuMessageSerializationFormat.Network) { + oclMessage = `/data2/${this.CurrentTransmissionId}//N/${oclMessage}`; + } else if (format !== AtsuMessageSerializationFormat.DCDU) { + oclMessage = `${this.Timestamp.dcduTimestamp()} TO ${this.Station}\n${oclMessage}`; + } + + return oclMessage; + } + + // used to deserialize event data + public deserialize(jsonData: any): void { + super.deserialize(jsonData); + + this.Callsign = jsonData.Callsign; + this.Destination = jsonData.Destination; + this.EntryPoint = jsonData.EntryPoint; + this.EntryTime = jsonData.EntryTime; + this.RequestedMach = jsonData.RequestedMach; + this.RequestedFlightlevel = jsonData.RequestedFlightlevel; + this.Freetext = jsonData.Freetext; + } +} + +export { AtsuMessageType, AtsuMessageDirection, AtsuMessageSerializationFormat, AtsuMessage }; diff --git a/fbw-a380x/src/systems/atsu/src/messages/TafMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/TafMessage.ts new file mode 100644 index 00000000000..880263ca1fb --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/TafMessage.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { NXDataStore } from '@shared/persistence'; +import { AtsuMessageType } from './AtsuMessage'; +import { WeatherMessage } from './WeatherMessage'; + +/** + * Defines the general TAF message format + */ +export class TafMessage extends WeatherMessage { + constructor() { + super(); + this.Type = AtsuMessageType.TAF; + this.Station = NXDataStore.get('CONFIG_TAF_SRC', 'MSFS'); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/messages/WeatherMessage.ts b/fbw-a380x/src/systems/atsu/src/messages/WeatherMessage.ts new file mode 100644 index 00000000000..493ceb4b802 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/src/messages/WeatherMessage.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { AtsuMessageType, AtsuMessageDirection, AtsuMessageSerializationFormat, AtsuMessage } from './AtsuMessage'; +import { wordWrap } from '../Common'; + +/** + * Defines the general weather message format + */ +export class WeatherMessage extends AtsuMessage { + public Reports = []; + + constructor() { + super(); + this.Direction = AtsuMessageDirection.Uplink; + } + + public serialize(format: AtsuMessageSerializationFormat) { + let type = ''; + switch (this.Type) { + case AtsuMessageType.METAR: + type = 'METAR'; + break; + case AtsuMessageType.TAF: + type = 'TAF'; + break; + default: + type = 'ATIS'; + break; + } + + let message = ''; + + if (format === AtsuMessageSerializationFormat.MCDU || format === AtsuMessageSerializationFormat.MCDUMonitored) { + this.Reports.forEach((report) => { + message += `{cyan}${type} ${report.airport}{end}\n`; + + // eslint-disable-next-line no-loop-func + wordWrap(report.report, 25).forEach((line) => { + if (line.startsWith('D-ATIS')) { + message += `{amber}${line}{end}\n`; + } else if (line === 'NO METAR AVAILABLE' || line === 'NO TAF AVAILABLE') { + message += `{amber}${line}{end}\n`; + } else { + message += `{green}${line}{end}\n`; + } + }); + + message += '{white}------------------------{end}\n'; + }); + } else { + this.Reports.forEach((report) => { + message += `${type} ${report.airport}\n`; + + // eslint-disable-next-line no-loop-func + message += `${report.report}\n`; + + message += '------------------------\n'; + }); + } + + return message; + } +} diff --git a/fbw-a380x/src/systems/atsu/tsconfig.json b/fbw-a380x/src/systems/atsu/tsconfig.json new file mode 100644 index 00000000000..b7d74e18154 --- /dev/null +++ b/fbw-a380x/src/systems/atsu/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "moduleResolution": "node", + "target": "ESNext", + "noEmit": true, + "typeRoots": ["../../typings"] + }, + "include": [ + "src/**/*", + "../../typings/**/*.d.ts" + ] + } diff --git a/fbw-a380x/src/systems/failures/index.ts b/fbw-a380x/src/systems/failures/index.ts new file mode 100644 index 00000000000..8420b1093fd --- /dev/null +++ b/fbw-a380x/src/systems/failures/index.ts @@ -0,0 +1 @@ +export * from './src'; diff --git a/fbw-a380x/src/systems/failures/rollup.config.js b/fbw-a380x/src/systems/failures/rollup.config.js new file mode 100644 index 00000000000..a8e2208626b --- /dev/null +++ b/fbw-a380x/src/systems/failures/rollup.config.js @@ -0,0 +1,43 @@ +'use strict'; + +const { join } = require('path'); +const babel = require('@rollup/plugin-babel').default; +const { typescriptPaths } = require('rollup-plugin-typescript-paths'); +const commonjs = require('@rollup/plugin-commonjs'); +const nodeResolve = require('@rollup/plugin-node-resolve').default; +const replace = require('@rollup/plugin-replace'); + +const extensions = ['.js', '.ts']; + +const src = join(__dirname, '..'); +const root = join(__dirname, '..', '..'); + +process.chdir(src); + +module.exports = { + input: join(__dirname, 'index.ts'), + plugins: [ + nodeResolve({ extensions }), + commonjs(), + babel({ + presets: ['@babel/preset-typescript', ['@babel/preset-env', { targets: { browsers: ['safari 11'] } }]], + plugins: [ + '@babel/plugin-proposal-class-properties', + ], + extensions, + }), + typescriptPaths({ + tsConfigPath: join(src, 'tsconfig.json'), + preserveExtensions: true, + }), + replace({ + 'process.env.NODE_ENV': '"production"', + 'preventAssignment': true, + }), + ], + output: { + file: join(root, 'flybywire-aircraft-a320-neo/html_ui/JS/generated/failures.js'), + format: 'umd', + name: 'Failures', + }, +}; diff --git a/fbw-a380x/src/systems/failures/src/a320.ts b/fbw-a380x/src/systems/failures/src/a320.ts new file mode 100644 index 00000000000..56afe6f4b9a --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/a320.ts @@ -0,0 +1,28 @@ +// One can rightfully argue that this constant shouldn't be located in @flybywiresim/failures. +// Once we create an A320 specific package, such as @flybywiresim/a320, we can move it there. +export const A320Failure = Object.freeze({ + TransformerRectifier1: 24000, + TransformerRectifier2: 24001, + TransformerRectifierEssential: 24002, + GreenReservoirLeak: 29000, + BlueReservoirLeak: 29001, + YellowReservoirLeak: 29002, + GreenReservoirAirLeak: 29003, + BlueReservoirAirLeak: 29004, + YellowReservoirAirLeak: 29005, + GreenReservoirReturnLeak: 29006, + BlueReservoirReturnLeak: 29007, + YellowReservoirReturnLeak: 29008, + LeftPfdDisplay: 31000, + RightPfdDisplay: 31001, + LgciuPowerSupply1: 32000, + LgciuPowerSupply2: 32001, + LgciuInternalError1: 32002, + LgciuInternalError2: 32003, + GearProxSensorDamageGearUplockLeft1: 32004, + GearProxSensorDamageDoorDownlockRight2: 32005, + GearProxSensorDamageGearUplockNose1: 32006, + GearProxSensorDamageDoorUplockLeft2: 32007, + RadioAltimeter1: 34000, + RadioAltimeter2: 34001, +}); diff --git a/fbw-a380x/src/systems/failures/src/communication/__tests__/integration.spec.ts b/fbw-a380x/src/systems/failures/src/communication/__tests__/integration.spec.ts new file mode 100644 index 00000000000..90010b2ebc8 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/__tests__/integration.spec.ts @@ -0,0 +1,83 @@ +import { QueuedSimVarReader, SimVarReaderWriter, TransactionReader, QueuedSimVarWriter, TransactionWriter } from '..'; +import { flushPromises } from '../../test-functions'; + +test('read/write', async () => { + const failureIdentifier = 1; + + const w = writer(failuresToFailableActivateSimVarName); + const writeCallback = jest.fn(); + w.write(failureIdentifier).then(writeCallback); + w.update(); + + const r = reader(failuresToFailableActivateSimVarName); + const readCallback = jest.fn(); + r.register(failureIdentifier, readCallback); + r.update(); + + await flushPromises(); + expect(readCallback).toHaveBeenCalled(); + expect(SimVar.GetSimVarValue(failuresToFailableActivateSimVarName, 'number')).toBe(0); + + w.update(); + + await flushPromises(); + expect(writeCallback).toHaveBeenCalled(); +}); + +test('transaction read/write', async () => { + const failureIdentifier = 1; + + const tw = transactionWriter(failuresToOrchestratorActivateSimVarName, failuresToOrchestratorTransactionSimVarName); + const writeCallback = jest.fn(); + + const tr = transactionReader(failuresToOrchestratorActivateSimVarName, failuresToOrchestratorTransactionSimVarName); + const readCallback = jest.fn(); + tr.register(failureIdentifier, readCallback); + + tw.write(failureIdentifier).then(writeCallback); + + tr.update(); + expect(readCallback).toHaveBeenCalled(); + expect(SimVar.GetSimVarValue(failuresToOrchestratorActivateSimVarName, 'number')).toBe(0); + + await flushPromises(); + expect(SimVar.GetSimVarValue(failuresToOrchestratorTransactionSimVarName, 'number')).toBe(failureIdentifier); + + tw.update(); + await flushPromises(); + tw.update(); + + expect(SimVar.GetSimVarValue(failuresToOrchestratorTransactionSimVarName, 'number')).toBe(0); + await flushPromises(); + expect(writeCallback).toHaveBeenCalled(); +}); + +const failuresToFailableActivateSimVarName = 'L:FAILURES_TO_FAILABLE_ACTIVATE'; +const failuresToOrchestratorActivateSimVarName = 'L:FAILURES_TO_ORCHESTRATOR_ACTIVATE'; +const failuresToOrchestratorTransactionSimVarName = 'L:FAILURES_ORCHESTRATOR_ACTIVATE_RECEIVED'; + +function writer(simVarName: string) { + return new QueuedSimVarWriter(new SimVarReaderWriter(simVarName)); +} + +function reader(simVarName: string) { + return new QueuedSimVarReader(new SimVarReaderWriter(simVarName)); +} + +function transactionReader(simVarName: string, transactionSimVarName: string) { + return new TransactionReader( + reader(simVarName), + new QueuedSimVarWriter( + new SimVarReaderWriter(transactionSimVarName), + ), + ); +} + +function transactionWriter(simVarName: string, transactionSimVarName: string) { + return new TransactionWriter( + new QueuedSimVarWriter( + new SimVarReaderWriter(simVarName), + ), + new SimVarReaderWriter(transactionSimVarName), + ); +} diff --git a/fbw-a380x/src/systems/failures/src/communication/callback-reader.ts b/fbw-a380x/src/systems/failures/src/communication/callback-reader.ts new file mode 100644 index 00000000000..3e9e2057325 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/callback-reader.ts @@ -0,0 +1,8 @@ +import { Updatable } from '.'; + +export interface CallbackReader extends Updatable { + /** + * Registers a callback to be called when the given identifier is read. + */ + register(identifier: number, callback: () => void): void; +} diff --git a/fbw-a380x/src/systems/failures/src/communication/index.ts b/fbw-a380x/src/systems/failures/src/communication/index.ts new file mode 100644 index 00000000000..3d19963e748 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/index.ts @@ -0,0 +1,7 @@ +export * from './readers'; +export * from './writers'; +export { SimVarReaderWriter } from './sim-var-reader-writer'; +export type { CallbackReader } from './callback-reader'; +export type { Reader } from './reader'; +export type { Updatable } from './updatable'; +export type { Writer } from './writer'; diff --git a/fbw-a380x/src/systems/failures/src/communication/reader.ts b/fbw-a380x/src/systems/failures/src/communication/reader.ts new file mode 100644 index 00000000000..01738c22d2c --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/reader.ts @@ -0,0 +1,3 @@ +export interface Reader { + read(): number; +} diff --git a/fbw-a380x/src/systems/failures/src/communication/readers/index.ts b/fbw-a380x/src/systems/failures/src/communication/readers/index.ts new file mode 100644 index 00000000000..f6153c53495 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/readers/index.ts @@ -0,0 +1,2 @@ +export { QueuedSimVarReader } from './queued-sim-var-reader'; +export { TransactionReader } from './transaction-reader'; diff --git a/fbw-a380x/src/systems/failures/src/communication/readers/queued-sim-var-reader.spec.ts b/fbw-a380x/src/systems/failures/src/communication/readers/queued-sim-var-reader.spec.ts new file mode 100644 index 00000000000..96e41181fc9 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/readers/queued-sim-var-reader.spec.ts @@ -0,0 +1,64 @@ +import { QueuedSimVarReader } from '.'; +import { SimVarReaderWriter } from '..'; + +describe('QueuedSimVarReader', () => { + test("doesn't read values not found in its collection", async () => { + const callback = jest.fn(); + await registerAndExecute(callback, async (r) => { + await SimVar.SetSimVarValue(simVarName, 'number', notInCollectionIdentifier); + r.update(); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('reads values found in its collection', async () => { + const callback = jest.fn(); + await registerAndExecute(callback, async (r) => { + await SimVar.SetSimVarValue(simVarName, 'number', inCollectionIdentifier); + r.update(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test("doesn't read the same value multiple times", async () => { + const callback = jest.fn(); + await registerAndExecute(callback, async (r) => { + await SimVar.SetSimVarValue(simVarName, 'number', inCollectionIdentifier); + r.update(); + r.update(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('does read the same value multiple times when set again', async () => { + const callback = jest.fn(); + await registerAndExecute(callback, async (r) => { + await SimVar.SetSimVarValue(simVarName, 'number', inCollectionIdentifier); + r.update(); + + await SimVar.SetSimVarValue(simVarName, 'number', inCollectionIdentifier); + r.update(); + }); + + expect(callback).toHaveBeenCalledTimes(2); + }); +}); + +const inCollectionIdentifier = 1; +const notInCollectionIdentifier = 2; + +async function registerAndExecute(callback: () => void, act: (r: QueuedSimVarReader) => Promise) { + const r = reader(); + r.register(inCollectionIdentifier, callback); + + await act(r); +} + +const simVarName = 'L:SIMVAR'; + +function reader() { + return new QueuedSimVarReader(new SimVarReaderWriter(simVarName)); +} diff --git a/fbw-a380x/src/systems/failures/src/communication/readers/queued-sim-var-reader.ts b/fbw-a380x/src/systems/failures/src/communication/readers/queued-sim-var-reader.ts new file mode 100644 index 00000000000..6903034ccde --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/readers/queued-sim-var-reader.ts @@ -0,0 +1,38 @@ +import { CallbackReader, Writer } from '..'; + +/** + * Provides queued reading on top of another reader. + * + * Requires that the variable is written by a queued writer. + * + * Internally this reads values and writes 0 when it consumes the value. + */ +export class QueuedSimVarReader implements CallbackReader { + private simVar: CallbackReader & Writer; + + private isResetting: boolean = false; + + constructor(simVar: CallbackReader & Writer) { + this.simVar = simVar; + } + + register(identifier: number, callback: () => void) { + this.simVar.register(identifier, () => { + this.resetSimVar(); + callback(); + }); + } + + update() { + if (!this.isResetting) { + this.simVar.update(); + } + } + + private resetSimVar() { + this.isResetting = true; + this.simVar.write(0).then(() => { + this.isResetting = false; + }); + } +} diff --git a/fbw-a380x/src/systems/failures/src/communication/readers/transaction-reader.spec.ts b/fbw-a380x/src/systems/failures/src/communication/readers/transaction-reader.spec.ts new file mode 100644 index 00000000000..7a67c605984 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/readers/transaction-reader.spec.ts @@ -0,0 +1,44 @@ +import { QueuedSimVarReader, TransactionReader } from '.'; +import { CallbackReader, SimVarReaderWriter, QueuedSimVarWriter } from '..'; +import { flushPromises } from '../../test-functions'; + +describe('TransactionReader', () => { + test('confirms receival', async () => { + const failureIdentifier = 1; + const r = reader(); + r.register(failureIdentifier, () => {}); + + await SimVar.SetSimVarValue(failuresSimVar, 'number', failureIdentifier); + r.update(); + await flushPromises(); + + expect(SimVar.GetSimVarValue(transactionSimVar, 'number')).toBe(failureIdentifier); + }); + + test('calls the registered callback when a value is read', async () => { + const failureIdentifier = 1; + const r = reader(); + const callback = jest.fn(); + r.register(failureIdentifier, callback); + + await SimVar.SetSimVarValue(failuresSimVar, 'number', failureIdentifier); + r.update(); + await flushPromises(); + + expect(callback).toHaveBeenCalled(); + }); +}); + +const failuresSimVar = 'L:FAILURES_TO_ORCHESTRATOR_ACTIVATE'; +const transactionSimVar = 'L:FAILURES_ORCHESTRATOR_ACTIVATE_RECEIVED'; + +function reader(): CallbackReader { + return new TransactionReader( + new QueuedSimVarReader( + new SimVarReaderWriter(failuresSimVar), + ), + new QueuedSimVarWriter( + new SimVarReaderWriter(transactionSimVar), + ), + ); +} diff --git a/fbw-a380x/src/systems/failures/src/communication/readers/transaction-reader.ts b/fbw-a380x/src/systems/failures/src/communication/readers/transaction-reader.ts new file mode 100644 index 00000000000..17b7ef5b8ea --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/readers/transaction-reader.ts @@ -0,0 +1,29 @@ +import { CallbackReader, Updatable, Writer } from '..'; + +/** + * Provides transactional reading on top of another reader. + * + * Requires that the variable is written by a transactional writer. + */ +export class TransactionReader implements CallbackReader { + private valueSimVar: CallbackReader; + + private transactionSimVar: Writer & Updatable; + + constructor(valueSimVar: CallbackReader, transactionSimVar: Writer & Updatable) { + this.valueSimVar = valueSimVar; + this.transactionSimVar = transactionSimVar; + } + + register(identifier: number, callback: () => void): void { + this.valueSimVar.register(identifier, () => { + callback(); + this.transactionSimVar.write(identifier); + }); + } + + update(): void { + this.valueSimVar.update(); + this.transactionSimVar.update(); + } +} diff --git a/fbw-a380x/src/systems/failures/src/communication/sim-var-reader-writer.spec.ts b/fbw-a380x/src/systems/failures/src/communication/sim-var-reader-writer.spec.ts new file mode 100644 index 00000000000..f2bb29bfcb8 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/sim-var-reader-writer.spec.ts @@ -0,0 +1,63 @@ +import { CallbackReader, SimVarReaderWriter } from '.'; + +describe('SimVarReaderWriter', () => { + test('reads values', async () => { + const simVarValue = 1; + await SimVar.SetSimVarValue(simVarName, 'number', simVarValue); + + expect(readerWriter().read()).toBe(simVarValue); + }); + + test('writes values', async () => { + const value = 1; + await readerWriter().write(value); + + expect(SimVar.GetSimVarValue(simVarName, 'number')).toBe(value); + }); + + test("does't read values which aren't in the collection", async () => { + const callback = jest.fn(); + await registerAndExecute(callback, async (r) => { + await SimVar.SetSimVarValue(simVarName, 'number', notInCollectionIdentifier); + r.update(); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('reads values that are in the collection', async () => { + const callback = jest.fn(); + await registerAndExecute(callback, async (r) => { + await SimVar.SetSimVarValue(simVarName, 'number', inCollectionIdentifier); + r.update(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('reads the same value multiple times', async () => { + const callback = jest.fn(); + await registerAndExecute(callback, async (r) => { + await SimVar.SetSimVarValue(simVarName, 'number', inCollectionIdentifier); + r.update(); + r.update(); + }); + + expect(callback).toHaveBeenCalledTimes(2); + }); +}); + +const simVarName = 'L:VARIABLE'; +const inCollectionIdentifier = 1; +const notInCollectionIdentifier = 2; + +function readerWriter(): SimVarReaderWriter { + return new SimVarReaderWriter(simVarName); +} + +async function registerAndExecute(callback: () => void, act: (r: CallbackReader) => Promise) { + const r = readerWriter(); + r.register(inCollectionIdentifier, callback); + + await act(r); +} diff --git a/fbw-a380x/src/systems/failures/src/communication/sim-var-reader-writer.ts b/fbw-a380x/src/systems/failures/src/communication/sim-var-reader-writer.ts new file mode 100644 index 00000000000..5c14cc53004 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/sim-var-reader-writer.ts @@ -0,0 +1,42 @@ +import { CallbackReader, Reader } from '.'; +import { Writer } from './writer'; + +/** + * Reads (either directly or through a callback) and writes variables. + */ +export class SimVarReaderWriter implements CallbackReader, Reader, Writer { + private simVarName: string; + + private callbacks: Map void> = new Map(); + + constructor(simVarName: string) { + this.simVarName = simVarName; + } + + register(identifier: number, callback: () => void): void { + this.callbacks.set(identifier, callback); + } + + update(): void { + const identifier = this.read(); + if (this.handles(identifier)) { + this.notify(identifier); + } + } + + read(): number { + return SimVar.GetSimVarValue(this.simVarName, 'number'); + } + + write(value: number): Promise { + return SimVar.SetSimVarValue(this.simVarName, 'number', value); + } + + private handles(identifier: number) { + return this.callbacks.get(identifier) !== undefined; + } + + private notify(identifier: number) { + this.callbacks.get(identifier)(); + } +} diff --git a/fbw-a380x/src/systems/failures/src/communication/updatable.ts b/fbw-a380x/src/systems/failures/src/communication/updatable.ts new file mode 100644 index 00000000000..b20bd57d803 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/updatable.ts @@ -0,0 +1,3 @@ +export interface Updatable { + update(): void; +} diff --git a/fbw-a380x/src/systems/failures/src/communication/writer.ts b/fbw-a380x/src/systems/failures/src/communication/writer.ts new file mode 100644 index 00000000000..c8e2ae3f8e0 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/writer.ts @@ -0,0 +1,8 @@ +export interface Writer { + /** + * Writes the given value. + * @param value the value to write to the SimVar. + * @returns a Promise which resolves once the value was written and consumed. + */ + write(value: number): Promise; +} diff --git a/fbw-a380x/src/systems/failures/src/communication/writers/index.ts b/fbw-a380x/src/systems/failures/src/communication/writers/index.ts new file mode 100644 index 00000000000..ebdb9e4b5af --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/writers/index.ts @@ -0,0 +1,2 @@ +export { QueuedSimVarWriter } from './queued-sim-var-writer'; +export { TransactionWriter } from './transaction-writer'; diff --git a/fbw-a380x/src/systems/failures/src/communication/writers/queue.spec.ts b/fbw-a380x/src/systems/failures/src/communication/writers/queue.spec.ts new file mode 100644 index 00000000000..b0cd9738085 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/writers/queue.spec.ts @@ -0,0 +1,50 @@ +import { Queue } from './queue'; + +describe('Queue', () => { + test('is empty when nothing is enqueued', () => { + expect(queue().size()).toBe(0); + }); + + test('dequeues undefined when empty', () => { + expect(queue().dequeue()).toBeUndefined(); + }); + + test('enqueues items', () => { + const q = queue(); + q.enqueue(1); + + expect(q.size()).toBe(1); + }); + + test('removes the item when dequeueing', () => { + const q = queue(); + q.enqueue(1); + + expect(q.dequeue()).toBe(1); + }); + + test('decreases its size by one when dequeueing', () => { + const q = queue(); + q.enqueue(1); + q.enqueue(2); + q.enqueue(3); + const length = q.size(); + + q.dequeue(); + + expect(q.size()).toBe(length - 1); + }); + + test('is "first in, first out"', () => { + const q = queue(); + q.enqueue(1); + q.enqueue(2); + q.enqueue(3); + + expect(q.dequeue()).toBe(1); + }); +}); + +function queue() { + return new Queue(); +} diff --git a/fbw-a380x/src/systems/failures/src/communication/writers/queue.ts b/fbw-a380x/src/systems/failures/src/communication/writers/queue.ts new file mode 100644 index 00000000000..4c0ccc99136 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/writers/queue.ts @@ -0,0 +1,15 @@ +export class Queue { + private store: T[] = []; + + enqueue(val: T) { + this.store.push(val); + } + + dequeue(): T | undefined { + return this.store.shift(); + } + + size(): number { + return this.store.length; + } +} diff --git a/fbw-a380x/src/systems/failures/src/communication/writers/queued-sim-var-writer.spec.ts b/fbw-a380x/src/systems/failures/src/communication/writers/queued-sim-var-writer.spec.ts new file mode 100644 index 00000000000..59bcbf22bc8 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/writers/queued-sim-var-writer.spec.ts @@ -0,0 +1,92 @@ +import { flushPromises } from '../../test-functions'; +import { QueuedSimVarWriter } from '.'; +import { SimVarReaderWriter } from '..'; + +describe('QueuedSimVarWriter', () => { + test('writes values', async () => { + const w = writer(); + w.write(1); + + expect(await consumeSimVarValueAndUpdate(w, 1)).toBe(true); + }); + + test('when empty immediately writes a value without requiring an update', async () => { + const w = writer(); + w.write(1); + + expect(await consumeSimVarValue(1)).toBe(true); + }); + + test("doesn't overwrite a value waiting for consumption", async () => { + const w = writer(); + w.write(1); + w.write(2); + + // Try consuming 2, which isn't yet written and therefore will be unsuccessful. + expect(await consumeSimVarValueAndUpdate(w, 2)).toBe(false); + }); + + test('writes the next value after consumption of the previous value', async () => { + const w = writer(); + w.write(1); + w.write(2); + + await consumeSimVarValueAndUpdate(w, 1); + + expect(await consumeSimVarValue(2)).toBe(true); + }); + + test('resolves the write as soon as the write and consumption are observed', async () => { + const w = writer(); + const promise = w.write(1); + + // Performs the write from the queue. + w.update(); + + // Observe the write by letting any pending promise callbacks run. + await flushPromises(); + + await consumeSimVarValue(1); + + // Observes the value was consumed. + w.update(); + + await promise; + }); +}); + +const simVarName = 'L:SIMVAR'; + +function writer() { + return new QueuedSimVarWriter(new SimVarReaderWriter(simVarName)); +} + +async function consumeSimVarValue(expected: number) { + if (SimVar.GetSimVarValue(simVarName, 'number') === expected) { + await SimVar.SetSimVarValue(simVarName, 'number', 0); + return true; + } + + return false; +} + +async function consumeSimVarValueAndUpdate(q: QueuedSimVarWriter, expected: number) { + return consumeSimVarValueAndUpdateInner(q, expected, 0); +} + +async function consumeSimVarValueAndUpdateInner(q: QueuedSimVarWriter, expected: number, iteration: number): Promise { + let consumed = false; + if (await consumeSimVarValue(expected)) { + consumed = true; + } + + q.update(); + // Observe the write by letting any pending promise callbacks run. + await flushPromises(); + + if (iteration < 10) { + consumed = await consumeSimVarValueAndUpdateInner(q, expected, iteration + 1) || consumed; + } + + return consumed; +} diff --git a/fbw-a380x/src/systems/failures/src/communication/writers/queued-sim-var-writer.ts b/fbw-a380x/src/systems/failures/src/communication/writers/queued-sim-var-writer.ts new file mode 100644 index 00000000000..7443653e508 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/writers/queued-sim-var-writer.ts @@ -0,0 +1,84 @@ +import { Queue } from './queue'; +import { Reader, Writer } from '..'; + +/** + * Provides queued writing on top of another writer. + * + * Requires that the variable is read by a queued reader. + * + * Internally this writes values to a SimVar one after the other, waiting for the SimVar to be + * consumed (set to 0) before writing the next value. + */ +export class QueuedSimVarWriter implements Writer { + private simVar: Reader & Writer; + + private messageQueue: Queue<() => WritingContext>; + + private context: WritingContext | undefined; + + constructor(simVar: Reader & Writer) { + this.simVar = simVar; + this.messageQueue = new Queue<() => WritingContext>(); + this.context = undefined; + } + + write(value: number): Promise { + return new Promise((resolve) => { + this.messageQueue.enqueue(() => { + const context: WritingContext = { + value, + isWritten: false, + resolve, + }; + this.simVar.write(value).then(() => { + context.isWritten = true; + }); + + return context; + }); + + // Whenever possible do not wait for an update before writing. + this.immediatelyWriteWhenAble(); + }); + } + + update() { + if (this.isWriting()) { + if (this.context.isWritten && this.isReadByConsumer()) { + this.finaliseWriting(); + } + } else if (this.messageQueue.size() > 0) { + this.writeNext(); + } + } + + private isWriting(): boolean { + return this.context !== undefined; + } + + private writeNext() { + const write = this.messageQueue.dequeue(); + this.context = write(); + } + + private finaliseWriting() { + this.context.resolve(); + this.context = undefined; + } + + private isReadByConsumer() { + return this.simVar.read() !== this.context.value; + } + + private immediatelyWriteWhenAble() { + if (!this.isWriting() && this.messageQueue.size() === 1) { + this.writeNext(); + } + } +} + +interface WritingContext { + value: number, + isWritten: boolean, + resolve: () => void, +} diff --git a/fbw-a380x/src/systems/failures/src/communication/writers/transaction-writer.spec.ts b/fbw-a380x/src/systems/failures/src/communication/writers/transaction-writer.spec.ts new file mode 100644 index 00000000000..90cea412e0e --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/writers/transaction-writer.spec.ts @@ -0,0 +1,119 @@ +import { TransactionWriter } from './transaction-writer'; +import { SimVarReaderWriter, Updatable, Writer, QueuedSimVarWriter } from '..'; +import { flushPromises } from '../../test-functions'; + +describe('TransationWriter', () => { + test('waits for receival confirmation', async () => { + const failureIdentifier = 1; + const w = writer(); + + const write = await writeAndConsumeValue(w, failureIdentifier); + const callback = jest.fn(); + write.innerPromise.then(callback); + + updateWriter(w, retryAfterNumberOfUpdates - 1); + + await flushPromises(); + expect(callback).not.toHaveBeenCalled(); + + await confirmReceival(w, failureIdentifier); + await flushPromises(); + + expect(callback).toHaveBeenCalled(); + }); + + test('clears the receival confirmation', async () => { + const failureIdentifier = 1; + const w = writer(); + + const write = await writeAndConsumeValue(w, failureIdentifier); + await confirmReceival(w, failureIdentifier); + await write.innerPromise; + + expect(receivalConfirmationSimVar()).toBe(0); + }); + + test('retries when receival not confirmed', async () => { + const failureIdentifier = 1; + const w = writer(); + + const write = await writeAndConsumeValue(w, failureIdentifier); + const callback = jest.fn(); + const promise = write.innerPromise.then(callback); + + updateWriter(w, retryAfterNumberOfUpdates); + + await flushPromises(); + expect(callback).not.toHaveBeenCalled(); + expect(await waitForWriteAndConsumeValue(w, failureIdentifier)).toBe(true); + + await confirmReceival(w, failureIdentifier); + await promise; + + expect(callback).toHaveBeenCalled(); + }); +}); + +const simVarName = 'L:SIMVAR'; +const transactionSimVarName = 'L:SIMVAR_CONFIRMATION'; +const retryAfterNumberOfUpdates = 30; + +function writer(): Writer & Updatable { + return new TransactionWriter( + new QueuedSimVarWriter( + new SimVarReaderWriter(simVarName), + ), + new SimVarReaderWriter(transactionSimVarName), + retryAfterNumberOfUpdates, + ); +} + +async function writeAndConsumeValue(writer: Writer & Updatable, value: number): Promise<{ innerPromise: Promise }> { + const promise = writer.write(value); + + await waitForWriteAndConsumeValue(writer, value); + + return { innerPromise: promise }; +} + +async function waitForWriteAndConsumeValue(writer: Writer & Updatable, value: number) { + // Performs the write from the queue. + writer.update(); + + // Observe the write by letting any pending promise callbacks run. + await flushPromises(); + + const consumed = await consumeSimVarValue(value); + + // Observes the value was consumed. + writer.update(); + + // Let any code execution by the write happen. + await flushPromises(); + + return consumed; +} + +async function consumeSimVarValue(expected: number) { + if (SimVar.GetSimVarValue(simVarName, 'number') === expected) { + await SimVar.SetSimVarValue(simVarName, 'number', 0); + return true; + } + + return false; +} + +async function confirmReceival(writer: Writer & Updatable, failureIdentifier: number) { + await SimVar.SetSimVarValue(transactionSimVarName, 'number', failureIdentifier); + writer.update(); +} + +function receivalConfirmationSimVar() { + return SimVar.GetSimVarValue(transactionSimVarName, 'number'); +} + +function updateWriter(writer: Writer & Updatable, times: number) { + for (let i = 0; i < times; i++) { + writer.update(); + } +} diff --git a/fbw-a380x/src/systems/failures/src/communication/writers/transaction-writer.ts b/fbw-a380x/src/systems/failures/src/communication/writers/transaction-writer.ts new file mode 100644 index 00000000000..639e9e77d16 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/communication/writers/transaction-writer.ts @@ -0,0 +1,111 @@ +import { Reader, Updatable, Writer } from '..'; + +/** + * Provides transactional writing on top of another writer. + * + * Requires that the written variable is read by a transactional reader. + */ +export class TransactionWriter implements Writer { + private retryAfterNumberOfUpdates: number; + + private valueSimVar: Writer & Updatable; + + private transactionSimVar: Reader & Writer; + + private openTransactions = new Map(); + + /** + * + * @param valueSimVar The writer to use for writing values. + * @param transactionSimVar The reader/writer to use for managing the transaction. + * @param retryAfterNumberOfUpdates The number of calls to `update` to wait before making another write attempt. Defaults to 30. + */ + constructor(valueSimVar: Writer & Updatable, transactionSimVar: Reader & Writer, retryAfterNumberOfUpdates = 30) { + this.retryAfterNumberOfUpdates = retryAfterNumberOfUpdates; + this.valueSimVar = valueSimVar; + this.transactionSimVar = transactionSimVar; + } + + /** + * Writes the value to the underlying writer and waits for a transactional + * reader to confirm the successful reading of the value after which the Promise resolves. + */ + async write(value: number): Promise { + return new Promise((resolve) => { + this.valueSimVar.write(value).then(() => { + this.addTransaction(value, resolve); + }); + }); + } + + update(): void { + this.valueSimVar.update(); + + if (!this.hasOpenTransactions()) { + return; + } + + this.resolveTransactionWithValue(this.transactionSimVar.read()); + this.increaseTransactionsWaitingDuration(); + this.retryWriteForExpiredTransactions(); + } + + private resolveTransactionWithValue(value: number) { + this.openTransactions.forEach((transaction) => { + if (transaction.value === value) { + this.resolveTransaction(transaction); + } + }); + } + + private increaseTransactionsWaitingDuration() { + this.openTransactions.forEach((transaction) => { + transaction.waitedUpdates++; + }); + } + + private retryWriteForExpiredTransactions() { + this.openTransactions.forEach((transaction) => { + if (transaction.waitedUpdates >= this.retryAfterNumberOfUpdates) { + this.retryWrite(transaction); + } + }); + } + + private retryWrite(transaction: Transaction) { + // Remove the existing write, to ensure we do not check and confirm + // during the retry attempt of the write. + this.removeTransaction(transaction.value); + + this.valueSimVar.write(transaction.value).then(() => { + // The caller is waiting for the original resolve function to be called, + // thus we pass it to the new expectation here. + this.addTransaction(transaction.value, transaction.resolve); + }); + } + + private hasOpenTransactions() { + return this.openTransactions.size; + } + + private addTransaction(value: number, resolve: () => void) { + this.openTransactions.set(value, { waitedUpdates: 0, value, resolve }); + } + + private resolveTransaction(transaction: Transaction) { + this.transactionSimVar.write(0).then(() => { + transaction.resolve(); + }); + this.removeTransaction(transaction.value); + } + + private removeTransaction(value: number) { + this.openTransactions.delete(value); + } +} + +interface Transaction { + waitedUpdates: number, + value: number, + resolve: () => void, +} diff --git a/fbw-a380x/src/systems/failures/src/failures-consumer.spec.ts b/fbw-a380x/src/systems/failures/src/failures-consumer.spec.ts new file mode 100644 index 00000000000..2c8d4c6ee94 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/failures-consumer.spec.ts @@ -0,0 +1,91 @@ +import { FailuresConsumer } from './failures-consumer'; +import { getActivateFailureSimVarName, getDeactivateFailureSimVarName } from './sim-vars'; + +describe('FailuresConsumer', () => { + describe('registers an identifier', () => { + test('with callback', () => { + const c = consumer(); + c.register(1, (_) => {}); + }); + + test('without callback', () => { + const c = consumer(); + c.register(1); + }); + + test('unless registered multiple times', () => { + const c = consumer(); + c.register(1); + + expect(() => c.register(1)).toThrow(); + }); + }); + + describe('calls the callback', () => { + test('when the failure is activated', async () => { + const c = consumer(); + const callback = jest.fn(); + c.register(1, callback); + + await SimVar.SetSimVarValue(activateSimVarName, 'number', 1); + c.update(); + + expect(callback).toHaveBeenCalled(); + expect(callback.mock.calls[0][0]).toBe(true); + }); + + test('when the failure is deactivated', async () => { + const c = consumer(); + const callback = jest.fn(); + c.register(1, callback); + + await SimVar.SetSimVarValue(deactivateSimVarName, 'number', 1); + c.update(); + + expect(callback).toHaveBeenCalled(); + expect(callback.mock.calls[0][0]).toBe(false); + }); + }); + + describe('indicates a failure is', () => { + test('inactive when never activated', () => { + const c = consumer(); + c.register(1, (_) => {}); + + c.update(); + + expect(c.isActive(1)).toBe(false); + }); + + test('active when activated', async () => { + const c = consumer(); + c.register(1, (_) => {}); + + await SimVar.SetSimVarValue(activateSimVarName, 'number', 1); + c.update(); + + expect(c.isActive(1)).toBe(true); + }); + + test('inactive when deactivated', async () => { + const c = consumer(); + c.register(1, (_) => {}); + + await SimVar.SetSimVarValue(activateSimVarName, 'number', 1); + c.update(); + + await SimVar.SetSimVarValue(deactivateSimVarName, 'number', 1); + c.update(); + + expect(c.isActive(1)).toBe(false); + }); + }); +}); + +const prefix = 'PREFIX'; +const activateSimVarName = getActivateFailureSimVarName(prefix); +const deactivateSimVarName = getDeactivateFailureSimVarName(prefix); + +function consumer() { + return new FailuresConsumer(prefix); +} diff --git a/fbw-a380x/src/systems/failures/src/failures-consumer.ts b/fbw-a380x/src/systems/failures/src/failures-consumer.ts new file mode 100644 index 00000000000..0a11cf94a66 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/failures-consumer.ts @@ -0,0 +1,45 @@ +import { QueuedSimVarReader, SimVarReaderWriter } from './communication'; +import { getActivateFailureSimVarName, getDeactivateFailureSimVarName } from './sim-vars'; + +export class FailuresConsumer { + private activeFailures = new Map(); + + private callbacks: Map void> = new Map(); + + private activateFailureReader: QueuedSimVarReader; + + private deactivateFailureReader: QueuedSimVarReader; + + constructor(simVarPrefix: string) { + this.activateFailureReader = new QueuedSimVarReader(new SimVarReaderWriter(getActivateFailureSimVarName(simVarPrefix))); + this.deactivateFailureReader = new QueuedSimVarReader(new SimVarReaderWriter(getDeactivateFailureSimVarName(simVarPrefix))); + } + + register(identifier: number, callback?: (isActive: boolean) => void) { + if (this.callbacks.get(identifier) !== undefined) { + throw new Error(`Cannot register the same failure identifier (${identifier}) multiple times.`); + } + + this.callbacks.set(identifier, callback || ((_) => {})); + this.activateFailureReader.register(identifier, () => { + this.onReadCallback(identifier, true); + }); + this.deactivateFailureReader.register(identifier, () => { + this.onReadCallback(identifier, false); + }); + } + + update() { + this.activateFailureReader.update(); + this.deactivateFailureReader.update(); + } + + isActive(identifier: number): boolean { + return this.activeFailures.get(identifier) === true; + } + + private onReadCallback(identifier: number, value: boolean) { + this.callbacks.get(identifier)(value); + this.activeFailures.set(identifier, value); + } +} diff --git a/fbw-a380x/src/systems/failures/src/failures-orchestrator.spec.ts b/fbw-a380x/src/systems/failures/src/failures-orchestrator.spec.ts new file mode 100644 index 00000000000..1038cc5c549 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/failures-orchestrator.spec.ts @@ -0,0 +1,107 @@ +import { FailuresOrchestrator } from '.'; +import { getActivateFailureSimVarName, getDeactivateFailureSimVarName } from './sim-vars'; +import { flushPromises } from './test-functions'; + +describe('FailuresOrchestrator', () => { + test('stores configured failures', () => { + const o = orchestrator(); + + const allFailures = o.getAllFailures(); + expect(allFailures).toHaveLength(1); + expect(allFailures[0]).toMatchObject({ + ata: 0, + identifier: 123, + name: 'test', + }); + }); + + describe('indicates a failure is', () => { + test('inactive when never activated', () => { + const o = orchestrator(); + expect(o.isActive(identifier)).toBe(false); + }); + + test('active when activated', async () => { + const o = orchestrator(); + + await activateFailure(o); + + expect(o.isActive(identifier)).toBe(true); + }); + + test('inactive when deactivated', async () => { + const o = orchestrator(); + // First activate the failure to ensure we're not just observing + // the lack of any change. + await activateFailure(o); + + await deactivateFailure(o); + + expect(o.isActive(identifier)).toBe(false); + }); + + describe('changing', () => { + test('while failure is activating', async () => { + const o = orchestrator(); + + expect(o.isChanging(identifier)).toBe(false); + + const promise = o.activate(identifier); + + expect(o.isChanging(identifier)).toBe(true); + + await flushPromises(); + await SimVar.SetSimVarValue(activateSimVarName, 'number', 0); + o.update(); + await promise; + + expect(o.isChanging(identifier)).toBe(false); + }); + + test('while failure is deactivating', async () => { + const o = orchestrator(); + + expect(o.isChanging(identifier)).toBe(false); + + const promise = o.deactivate(identifier); + + expect(o.isChanging(identifier)).toBe(true); + + await flushPromises(); + await SimVar.SetSimVarValue(deactivateSimVarName, 'number', 0); + o.update(); + await promise; + + expect(o.isChanging(identifier)).toBe(false); + }); + }); + }); +}); + +const prefix = 'PREFIX'; +const activateSimVarName = getActivateFailureSimVarName(prefix); +const deactivateSimVarName = getDeactivateFailureSimVarName(prefix); + +const identifier = 123; +const name = 'test'; + +function orchestrator() { + return new FailuresOrchestrator(prefix, [[0, identifier, name]]); +} + +function activateFailure(o: FailuresOrchestrator) { + return activateOrDeactivateFailure(o, true); +} + +function deactivateFailure(o: FailuresOrchestrator) { + return activateOrDeactivateFailure(o, false); +} + +async function activateOrDeactivateFailure(o: FailuresOrchestrator, activate: boolean) { + const promise = activate ? o.activate(identifier) : o.deactivate(identifier); + await flushPromises(); + await SimVar.SetSimVarValue(activate ? activateSimVarName : deactivateSimVarName, 'number', 0); + o.update(); + + await promise; +} diff --git a/fbw-a380x/src/systems/failures/src/failures-orchestrator.ts b/fbw-a380x/src/systems/failures/src/failures-orchestrator.ts new file mode 100644 index 00000000000..5bf0784d29c --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/failures-orchestrator.ts @@ -0,0 +1,90 @@ +import { AtaChapterNumber } from '@shared/ata'; +import { QueuedSimVarWriter, SimVarReaderWriter } from './communication'; +import { getActivateFailureSimVarName, getDeactivateFailureSimVarName } from './sim-vars'; + +export interface Failure { + ata: AtaChapterNumber, + identifier: number, + name: string, +} + +/** + * Orchestrates the activation and deactivation of failures. + * + * Only a single instance of the orchestrator should exist within the whole application. + */ +export class FailuresOrchestrator { + private failures: Failure[] = []; + + private activeFailures = new Set(); + + private changingFailures = new Set(); + + private activateFailureQueue: QueuedSimVarWriter; + + private deactivateFailureQueue: QueuedSimVarWriter; + + constructor(simVarPrefix: string, failures: [AtaChapterNumber, number, string][]) { + this.activateFailureQueue = new QueuedSimVarWriter(new SimVarReaderWriter(getActivateFailureSimVarName(simVarPrefix))); + this.deactivateFailureQueue = new QueuedSimVarWriter(new SimVarReaderWriter(getDeactivateFailureSimVarName(simVarPrefix))); + failures.forEach((failure) => { + this.failures.push({ + ata: failure[0], + identifier: failure[1], + name: failure[2], + }); + }); + } + + update() { + this.activateFailureQueue.update(); + this.deactivateFailureQueue.update(); + } + + /** + * Activates the failure with the given identifier. + */ + async activate(identifier: number): Promise { + this.changingFailures.add(identifier); + await this.activateFailureQueue.write(identifier); + this.changingFailures.delete(identifier); + this.activeFailures.add(identifier); + } + + /** + * Deactivates the failure with the given identifier. + */ + async deactivate(identifier: number): Promise { + this.changingFailures.add(identifier); + await this.deactivateFailureQueue.write(identifier); + this.changingFailures.delete(identifier); + this.activeFailures.delete(identifier); + } + + /** + * Determines whether or not the failure with the given identifier is active. + */ + isActive(identifier: number): boolean { + return this.activeFailures.has(identifier); + } + + /** + * Determines whether or not the failure with the given identifier is currently + * changing its state between active and inactive. + */ + isChanging(identifier: number): boolean { + return this.changingFailures.has(identifier); + } + + getAllFailures(): Readonly[]> { + return this.failures; + } + + getActiveFailures(): Set { + return new Set(this.activeFailures); + } + + getChangingFailures(): Set { + return new Set(this.changingFailures); + } +} diff --git a/fbw-a380x/src/systems/failures/src/index.ts b/fbw-a380x/src/systems/failures/src/index.ts new file mode 100644 index 00000000000..88630500eec --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/index.ts @@ -0,0 +1,4 @@ +export { FailuresConsumer } from './failures-consumer'; +export { FailuresOrchestrator } from './failures-orchestrator'; +export type { Failure } from './failures-orchestrator'; +export { A320Failure } from './a320'; diff --git a/fbw-a380x/src/systems/failures/src/sim-vars.ts b/fbw-a380x/src/systems/failures/src/sim-vars.ts new file mode 100644 index 00000000000..faac36e5dee --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/sim-vars.ts @@ -0,0 +1,7 @@ +export function getActivateFailureSimVarName(prefix: string) { + return `L:${prefix}_FAILURE_ACTIVATE`; +} + +export function getDeactivateFailureSimVarName(prefix: string) { + return `L:${prefix}_FAILURE_DEACTIVATE`; +} diff --git a/fbw-a380x/src/systems/failures/src/test-functions.ts b/fbw-a380x/src/systems/failures/src/test-functions.ts new file mode 100644 index 00000000000..27143708b81 --- /dev/null +++ b/fbw-a380x/src/systems/failures/src/test-functions.ts @@ -0,0 +1,3 @@ +export function flushPromises() { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/fbw-a380x/src/systems/failures/tsconfig.json b/fbw-a380x/src/systems/failures/tsconfig.json new file mode 100644 index 00000000000..5935158a7b7 --- /dev/null +++ b/fbw-a380x/src/systems/failures/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "noEmit": true, + "moduleResolution": "node", + // Babel assumes isolated modules, therefore enable it here as well. + "isolatedModules": true + }, + "include": [ + "src/**/*", + ] +} diff --git a/fbw-a380x/src/systems/fmgc/rollup.config.js b/fbw-a380x/src/systems/fmgc/rollup.config.js new file mode 100644 index 00000000000..7c6a17ed2ac --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/rollup.config.js @@ -0,0 +1,85 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +'use strict'; + +const { join } = require('path'); +const babel = require('@rollup/plugin-babel').default; +const { typescriptPaths } = require('rollup-plugin-typescript-paths'); +const commonjs = require('@rollup/plugin-commonjs'); +const nodeResolve = require('@rollup/plugin-node-resolve').default; +const replace = require('@rollup/plugin-replace'); +const copy = require('rollup-plugin-copy'); + +const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; + +const src = join(__dirname, '..'); +const root = join(__dirname, '..', '..'); + +process.chdir(src); + +module.exports = { + input: join(__dirname, 'src/index.ts'), + plugins: [ + copy({ + targets: [ + { + src: 'fmgc/src/utils/LzUtf8.js', + dest: '../flybywire-aircraft-a320-neo/html_ui/JS/fmgc/', + }, + ], + }), + nodeResolve({ extensions }), + commonjs({ include: /node_modules/ }), + babel({ + presets: [ + ['@babel/preset-env', { targets: { safari: '11' } }], + ['@babel/preset-react', { runtime: 'automatic' }], + ['@babel/preset-typescript'], + ], + plugins: [ + '@babel/plugin-proposal-class-properties', + ['@babel/plugin-transform-runtime', { regenerator: true }], + ['module-resolver', { alias: { '@flybywiresim/failures': '../src/failures' } }], + ], + babelHelpers: 'runtime', + compact: false, + extensions, + }), + typescriptPaths({ + tsConfigPath: join(src, 'tsconfig.json'), + preserveExtensions: true, + }), + replace({ + 'DEBUG': 'false', + 'process.env.NODE_ENV': '"production"', + 'preventAssignment': true, + }), + ], + output: { + file: join(root, 'flybywire-aircraft-a320-neo/html_ui/JS/fmgc/fmgc.js'), + format: 'umd', + name: 'Fmgc', + }, +}; diff --git a/fbw-a380x/src/systems/fmgc/src/NavigationDatabase.ts b/fbw-a380x/src/systems/fmgc/src/NavigationDatabase.ts new file mode 100644 index 00000000000..04cee1c930f --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/NavigationDatabase.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { + Airport, + Airway, + Approach, + ApproachType, + Database, + ExternalBackend, + MsfsBackend, + Waypoint +} from 'msfs-navdata'; + +/** + * The backend for a navigation database + */ +export enum NavigationDatabaseBackend { + Msfs, + Navigraph, +} + +/** + * High level abstraction for the FMS navigation database + * + * Only to be used by user-facing functions to search for data. Raw flight plan editing should use the `backendDatabase` property directly + */ +export class NavigationDatabase { + readonly backendDatabase: Database + + constructor( + readonly backend: NavigationDatabaseBackend, + ) { + if (backend === NavigationDatabaseBackend.Msfs) { + this.backendDatabase = new Database(new MsfsBackend() as any); + } else if (backend === NavigationDatabaseBackend.Navigraph) { + this.backendDatabase = new Database(new ExternalBackend('http://localhost:5000')); + } else { + throw new Error('[FMS/DB] Cannot instantiate NavigationDatabase with backend other than \'Msfs\' or \'Navigraph\''); + } + } + + async searchAirport(icao: string): Promise { + return this.backendDatabase.getAirports([icao]).then((results) => results[0]); + } + + async searchFix(ident: string): Promise { + const waypoints = await this.backendDatabase.getWaypoints([ident]); + const vors = await this.backendDatabase.getNavaids([ident]); + const ndbs = await this.backendDatabase.getNDBs([ident]); + + return [...waypoints, ...vors, ...ndbs]; + } + + async searchAirway(ident: string): Promise { + return this.backendDatabase.getAirways([ident]); + } + + private static approachSuffix(approach: Approach): string { + if (approach.multipleIndicator.length < 1) { + return approach.runwayIdent; + } + + return `${approach.runwayIdent.padEnd(3, '-')}${approach.multipleIndicator}`; + } + + static formatLongApproachIdent(approach: Approach): string { + let suffix = this.approachSuffix(approach); + + switch (approach.type) { + case ApproachType.LocBackcourse: // TODO confirm + case ApproachType.Loc: + return `LOC${suffix}`; + case ApproachType.VorDme: + case ApproachType.Vor: + case ApproachType.Vortac: + case ApproachType.Tacan: // TODO confirm + return `VOR${suffix}`; + case ApproachType.Fms: + case ApproachType.Gps: + case ApproachType.Rnav: + return `RNAV${suffix}`; + case ApproachType.Igs: + return `IGS${suffix}`; + case ApproachType.Ils: + return `ILS${suffix}`; + case ApproachType.Gls: + return `GLS${suffix}`; + case ApproachType.Mls: + case ApproachType.MlsTypeA: + case ApproachType.MlsTypeBC: + return `MLS${suffix}`; + case ApproachType.Ndb: + case ApproachType.NdbDme: + return `NDB${suffix}`; + case ApproachType.Sdf: + return `SDF${suffix}`; + case ApproachType.Lda: + return `LDA${suffix}`; + default: + return `???${suffix}`; + } + } + + static formatShortApproachIdent(approach: Approach): string { + const ident = this.formatLongApproachIdent(approach); + if (ident.startsWith('RNAV')) { + return `RNV${ident.substring(4)}`; + } + return ident; + } + + static formatLongRunwayIdent(airportIdent: string, runwayIdent: string): string { + return `${airportIdent}${this.formatShortRunwayIdent(runwayIdent)}`; + } + + static formatShortRunwayIdent(runwayIdent: string): string { + return runwayIdent.substring(2); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/components/EfisLabels.ts b/fbw-a380x/src/systems/fmgc/src/components/EfisLabels.ts new file mode 100644 index 00000000000..ac13bbad25f --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/EfisLabels.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { FlightLevel } from '@fmgc/guidance/vnav/verticalFlightPlan/VerticalFlightPlan'; +import { FlightPlanManager } from '@fmgc/wtsdk'; +import { FmgcComponent } from './FmgcComponent'; + +export class EfisLabels implements FmgcComponent { + private lastTransitionAltitude: Feet; + + private lastTransitionLevel: FlightLevel; + + private flightPlanManager: FlightPlanManager; + + init(_baseInstrument: BaseInstrument, flightPlanManager: FlightPlanManager): void { + this.flightPlanManager = flightPlanManager; + } + + update(_deltaTime: number): void { + const transitionAltitude = 18_000; + const transitionLevel = 180; + + // FIXME ARINC429 when the PR adding a TS impl. lands... + if (transitionAltitude !== this.lastTransitionAltitude) { + SimVar.SetSimVarValue('L:AIRLINER_TRANS_ALT', 'Number', transitionAltitude ?? 0); + this.lastTransitionAltitude = transitionAltitude; + } + + if (transitionLevel !== this.lastTransitionLevel) { + SimVar.SetSimVarValue('L:AIRLINER_APPR_TRANS_ALT', 'Number', (transitionLevel ?? 0) * 100); + this.lastTransitionLevel = transitionLevel; + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/components/FmgcComponent.ts b/fbw-a380x/src/systems/fmgc/src/components/FmgcComponent.ts new file mode 100644 index 00000000000..8516f4a32d8 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/FmgcComponent.ts @@ -0,0 +1,6 @@ +import { FlightPlanManager } from '@fmgc/wtsdk'; + +export interface FmgcComponent { + init(baseInstrument: BaseInstrument, flightPlanManager: FlightPlanManager): void; + update(deltaTime: number): void; +} diff --git a/fbw-a380x/src/systems/fmgc/src/components/ReadySignal.ts b/fbw-a380x/src/systems/fmgc/src/components/ReadySignal.ts new file mode 100644 index 00000000000..f561251cd55 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/ReadySignal.ts @@ -0,0 +1,23 @@ +import { FlightPlanManager } from '@fmgc/wtsdk'; +import { UpdateThrottler } from '@shared/UpdateThrottler'; +import { FmgcComponent } from './FmgcComponent'; + +export class ReadySignal implements FmgcComponent { + private baseInstrument: BaseInstrument = null; + + private updateThrottler = new UpdateThrottler(1000); + + init(baseInstrument: BaseInstrument, _flightPlanManager: FlightPlanManager): void { + this.baseInstrument = baseInstrument; + } + + update(deltaTime: number): void { + if (this.updateThrottler.canUpdate(deltaTime) !== -1 + && this.baseInstrument.getGameState() === GameState.ingame + && SimVar.GetSimVarValue('L:A32NX_IS_READY', 'number') !== 1) { + // set ready signal that JS code is initialized and flight is actually started + // -> user pressed 'READY TO FLY' button + SimVar.SetSimVarValue('L:A32NX_IS_READY', 'number', 1); + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/components/fms-messages/FmsMessages.ts b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/FmsMessages.ts new file mode 100644 index 00000000000..01e95ab28d3 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/FmsMessages.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { FMMessage, FMMessageTriggers } from '@shared/FmMessages'; +import { FmgcComponent } from '../FmgcComponent'; +import { GpsPrimary } from './GpsPrimary'; +import { GpsPrimaryLost } from './GpsPrimaryLost'; +import { MapPartlyDisplayedLeft, MapPartlyDisplayedRight } from './MapPartlyDisplayed'; + +/** + * This class manages Type II messages sent from the FMGC. + * + * Since many of those are also sent to the EFIS, this class sets a bitfield signalling the active messages to the DMCs + * + * At the moment, other Type II messages which are not displayed on the EFIS are declared in the old JavaScript CDU/"FMC". + * + * **Note:** The plan is eventually to move them here as well - but since they can be triggered manually on pilot output as well, and it + * is not currently convenient to use this class from the JS CDU, we will not do that at the moment + * + * -Benjamin + */ +export class FmsMessages implements FmgcComponent { + private listener = RegisterViewListener('JS_LISTENER_SIMVARS', null, true); + + private ndMessageFlags: Record<'L' | 'R', number> = { + L: 0, + R: 0, + }; + + private messageSelectors: FMMessageSelector[] = [ + new GpsPrimary(), + new GpsPrimaryLost(), + new MapPartlyDisplayedLeft(), + new MapPartlyDisplayedRight(), + ]; + + init(): void { + // Do nothing + } + + update(deltaTime: number): void { + let didMutateNd = false; + for (const selector of this.messageSelectors) { + const newState = selector.process(deltaTime); + const message = selector.message; + + switch (newState) { + case FMMessageUpdate.SEND: + if (message.text) { + this.listener.triggerToAllSubscribers(FMMessageTriggers.SEND_TO_MCDU, message); + } + + if (message.ndFlag > 0) { + if (selector.efisSide) { + this.ndMessageFlags[selector.efisSide] |= message.ndFlag; + } else { + for (const side in this.ndMessageFlags) { + if (Object.prototype.hasOwnProperty.call(this.ndMessageFlags, side)) { + this.ndMessageFlags[side] |= message.ndFlag; + } + } + } + didMutateNd = true; + } + break; + case FMMessageUpdate.RECALL: + if (message.text) { + this.listener.triggerToAllSubscribers(FMMessageTriggers.RECALL_FROM_MCDU_WITH_ID, message.text); // TODO id + } + + if (message.ndFlag > 0) { + if (selector.efisSide) { + this.ndMessageFlags[selector.efisSide] &= ~message.ndFlag; + } else { + for (const side in this.ndMessageFlags) { + if (Object.prototype.hasOwnProperty.call(this.ndMessageFlags, side)) { + this.ndMessageFlags[side] &= ~message.ndFlag; + } + } + } + didMutateNd = true; + } + break; + case FMMessageUpdate.NO_ACTION: + break; + default: + throw new Error('Invalid FM message update state'); + } + } + if (didMutateNd) { + for (const side in this.ndMessageFlags) { + if (Object.prototype.hasOwnProperty.call(this.ndMessageFlags, side)) { + SimVar.SetSimVarValue(`L:A32NX_EFIS_${side}_ND_FM_MESSAGE_FLAGS`, 'number', this.ndMessageFlags[side]); + } + } + } + } + + send(messageClass: { new(): FMMessageSelector }): void { + const message = this.messageSelectors.find((it) => it instanceof messageClass).message; + + this.listener.triggerToAllSubscribers(FMMessageTriggers.SEND_TO_MCDU, message); + + if (message.ndFlag) { + for (const side in this.ndMessageFlags) { + if (Object.prototype.hasOwnProperty.call(this.ndMessageFlags, side)) { + this.ndMessageFlags[side] |= message.ndFlag; + SimVar.SetSimVarValue(`L:A32NX_EFIS_${side}_ND_FM_MESSAGE_FLAGS`, 'number', this.ndMessageFlags[side]); + } + } + } + } + + recall(messageClass: { new(): FMMessageSelector }): void { + const message = this.messageSelectors.find((it) => it instanceof messageClass).message; + + this.listener.triggerToAllSubscribers(FMMessageTriggers.RECALL_FROM_MCDU_WITH_ID, message.text); // TODO id + + if (message.ndFlag) { + for (const side in this.ndMessageFlags) { + if (Object.prototype.hasOwnProperty.call(this.ndMessageFlags, side)) { + this.ndMessageFlags[side] &= ~message.ndFlag; + SimVar.SetSimVarValue(`L:A32NX_EFIS_${side}_ND_FM_MESSAGE_FLAGS`, 'number', this.ndMessageFlags[side]); + } + } + } + } + + recallId(id: number) { + const message = this.messageSelectors.find((it) => it.message.id === id).message; + + this.listener.triggerToAllSubscribers(FMMessageTriggers.RECALL_FROM_MCDU_WITH_ID, message.text); // TODO id + + if (message.ndFlag) { + for (const side in this.ndMessageFlags) { + if (Object.prototype.hasOwnProperty.call(this.ndMessageFlags, side)) { + this.ndMessageFlags[side] &= ~message.ndFlag; + SimVar.SetSimVarValue(`L:A32NX_EFIS_${side}_ND_FM_MESSAGE_FLAGS`, 'number', this.ndMessageFlags[side]); + } + } + } + } +} + +/** + * Type II message update state. + * + * Used when a message selector implements the {@link FMMessageSelector.process `process`} method. + */ +export enum FMMessageUpdate { + /** + * Self-explanatory + */ + NO_ACTION, + + /** + * Send the message to the MCDU, and EFIS target if applicable + */ + SEND, + + /** + * Recall the message from the MCDU, and EFIS target if applicable + */ + RECALL, +} + +/** + * Defines a selector for a Type II message. + */ +export interface FMMessageSelector { + message: FMMessage; + + efisSide?: 'L' | 'R'; + + /** + * Optionally triggers a message when there isn't any other system or Redux update triggering it. + */ + process(deltaTime: number): FMMessageUpdate; +} diff --git a/fbw-a380x/src/systems/fmgc/src/components/fms-messages/GpsPrimary.ts b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/GpsPrimary.ts new file mode 100644 index 00000000000..726b8890e05 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/GpsPrimary.ts @@ -0,0 +1,20 @@ +import { FMMessage, FMMessageTypes } from '@shared/FmMessages'; +import { FMMessageSelector, FMMessageUpdate } from './FmsMessages'; + +export class GpsPrimary implements FMMessageSelector { + message: FMMessage = FMMessageTypes.GpsPrimary; + + private lastState = false; + + process(_: number): FMMessageUpdate { + const newState = SimVar.GetSimVarValue('L:A32NX_ADIRS_USES_GPS_AS_PRIMARY', 'Bool') === 1; + + if (newState !== this.lastState) { + this.lastState = newState; + + return newState ? FMMessageUpdate.SEND : FMMessageUpdate.RECALL; + } + + return FMMessageUpdate.NO_ACTION; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/components/fms-messages/GpsPrimaryLost.ts b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/GpsPrimaryLost.ts new file mode 100644 index 00000000000..2fae5036b3e --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/GpsPrimaryLost.ts @@ -0,0 +1,43 @@ +import { FMMessage, FMMessageTypes } from '@shared/FmMessages'; +import { ConfirmationNode, Trigger } from '@shared/logic'; +import { FMMessageSelector, FMMessageUpdate } from './FmsMessages'; + +/** + * Since this happens when the simvar goes to zero, we need to use some CONF nodes to make sure we do not count the initial + * first-frame value, as the ADIRS module might not have run yet. + */ +export class GpsPrimaryLost implements FMMessageSelector { + message: FMMessage = FMMessageTypes.GpsPrimaryLost; + + private confLost = new ConfirmationNode(1_000); + + private trigLost = new Trigger(true); + + private confRegained = new ConfirmationNode(1_000); + + private trigRegained = new Trigger(true); + + process(deltaTime: number): FMMessageUpdate { + const lostNow = SimVar.GetSimVarValue('L:A32NX_ADIRS_USES_GPS_AS_PRIMARY', 'Bool') === 0; + + this.confLost.input = lostNow; + this.confLost.update(deltaTime); + this.trigLost.input = this.confLost.output; + this.trigLost.update(deltaTime); + + this.confRegained.input = !lostNow; + this.confRegained.update(deltaTime); + this.trigRegained.input = this.confRegained.output; + this.trigRegained.update(deltaTime); + + if (this.trigLost.output) { + return FMMessageUpdate.SEND; + } + + if (this.trigRegained.output) { + return FMMessageUpdate.RECALL; + } + + return FMMessageUpdate.NO_ACTION; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/components/fms-messages/MapPartlyDisplayed.ts b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/MapPartlyDisplayed.ts new file mode 100644 index 00000000000..f469ce5bfb8 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/MapPartlyDisplayed.ts @@ -0,0 +1,41 @@ +import { FMMessage, FMMessageTypes } from '@shared/FmMessages'; +import { Trigger } from '@shared/logic'; +import { FMMessageSelector, FMMessageUpdate } from './FmsMessages'; + +abstract class MapPartlyDisplayed implements FMMessageSelector { + message: FMMessage = FMMessageTypes.MapPartlyDisplayed; + + abstract efisSide: 'L' | 'R'; + + private trigRising = new Trigger(true); + + private trigFalling = new Trigger(true); + + process(deltaTime: number): FMMessageUpdate { + const partlyDisplayed = SimVar.GetSimVarValue(`L:A32NX_EFIS_${this.efisSide}_MAP_PARTLY_DISPLAYED`, 'boolean'); + + this.trigRising.input = partlyDisplayed === 1; + this.trigRising.update(deltaTime); + + this.trigFalling.input = partlyDisplayed === 0; + this.trigFalling.update(deltaTime); + + if (this.trigRising.output) { + return FMMessageUpdate.SEND; + } + + if (this.trigFalling.output) { + return FMMessageUpdate.RECALL; + } + + return FMMessageUpdate.NO_ACTION; + } +} + +export class MapPartlyDisplayedLeft extends MapPartlyDisplayed { + efisSide: 'L' | 'R' = 'L'; +} + +export class MapPartlyDisplayedRight extends MapPartlyDisplayed { + efisSide: 'L' | 'R' = 'R'; +} diff --git a/fbw-a380x/src/systems/fmgc/src/components/fms-messages/index.ts b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/index.ts new file mode 100644 index 00000000000..deea3cc3f96 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/fms-messages/index.ts @@ -0,0 +1 @@ +export { FmsMessages } from './FmsMessages'; diff --git a/fbw-a380x/src/systems/fmgc/src/components/index.ts b/fbw-a380x/src/systems/fmgc/src/components/index.ts new file mode 100644 index 00000000000..15fab9a004e --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/components/index.ts @@ -0,0 +1,25 @@ +import { ReadySignal } from '@fmgc/components/ReadySignal'; +import { FlightPlanManager } from '@fmgc/wtsdk'; +import { EfisLabels } from './EfisLabels'; +import { FmgcComponent } from './FmgcComponent'; +import { FmsMessages } from './fms-messages'; + +const fmsMessages = new FmsMessages(); + +const components: FmgcComponent[] = [ + fmsMessages, + new EfisLabels(), + new ReadySignal(), +]; + +export function initComponents(baseInstrument: BaseInstrument, flightPlanManager: FlightPlanManager): void { + components.forEach((component) => component.init(baseInstrument, flightPlanManager)); +} + +export function updateComponents(deltaTime: number): void { + components.forEach((component) => component.update(deltaTime)); +} + +export function recallMessageById(id: number) { + fmsMessages.recallId(id); +} diff --git a/fbw-a380x/src/systems/fmgc/src/efis/EfisCommon.ts b/fbw-a380x/src/systems/fmgc/src/efis/EfisCommon.ts new file mode 100644 index 00000000000..c830f0197c8 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/efis/EfisCommon.ts @@ -0,0 +1,77 @@ +import { Mode, RangeSetting } from '@shared/NavigationDisplay'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; + +export function withinEditArea(lla: Coordinates, range: RangeSetting, mode: Mode, planCentre: Coordinates, trueHeading: DegreesTrue): boolean { + const [editAhead, editBehind, editBeside] = calculateEditArea(range, mode); + + const dist = Avionics.Utils.computeGreatCircleDistance(planCentre, lla); + + let bearing = Avionics.Utils.computeGreatCircleHeading(planCentre, lla); + if (mode !== Mode.PLAN) { + bearing = Avionics.Utils.clampAngle(bearing - trueHeading); + } + bearing = bearing * Math.PI / 180; + + const dx = dist * Math.sin(bearing); + const dy = dist * Math.cos(bearing); + + return Math.abs(dx) < editBeside && dy > -editBehind && dy < editAhead; +} + +export function calculateEditArea(range: RangeSetting, mode: Mode): [number, number, number] { + switch (mode) { + case Mode.ARC: + if (range <= 10) { + return [10.5, 3.5, 8.3]; + } + if (range <= 20) { + return [20.5, 7, 16.6]; + } + if (range <= 40) { + return [40.5, 14, 33.2]; + } + if (range <= 80) { + return [80.5, 28, 66.4]; + } + if (range <= 160) { + return [160.5, 56, 132.8]; + } + return [320.5, 112, 265.6]; + case Mode.ROSE_NAV: + if (range <= 10) { + return [7.6, 7.1, 7.1]; + } + if (range <= 20) { + return [14.7, 14.2, 14.2]; + } + if (range <= 40) { + return [28.9, 28.4, 28.4]; + } + if (range <= 80) { + return [57.3, 56.8, 56.8]; + } + if (range <= 160) { + return [114.1, 113.6, 113.6]; + } + return [227.7, 227.2, 227.2]; + case Mode.PLAN: + if (range <= 10) { + return [7, 7, 7]; + } + if (range <= 20) { + return [14, 14, 14]; + } + if (range <= 40) { + return [28, 28, 28]; + } + if (range <= 80) { + return [56, 56, 56]; + } + if (range <= 160) { + return [112, 112, 112]; + } + return [224, 224, 224]; + default: + return [0, 0, 0]; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/efis/EfisSymbols.ts b/fbw-a380x/src/systems/fmgc/src/efis/EfisSymbols.ts new file mode 100644 index 00000000000..09711d0c243 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/efis/EfisSymbols.ts @@ -0,0 +1,618 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { EfisOption, Mode, NdSymbol, NdSymbolTypeFlags, RangeSetting, rangeSettings } from '@shared/NavigationDisplay'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { Geometry } from '@fmgc/guidance/Geometry'; +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { bearingTo, distanceTo } from 'msfs-geo'; +import { FlowEventSync } from '@shared/FlowEventSync'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { Airport, AltitudeDescriptor, LegType, Runway, WaypointDescriptor } from 'msfs-navdata'; +import { MathUtils } from '@shared/MathUtils'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { NavigationDatabase } from '@fmgc/NavigationDatabase'; +import { RunwaySurface, VorType } from '../types/fstypes/FSEnums'; +import { NearbyFacilities } from './NearbyFacilities'; + +export class EfisSymbols { + private blockUpdate = false; + + private guidanceController: GuidanceController; + + private nearby: NearbyFacilities; + + private syncer: FlowEventSync = new FlowEventSync(); + + private static sides = ['L', 'R']; + + private lastMode = { L: -1, R: -1 }; + + private lastRange = { L: 0, R: 0 }; + + private lastEfisOption = { L: 0, R: 0 }; + + private lastPlanCentre = undefined; + + private lastPpos: Coordinates = { lat: 0, long: 0 }; + + private lastTrueHeading: number = -1; + + private lastNearbyFacilitiesVersion; + + private lastFpVersion; + + constructor(guidanceController: GuidanceController) { + this.guidanceController = guidanceController; + this.nearby = new NearbyFacilities(); + } + + init(): void { + this.nearby.init(); + } + + async update(deltaTime: number): Promise { + this.nearby.update(deltaTime); + + if (this.blockUpdate) { + return; + } + + // TODO use FMGC position + const ppos = { + lat: SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'), + long: SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'), + }; + const trueHeading = SimVar.GetSimVarValue('PLANE HEADING DEGREES TRUE', 'degrees'); + + // TODO planar distance in msfs-geo + const pposChanged = distanceTo(this.lastPpos, ppos) > 2; + if (pposChanged) { + this.lastPpos = ppos; + } + const trueHeadingChanged = MathUtils.diffAngle(trueHeading, this.lastTrueHeading) > 2; + if (trueHeadingChanged) { + this.lastTrueHeading = trueHeading; + } + + const nearbyFacilitiesChanged = this.nearby.version !== this.lastNearbyFacilitiesVersion; + this.lastNearbyFacilitiesVersion = this.nearby.version; + const fpChanged = this.lastFpVersion !== FlightPlanService.version; + this.lastFpVersion = FlightPlanService.version; + // FIXME map reference point should be per side + const planCentreIndex = SimVar.GetSimVarValue('L:A32NX_SELECTED_WAYPOINT', 'number'); + + if (!FlightPlanService.active.hasElement(planCentreIndex)) { + return; + } + + let planCentre = FlightPlanService.active.elementAt(planCentreIndex); + + if (planCentre?.isDiscontinuity === true) { + planCentre = FlightPlanService.active.elementAt(Math.max(0, (planCentreIndex - 1))); + } + + if (planCentre?.isDiscontinuity === true) { + throw new Error('bruh'); + } + + const termination = planCentre?.terminationWaypoint()?.location; + + if (termination) { + this.lastPlanCentre = termination; + } + + const planCentreChanged = termination?.lat !== this.lastPlanCentre?.lat || termination?.long !== this.lastPlanCentre?.long; + + const activeFp = FlightPlanService.active; + // TODO temp f-pln + + const hasSuitableRunway = (airport: RawAirport): boolean => { + for (const runway of airport.runways) { + switch (runway.surface) { + case RunwaySurface.Asphalt: + case RunwaySurface.Bituminous: + case RunwaySurface.Concrete: + case RunwaySurface.Tarmac: + if (runway.length >= 1500 && runway.width >= 30) { + return true; + } + break; + default: + break; + } + } + return false; + }; + + for (const side of EfisSymbols.sides) { + const range = rangeSettings[SimVar.GetSimVarValue(`L:A32NX_EFIS_${side}_ND_RANGE`, 'number')]; + const mode: Mode = SimVar.GetSimVarValue(`L:A32NX_EFIS_${side}_ND_MODE`, 'number'); + const efisOption = SimVar.GetSimVarValue(`L:A32NX_EFIS_${side}_OPTION`, 'Enum'); + + const rangeChange = this.lastRange[side] !== range; + this.lastRange[side] = range; + const modeChange = this.lastMode[side] !== mode; + this.lastMode[side] = mode; + const efisOptionChange = this.lastEfisOption[side] !== efisOption; + this.lastEfisOption[side] = efisOption; + const nearbyOverlayChanged = efisOption !== EfisOption.Constraints && efisOption !== EfisOption.None && nearbyFacilitiesChanged; + + if (!pposChanged && !trueHeadingChanged && !rangeChange && !modeChange && !efisOptionChange && !nearbyOverlayChanged && !fpChanged && !planCentreChanged) { + continue; + } + + if (mode === Mode.PLAN && !planCentre) { + this.syncer.sendEvent(`A32NX_EFIS_${side}_SYMBOLS`, []); + return; + } + + const [editAhead, editBehind, editBeside] = this.calculateEditArea(range, mode); + + // eslint-disable-next-line no-loop-func + const withinEditArea = (ll): boolean => { + // FIXME + if (!termination) { + return true; + } + + const dist = distanceTo(mode === Mode.PLAN ? termination : ppos, ll); + let bearing = bearingTo(mode === Mode.PLAN ? termination : ppos, ll); + if (mode !== Mode.PLAN) { + bearing = MathUtils.clampAngle(bearing - trueHeading); + } + bearing = bearing * Math.PI / 180; + const dx = dist * Math.sin(bearing); + const dy = dist * Math.cos(bearing); + return Math.abs(dx) < editBeside && dy > -editBehind && dy < editAhead; + }; + + const symbols: NdSymbol[] = []; + + // symbols most recently inserted always end up at the end of the array + // we reverse the array at the end to make sure symbols are drawn in the correct order + // eslint-disable-next-line no-loop-func + const upsertSymbol = (symbol: NdSymbol): void => { + if (DEBUG) { + console.time(`upsert symbol ${symbol.databaseId}`); + } + const symbolIdx = symbols.findIndex((s) => s.databaseId === symbol.databaseId); + if (symbolIdx !== -1) { + const oldSymbol = symbols.splice(symbolIdx, 1)[0]; + symbol.constraints = symbol.constraints ?? oldSymbol.constraints; + symbol.direction = symbol.direction ?? oldSymbol.direction; + symbol.length = symbol.length ?? oldSymbol.length; + symbol.location = symbol.location ?? oldSymbol.location; + symbol.type |= oldSymbol.type; + if (oldSymbol.radials) { + if (symbol.radials) { + symbol.radials.push(...oldSymbol.radials); + } else { + symbol.radials = oldSymbol.radials; + } + } + if (oldSymbol.radii) { + if (symbol.radii) { + symbol.radii.push(...oldSymbol.radii); + } else { + symbol.radii = oldSymbol.radii; + } + } + } + symbols.push(symbol); + }; + + // TODO ADIRs aligned (except in plan mode...?) + if (efisOption === EfisOption.VorDmes) { + for (const vor of this.nearby.nearbyVhfNavaids.values()) { + if (vor.type !== VorType.VORDME && vor.type !== VorType.VOR && vor.type !== VorType.DME && vor.type !== VorType.VORTAC && vor.type !== VorType.TACAN) { + continue; + } + const ll = { lat: vor.lat, long: vor.lon }; + if (withinEditArea(ll)) { + upsertSymbol({ + databaseId: vor.icao, + ident: vor.icao.substring(7, 12), + location: ll, + type: this.vorDmeTypeFlag(vor.type) | NdSymbolTypeFlags.EfisOption, + }); + } + } + } else if (efisOption === EfisOption.Ndbs) { + for (const ndb of this.nearby.nearbyNdbNavaids.values()) { + const ll = { lat: ndb.lat, long: ndb.lon }; + if (withinEditArea(ll)) { + upsertSymbol({ + databaseId: ndb.icao, + ident: ndb.icao.substring(7, 12), + location: ll, + type: NdSymbolTypeFlags.Ndb | NdSymbolTypeFlags.EfisOption, + }); + } + } + } else if (efisOption === EfisOption.Airports) { + for (const ap of this.nearby.nearbyAirports.values()) { + const ll = { lat: ap.lat, long: ap.lon }; + if (withinEditArea(ll) && hasSuitableRunway(ap)) { + upsertSymbol({ + databaseId: ap.icao, + ident: ap.icao.substring(7, 12), + location: ll, + type: NdSymbolTypeFlags.Airport | NdSymbolTypeFlags.EfisOption, + }); + } + } + } else if (efisOption === EfisOption.Waypoints) { + for (const wp of this.nearby.nearbyWaypoints.values()) { + const ll = { lat: wp.lat, long: wp.lon }; + if (withinEditArea(ll)) { + upsertSymbol({ + databaseId: wp.icao, + ident: wp.icao.substring(7, 12), + location: ll, + type: NdSymbolTypeFlags.Waypoint | NdSymbolTypeFlags.EfisOption, + }); + } + } + } + + // TODO port over + // for (let i = 0; i < 4; i++) { + // const fixInfo = this.flightPlanManager.getFixInfo(i as 0 | 1 | 2 | 3); + // const refFix = fixInfo?.getRefFix(); + // if (refFix !== undefined) { + // upsertSymbol({ + // databaseId: refFix.icao, + // ident: refFix.ident, + // location: refFix.infos.coordinates, + // type: NdSymbolTypeFlags.FixInfo, + // radials: fixInfo.getRadialTrueBearings(), + // radii: [fixInfo.getRadiusValue()], + // }); + // } + // } + + const formatConstraintAlt = (alt: number, descent: boolean, prefix: string = '') => { + // const transAlt = activeFp?.originTransitionAltitudePilot ?? activeFp?.originTransitionAltitudeDb; + // const transFl = activeFp?.destinationTransitionLevelPilot ?? activeFp?.destinationTransitionLevelDb; + const transAlt = 18_000; + const transFl = 180; + + if (descent) { + const fl = Math.round(alt / 100); + if (transFl && fl >= transFl) { + return `${prefix}FL${fl}`; + } + } else if (transAlt && alt >= transAlt) { + return `${prefix}FL${Math.round(alt / 100)}`; + } + return `${prefix}${Math.round(alt)}`; + }; + + const formatConstraintSpeed = (speed: number, prefix: string = '') => `${prefix}${Math.floor(speed)}KT`; + + for (const [index, leg] of this.guidanceController.activeGeometry.legs.entries()) { + if (!leg.isNull && leg.terminationWaypoint && leg.displayedOnMap) { + if (!('location' in leg.terminationWaypoint)) { + const isActive = index === this.guidanceController.activeLegIndex; + + let type = NdSymbolTypeFlags.FlightPlan; + + if (isActive) { + type |= NdSymbolTypeFlags.ActiveLegTermination; + } + + if (leg.metadata.isInMissedApproach) { + type |= NdSymbolTypeFlags.MissedApproach; + } + + const ident = leg.ident; + const cutIdent = leg.ident.substring(0, 4).padEnd(5, ' '); + const id = (Math.random() * 10_000_000).toString().substring(0, 5); + + upsertSymbol({ + databaseId: `X${id}${cutIdent}`, + ident, + type, + location: leg.terminationWaypoint, + }); + } + } + } + + // TODO don't send the waypoint before active once FP sequencing is properly implemented + // (currently sequences with guidance which is too early) + // eslint-disable-next-line no-lone-blocks + { + for (let i = activeFp.legCount - 1; i >= (activeFp.activeLegIndex - 1) && i >= 0; i--) { + const wp = activeFp.elementAt(i); + + if (wp.isDiscontinuity === true) { + continue; + } + + // Managed by legs + // FIXME these should integrate with the normal algorithms to pick up contraints, not be drawn in enroute ranges, etc. + const legType = wp.type; + if ( + legType === LegType.CA || legType === LegType.CR || legType === LegType.CI + || legType === LegType.FM + || legType === LegType.VA || legType === LegType.VI || legType === LegType.VM + ) { + continue; + } + + if (wp.definition.waypointDescriptor === WaypointDescriptor.Airport || wp.definition.waypointDescriptor === WaypointDescriptor.Runway) { + // we pick these up later + continue; + } + + // if range >= 160, don't include terminal waypoints, except at enroute boundary + // TODO port over + // if (range >= 160) { + // const segment = activeFp.findSegmentByWaypointIndex(i); + // if (segment.type === SegmentType.Departure) { + // // keep the last waypoint from the SID as it is the enroute boundary + // if (!activeFp.isLastWaypointInSegment(i)) { + // continue; + // } + // } else if (segment.type !== SegmentType.Enroute) { + // continue; + // } + // } + + if ((!wp.isXF() && !wp.isHX()) || !withinEditArea(wp.terminationWaypoint().location)) { + continue; + } + + let type = NdSymbolTypeFlags.FlightPlan; + const constraints = []; + let direction; + + // TODO PI leg + const isCourseReversal = wp.type === LegType.HA || wp.type === LegType.HF || wp.type === LegType.HM; + + if (i === activeFp.activeLegIndex) { + type |= NdSymbolTypeFlags.ActiveLegTermination; + } else if (isCourseReversal && i > (activeFp.activeLegIndex + 1) && range <= 80) { + if (wp.definition.turnDirection === 'L') { + type |= NdSymbolTypeFlags.CourseReversalLeft; + } else { + type |= NdSymbolTypeFlags.CourseReversalRight; + } + direction = wp.definition.magneticCourse; // TODO true + } + + if (i >= activeFp.firstMissedApproachLeg) { + type |= NdSymbolTypeFlags.MissedApproach; + } + + if (wp.definition.altitudeDescriptor > 0 && wp.definition.altitudeDescriptor < 6) { + // TODO vnav to predict + type |= NdSymbolTypeFlags.ConstraintUnknown; + } + + if (efisOption === EfisOption.Constraints) { + const descent = wp.segment.class === SegmentClass.Arrival; + switch (wp.definition.altitudeDescriptor) { + case AltitudeDescriptor.AtAlt1: + constraints.push(formatConstraintAlt(wp.definition.altitude1, descent)); + break; + case AltitudeDescriptor.AtOrAboveAlt1: + constraints.push(formatConstraintAlt(wp.definition.altitude1, descent, '+')); + break; + case AltitudeDescriptor.AtOrBelowAlt1: + constraints.push(formatConstraintAlt(wp.definition.altitude1, descent, '-')); + break; + case AltitudeDescriptor.BetweenAlt1Alt2: + constraints.push(formatConstraintAlt(wp.definition.altitude1, descent, '-')); + constraints.push(formatConstraintAlt(wp.definition.altitude2, descent, '+')); + break; + default: + // FIXME do the rest + break; + } + + if (wp.definition.speed > 0) { + constraints.push(formatConstraintSpeed(wp.definition.speed)); + } + } + + upsertSymbol({ + databaseId: wp.terminationWaypoint().databaseId, + ident: wp.ident, + location: wp.terminationWaypoint().location, + type, + constraints: constraints.length > 0 ? constraints : undefined, + direction, + }); + } + } + + const airports: [Airport, Runway][] = [ + [activeFp.originAirport, activeFp.originRunway], + [activeFp.destinationAirport, activeFp.destinationRunway], + ]; + for (const [airport, runway] of airports) { + if (!airport) { + continue; + } + if (runway) { + if (withinEditArea(runway.thresholdLocation)) { + upsertSymbol({ + databaseId: airport.databaseId, + ident: NavigationDatabase.formatLongRunwayIdent(airport.ident, runway.ident), + location: runway.thresholdLocation, + direction: runway.bearing, + length: runway.length / MathUtils.DIV_METRES_TO_NAUTICAL_MILES, + type: NdSymbolTypeFlags.Runway, + }); + } + } else if (withinEditArea(airport.location)) { + upsertSymbol({ + databaseId: airport.databaseId, + ident: airport.ident, + location: airport.location, + type: NdSymbolTypeFlags.Airport, + }); + } + } + + // Pseudo waypoints + + for (const pwp of this.guidanceController.currentPseudoWaypoints.filter((it) => it)) { + upsertSymbol({ + databaseId: `W ${pwp.ident}`, + ident: pwp.ident, + location: pwp.efisSymbolLla, + type: pwp.efisSymbolFlag, + }); + } + + const wordsPerSymbol = 6; + const maxSymbols = 640 / wordsPerSymbol; + if (symbols.length > maxSymbols) { + symbols.splice(0, symbols.length - maxSymbols); + this.guidanceController.efisStateForSide[side].dataLimitReached = true; + } else { + this.guidanceController.efisStateForSide[side].dataLimitReached = false; + } + + this.syncer.sendEvent(`A32NX_EFIS_${side}_SYMBOLS`, symbols); + + // make sure we don't run too often + this.blockUpdate = true; + setTimeout(() => { + this.blockUpdate = false; + }, 200); + } + } + + private generatePathVectorSymbol(vector: PathVector): NdSymbol { + let typeVectorPart: number; + if (vector.type === PathVectorType.Line) { + typeVectorPart = NdSymbolTypeFlags.FlightPlanVectorLine; + } else if (vector.type === PathVectorType.Arc) { + typeVectorPart = NdSymbolTypeFlags.FlightPlanVectorArc; + } else if (vector.type === PathVectorType.DebugPoint) { + typeVectorPart = NdSymbolTypeFlags.FlightPlanVectorDebugPoint; + } + + // FIXME https://cdn.discordapp.com/attachments/845070631644430359/911876826169741342/brabs.gif + const id = Math.round(Math.random() * 10_000).toString(); + + const symbol: NdSymbol = { + databaseId: id, + ident: vector.type === PathVectorType.DebugPoint ? vector.annotation : id, + type: NdSymbolTypeFlags.ActiveFlightPlanVector | typeVectorPart, + location: vector.startPoint, + }; + + if (vector.type === PathVectorType.Line) { + symbol.lineEnd = vector.endPoint; + } + + if (vector.type === PathVectorType.Arc) { + symbol.arcEnd = vector.endPoint; + symbol.arcRadius = distanceTo(vector.startPoint, vector.centrePoint); + symbol.arcSweepAngle = vector.sweepAngle; + } + + return symbol; + } + + private vorDmeTypeFlag(type: VorType): NdSymbolTypeFlags { + switch (type) { + case VorType.VORDME: + case VorType.VORTAC: + return NdSymbolTypeFlags.VorDme; + case VorType.VOR: + return NdSymbolTypeFlags.Vor; + case VorType.DME: + case VorType.TACAN: + return NdSymbolTypeFlags.Dme; + default: + return 0; + } + } + + private findPointFromEndOfPath(path: Geometry, distanceFromEnd: NauticalMiles): Coordinates | undefined { + let accumulator = 0; + + // FIXME take transitions into account on newer FMSs + for (const [, leg] of path.legs) { + accumulator += leg.distance; + + if (accumulator > distanceFromEnd) { + const distanceFromEndOfLeg = distanceFromEnd - (accumulator - leg.distance); + + return leg.getPseudoWaypointLocation(distanceFromEndOfLeg); + } + } + + // console.error(`[VNAV/findPointFromEndOfPath] ${distanceFromEnd.toFixed(2)}nm is larger than the total lateral path.`); + + return undefined; + } + + private calculateEditArea(range: RangeSetting, mode: Mode): [number, number, number] { + switch (mode) { + case Mode.ARC: + if (range <= 10) { + return [10.5, 3.5, 8.3]; + } + if (range <= 20) { + return [20.5, 7, 16.6]; + } + if (range <= 40) { + return [40.5, 14, 33.2]; + } + if (range <= 80) { + return [80.5, 28, 66.4]; + } + if (range <= 160) { + return [160.5, 56, 132.8]; + } + return [320.5, 112, 265.6]; + case Mode.ROSE_NAV: + if (range <= 10) { + return [7.6, 7.1, 7.1]; + } + if (range <= 20) { + return [14.7, 14.2, 14.2]; + } + if (range <= 40) { + return [28.9, 28.4, 28.4]; + } + if (range <= 80) { + return [57.3, 56.8, 56.8]; + } + if (range <= 160) { + return [114.1, 113.6, 113.6]; + } + return [227.7, 227.2, 227.2]; + case Mode.PLAN: + if (range <= 10) { + return [7, 7, 7]; + } + if (range <= 20) { + return [14, 14, 14]; + } + if (range <= 40) { + return [28, 28, 28]; + } + if (range <= 80) { + return [56, 56, 56]; + } + if (range <= 160) { + return [112, 112, 112]; + } + return [224, 224, 224]; + default: + return [0, 0, 0]; + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/efis/EfisVectors.ts b/fbw-a380x/src/systems/fmgc/src/efis/EfisVectors.ts new file mode 100644 index 00000000000..6a9902d86bf --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/efis/EfisVectors.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { EfisSide, EfisVectorsGroup } from '@shared/NavigationDisplay'; +import { PathVector, pathVectorLength, pathVectorValid } from '@fmgc/guidance/lnav/PathVector'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { ArmedLateralMode, isArmed, LateralMode } from '@shared/autopilot'; +import { FlowEventSync } from '@shared/FlowEventSync'; + +const UPDATE_TIMER = 2_500; + +export class EfisVectors { + private syncer: FlowEventSync = new FlowEventSync(); + + constructor( + private guidanceController: GuidanceController, + ) { + this.listener = this.guidanceController.viewListener; + } + + private currentActiveVectors = []; + + private currentActiveMissedApproachVectors = []; + + private currentDashedVectors = []; + + private currentTemporaryVectors = []; + + private currentSecondaryVectors = []; + + public forceUpdate() { + this.updateTimer = UPDATE_TIMER + 1; + } + + private updateTimer = 0; + + public update(deltaTime: number): void { + this.updateTimer += deltaTime; + + if (this.updateTimer < UPDATE_TIMER) { + return; + } + + this.updateTimer = 0; + + if (LnavConfig.DEBUG_PERF) { + console.time('vectors transmit'); + } + + const activeFlightPlanVectors = this.guidanceController.activeGeometry?.getAllPathVectors(this.guidanceController.activeLegIndex) ?? []; + const activeFlightPlanMissedApproachVectors = this.guidanceController.activeGeometry?.getAllPathVectors(this.guidanceController.activeLegIndex, true); + const temporaryFlightPlanVectors = this.guidanceController.temporaryGeometry?.getAllPathVectors(this.guidanceController.temporaryLegIndex) ?? []; + const secondaryFlightPlanVectors = this.guidanceController.secondaryGeometry?.getAllPathVectors() ?? []; + + const visibleActiveFlightPlanVectors = activeFlightPlanVectors + .filter((vector) => EfisVectors.isVectorReasonable(vector)); + const visibleActiveFlightPlanMissedApproachVectors = activeFlightPlanMissedApproachVectors + .filter((vector) => EfisVectors.isVectorReasonable(vector)); + const visibleTemporaryFlightPlanVectors = temporaryFlightPlanVectors + .filter((vector) => EfisVectors.isVectorReasonable(vector)); + const visibleSecondaryFlightPlanVectors = secondaryFlightPlanVectors + .filter((vector) => EfisVectors.isVectorReasonable(vector)); + + if (visibleActiveFlightPlanVectors.length !== activeFlightPlanVectors.length) { + this.guidanceController.efisStateForSide.L.legsCulled = true; + this.guidanceController.efisStateForSide.R.legsCulled = true; + } else { + this.guidanceController.efisStateForSide.L.legsCulled = false; + this.guidanceController.efisStateForSide.R.legsCulled = false; + } + + // ACTIVE + + const engagedLateralMode = SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_MODE', 'Number') as LateralMode; + const armedLateralMode = SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_ARMED', 'Enum'); + const navArmed = isArmed(armedLateralMode, ArmedLateralMode.NAV); + + const transmitActive = engagedLateralMode === LateralMode.NAV || engagedLateralMode === LateralMode.LOC_CPT || engagedLateralMode === LateralMode.LOC_TRACK || navArmed; + const clearActive = !transmitActive && this.currentActiveVectors.length > 0; + + if (transmitActive) { + this.currentActiveVectors = visibleActiveFlightPlanVectors; + this.currentActiveMissedApproachVectors = visibleActiveFlightPlanMissedApproachVectors; + + this.transmitGroup(this.currentActiveVectors, EfisVectorsGroup.ACTIVE); + this.transmitGroup(this.currentActiveMissedApproachVectors, EfisVectorsGroup.MISSED); + } + + if (clearActive) { + this.currentActiveVectors = []; + + this.transmitGroup(this.currentActiveVectors, EfisVectorsGroup.ACTIVE); + } + + // DASHED + + const transmitDashed = !transmitActive; + const clearDashed = !transmitDashed && this.currentDashedVectors.length > 0; + + if (transmitDashed) { + this.currentDashedVectors = visibleActiveFlightPlanVectors; + + this.transmitGroup(this.currentDashedVectors, EfisVectorsGroup.DASHED); + } + + if (clearDashed) { + this.currentDashedVectors = []; + + this.transmitGroup(this.currentDashedVectors, EfisVectorsGroup.DASHED); + } + + // TEMPORARY + + const transmitTemporary = this.guidanceController.hasTemporaryFlightPlan && this.guidanceController.temporaryGeometry?.legs?.size > 0; + const clearTemporary = !transmitTemporary && this.currentTemporaryVectors.length > 0; + + if (transmitTemporary) { + this.currentTemporaryVectors = visibleTemporaryFlightPlanVectors; + + this.transmitGroup(this.currentTemporaryVectors, EfisVectorsGroup.TEMPORARY); + } + + if (clearTemporary) { + this.currentTemporaryVectors = []; + + this.transmitGroup(this.currentTemporaryVectors, EfisVectorsGroup.TEMPORARY); + } + + // SECONDARY + + const transmitSecondary = this.guidanceController.secondaryGeometry?.legs?.size > 0; + const ClearSecondary = !transmitSecondary && this.currentSecondaryVectors.length > 0; + + if (transmitSecondary) { + this.currentSecondaryVectors = visibleSecondaryFlightPlanVectors; + + this.transmitGroup(this.currentSecondaryVectors, EfisVectorsGroup.SECONDARY); + } + + if (ClearSecondary) { + this.currentSecondaryVectors = []; + + this.transmitGroup(this.currentSecondaryVectors, EfisVectorsGroup.SECONDARY); + } + + if (LnavConfig.DEBUG_PERF) { + console.timeEnd('vectors transmit'); + } + } + + /** + * Protect against potential perf issues from immense vectors + */ + private static isVectorReasonable(vector: PathVector): boolean { + if (!pathVectorValid(vector)) { + return false; + } + + const length = pathVectorLength(vector); + + return length <= 5_000; + } + + private transmitGroup(vectors: PathVector[], group: EfisVectorsGroup): void { + this.transmit(vectors, group, 'L'); + this.transmit(vectors, group, 'R'); + } + + private transmit(vectors: PathVector[], vectorsGroup: EfisVectorsGroup, side: EfisSide): void { + this.syncer.sendEvent(`A32NX_EFIS_VECTORS_${side}_${EfisVectorsGroup[vectorsGroup]}`, vectors); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/efis/NearbyFacilities.ts b/fbw-a380x/src/systems/fmgc/src/efis/NearbyFacilities.ts new file mode 100644 index 00000000000..d3ea91a5c4a --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/efis/NearbyFacilities.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { UpdateThrottler } from '@shared/UpdateThrottler'; +import { NearestSearchType } from '../types/fstypes/FSEnums'; + +// WARNING: this is a temporary implementation until the new nav database is complete +// Do not write any code which depends on it +export class NearbyFacilities { + nearbyAirports: Map = new Map(); + + nearbyNdbNavaids: Map = new Map(); + + nearbyVhfNavaids: Map = new Map(); + + nearbyWaypoints: Map = new Map(); + + version: number = 0; + + private listener: ViewListener.ViewListener; + + private initDone = false; + + private airportSessionId: number; + + private ndbSessionId: number; + + private vorSessionId: number; + + private waypointSessionId: number; + + private lastPpos = { lat: 0, long: 0 }; + + private throttler = new UpdateThrottler(10000); + + private radius = 381 * 1852; // metres + + private limit = 160; + + constructor() { + this.listener = RegisterViewListener('JS_LISTENER_FACILITY', async () => { + this.listener.on('SendAirport', this.addAirport.bind(this), null); + this.listener.on('SendIntersection', this.addWaypoint.bind(this), null); + this.listener.on('SendNdb', this.addNdbNavaid.bind(this), null); + this.listener.on('SendVor', this.addVhfNavaid.bind(this), null); + this.listener.on('NearestSearchCompleted', this.onSearchCompleted.bind(this), null); + + this.airportSessionId = await Coherent.call('START_NEAREST_SEARCH_SESSION', NearestSearchType.Airport); + this.ndbSessionId = await Coherent.call('START_NEAREST_SEARCH_SESSION', NearestSearchType.Ndb); + this.vorSessionId = await Coherent.call('START_NEAREST_SEARCH_SESSION', NearestSearchType.Vor); + this.waypointSessionId = await Coherent.call('START_NEAREST_SEARCH_SESSION', NearestSearchType.Intersection); + this.initDone = true; + }); + } + + init(): void { + // Do nothing for now + } + + async update(deltaTime: number): Promise { + if (!this.initDone || this.throttler.canUpdate(deltaTime) === -1) { + return; + } + + const ppos = { + lat: SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'), + long: SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'), + }; + if (Avionics.Utils.computeDistance(ppos, this.lastPpos) > 5) { + this.lastPpos = ppos; + } + Coherent.call('SEARCH_NEAREST', this.airportSessionId, this.lastPpos.lat, this.lastPpos.long, this.radius, this.limit); + Coherent.call('SEARCH_NEAREST', this.vorSessionId, this.lastPpos.lat, this.lastPpos.long, this.radius, this.limit); + Coherent.call('SEARCH_NEAREST', this.ndbSessionId, this.lastPpos.lat, this.lastPpos.long, this.radius, this.limit); + Coherent.call('SEARCH_NEAREST', this.waypointSessionId, this.lastPpos.lat, this.lastPpos.long, this.radius, this.limit); + } + + onSearchCompleted(result: NearestSearch): void { + let nearestList: Map; + let loadCall; + switch (result.sessionId) { + case this.airportSessionId: + nearestList = this.nearbyAirports; + loadCall = 'LOAD_AIRPORTS'; + break; + case this.ndbSessionId: + nearestList = this.nearbyNdbNavaids; + loadCall = 'LOAD_NDBS'; + break; + case this.vorSessionId: + nearestList = this.nearbyVhfNavaids; + loadCall = 'LOAD_VORS'; + break; + case this.waypointSessionId: + nearestList = this.nearbyWaypoints; + loadCall = 'LOAD_INTERSECTIONS'; + break; + default: + console.error('Unknown session', result.sessionId); + return; + } + + for (const icao of result.removed) { + delete nearestList[icao]; + this.version++; + } + + const loadIcaos = []; + for (const icao of result.added) { + if (nearestList.has(icao)) { + continue; + } + loadIcaos.push(icao); + } + if (loadIcaos.length > 0) { + Coherent.call(loadCall, loadIcaos); + } + } + + addAirport(airport: RawAirport): void { + this.nearbyAirports.set(airport.icao, airport); + this.version++; + } + + addWaypoint(waypoint: RawIntersection): void { + this.nearbyWaypoints.set(waypoint.icao, waypoint); + this.version++; + } + + addNdbNavaid(ndb: RawNdb): void { + this.nearbyNdbNavaids.set(ndb.icao, ndb); + this.version++; + } + + addVhfNavaid(vor: RawVor): void { + this.nearbyVhfNavaids.set(vor.icao, vor); + this.version++; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightphase/FlightPhaseManager.ts b/fbw-a380x/src/systems/fmgc/src/flightphase/FlightPhaseManager.ts new file mode 100644 index 00000000000..ddc5e86f07a --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightphase/FlightPhaseManager.ts @@ -0,0 +1,192 @@ +import { Phase, PreFlightPhase, TakeOffPhase, ClimbPhase, CruisePhase, DescentPhase, ApproachPhase, GoAroundPhase, DonePhase } from '@fmgc/flightphase/Phase'; +import { VerticalMode } from '@shared/autopilot'; +import { FmgcFlightPhase, isAnEngineOn, isOnGround, isReady, isSlewActive } from '@shared/flightphase'; +import { ConfirmationNode } from '@shared/logic'; + +function canInitiateDes(distanceToDestination: number): boolean { + const fl = Math.round(Simplane.getAltitude() / 100); + const fcuSelFl = Simplane.getAutoPilotDisplayedAltitudeLockValue('feet') / 100; + const cruiseFl = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number') / 100; + + // Can initiate descent? OR Can initiate early descent? + return ((distanceToDestination < 200 || fl < 200) && fcuSelFl < cruiseFl && fcuSelFl < fl) + || (distanceToDestination >= 200 && fl > 200 && fcuSelFl <= 200); +} + +export class FlightPhaseManager { + private onGroundConfirmationNode = new ConfirmationNode(30 * 1000); + + private activePhase: FmgcFlightPhase = this.initialPhase || FmgcFlightPhase.Preflight; + + private phases: { [key in FmgcFlightPhase]: Phase } = { + [FmgcFlightPhase.Preflight]: new PreFlightPhase(), + [FmgcFlightPhase.Takeoff]: new TakeOffPhase(), + [FmgcFlightPhase.Climb]: new ClimbPhase(), + [FmgcFlightPhase.Cruise]: new CruisePhase(), + [FmgcFlightPhase.Descent]: new DescentPhase(), + [FmgcFlightPhase.Approach]: new ApproachPhase(), + [FmgcFlightPhase.GoAround]: new GoAroundPhase(), + [FmgcFlightPhase.Done]: new DonePhase(), + } + + private phaseChangeListeners: Array<(prev: FmgcFlightPhase, next: FmgcFlightPhase) => void> = []; + + get phase() { + return this.activePhase; + } + + get initialPhase() { + return SimVar.GetSimVarValue('L:A32NX_INITIAL_FLIGHT_PHASE', 'number'); + } + + init(): void { + console.log(`FMGC Flight Phase: ${this.phase}`); + this.phases[this.phase].init(); + this.changePhase(this.activePhase); + } + + shouldActivateNextPhase(_deltaTime: number): void { + // process transitions only when plane is ready + if (isReady() && !isSlewActive()) { + if (this.shouldActivateDonePhase(_deltaTime)) { + this.changePhase(FmgcFlightPhase.Done); + } else if (this.phases[this.phase].shouldActivateNextPhase(_deltaTime)) { + this.changePhase(this.phases[this.phase].nextPhase); + } + } else if (isReady() && isSlewActive()) { + this.handleSlewSituation(_deltaTime); + } else if (this.activePhase !== this.initialPhase) { + // ensure correct init of phase + this.activePhase = this.initialPhase; + this.changePhase(this.initialPhase); + } + } + + addOnPhaseChanged(cb: (prev: FmgcFlightPhase, next: FmgcFlightPhase) => void): void { + this.phaseChangeListeners.push(cb); + } + + handleFcuAltKnobPushPull(distanceToDestination: number): void { + switch (this.phase) { + case FmgcFlightPhase.Takeoff: + this.changePhase(FmgcFlightPhase.Climb); + break; + case FmgcFlightPhase.Climb: + case FmgcFlightPhase.Cruise: + if (canInitiateDes(distanceToDestination)) { + this.changePhase(FmgcFlightPhase.Descent); + } + break; + default: + } + } + + handleFcuAltKnobTurn(distanceToDestination: number): void { + if (this.phase === FmgcFlightPhase.Cruise) { + const activeVerticalMode = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Enum'); + const VS = SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_VS_SELECTED', 'feet per minute'); + const FPA = SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_FPA_SELECTED', 'Degrees'); + if ( + (activeVerticalMode === VerticalMode.OP_DES + || (activeVerticalMode === VerticalMode.VS && VS < 0) + || (activeVerticalMode === VerticalMode.FPA && FPA < 0) + || activeVerticalMode === VerticalMode.DES) + && canInitiateDes(distanceToDestination)) { + this.changePhase(FmgcFlightPhase.Descent); + } + } + } + + handleFcuVSKnob(distanceToDestination: number, onStepClimbDescent: () => void): void { + if (this.phase === FmgcFlightPhase.Climb || this.phase === FmgcFlightPhase.Cruise) { + /** a timeout of 100ms is required in order to receive the updated autopilot vertical mode */ + setTimeout(() => { + const activeVerticalMode = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Enum'); + const VS = SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_VS_SELECTED', 'feet per minute'); + const FPA = SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_FPA_SELECTED', 'Degrees'); + if ((activeVerticalMode === VerticalMode.VS && VS < 0) || (activeVerticalMode === VerticalMode.FPA && FPA < 0)) { + if (canInitiateDes(distanceToDestination)) { + this.changePhase(FmgcFlightPhase.Descent); + } else { + onStepClimbDescent(); + } + } + }, 100); + } + } + + handleNewCruiseAltitudeEntered(newCruiseFlightLevel: number): void { + const currentFlightLevel = Math.round(SimVar.GetSimVarValue('INDICATED ALTITUDE:3', 'feet') / 100); + if (this.activePhase === FmgcFlightPhase.Approach) { + this.changePhase(FmgcFlightPhase.Climb); + } else if (currentFlightLevel < newCruiseFlightLevel + && this.activePhase === FmgcFlightPhase.Descent) { + this.changePhase(FmgcFlightPhase.Climb); + } else if (currentFlightLevel > newCruiseFlightLevel + && (this.activePhase === FmgcFlightPhase.Climb + || this.activePhase === FmgcFlightPhase.Descent)) { + this.changePhase(FmgcFlightPhase.Cruise); + } + } + + handleNewDestinationAirportEntered(): void { + if (this.activePhase === FmgcFlightPhase.GoAround) { + const accAlt = SimVar.GetSimVarValue('L:AIRLINER_ACC_ALT_GOAROUND', 'Number'); + if (Simplane.getAltitude() > accAlt) { + this.changePhase(FmgcFlightPhase.Climb); + } + } + } + + changePhase(newPhase: FmgcFlightPhase): void { + const prevPhase = this.phase; + console.log(`FMGC Flight Phase: ${prevPhase} => ${newPhase}`); + this.activePhase = newPhase; + SimVar.SetSimVarValue('L:A32NX_FMGC_FLIGHT_PHASE', 'number', newPhase); + // Updating old SimVar to ensure backwards compatibility + SimVar.SetSimVarValue('L:AIRLINER_FLIGHT_PHASE', 'number', (newPhase < FmgcFlightPhase.Takeoff ? FmgcFlightPhase.Preflight : newPhase + 1)); + + this.phases[this.phase].init(); + + for (const pcl of this.phaseChangeListeners) { + pcl(prevPhase, newPhase); + } + + this.shouldActivateNextPhase(0); + } + + tryGoInApproachPhase(): boolean { + if ( + this.phase === FmgcFlightPhase.Preflight + || this.phase === FmgcFlightPhase.Takeoff + || this.phase === FmgcFlightPhase.Done + ) { + return false; + } + + if (this.phase !== FmgcFlightPhase.Approach) { + this.changePhase(FmgcFlightPhase.Approach); + } + + return true; + } + + shouldActivateDonePhase(_deltaTime: number): boolean { + this.onGroundConfirmationNode.input = isOnGround(); + this.onGroundConfirmationNode.update(_deltaTime); + return this.onGroundConfirmationNode.output && !isAnEngineOn() && this.phase !== FmgcFlightPhase.Done && this.phase !== FmgcFlightPhase.Preflight; + } + + handleSlewSituation(_deltaTime: number) { + switch (this.phase) { + case FmgcFlightPhase.Preflight: + case FmgcFlightPhase.Takeoff: + case FmgcFlightPhase.Done: + if (Simplane.getAltitudeAboveGround() >= 1500) { + this.changePhase(FmgcFlightPhase.Climb); + } + break; + default: + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightphase/Phase.ts b/fbw-a380x/src/systems/fmgc/src/flightphase/Phase.ts new file mode 100644 index 00000000000..48cea829713 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightphase/Phase.ts @@ -0,0 +1,132 @@ +import { VerticalMode } from '@shared/autopilot'; +import { FmgcFlightPhase, getAutopilotVerticalMode, isAllEngineOn, isAnEngineOn, isOnGround, conditionTakeOff } from '@shared/flightphase'; +import { ConfirmationNode } from '@shared/logic'; + +export abstract class Phase { + // eslint-disable-next-line no-empty-function + init(): void { /* prototype function */ } + + abstract shouldActivateNextPhase(time: any): boolean; + + nextPhase: FmgcFlightPhase; +} + +export class PreFlightPhase extends Phase { + takeoffConfirmation = new ConfirmationNode(0.2 * 1000); + + init() { + this.nextPhase = FmgcFlightPhase.Takeoff; + } + + shouldActivateNextPhase(_deltaTime) { + this.takeoffConfirmation.input = conditionTakeOff(); + this.takeoffConfirmation.update(_deltaTime); + return this.takeoffConfirmation.output; + } +} + +export class TakeOffPhase extends Phase { + accelerationAltitudeMsl: number; + + accelerationAltitudeMslEo: number; + + init() { + this.nextPhase = FmgcFlightPhase.Climb; + SimVar.SetSimVarValue('L:A32NX_COLD_AND_DARK_SPAWN', 'Bool', false); + const accAlt = SimVar.GetSimVarValue('L:AIRLINER_ACC_ALT', 'Number'); + const thrRedAlt = SimVar.GetSimVarValue('L:AIRLINER_THR_RED_ALT', 'Number'); + this.accelerationAltitudeMsl = accAlt || thrRedAlt; + this.accelerationAltitudeMslEo = SimVar.GetSimVarValue('L:A32NX_ENG_OUT_ACC_ALT', 'feet'); + } + + shouldActivateNextPhase(_deltaTime) { + return Simplane.getAltitude() > (isAllEngineOn() ? this.accelerationAltitudeMsl : this.accelerationAltitudeMslEo); + } +} + +export class ClimbPhase extends Phase { + init() { + this.nextPhase = FmgcFlightPhase.Cruise; + } + + shouldActivateNextPhase(_deltaTime) { + const cruiseFl = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number') / 100; + const fl = Math.round(SimVar.GetSimVarValue('INDICATED ALTITUDE:3', 'feet') / 100); + return fl >= cruiseFl; + } +} + +export class CruisePhase extends Phase { + init() { + // switch out of cruise phase is handled in FlightPhaseManager + this.nextPhase = FmgcFlightPhase.Cruise; + } + + shouldActivateNextPhase(_deltaTime) { + return false; + } +} + +export class DescentPhase extends Phase { + init() { + this.nextPhase = FmgcFlightPhase.Approach; + } + + shouldActivateNextPhase(_deltaTime) { + const fl = Math.round(SimVar.GetSimVarValue('INDICATED ALTITUDE:3', 'feet') / 100); + const fcuSelFl = Simplane.getAutoPilotDisplayedAltitudeLockValue('feet') / 100; + const cruiseFl = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number') / 100; + + if (fl === cruiseFl && fcuSelFl === fl) { + this.nextPhase = FmgcFlightPhase.Cruise; + return true; + } + + // APPROACH phase from DECEL pseudo waypoint case. This is decided by the new TS FMS. + return !!SimVar.GetSimVarValue('L:A32NX_FM_ENABLE_APPROACH_PHASE', 'Bool'); + } +} + +export class ApproachPhase extends Phase { + landingConfirmation = new ConfirmationNode(30 * 1000); + + init() { + SimVar.SetSimVarValue('L:AIRLINER_TO_FLEX_TEMP', 'Number', 0); + this.nextPhase = FmgcFlightPhase.Done; + } + + shouldActivateNextPhase(_deltaTime) { + if (getAutopilotVerticalMode() === VerticalMode.SRS_GA) { + this.nextPhase = FmgcFlightPhase.GoAround; + return true; + } + + this.landingConfirmation.input = isOnGround(); + this.landingConfirmation.update(_deltaTime); + return this.landingConfirmation.output || !isAnEngineOn(); + } +} + +export class GoAroundPhase extends Phase { + init() { + SimVar.SetSimVarValue('L:AIRLINER_TO_FLEX_TEMP', 'Number', 0); + this.nextPhase = FmgcFlightPhase.GoAround; + } + + shouldActivateNextPhase(_deltaTime) { + // there is no automatic switch from this phase + return false; + } +} + +export class DonePhase extends Phase { + init() { + SimVar.SetSimVarValue('L:AIRLINER_TO_FLEX_TEMP', 'Number', 0); + this.nextPhase = FmgcFlightPhase.Done; + } + + shouldActivateNextPhase(_deltaTime) { + // there is no automatic switch from this phase + return false; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightphase/index.ts b/fbw-a380x/src/systems/fmgc/src/flightphase/index.ts new file mode 100644 index 00000000000..61683d5c889 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightphase/index.ts @@ -0,0 +1,9 @@ +import { FlightPhaseManager } from './FlightPhaseManager'; + +const flightPhaseManager = new FlightPhaseManager(); + +export { FlightPhaseManager }; + +export function getFlightPhaseManager(): FlightPhaseManager { + return flightPhaseManager; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/DirectTo.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/DirectTo.ts new file mode 100644 index 00000000000..725a626cb96 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/DirectTo.ts @@ -0,0 +1,51 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { FlightPlanSegment } from './FlightPlanSegment'; + +/** + * Information about the current direct-to procedures in the flight plan. + */ +export class DirectTo { + /** Whether or not the current direct-to is in the flight plan. */ + public waypointIsInFlightPlan = false; + + /** Whether or not direct-to is active. */ + public isActive = false; + + /** The current direct-to waypoint, if not part of the flight plan. */ + public waypoint?: WayPoint; + + /** The current direct-to waypoint index, if part of the flight plan. */ + public planWaypointIndex = 0; + + /** The intercept points towards the direct. */ + public interceptPoints?: WayPoint[]; + + /** The current active index in the direct to waypoints. */ + public currentWaypointIndex = 0; + + /** The segments of the direct plan. */ + public segments?: FlightPlanSegment[]; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/FixInfo.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/FixInfo.ts new file mode 100644 index 00000000000..d61a2256d6b --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/FixInfo.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { FlightPlanManager } from '@fmgc/wtsdk'; + +export interface FixInfoRadial { + // true degrees + trueBearing: number, + magneticBearing: number, + time?: number, + dtg?: number, + alt?: number, +} + +export interface FixInfoRadius { + // nautical miles + radius: number, + time?: number, + dtg?: number, + alt?: number, +} + +export class FixInfo { + private flightPlanManager: FlightPlanManager; + + private refFix: WayPoint; + + private radials: FixInfoRadial[] = []; + + private radius: FixInfoRadius; + + private abeam: boolean = false; + + constructor(flightPlanManager: FlightPlanManager) { + this.flightPlanManager = flightPlanManager; + } + + setRefFix(fix?: WayPoint): void { + this.radials.length = 0; + this.radius = undefined; + this.abeam = false; + this.refFix = fix; + this.flightPlanManager.updateFlightPlanVersion(); + } + + getRefFix(): WayPoint | undefined { + return this.refFix; + } + + getRefFixIdent(): string | undefined { + return this.refFix?.ident; + } + + setRadial(index: 0 | 1, magneticBearing?: number): void { + if (magneticBearing !== undefined) { + const trueBearing = Avionics.Utils.clampAngle(magneticBearing + Facilities.getMagVar(this.refFix.infos.coordinates.lat, this.refFix.infos.coordinates.long)); + this.radials[index] = { magneticBearing, trueBearing }; + } else { + this.radials.splice(index, 1); + } + // TODO calculate flight plan intercepts + this.flightPlanManager.updateFlightPlanVersion(); + } + + getRadial(index: 0 | 1): FixInfoRadial | undefined { + return this.radials[index]; + } + + getRadialTrueBearings(): number[] { + return this.radials.map((r) => r.trueBearing); + } + + setRadius(radius?: number): void { + if (radius !== undefined) { + this.radius = { radius }; + } else { + this.radius = undefined; + } + // TODO calculate flight plan intercepts + this.flightPlanManager.updateFlightPlanVersion(); + } + + getRadius(): FixInfoRadius | undefined { + return this.radius; + } + + getRadiusValue(): number | undefined { + return this.radius?.radius; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/FixNamingScheme.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/FixNamingScheme.ts new file mode 100644 index 00000000000..14988a39928 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/FixNamingScheme.ts @@ -0,0 +1,75 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** Generates fix names based on the ARINC default naming scheme. */ +export class FixNamingScheme { + private static alphabet: string[] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', + 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; + + /** + * Generates a fix name for a vector type fix. + * + * @returns The generated fix name. + */ + public static vector(): string { + return 'MANUAL'; + } + + /** + * Generates a fix name for a heading to altitude type fix. + * + * @param altitudeFeet The altitude that will be flown to. + * + * @returns The generated fix name. + */ + public static headingUntilAltitude(altitudeFeet: number): string { + return Math.round(altitudeFeet).toString(); + } + + /** + * Generates a fix name for a course to distance type fix. + * + * @param course The course that will be flown. + * @param distance The distance along the course or from the reference fix. + * + * @returns The generated fix name. + */ + public static courseToDistance(course: number, distance: number): string { + const roundedDistance = Math.round(distance); + const distanceAlpha = distance > 26 ? 'Z' : this.alphabet[roundedDistance]; + + return `D${course.toFixed(0).padStart(3, '0')}${distanceAlpha}`; + } + + /** + * Generates a fix name for a course turn to intercept type fix. + * + * @param course The course that will be turned to. + * + * @returns The generated fix name. + */ + public static courseToIntercept(course: number): string { + return 'INTCPT'; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanAsoboSync.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanAsoboSync.ts new file mode 100644 index 00000000000..5c7367c6268 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanAsoboSync.ts @@ -0,0 +1,306 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { FlightPlanManager } from './FlightPlanManager'; + +/** A class for syncing a flight plan with the game */ +export class FlightPlanAsoboSync { + static fpChecksum = 0; + + static fpListenerInitialized = false; + + public static init() { + if (!this.fpListenerInitialized) { + RegisterViewListener('JS_LISTENER_FLIGHTPLAN'); + this.fpListenerInitialized = true; + } + } + + public static async LoadFromGame(fpln: FlightPlanManager): Promise { + return new Promise((resolve) => { + this.init(); + setTimeout(() => { + Coherent.call('LOAD_CURRENT_GAME_FLIGHT').catch(console.error); + Coherent.call('LOAD_CURRENT_ATC_FLIGHTPLAN').catch(console.error); + setTimeout(() => { + Coherent.call('GET_FLIGHTPLAN').then(async (data: Record) => { + console.log('COHERENT GET_FLIGHTPLAN received'); + const { isDirectTo } = data; + + // TODO: talk to matt about dirto + if (!isDirectTo) { + // TODO FIXME: better handling of mid-air spawning and syncing fpln + if (data.waypoints.length === 0 || data.waypoints[0].icao[0] !== 'A') { + fpln.resumeSync(); + resolve(); + return; + } + + await fpln._parentInstrument.facilityLoader.getFacilityRaw(data.waypoints[0].icao, 10000).catch((e) => { + console.error('[FP LOAD] Error getting first wp data'); + console.error(e); + }); + + // set origin + await fpln.setOrigin(data.waypoints[0].icao).catch((e) => { + console.error('[FP LOAD] Error setting origin'); + console.error(e); + }); + + // set dest + await fpln.setDestination(data.waypoints[data.waypoints.length - 1].icao).catch((e) => { + console.error('[FP LOAD] Error setting Destination'); + console.error(e); + }); + + // set route + + const enrouteStart = (data.departureWaypointsSize === -1) ? 1 : data.departureWaypointsSize; + // Find out first approach waypoint, - 1 to skip destination + const enrouteEnd = data.waypoints.length - ((data.arrivalWaypointsSize === -1) ? 1 : data.arrivalWaypointsSize) - 1; + const enroute = data.waypoints.slice(enrouteStart, enrouteEnd - 1); + for (let i = 0; i < enroute.length - 1; i++) { + const wpt = enroute[i]; + if (wpt.icao.trim() !== '') { + fpln.addWaypoint(wpt.icao, Infinity, () => console.log(`[FP LOAD] Adding [${wpt.icao}]... SUCCESS`)).catch(console.error); + } + } + + // set departure + // rwy index + await fpln.setDepartureRunwayIndex(data.departureRunwayIndex) + // .then(() => console.log(`[FP LOAD] Setting Departure Runway ${data.departureRunwayIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Departure Runway ${data.departureRunwayIndex} ... FAILED`); + console.error(e); + }); + // proc index + await fpln.setDepartureProcIndex(data.departureProcIndex) + // .then(() => console.log(`[FP LOAD] Setting Departure Procedure ${data.departureProcIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Departure Procedure ${data.departureProcIndex} ... FAILED`); + console.error(e); + }); + // origin runway + if (data.originRunwayIndex !== -1) { + await fpln.setOriginRunwayIndex(data.originRunwayIndex) + // .then(() => console.log(`[FP LOAD] Setting Origin ${data.originRunwayIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Origin ${data.originRunwayIndex} ... FAILED`); + console.error(e); + }); + } else if (data.departureRunwayIndex !== -1 && data.departureProcIndex !== -1) { + await fpln.setOriginRunwayIndexFromDeparture() + // .then(() => console.log(`[FP LOAD] Setting Origin using ${data.departureProcIndex}/${data.departureRunwayIndex}... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Origin using ${data.departureProcIndex}/${data.departureRunwayIndex} ... FAILED`); + console.error(e); + }); + } + // enroutetrans index + await fpln.setDepartureEnRouteTransitionIndex(data.departureEnRouteTransitionIndex) + // .then(() => console.log(`[FP LOAD] Setting Departure En Route Transition ${data.departureEnRouteTransitionIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Departure En Route Transition ${data.departureEnRouteTransitionIndex} ... FAILED`); + console.error(e); + }); + // set approach + // rwy index + await fpln.setArrivalRunwayIndex(data.arrivalRunwayIndex) + // .then(() => console.log(`[FP LOAD] Setting Arrival Runway ${data.arrivalRunwayIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Arrival Runway ${data.arrivalRunwayIndex} ... FAILED`); + console.error(e); + }); + // approach index + await fpln.setApproachIndex(data.approachIndex) + // .then(() => console.log(`[FP LOAD] Setting Approach ${data.approachIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Approach ${data.approachIndex} ... FAILED`); + console.error(e); + }); + // approachtrans index + await fpln.setApproachTransitionIndex(data.approachTransitionIndex) + // .then(() => console.log(`[FP LOAD] Setting Approach Transition ${data.approachTransitionIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Approach Transition ${data.approachTransitionIndex} ... FAILED`); + console.error(e); + }); + + // set arrival + // arrivalproc index + await fpln.setArrivalProcIndex(data.arrivalProcIndex) + // .then(() => console.log(`[FP LOAD] Setting Arrival Procedure ${data.arrivalProcIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Arrival Procedure ${data.arrivalProcIndex} ... FAILED`); + console.error(e); + }); + // arrivaltrans index + await fpln.setArrivalEnRouteTransitionIndex(data.arrivalEnRouteTransitionIndex) + // .then(() => console.log(`[FP LOAD] Setting En Route Transition ${data.arrivalEnRouteTransitionIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting En Route Transition ${data.arrivalEnRouteTransitionIndex} ... FAILED`); + console.error(e); + }); + + await fpln.setDestinationRunwayIndexFromApproach() + // .then(() => console.log(`[FP LOAD] Setting Destination Runway using ${data.approachIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP LOAD] Setting Destination Runway using ${data.approachIndex} ... FAILED`); + console.error(e); + }); + + fpln.resumeSync(); + + this.fpChecksum = fpln.getCurrentFlightPlan().checksum; + // Potential CTD source? + Coherent.call('SET_ACTIVE_WAYPOINT_INDEX', 0) + .catch((e) => console.error('[FP LOAD] Error when setting Active WP')); + Coherent.call('RECOMPUTE_ACTIVE_WAYPOINT_INDEX') + .catch((e) => console.error('[FP LOAD] Error when recomputing Active WP')); + resolve(); + } + }).catch(console.error); + }, 500); + }, 200); + }); + } + + public static async SaveToGame(fpln) { + return __awaiter(this, 0, 0, function* () { + return new Promise(() => __awaiter(this, 0, 0, function* () { + FlightPlanAsoboSync.init(); + const plan = fpln.getCurrentFlightPlan(); + if ((plan.checksum !== this.fpChecksum)) { + // await Coherent.call("CREATE_NEW_FLIGHTPLAN").catch(console.error); + yield Coherent.call('SET_CURRENT_FLIGHTPLAN_INDEX', 0, false).catch(console.error); + yield Coherent.call('CLEAR_CURRENT_FLIGHT_PLAN').catch(console.error); + if (plan.hasPersistentOrigin && plan.hasDestination) { + yield Coherent.call('SET_ORIGIN', plan.persistentOriginAirfield.icao, false).catch(console.error); + // .then(() => console.log('[FP SAVE] Setting Origin Airfield... SUCCESS')); + yield Coherent.call('SET_DESTINATION', plan.destinationAirfield.icao, false).catch(console.error); + // .then(() => console.log('[FP SAVE] Setting Destination Airfield... SUCCESS')); + let coIndex = 1; + for (let i = 0; i < plan.enroute.waypoints.length; i++) { + const wpt = plan.enroute.waypoints[i]; + if (wpt.icao.trim() !== '') { + yield Coherent.call('ADD_WAYPOINT', wpt.icao, coIndex, false).catch(console.error); + // .then(() => console.log(`[FP SAVE] Adding Waypoint [${wpt.icao}]... SUCCESS`)); + coIndex++; + } + } + yield Coherent.call('SET_ORIGIN_RUNWAY_INDEX', plan.procedureDetails.originRunwayIndex) + // .then(() => console.log(`[FP SAVE] Setting Origin Runway ${plan.procedureDetails.originRunwayIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP SAVE] Setting Origin Runway ${plan.procedureDetails.originRunwayIndex} ... FAILED`); + console.error(e); + }); + yield Coherent.call('SET_DEPARTURE_RUNWAY_INDEX', plan.procedureDetails.departureRunwayIndex) + // .then(() => console.log(`[FP SAVE] Setting Departure Runway ${plan.procedureDetails.departureRunwayIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP SAVE] Setting Departure Runway ${plan.procedureDetails.departureRunwayIndex} ... FAILED`); + console.error(e); + }); + yield Coherent.call('SET_DEPARTURE_PROC_INDEX', plan.procedureDetails.departureIndex) + // .then(() => console.log(`[FP SAVE] Setting Departure Procedure ${plan.procedureDetails.departureIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP SAVE] Setting Departure Procedure ${plan.procedureDetails.departureIndex} ... FAILED`); + console.error(e); + }); + yield Coherent.call('SET_DEPARTURE_ENROUTE_TRANSITION_INDEX', plan.procedureDetails.departureTransitionIndex) + // .then(() => console.log(`[FP SAVE] Setting Departure Transition ${plan.procedureDetails.departureTransitionIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP SAVE] Setting Departure Transition ${plan.procedureDetails.departureTransitionIndex} ... FAILED`); + console.error(e); + }); + yield Coherent.call('SET_ARRIVAL_RUNWAY_INDEX', plan.procedureDetails.arrivalRunwayIndex) + // .then(() => console.log(`[FP SAVE] Setting Arrival Runway ${plan.procedureDetails.arrivalRunwayIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP SAVE] Setting Arrival Runway ${plan.procedureDetails.arrivalRunwayIndex} ... FAILED`); + console.error(e); + }); + yield Coherent.call('SET_ARRIVAL_PROC_INDEX', plan.procedureDetails.arrivalIndex) + // .then(() => console.log(`[FP SAVE] Setting Arrival Procedure ${plan.procedureDetails.arrivalIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP SAVE] Setting Arrival Procedure ${plan.procedureDetails.arrivalIndex} ... FAILED`); + console.error(e); + }); + yield Coherent.call('SET_ARRIVAL_ENROUTE_TRANSITION_INDEX', plan.procedureDetails.arrivalTransitionIndex) + // .then(() => console.log(`[FP SAVE] Setting Arrival En Route Transition ${plan.procedureDetails.arrivalTransitionIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP SAVE] Setting Arrival En Route Transition ${plan.procedureDetails.arrivalTransitionIndex} ... FAILED`); + console.error(e); + }); + yield Coherent.call('SET_APPROACH_INDEX', plan.procedureDetails.approachIndex) + .then(() => { + // console.log(`[FP SAVE] Setting Approach ${plan.procedureDetails.approachIndex} ... SUCCESS`); + Coherent.call('SET_APPROACH_TRANSITION_INDEX', plan.procedureDetails.approachTransitionIndex) + // .then(() => console.log(`[FP SAVE] Setting Approach Transition ${plan.procedureDetails.approachTransitionIndex} ... SUCCESS`)) + .catch((e) => { + console.error(`[FP SAVE] Setting Approach Transition ${plan.procedureDetails.approachTransitionIndex} ... FAILED`); + console.error(e); + }); + }) + .catch((e) => { + console.error(`[FP SAVE] Setting Approach ${plan.procedureDetails.approachIndex} ... FAILED`); + console.error(e); + }); + } + this.fpChecksum = plan.checksum; + } + Coherent.call('RECOMPUTE_ACTIVE_WAYPOINT_INDEX') + .catch((e) => console.log('[FP SAVE] Setting Active Waypoint... FAILED')) + .then(() => console.log('[FP SAVE] Setting Active Waypoint... SUCCESS')); + })); + }); + } +} + +function __awaiter(thisArg, _arguments, P, generator) { + function adopt(value) { + return value instanceof P ? value : new P((resolve) => { + resolve(value); + }); + } + return new (P || (P = Promise))((resolve, reject) => { + function fulfilled(value) { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + } + function rejected(value) { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + } + function step(result) { + result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); + } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanManager.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanManager.ts new file mode 100644 index 00000000000..19d12632fc5 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanManager.ts @@ -0,0 +1,1977 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { NXDataStore } from '@shared/persistence'; +import { LegType, TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { FlightLevel } from '@fmgc/guidance/vnav/verticalFlightPlan/VerticalFlightPlan'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { ApproachStats, HoldData } from '@fmgc/flightplanning/data/flightplan'; +import { SegmentType } from '@fmgc/wtsdk'; +import { UpdateThrottler } from '@shared/UpdateThrottler'; +import { MathUtils } from '@shared/MathUtils'; +import { ManagedFlightPlan } from './ManagedFlightPlan'; +import { GPS } from './GPS'; +import { FlightPlanSegment } from './FlightPlanSegment'; +import { FlightPlanAsoboSync } from './FlightPlanAsoboSync'; +import { FixInfo } from './FixInfo'; + +export enum WaypointConstraintType { + CLB = 1, + DES = 2, +} + +export enum FlightPlans { + Active, + Temporary, +} + +/** + * Navigation flight areas defined in the OPC database + */ +export enum FlightArea { + Terminal, + Takeoff, + Enroute, + Oceanic, + VorApproach, + GpsApproach, + PrecisionApproach, + NonPrecisionApproach, +} + +/** + * A system for managing flight plan data used by various instruments. + */ +export class FlightPlanManager { + private _isRegistered = false; + + private _isMaster = false; + + private _isSyncPaused = false; + + private _currentFlightPlanVersion = 0; + + private __currentFlightPlanIndex = 0; + + public static DEBUG_INSTANCE: FlightPlanManager; + + public static FlightPlanKey = 'A32NX.FlightPlan'; + + public static FlightPlanCompressedKey = 'A32NX.FlightPlan.Compressed'; + + public static FlightPlanVersionKey = 'L:A32NX.FlightPlan.Version'; + + public activeArea: FlightArea = FlightArea.Terminal; + + /** + * The current stored flight plan data. + * @type ManagedFlightPlan[] + */ + private _flightPlans: ManagedFlightPlan[] = []; + + private _fixInfos: FixInfo[] = []; + + private updateThrottler = new UpdateThrottler(2000); + + /** + * Constructs an instance of the FlightPlanManager with the provided + * parent instrument attached. + * @param parentInstrument The parent instrument attached to this FlightPlanManager. + */ + constructor(public _parentInstrument: BaseInstrument) { + this._currentFlightPlanVersion = SimVar.GetSimVarValue(FlightPlanManager.FlightPlanVersionKey, 'number'); + + this._loadFlightPlans(); + + if (_parentInstrument.instrumentIdentifier === 'A320_Neo_CDU') { + this._isMaster = true; + _parentInstrument.addEventListener('FlightStart', async () => { + const plan = new ManagedFlightPlan(); + plan.setParentInstrument(_parentInstrument); + this._flightPlans = []; + this._flightPlans.push(plan); + if (NXDataStore.get('FP_SYNC', 'LOAD') !== 'NONE') { + this.pauseSync(); + await FlightPlanAsoboSync.LoadFromGame(this).catch(console.error); + } + this.resumeSync(); + }); + for (let i = 0; i < 4; i++) { + this._fixInfos.push(new FixInfo(this)); + } + } + + FlightPlanManager.DEBUG_INSTANCE = this; + } + + public get _currentFlightPlanIndex() { + return this.__currentFlightPlanIndex; + } + + public set _currentFlightPlanIndex(value) { + this.__currentFlightPlanIndex = value; + } + + public update(deltaTime: number): void { + if (this.updateThrottler.canUpdate(deltaTime) !== -1) { + const tmpy = this._flightPlans[FlightPlans.Temporary]; + if (tmpy && this.__currentFlightPlanIndex === FlightPlans.Temporary) { + if (tmpy.updateTurningPoint()) { + this.updateFlightPlanVersion(); + } + } + } + + this.updateActiveArea(); + } + + public onCurrentGameFlightLoaded(_callback: () => any) { + _callback(); + } + + public registerListener() { + } + + public addHardCodedConstraints(wp) { + } + + /** + * Loads sim flight plan data into WayPoint objects for consumption. + * @param data The flight plan data to load. + * @param currentWaypoints The waypoints array to modify with the data loaded. + * @param callback A callback to call when the data has completed loading. + */ + private _loadWaypoints(data: any, currentWaypoints: any, callback: () => void) { + } + + /** + * Updates the current active waypoint index from the sim. + */ + public async updateWaypointIndex() { + // const waypointIndex = await Coherent.call("GET_ACTIVE_WAYPOINT_INDEX"); + // this._activeWaypointIndex = waypointIndex; + } + + /** + * Scans for updates to the synchronized flight plan and loads them into the flight plan + * manager if the flight plan is out of date. + * @param {() => void} callback A callback to call when the update has completed. + * @param {Boolean} log Whether or not to log the loaded flight plan value. + */ + public updateFlightPlan(callback: () => void = () => { }, log = false, force = false): void { + const flightPlanVersion = SimVar.GetSimVarValue(FlightPlanManager.FlightPlanVersionKey, 'number'); + if (flightPlanVersion !== this._currentFlightPlanVersion || force) { + this._loadFlightPlans(); + this._currentFlightPlanVersion = flightPlanVersion; + } + + callback(); + } + + /** + * Loads the flight plans from data storage. + */ + public _loadFlightPlans(): void { + this._getFlightPlan(); + + if (this._flightPlans.length === 0) { + const newFpln = new ManagedFlightPlan(); + newFpln.setParentInstrument(this._parentInstrument); + this._flightPlans.push(new ManagedFlightPlan()); + } else { + this._flightPlans = this._flightPlans.map((fp) => ManagedFlightPlan.fromObject(fp, this._parentInstrument)); + } + } + + public updateCurrentApproach(callback = () => { }, log = false): void { + callback(); + } + + public get cruisingAltitude(): number { + return 0; + } + + public isCurrentFlightPlanTemporary(): boolean { + return this.getCurrentFlightPlanIndex() === 1; + } + + /** + * Gets the index of the currently active flight plan. + */ + public getCurrentFlightPlanIndex(): number { + return this._currentFlightPlanIndex; + } + + /** + * Switches the active flight plan index to the supplied index. + * @param index The index to now use for the active flight plan. + * @param callback A callback to call when the operation has completed. + */ + public setCurrentFlightPlanIndex(index: number, callback = EmptyCallback.Boolean): void { + if (index >= 0 && index < this._flightPlans.length) { + this._currentFlightPlanIndex = index; + callback(true); + } else { + callback(false); + } + } + + /** + * Creates a new flight plan. + * @param callback A callback to call when the operation has completed. + */ + public createNewFlightPlan(callback = EmptyCallback.Void): void { + const newFlightPlan = new ManagedFlightPlan(); + newFlightPlan.setParentInstrument(this._parentInstrument); + this._flightPlans.push(newFlightPlan); + this.updateFlightPlanVersion().catch(console.error); + + callback(); + } + + /** + * Copies the currently active flight plan into the specified flight plan index. + * @param index The index to copy the currently active flight plan into. + * @param callback A callback to call when the operation has completed. + */ + public async copyCurrentFlightPlanInto(index: number, callback = EmptyCallback.Void): Promise { + const copiedFlightPlan = this._flightPlans[this._currentFlightPlanIndex].copy(); + const { activeWaypointIndex } = copiedFlightPlan; + + if (this._currentFlightPlanIndex === FlightPlans.Temporary && index === FlightPlans.Active) { + copiedFlightPlan.waypoints.forEach((wp) => delete wp.additionalData.dynamicPpos); + } + + this._flightPlans[index] = copiedFlightPlan; + + if (index === 0) { + await GPS.setActiveWaypoint(activeWaypointIndex).catch(console.error); + } + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + /** + * Copies the flight plan at the specified index to the currently active flight plan index. + * @param index The index to copy into the currently active flight plan. + * @param callback A callback to call when the operation has completed. + */ + public async copyFlightPlanIntoCurrent(index: number, callback = EmptyCallback.Void): Promise { + const copiedFlightPlan = this._flightPlans[index].copy(); + const { activeWaypointIndex } = copiedFlightPlan; + + this._flightPlans[this._currentFlightPlanIndex] = copiedFlightPlan; + + if (this._currentFlightPlanIndex === 0) { + await GPS.setActiveWaypoint(activeWaypointIndex).catch(console.error); + } + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + /** + * Clears the currently active flight plan. + * @param callback A callback to call when the operation has completed. + */ + public async clearFlightPlan(callback = EmptyCallback.Void): Promise { + await this._flightPlans[this._currentFlightPlanIndex].clearPlan().catch(console.error); + for (const fixInfo of this._fixInfos) { + fixInfo.setRefFix(); + } + this.updateFlightPlanVersion().catch(console.error); + + callback(); + } + + public async deleteFlightPlan(flightPlanIndex): Promise { + if (this._flightPlans[flightPlanIndex]) { + delete this._flightPlans[flightPlanIndex]; + } + } + + /** + * Gets the origin of the currently active flight plan. + */ + public getOrigin(flightPlanIndex = this._currentFlightPlanIndex): WayPoint | undefined { + return this._flightPlans[flightPlanIndex].originAirfield; + } + + /** + * Gets the origin of the currently active flight plan, even after it has been cleared for a direct-to. + */ + public getPersistentOrigin(flightPlanIndex = this._currentFlightPlanIndex): WayPoint | undefined { + return this._flightPlans[flightPlanIndex].persistentOriginAirfield; + } + + /** + * Sets the origin in the currently active flight plan. + * @param icao The ICAO designation of the origin airport. + * @param callback A callback to call when the operation has completed. + */ + public async setOrigin(icao: string, callback = () => { }): Promise { + const sameAirport = this.getOrigin()?.ident === icao; + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const airport = await this._parentInstrument.facilityLoader.getFacilityRaw(icao).catch(console.error); + if (airport) { + airport.additionalData = {}; + airport.additionalData.legType = LegType.IF; + + await currentFlightPlan.clearPlan().catch(console.error); + await currentFlightPlan.addWaypoint(airport, 0); + // clear pilot trans alt + this.setOriginTransitionAltitude(undefined, false); + // TODO get origin trans alt from database + // until then, don't erase the database value from ATSU if same airport as before + if (!sameAirport) { + this.setOriginTransitionAltitude(undefined, true); + } + this.updateFlightPlanVersion().catch(console.error); + } + callback(); + } + + /** + * Gets the index of the active waypoint in the flight plan. + * @param forceSimVarCall Unused + * @param useCorrection Unused + */ + public getActiveWaypointIndex(forceSimVarCall = false, useCorrection = false, flightPlanIndex = NaN): number { + if (isNaN(flightPlanIndex)) { + return this._flightPlans[this._currentFlightPlanIndex].activeWaypointIndex; + } + + return this._flightPlans[flightPlanIndex]?.activeWaypointIndex ?? -1; + } + + public isActiveWaypointAtEnd(forceSimVarCall = false, useCorrection = false, flightPlanIndex = NaN): boolean { + if (isNaN(flightPlanIndex)) { + return this._flightPlans[this._currentFlightPlanIndex].activeWaypointIndex + 1 === this.getWaypointsCount(this._currentFlightPlanIndex) - 1; + } + return this._flightPlans[flightPlanIndex].activeWaypointIndex === this.getWaypointsCount(flightPlanIndex) - 1; + } + + /** + * Sets the index of the active waypoint in the flight plan. + * @param index The index to make active in the flight plan. + * @param callback A callback to call when the operation has completed. + * @param fplnIndex The index of the flight plan + */ + public setActiveWaypointIndex(index: number, callback = EmptyCallback.Void, fplnIndex = this._currentFlightPlanIndex): void { + const currentFlightPlan = this._flightPlans[fplnIndex]; + // we allow the last leg to be sequenced therefore the index can be 1 past the end of the plan length + if (index >= 0 && index <= currentFlightPlan.length) { + currentFlightPlan.activeWaypointIndex = index; + Coherent.call('SET_ACTIVE_WAYPOINT_INDEX', index + 1).catch(console.error); + + if (currentFlightPlan.directTo.isActive && currentFlightPlan.directTo.waypointIsInFlightPlan + && currentFlightPlan.activeWaypointIndex > currentFlightPlan.directTo.planWaypointIndex) { + currentFlightPlan.directTo.isActive = false; + } + } + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + /** Unknown */ + public recomputeActiveWaypointIndex(callback = EmptyCallback.Void): void { + callback(); + } + + /** + * Gets the index of the waypoint prior to the currently active waypoint. + * @param forceSimVarCall Unused + */ + public getPreviousActiveWaypoint(forceSimVarCall = false): WayPoint { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const previousWaypointIndex = currentFlightPlan.activeWaypointIndex - 1; + + return currentFlightPlan.getWaypoint(previousWaypointIndex); + } + + /** + * Gets the ident of the active waypoint. + * @param forceSimVarCall Unused + */ + public getActiveWaypointIdent(forceSimVarCall = false): string { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + if (currentFlightPlan.activeWaypoint) { + return currentFlightPlan.activeWaypoint.ident; + } + + return ''; + } + + /** + * Gets the active waypoint index from fs9gps. Currently unimplemented. + * @param forceSimVarCall Unused + */ + public getGPSActiveWaypointIndex(forceSimVarCall = false): number { + return this.getActiveWaypointIndex(); + } + + /** + * Gets the active waypoint. + * @param forceSimVarCall Unused + * @param useCorrection Unused + */ + public getActiveWaypoint(forceSimVarCall = false, useCorrection = false, flightPlanIndex = NaN): WayPoint { + if (isNaN(flightPlanIndex)) { + flightPlanIndex = this._currentFlightPlanIndex; + } + + return this._flightPlans[flightPlanIndex].activeWaypoint; + } + + /** + * Gets the next waypoint following the active waypoint. + * @param forceSimVarCall Unused + */ + public getNextActiveWaypoint(forceSimVarCall = false): WayPoint { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const nextWaypointIndex = currentFlightPlan.activeWaypointIndex + 1; + + return currentFlightPlan.getWaypoint(nextWaypointIndex); + } + + /** + * Gets the distance, in NM, to the active waypoint. + */ + public getDistanceToActiveWaypoint(): number { + // TODO Replace with ADIRS getLatitude() getLongitude() + const lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + const long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + const ll = new LatLongAlt(lat, long); + + const waypoint = this.getActiveWaypoint(); + if (waypoint && waypoint.infos) { + return Avionics.Utils.computeDistance(ll, waypoint.infos.coordinates); + } + + return 0; + } + + /** + * + * @param fplnIndex index of the flight plan of interest, default active fp + * @returns distance in NM, or -1 on error + */ + public getDistanceToDestination(fplnIndex: number = -1): number { + if (fplnIndex < 0) { + fplnIndex = this._currentFlightPlanIndex; + } + + const destIndex = this.getDestinationIndex(); + if (destIndex < 0) { + return -1; + } + + // TODO get proper pos from FMGC + const fmPos = { + lat: SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'), + long: SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'), + }; + + const fpln = this._flightPlans[fplnIndex]; + const stats = fpln.computeWaypointStatistics(fmPos); + + return stats.get(destIndex)?.distanceFromPpos ?? -1; + } + + public getApproachStats(): ApproachStats | undefined { + const name = this.getApproach(FlightPlans.Active)?.name; + if (!name) { + return undefined; + } + + const distanceFromPpos = this.getDistanceToDestination(FlightPlans.Active); + + return { + name, + distanceFromPpos, + }; + } + + /** + * Gets the bearing, in degrees, to the active waypoint. + */ + public getBearingToActiveWaypoint(): number { + // TODO Replace with ADIRS getLatitude() getLongitude() + const lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + const long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + const ll = new LatLongAlt(lat, long); + + const waypoint = this.getActiveWaypoint(); + if (waypoint && waypoint.infos) { + return Avionics.Utils.computeGreatCircleHeading(ll, waypoint.infos.coordinates); + } + + return 0; + } + + /** + * Gets the estimated time enroute to the active waypoint. + */ + public getETEToActiveWaypoint(): number { + // TODO Replace with ADIRS getLatitude() getLongitude() + const lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + const long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + const ll = new LatLongAlt(lat, long); + + const waypoint = this.getActiveWaypoint(); + if (waypoint && waypoint.infos) { + const dist = Avionics.Utils.computeDistance(ll, waypoint.infos.coordinates); + let groundSpeed = SimVar.GetSimVarValue('GPS GROUND SPEED', 'knots'); + if (groundSpeed < 50) { + groundSpeed = 50; + } + if (groundSpeed > 0.1) { + return dist / groundSpeed * 3600; + } + } + + return 0; + } + + /** + * Gets the destination airfield of the current flight plan, if any. + */ + public getDestination(flightPlanIndex = this._currentFlightPlanIndex): WayPoint | undefined { + return this._flightPlans[flightPlanIndex].destinationAirfield; + } + + /** + * Gets the index of the destination airfield in the current flight plan, if any + * @param flightPlanIndex flight plan index + * @returns Index of destination + */ + public getDestinationIndex(): number { + if (this.getDestination()) { + return this.getWaypointsCount() - 1; + } + return -1; + } + + /** + * Gets the currently selected departure information for the current flight plan. + */ + public getDeparture(flightPlanIndex = NaN): WayPoint | undefined { + const origin = this.getOrigin(); + if (isNaN(flightPlanIndex)) { + flightPlanIndex = this._currentFlightPlanIndex; + } + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + + if (origin) { + const originInfos = origin.infos as AirportInfo; + if (originInfos.departures !== undefined && currentFlightPlan.procedureDetails.departureIndex !== -1) { + return originInfos.departures[currentFlightPlan.procedureDetails.departureIndex]; + } + } + + return undefined; + } + + /** + * Gets the currently selected departure information for the current flight plan, even after a direct-to. + */ + public getDepartureName(): string | undefined { + const origin = this.getPersistentOrigin(); + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (origin) { + const originInfos = origin.infos as AirportInfo; + if (originInfos.departures !== undefined && currentFlightPlan.procedureDetails.departureIndex !== -1) { + return originInfos.departures[currentFlightPlan.procedureDetails.departureIndex].name; + } + } + + return undefined; + } + + /** + * Gets the currently selected arrival information for the current flight plan. + */ + public getArrival(): any | undefined { + const destination = this.getDestination(); + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (destination) { + const originInfos = destination.infos as AirportInfo; + if (originInfos.arrivals !== undefined && currentFlightPlan.procedureDetails.arrivalIndex !== -1) { + return originInfos.arrivals[currentFlightPlan.procedureDetails.arrivalIndex]; + } + } + + return undefined; + } + + /** + * Gets the currently selected approach information for the current flight plan. + */ + public getAirportApproach(): any | undefined { + const destination = this.getDestination(); + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (destination) { + const originInfos = destination.infos as AirportInfo; + if (originInfos.approaches !== undefined && currentFlightPlan.procedureDetails.approachIndex !== -1) { + return originInfos.approaches[currentFlightPlan.procedureDetails.approachIndex]; + } + } + + return undefined; + } + + /** + * Gets the departure waypoints for the current flight plan. + */ + public getDepartureWaypoints(): WayPoint[] { + return this._flightPlans[this._currentFlightPlanIndex].departure.waypoints; + } + + /** + * Gets a map of the departure waypoints (?) + */ + public getDepartureWaypointsMap(): WayPoint[] { + return this._flightPlans[this._currentFlightPlanIndex].departure.waypoints; + } + + /** + * Gets the enroute waypoints for the current flight plan. + * @param outFPIndex An array of waypoint indexes to be pushed to. + */ + public getEnRouteWaypoints(outFPIndex: number[]): WayPoint[] { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const enrouteSegment = currentFlightPlan.enroute; + + if (enrouteSegment !== FlightPlanSegment.Empty) { + for (let i = 0; i < enrouteSegment.waypoints.length; i++) { + outFPIndex.push(enrouteSegment.offset + i); + } + } + + return enrouteSegment.waypoints; + } + + /** + * Gets the index of the last waypoint in the enroute segment of the current flight plan. + */ + public getEnRouteWaypointsFirstIndex(flightPlanIndex = this._currentFlightPlanIndex): number | null { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + const enrouteSegment = currentFlightPlan?.enroute; + + return enrouteSegment?.offset; + } + + /** + * Gets the index of the last waypoint in the enroute segment of the current flight plan. + */ + public getEnRouteWaypointsLastIndex(flightPlanIndex = this._currentFlightPlanIndex): number | null { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + const enrouteSegment = currentFlightPlan?.enroute; + + return enrouteSegment ? enrouteSegment.offset + (enrouteSegment.waypoints.length - 1) : null; + } + + /** + * Gets the arrival waypoints for the current flight plan. + */ + public getArrivalWaypoints(): WayPoint[] { + return this._flightPlans[this._currentFlightPlanIndex].arrival.waypoints; + } + + /** + * Gets the arrival waypoints for the current flight plan as a map. (?) + */ + public getArrivalWaypointsMap(): WayPoint[] { + return this._flightPlans[this._currentFlightPlanIndex].arrival.waypoints; + } + + /** + * Gets the waypoints for the current flight plan with altitude constraints. + */ + public getWaypointsWithAltitudeConstraints(): WayPoint[] { + return this._flightPlans[this._currentFlightPlanIndex].waypoints; + } + + /** + * Gets the flight plan segment for a flight plan waypoint. + * @param waypoint The waypoint we want to find the segment for. + */ + public getSegmentFromWaypoint(waypoint: WayPoint | undefined, flightPlanIndex = NaN): FlightPlanSegment { + if (isNaN(flightPlanIndex)) { + flightPlanIndex = this._currentFlightPlanIndex; + } + + const index = waypoint === undefined ? this.getActiveWaypointIndex() : this.indexOfWaypoint(waypoint); + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + return currentFlightPlan.findSegmentByWaypointIndex(index); + } + + /** + * Sets the destination for the current flight plan. + * @param icao The ICAO designation for the destination airfield. + * @param callback A callback to call once the operation completes. + */ + public async setDestination(icao: string, callback = () => { }): Promise { + const sameAirport = this.getDestination()?.ident === icao; + const waypoint = await this._parentInstrument.facilityLoader.getFacilityRaw(icao); + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const destinationIndex = currentFlightPlan.length - 1; + + if (currentFlightPlan.hasDestination) { + currentFlightPlan.removeWaypoint(destinationIndex); + } + currentFlightPlan.addWaypoint(waypoint); + + // make the waypoint before a discontinuity + /* + const { waypoints } = currentFlightPlan; + if (waypoints.length > 0 && destinationIndex > 0) { + const previous = currentFlightPlan.waypoints[destinationIndex - 1]; + // ensure we do not overwrite a possible discontinuityCanBeCleared + if (!previous.endsInDiscontinuity) { + previous.endsInDiscontinuity = true; + previous.discontinuityCanBeCleared = true; + } + } + */ + + // clear pilot trans level + this.setDestinationTransitionLevel(undefined, false); + // TODO get destination trans level from database + // until then, don't erase the database value from ATSU if same airport as before + if (!sameAirport) { + this.setDestinationTransitionLevel(undefined, true); + } + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + /** + * Adds a waypoint to the current flight plan. + * @param icao The ICAO designation for the waypoint. + * @param index The index of the waypoint to add. + * @param callback A callback to call once the operation completes. + * @param setActive Whether or not to set the added waypoint as active immediately. + */ + public async addWaypoint(icao: string, index = Infinity, callback = () => { }, setActive = true): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const waypoint = await this._parentInstrument.facilityLoader.getFacilityRaw(icao) + .catch((e) => { + console.log(`addWaypoint: [${icao}] Error`); + console.error(e); + }); + if (waypoint) { + currentFlightPlan.addWaypoint(waypoint, index); + if (setActive) { + // currentFlightPlan.activeWaypointIndex = index; + } + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + } + + /** + * Adds a user waypoint to the current flight plan. + * @param waypoint The user waypoint to add. + * @param index The index to add the waypoint at in the flight plan. + * @param callback A callback to call once the operation completes. + */ + public async addUserWaypoint(waypoint: WayPoint, index = Infinity, callback = () => { }): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + currentFlightPlan.addWaypoint(waypoint, index); + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + public setLegAltitudeDescription(waypoint: WayPoint, code: number, callback = () => { }): void { + if (waypoint) { + waypoint.legAltitudeDescription = code; + this.updateFlightPlanVersion().catch(console.error); + } + callback(); + } + + /** + * Sets the altitude constraint for a waypoint in the current flight plan. + * @param altitude The altitude to set for the waypoint. + * @param index The index of the waypoint to set. + * @param callback A callback to call once the operation is complete. + * @param isDescentConstraint For enroute waypoints, indicates whether constraint is a descent or climb constraint + */ + public setWaypointAltitude(altitude: number, index: number, callback = () => { }, isDescentConstraint?: boolean): void { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const waypoint = currentFlightPlan.getWaypoint(index); + + if (waypoint) { + waypoint.legAltitude1 = altitude; + if (isDescentConstraint !== undefined && !waypoint.additionalData.constraintType) { + // this propagates through intermediate waypoints + if (isDescentConstraint) { + this.setFirstDesConstraintWaypoint(index); + } else { + this.setLastClbConstraintWaypoint(index); + } + } + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Sets the speed constraint for a waypoint in the current flight plan. + * @param speed The speed to set for the waypoint. + * @param index The index of the waypoint to set. + * @param callback A callback to call once the operation is complete. + * @param isDescentConstraint For enroute waypoints, indicates whether constraint is a descent or climb constraint + */ + public setWaypointSpeed(speed: number, index: number, callback = () => { }, isDescentConstraint?: boolean): void { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const waypoint = currentFlightPlan.getWaypoint(index); + if (waypoint) { + waypoint.speedConstraint = speed; + // this propagates through intermediate waypoints + if (isDescentConstraint) { + this.setFirstDesConstraintWaypoint(index); + } else { + this.setLastClbConstraintWaypoint(index); + } + this.updateFlightPlanVersion(); + } + callback(); + } + + private setLastClbConstraintWaypoint(index: number) { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + for (let i = index; i >= 0; i--) { + const waypoint = currentFlightPlan.getWaypoint(i); + if (waypoint) { + waypoint.additionalData.constraintType = WaypointConstraintType.CLB; + } + } + } + + private setFirstDesConstraintWaypoint(index: number) { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + for (let i = index; i < this.getWaypointsCount(); i++) { + const waypoint = currentFlightPlan.getWaypoint(i); + if (waypoint) { + waypoint.additionalData.constraintType = WaypointConstraintType.DES; + } + } + } + + /** + * Sets additional data on a waypoint in the current flight plan. + * @param index The index of the waypoint to set additional data for. + * @param key The key of the data. + * @param value The value of the data. + * @param callback A callback to call once the operation is complete. + */ + public setWaypointAdditionalData(index: number, key: string, value: any, callback = () => { }): void { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const waypoint = currentFlightPlan.getWaypoint(index); + + if (waypoint) { + waypoint.additionalData[key] = value; + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Gets additional data on a waypoint in the current flight plan. + * @param index The index of the waypoint to set additional data for. + * @param key The key of the data. + * @param callback A callback to call with the value once the operation is complete. + */ + public getWaypointAdditionalData(index: number, key: string, callback: (any) => void = () => { }) { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const waypoint = currentFlightPlan.getWaypoint(index); + + if (waypoint) { + callback(waypoint.additionalData[key]); + } else { + callback(undefined); + } + } + + /** + * Reverses the currently active flight plan. + * @param {() => void} callback A callback to call when the operation is complete. + */ + public invertActiveFlightPlan(callback = () => { }): void { + this._flightPlans[this._currentFlightPlanIndex].reverse(); + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + /** + * Not sure what this is supposed to do. + * @param callback Stuff? + */ + public getApproachIfIcao(callback: (any) => void = () => { }): void { + callback(this.getApproach()); + } + + /** + * Unused + * @param {*} _callback Unused + */ + public addFlightPlanUpdateCallback(_callback) { + } + + /** + * Adds a waypoint to the currently active flight plan by ident(?) + * @param ident The ident of the waypoint. + * @param index The index to add the waypoint at. + * @param callback A callback to call when the operation finishes. + */ + public addWaypointByIdent(ident: string, index: number, callback = EmptyCallback.Void): void { + this.addWaypoint(ident, index, callback).catch(console.error); + } + + /** + * Removes a waypoint from the currently active flight plan. + * @param index The index of the waypoint to remove. + * @param noDiscontinuity Don't create a discontinuity + * @param callback A callback to call when the operation finishes. + */ + public removeWaypoint(index: number, noDiscontinuity = false, callback = () => { }): void { + this._flightPlans[this._currentFlightPlanIndex].removeWaypoint(index, noDiscontinuity); + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + addWaypointOverfly(index: number, thenSetActive = false, callback = () => { }): void { + this._flightPlans[this._currentFlightPlanIndex].setWaypointOverfly(index, true); + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + removeWaypointOverfly(index: number, thenSetActive = false, callback = () => { }): void { + this._flightPlans[this._currentFlightPlanIndex].setWaypointOverfly(index, false); + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + addOrEditManualHold( + index: number, + desiredHold: HoldData, + modifiedHold: HoldData, + defaultHold: HoldData, + ): number { + const holdIndex = this._flightPlans[this._currentFlightPlanIndex].addOrEditManualHold( + index, + desiredHold, + modifiedHold, + defaultHold, + ); + + this.updateFlightPlanVersion().catch(console.error); + return holdIndex; + } + + /** + * Truncates a flight plan after a specific waypoint. + * @param index The index of the first waypoint to remove. + * @param callback A callback to call when the operation finishes. + */ + public truncateWaypoints(index: number, thenSetActive = false, callback = () => { }): void { + const fp = this._flightPlans[this._currentFlightPlanIndex]; + for (let i = fp.length; i >= index; i--) { + fp.removeWaypoint(index); + } + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + /** + * Gets the index of a given waypoint in the current flight plan. + * @param waypoint The waypoint to get the index of. + */ + public indexOfWaypoint(waypoint: WayPoint): number { + return this._flightPlans[this._currentFlightPlanIndex].waypoints.indexOf(waypoint); + } + + /** + * Gets the number of waypoints in a flight plan. + * @param flightPlanIndex The index of the flight plan. If omitted, will get the current flight plan. + */ + public getWaypointsCount(flightPlanIndex = NaN): number { + if (isNaN(flightPlanIndex)) { + return this._flightPlans[this._currentFlightPlanIndex]?.length ?? 0; + } + + return this._flightPlans[flightPlanIndex]?.length ?? 0; + } + + /** + * Gets a count of the number of departure waypoints in the current flight plan. + */ + public getDepartureWaypointsCount(): number { + return this._flightPlans[this._currentFlightPlanIndex].departure.waypoints.length; + } + + /** + * Gets a count of the number of arrival waypoints in the current flight plan. + */ + public getArrivalWaypointsCount(): number { + return this._flightPlans[this._currentFlightPlanIndex].arrival.waypoints.length; + } + + /** + * Gets a waypoint from a flight plan. + * @param index The index of the waypoint to get. + * @param flightPlanIndex The index of the flight plan to get the waypoint from. If omitted, will get from the current flight plan. + * @param considerApproachWaypoints Whether or not to consider approach waypoints. + */ + public getWaypoint(index: number, flightPlanIndex = NaN, considerApproachWaypoints = false): WayPoint { + if (isNaN(flightPlanIndex)) { + flightPlanIndex = this._currentFlightPlanIndex; + } + + return this._flightPlans[flightPlanIndex].getWaypoint(index); + } + + /** + * Gets all non-approach waypoints from a flight plan. + * + * @param flightPlanIndex The index of the flight plan to get the waypoints from. If omitted, will get from the current flight plan. + */ + public getWaypoints(flightPlanIndex = NaN): WayPoint[] { + if (isNaN(flightPlanIndex)) { + flightPlanIndex = this._currentFlightPlanIndex; + } + + return this._flightPlans[flightPlanIndex].nonApproachWaypoints; + } + + /** + * Gets all waypoints from a flight plan. + * @param flightPlanIndex The index of the flight plan to get the waypoints from. If omitted, will get from the current flight plan. + */ + public getAllWaypoints(flightPlanIndex?: number): WayPoint[] { + if (flightPlanIndex === undefined) { + flightPlanIndex = this._currentFlightPlanIndex; + } + + return this._flightPlans[flightPlanIndex].waypoints; + } + + /** + * Gets the departure runway index, based on the departure in a flight plan. + */ + public getDepartureRunwayIndex(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + if (currentFlightPlan.hasOrigin) { + return currentFlightPlan.procedureDetails.departureRunwayIndex; + } + + return -1; + } + + /** + * Gets the index value of the origin runway (oneWayRunways) in a flight plan. + */ + public getOriginRunwayIndex(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.procedureDetails.originRunwayIndex !== -1 && currentFlightPlan.originAirfield) { + return currentFlightPlan.procedureDetails.originRunwayIndex; + } + return -1; + } + + /** + * Gets the string value of the departure runway in the current flight plan. + */ + public getOriginRunway(): OneWayRunway { + const runwayIndex = this.getOriginRunwayIndex(); + if (runwayIndex !== -1) { + return this.getOrigin().infos.oneWayRunways[runwayIndex]; + } + return undefined; + } + + /** + * Gets the best runway based on the current plane heading. + */ + public getDetectedCurrentRunway(): OneWayRunway { + const origin = this.getOrigin(); + + if (origin && origin.infos instanceof AirportInfo) { + const runways = origin.infos.oneWayRunways; + + if (runways && runways.length > 0) { + const direction = Simplane.getHeadingMagnetic(); + let bestRunway = runways[0]; + let bestDeltaAngle = Math.abs(MathUtils.diffAngle(direction, bestRunway.direction)); + + for (let i = 1; i < runways.length; i++) { + const deltaAngle = Math.abs(MathUtils.diffAngle(direction, runways[i].direction)); + if (deltaAngle < bestDeltaAngle) { + bestDeltaAngle = deltaAngle; + bestRunway = runways[i]; + } + } + + return bestRunway; + } + } + return undefined; + } + + /** + * Gets the departure procedure index for the current flight plan. + */ + public getDepartureProcIndex(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + return currentFlightPlan.procedureDetails.departureIndex; + } + + /** + * Sets the departure procedure index for the current flight plan. + * @param index The index of the departure procedure in the origin airport departures information. + * @param callback A callback to call when the operation completes. + */ + public async setDepartureProcIndex(index: number, callback = () => { }): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.procedureDetails.departureIndex !== index) { + currentFlightPlan.procedureDetails.departureIndex = index; + await currentFlightPlan.buildDeparture().catch(console.error); + + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Sets the departure runway index for the current flight plan. + * @param index The index of the runway in the origin airport runway information. + * @param callback A callback to call when the operation completes. + */ + public async setDepartureRunwayIndex(index: number, callback = EmptyCallback.Void): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.procedureDetails.departureRunwayIndex !== index) { + currentFlightPlan.procedureDetails.departureRunwayIndex = index; + await currentFlightPlan.buildDeparture().catch(console.error); + + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Sets the origin runway index for the current flight plan. + * @param index The index of the runway in the origin airport runway information. + * @param callback A callback to call when the operation completes. + */ + public async setOriginRunwayIndex(index: number, callback = EmptyCallback.Void): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + if (currentFlightPlan.procedureDetails.originRunwayIndex !== index) { + currentFlightPlan.procedureDetails.originRunwayIndex = index; + await currentFlightPlan.buildDeparture().catch(console.error); + + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + public async setOriginRunwayIndexFromDeparture() { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.hasOrigin + && currentFlightPlan.procedureDetails.departureRunwayIndex !== -1 + && currentFlightPlan.procedureDetails.departureIndex !== -1 + && currentFlightPlan.originAirfield + ) { + const transition = (currentFlightPlan.originAirfield.infos as AirportInfo) + .departures[currentFlightPlan.procedureDetails.departureIndex] + .runwayTransitions[currentFlightPlan.procedureDetails.departureRunwayIndex]; + const runways = (currentFlightPlan.originAirfield.infos as AirportInfo).oneWayRunways; + await this.setOriginRunwayIndex(runways.findIndex((r) => r.number === transition.runwayNumber && r.designator === transition.runwayDesignation)); + } + } + + /** + * Gets the departure transition index for the current flight plan. + */ + public getDepartureEnRouteTransitionIndex(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + return currentFlightPlan.procedureDetails.departureTransitionIndex; + } + + /** + * Sets the departure transition index for the current flight plan. + * @param index The index of the departure transition to select. + * @param callback A callback to call when the operation completes. + */ + public async setDepartureEnRouteTransitionIndex(index: number, callback = EmptyCallback.Void): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.procedureDetails.departureTransitionIndex !== index) { + currentFlightPlan.procedureDetails.departureTransitionIndex = index; + await currentFlightPlan.buildDeparture().catch(console.error); + + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Unused + */ + public getDepartureDiscontinuity() { + } + + /** + * Unused + * @param callback A callback to call when the operation completes. + */ + public clearDepartureDiscontinuity(callback = EmptyCallback.Void) { + callback(); + } + + /** + * Removes the departure from the currently active flight plan. + * @param callback A callback to call when the operation completes. + */ + public async removeDeparture(callback = () => { }): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + currentFlightPlan.procedureDetails.departureIndex = -1; + await currentFlightPlan.buildDeparture().catch(console.error); + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + /** + * Gets the arrival procedure index in the currenly active flight plan. + */ + public getArrivalProcIndex(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + if (currentFlightPlan.hasDestination && currentFlightPlan.procedureDetails.arrivalIndex !== -1) { + return currentFlightPlan.procedureDetails.arrivalIndex; + } + + return -1; + } + + /** + * Gets the arrival transition procedure index in the currently active flight plan. + */ + public getArrivalTransitionIndex(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + return currentFlightPlan.procedureDetails.arrivalTransitionIndex; + } + + /** + * Sets the arrival procedure index for the current flight plan. + * @param {Number} index The index of the arrival procedure to select. + * @param {() => void} callback A callback to call when the operation completes. + */ + public async setArrivalProcIndex(index, callback = () => { }) { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.procedureDetails.arrivalIndex !== index) { + // console.log('FPM: setArrivalProcIndex: SET STAR ', currentFlightPlan.destinationAirfield.infos.arrivals[index].name); + currentFlightPlan.procedureDetails.arrivalTransitionIndex = -1; + currentFlightPlan.procedureDetails.arrivalIndex = index; + currentFlightPlan.procedureDetails.approachTransitionIndex = -1; + + await currentFlightPlan.rebuildArrivalApproach(); + + this.updateFlightPlanVersion().catch(console.error); + } + + // TODO check for transition level coded in procedure... + // pick higher of procedure or destination airfield trans fl + + callback(); + } + + /** + * Unused + */ + public getArrivalDiscontinuity() { + } + + /** + * Unused + * @param {*} callback + */ + public clearArrivalDiscontinuity(callback = EmptyCallback.Void) { + callback(); + } + + /** + * Clears a discontinuity from the end of a waypoint. + * @param index + */ + public clearDiscontinuity(index: number): boolean { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + const waypoint = currentFlightPlan.getWaypoint(index); + const nextWaypoint = currentFlightPlan.getWaypoint(index + 1); + + if (waypoint !== undefined && nextWaypoint !== undefined && waypoint.discontinuityCanBeCleared) { + waypoint.endsInDiscontinuity = false; + switch (nextWaypoint.additionalData.legType) { + case LegType.FA: + case LegType.FC: + case LegType.FD: + case LegType.FM: + case LegType.HA: + case LegType.HF: + case LegType.HM: + case LegType.PI: + this.addWaypointByIdent(nextWaypoint.icao, index + 1, () => this.updateFlightPlanVersion().catch(console.error)); + break; + default: + this.updateFlightPlanVersion().catch(console.error); + } + + return true; + } + + this.updateFlightPlanVersion().catch(console.error); + return false; + } + + /** + * Sets the arrival transition index for the current flight plan. + * @param {Number} index The index of the arrival transition to select. + * @param {() => void} callback A callback to call when the operation completes. + */ + public async setArrivalEnRouteTransitionIndex(index, callback = () => { }): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + // console.log('FPM: setArrivalEnRouteTransitionIndex: SET TRANSITION - ARRIVAL', + // currentFlightPlan.destinationAirfield.infos.arrivals[currentFlightPlan.procedureDetails.arrivalIndex].enRouteTransitions[index].name); + + if (currentFlightPlan.procedureDetails.arrivalTransitionIndex !== index) { + currentFlightPlan.procedureDetails.arrivalTransitionIndex = index; + await currentFlightPlan.rebuildArrivalApproach(); + + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Sets the arrival runway index in the currently active flight plan. + * @param {Number} index The index of the runway to select. + * @param {() => void} callback A callback to call when the operation completes. + */ + public async setArrivalRunwayIndex(index, callback = () => { }): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.procedureDetails.arrivalRunwayIndex !== index) { + /* if (currentFlightPlan.procedureDetails.arrivalIndex >= 0) { + console.log(`setArrivalRunwayIndex: Finishing at + ${currentFlightPlan.destinationAirfield.infos.arrivals[currentFlightPlan.procedureDetails.arrivalIndex].runwayTransitions[index].name}`); + } else { + console.log('setArrivalRunwayIndex: Finishing at none'); + } */ + currentFlightPlan.procedureDetails.arrivalRunwayIndex = index; + await currentFlightPlan.rebuildArrivalApproach(); + + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Sets the destination runway index in the currently active flight plan. + * @param index The index of the runway to select. + * @param runwayExtension The length of the runway extension fix to create, or -1 if none. + * @param callback A callback to call when the operation completes. + */ + public async setDestinationRunwayIndex(index: number, runwayExtension = -1, callback: () => void = () => { }): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + // console.log('setDestinationRunwayIndex - APPROACH'); + + if (currentFlightPlan.procedureDetails.destinationRunwayIndex !== index + || currentFlightPlan.procedureDetails.destinationRunwayExtension !== runwayExtension) { + currentFlightPlan.procedureDetails.destinationRunwayIndex = index; + currentFlightPlan.procedureDetails.destinationRunwayExtension = runwayExtension; + + await currentFlightPlan.buildApproach().catch(console.error); + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Sets the destination runway index using the current selected approach + */ + public async setDestinationRunwayIndexFromApproach() { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.hasDestination && currentFlightPlan.procedureDetails.approachIndex !== -1) { + console.error('Destination runway index is -1 with valid STAR'); + const approach = (currentFlightPlan.destinationAirfield.infos as AirportInfo).approaches[currentFlightPlan.procedureDetails.approachIndex]; + const destRunways = (currentFlightPlan.destinationAirfield.infos as AirportInfo).oneWayRunways; + + await this.setDestinationRunwayIndex(destRunways.findIndex((r) => r.number === approach.runwayNumber && r.designator === approach.runwayDesignator)); + } + } + + /** + * Gets the index of the approach in the currently active flight plan. + */ + public getApproachIndex(): number { + return this._flightPlans[this._currentFlightPlanIndex].procedureDetails.approachIndex; + } + + /** + * Sets the approach index in the currently active flight plan. + * @param index The index of the approach in the destination airport information. + * @param callback A callback to call when the operation has completed. + * @param transition The approach transition index to set in the approach information. + */ + public async setApproachIndex(index: number, callback = () => { }, transition = -1): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + // console.log(currentFlightPlan); + + if (currentFlightPlan.procedureDetails.approachIndex !== index) { + // console.log('FPM: setApproachIndex - APPROACH', currentFlightPlan.destinationAirfield.infos.approaches[index].name); + currentFlightPlan.procedureDetails.approachIndex = index; + currentFlightPlan.procedureDetails.approachTransitionIndex = -1; + currentFlightPlan.procedureDetails.arrivalIndex = -1; + currentFlightPlan.procedureDetails.arrivalTransitionIndex = -1; + await currentFlightPlan.rebuildArrivalApproach(); + + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Whether or not an approach is loaded in the current flight plan. + * @param forceSimVarCall Unused + */ + public isLoadedApproach(forceSimVarCall = false): boolean { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + return currentFlightPlan.procedureDetails.approachIndex !== -1; + } + + /** + * Whether or not the approach is active in the current flight plan. + * @param forceSimVarCall Unused + */ + public isActiveApproach(forceSimVarCall = false): boolean { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + return currentFlightPlan.approach.waypoints.length > 0 + && currentFlightPlan.activeWaypointIndex >= currentFlightPlan.approach.offset; + } + + /** + * Activates the approach segment in the current flight plan. + * @param {() => void} callback + */ + public async activateApproach(callback = EmptyCallback.Void): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + if (!this.isActiveApproach() && currentFlightPlan.approach.offset >= 0) { + await GPS.setActiveWaypoint(currentFlightPlan.approach.offset).catch(console.error); + } + + callback(); + } + + /** + * Deactivates the approach segments in the current flight plan. + */ + public deactivateApproach() { + } + + /** + * Attemptes to auto-activate the approach in the current flight plan. + */ + public tryAutoActivateApproach() { + } + + /** + * Gets the index of the active waypoint on the approach in the current flight plan. + */ + public getApproachActiveWaypointIndex() { + return this._flightPlans[this._currentFlightPlanIndex].activeWaypointIndex; + } + + /** + * Gets the approach procedure from the current flight plan destination airport procedure information. + */ + public getApproach(flightPlanIndex = this._currentFlightPlanIndex): RawApproach { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + if (currentFlightPlan.hasDestination && currentFlightPlan.procedureDetails.approachIndex !== -1) { + return (currentFlightPlan.destinationAirfield.infos as AirportInfo).approaches[currentFlightPlan.procedureDetails.approachIndex]; + } + + return undefined; + } + + /** + * Gets the index of the approach transition in the current flight plan. + */ + public getApproachTransitionIndex(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + return currentFlightPlan.procedureDetails.approachTransitionIndex; + } + + /** + * Gets the last waypoint index before the start of the approach segment in + * the current flight plan. + */ + public getLastIndexBeforeApproach(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + // TODO: if we have an approach return last index + if (currentFlightPlan.approach !== FlightPlanSegment.Empty) { + return currentFlightPlan.approach.offset - 1; + } + return this.getWaypointsCount(); + } + + /** + * Gets the destination runway from the current flight plan. + */ + public getDestinationRunway(): OneWayRunway { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + const runwayIndex = this.getDestinationRunwayIndex(); + if (runwayIndex !== -1) { + return (currentFlightPlan.destinationAirfield.infos as AirportInfo).oneWayRunways[runwayIndex]; + } + return undefined; + } + + /** + * Gets the destination runway index (oneWayRunways) from the current flight plan. + */ + public getDestinationRunwayIndex(): number { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + if (currentFlightPlan.procedureDetails.destinationRunwayIndex !== -1 && currentFlightPlan.destinationAirfield) { + return currentFlightPlan.procedureDetails.destinationRunwayIndex; + } + + if (currentFlightPlan.hasDestination && currentFlightPlan.procedureDetails.approachIndex !== -1) { + console.error('Destination runway index is -1 with valid STAR'); + const approach = (currentFlightPlan.destinationAirfield.infos as AirportInfo).approaches[currentFlightPlan.procedureDetails.approachIndex]; + const runways = (currentFlightPlan.destinationAirfield.infos as AirportInfo).oneWayRunways; + + return runways.findIndex((r) => r.number === approach.runwayNumber && r.designator === approach.runwayDesignator); + } + return -1; + } + + /** + * Gets the approach waypoints for the current flight plan. + */ + public getApproachWaypoints(): WayPoint[] { + return this._flightPlans[this._currentFlightPlanIndex].approach.waypoints; + } + + /** + * Sets the approach transition index for the current flight plan. + * @param index The index of the transition in the destination airport approach information. + * @param callback A callback to call when the operation completes. + */ + public async setApproachTransitionIndex(index: number, callback = () => { }): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + // console.log('setApproachTransitionIndex - APPROACH'); + + if (currentFlightPlan.procedureDetails.approachTransitionIndex !== index) { + // console.log(`setApproachIndex: APPR TRANS ${currentFlightPlan.destinationAirfield.infos.approaches[currentFlightPlan.procedureDetails.approachIndex].transitions[index].name}`); + currentFlightPlan.procedureDetails.approachTransitionIndex = index; + await currentFlightPlan.rebuildArrivalApproach(); + + this.updateFlightPlanVersion().catch(console.error); + } + + callback(); + } + + /** + * Removes the arrival segment from the current flight plan. + * @param callback A callback to call when the operation completes. + */ + public async removeArrival(callback = () => { }): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + // console.log('remove arrival - ARRIVAL'); + + currentFlightPlan.procedureDetails.arrivalIndex = -1; + currentFlightPlan.procedureDetails.arrivalRunwayIndex = -1; + currentFlightPlan.procedureDetails.arrivalTransitionIndex = -1; + + await currentFlightPlan.buildArrival().catch(console.error); + + this.updateFlightPlanVersion().catch(console.error); + callback(); + } + + /** + * Inserts direct-to an ICAO designated fix. + * + * @param icao The ICAO designation for the fix to fly direct-to. + */ + public async insertDirectTo(waypoint: WayPoint): Promise { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + + await currentFlightPlan.addDirectTo(waypoint); + + this.updateFlightPlanVersion().catch(console.error); + } + + /** + * Cancels the current direct-to and proceeds back along the flight plan. + * @param callback A callback to call when the operation completes. + */ + public cancelDirectTo(callback = EmptyCallback.Void): void { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + // currentFlightPlan.directTo.cancel(); + + callback(); + } + + /** + * Gets whether or not the flight plan is current in a direct-to procedure. + */ + public getIsDirectTo(): boolean { + return this._flightPlans[this._currentFlightPlanIndex].directTo.isActive; + } + + /** + * Gets the target of the direct-to procedure in the current flight plan. + */ + public getDirectToTarget(): WayPoint { + const currentFlightPlan = this._flightPlans[this._currentFlightPlanIndex]; + if (currentFlightPlan.directTo.waypointIsInFlightPlan) { + return currentFlightPlan.waypoints[currentFlightPlan.directTo.planWaypointIndex]; + } + + return currentFlightPlan.directTo.waypoint; + } + + /** + * Gets the origin/start waypoint of the direct-to procedure in the current flight plan. + */ + public getDirecToOrigin(): WayPoint { + return this._flightPlans[this._currentFlightPlanIndex].directTo.interceptPoints[0]; + } + + public getCoordinatesHeadingAtDistanceAlongFlightPlan(_distance) { + } + + /** + * 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. + */ + public getCoordinatesAtNMFromDestinationAlongFlightPlan(distance: number): LatLongAlt | null { + const allWaypoints = this.getAllWaypoints(); + const destination = this.getDestination(); + + if (destination) { + const fromStartDistance = destination.cumulativeDistanceInFP - distance; + let prevIndex; + let prev; + let next; + for (let i = 0; i < allWaypoints.length - 1; i++) { + prevIndex = i; + prev = allWaypoints[i]; + next = allWaypoints[i + 1]; + if (prev.cumulativeDistanceInFP < fromStartDistance && next.cumulativeDistanceInFP > fromStartDistance) { + break; + } + } + const prevCD = prev.cumulativeDistanceInFP; + const nextCD = next.cumulativeDistanceInFP; + const d = (fromStartDistance - prevCD) / (nextCD - prevCD); + const output = new LatLongAlt(); + output.lat = Avionics.Utils.lerpAngle(prev.infos.coordinates.lat, next.infos.coordinates.lat, d); + output.long = Avionics.Utils.lerpAngle(prev.infos.coordinates.long, next.infos.coordinates.long, d); + return output; + } + + return null; + } + + /** + * Gets the current stored flight plan + */ + public _getFlightPlan(): void { + if (!LnavConfig.DEBUG_SAVE_FPLN_LOCAL_STORAGE) { + return; + } + const fpln = window.localStorage.getItem(FlightPlanManager.FlightPlanKey); + if (fpln === null || fpln === '') { + this._flightPlans = []; + const initFpln = new ManagedFlightPlan(); + initFpln.setParentInstrument(this._parentInstrument); + this._flightPlans.push(initFpln); + } else if (window.localStorage.getItem(FlightPlanManager.FlightPlanCompressedKey) === '1') { + this._flightPlans = JSON.parse(LZUTF8.decompress(fpln, { inputEncoding: 'StorageBinaryString' })); + } else { + try { + this._flightPlans = JSON.parse(fpln); + } catch (e) { + // Assume we failed because compression status did not match up. Try to decompress anyway. + + this._flightPlans = JSON.parse(LZUTF8.decompress(fpln, { inputEncoding: 'StorageBinaryString' })); + } + } + } + + public getCurrentFlightPlan(): ManagedFlightPlan { + return this._flightPlans[this._currentFlightPlanIndex]; + } + + public getFlightPlan(index): ManagedFlightPlan { + return this._flightPlans[index]; + } + + /** + * Updates the synchronized flight plan version and saves it to shared storage. + */ + public async updateFlightPlanVersion(): Promise { + if (this._isSyncPaused) { + return; + } + + if (LnavConfig.DEBUG_SAVE_FPLN_LOCAL_STORAGE) { + let fpJson = JSON.stringify(this._flightPlans.map((fp) => fp.serialize())); + if (fpJson.length > 2500000) { + fpJson = LZUTF8.compress(fpJson, { outputEncoding: 'StorageBinaryString' }); + window.localStorage.setItem(FlightPlanManager.FlightPlanCompressedKey, '1'); + } else { + window.localStorage.setItem(FlightPlanManager.FlightPlanCompressedKey, '0'); + } + window.localStorage.setItem(FlightPlanManager.FlightPlanKey, fpJson); + } + SimVar.SetSimVarValue(FlightPlanManager.FlightPlanVersionKey, 'number', ++this._currentFlightPlanVersion); + if (NXDataStore.get('FP_SYNC', 'LOAD') === 'SAVE') { + FlightPlanAsoboSync.SaveToGame(this).catch(console.error); + } + } + + public pauseSync(): void { + this._isSyncPaused = true; + console.log('FlightPlan Sync Paused'); + } + + public resumeSync(): void { + this._isSyncPaused = false; + this.updateFlightPlanVersion().catch(console.error); + console.log('FlightPlan Sync Resume'); + } + + get currentFlightPlanVersion(): number { + return this._currentFlightPlanVersion; + } + + public getOriginTransitionAltitude(flightPlanIndex: number = this._currentFlightPlanIndex): Feet | undefined { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + return currentFlightPlan.originTransitionAltitudePilot ?? currentFlightPlan.originTransitionAltitudeDb; + } + + /** + * The transition altitude for the origin in the *active* flight plan + */ + get originTransitionAltitude(): number | undefined { + return this.getOriginTransitionAltitude(0); + } + + public getOriginTransitionAltitudeIsFromDb(flightPlanIndex: number = 0): boolean { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + return currentFlightPlan.originTransitionAltitudePilot === undefined; + } + + /** + * Is the transition altitude for the origin in the *active* flight plan from the database? + */ + get originTransitionAltitudeIsFromDb(): boolean { + return this.getOriginTransitionAltitudeIsFromDb(0); + } + + /** + * Set the transition altitude for the origin + * @param altitude transition altitude + * @param database is this value from the database, or pilot? + * @param flightPlanIndex index of flight plan to be edited, defaults to current plan being edited (not active!) + */ + public setOriginTransitionAltitude(altitude?: number, database: boolean = false, flightPlanIndex = this._currentFlightPlanIndex) { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + if (database) { + currentFlightPlan.originTransitionAltitudeDb = altitude; + } else { + currentFlightPlan.originTransitionAltitudePilot = altitude; + } + this.updateFlightPlanVersion(); + } + + public getDestinationTransitionLevel(flightPlanIndex: number = this._currentFlightPlanIndex): FlightLevel | undefined { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + return currentFlightPlan.destinationTransitionLevelPilot ?? currentFlightPlan.destinationTransitionLevelDb; + } + + /** + * The transition level for the destination in the *active* flight plan + */ + get destinationTransitionLevel(): FlightLevel | undefined { + return this.getDestinationTransitionLevel(0); + } + + public getDestinationTransitionLevelIsFromDb(flightPlanIndex: number = this._currentFlightPlanIndex): boolean { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + return currentFlightPlan.destinationTransitionLevelPilot === undefined; + } + + /** + * Is the transition level for the destination in the *active* flight plan from the database? + */ + get destinationTransitionLevelIsFromDb(): boolean { + return this.getDestinationTransitionLevelIsFromDb(0); + } + + /** + * Set the transition level for the destination + * @param flightLevel transition level + * @param database is this value from the database, or pilot? + * @param flightPlanIndex index of flight plan to be edited, defaults to current plan being edited (not active!) + */ + public setDestinationTransitionLevel(flightLevel?: FlightLevel, database: boolean = false, flightPlanIndex = this._currentFlightPlanIndex) { + const currentFlightPlan = this._flightPlans[flightPlanIndex]; + if (database) { + currentFlightPlan.destinationTransitionLevelDb = flightLevel; + } else { + currentFlightPlan.destinationTransitionLevelPilot = flightLevel; + } + this.updateFlightPlanVersion(); + } + + public getFixInfo(index: 0 | 1 | 2 | 3): FixInfo { + return this._fixInfos[index]; + } + + public isWaypointInUse(icao: string): boolean { + for (const fp of this._flightPlans) { + for (let i = 0; i < fp.waypoints.length; i++) { + if (fp.getWaypoint(i).icao === icao) { + return true; + } + } + } + for (const fixInfo of this._fixInfos) { + if (fixInfo.getRefFix()?.infos.icao === icao) { + return true; + } + } + return false; + } + + get activeFlightPlan(): ManagedFlightPlan | undefined { + return this._flightPlans[FlightPlans.Active]; + } + + getApproachType(flightPlanIndex = this._currentFlightPlanIndex): ApproachType | undefined { + const fp = this._flightPlans[flightPlanIndex]; + return fp?.procedureDetails.approachType ?? undefined; + } + + getGlideslopeIntercept(flightPlanIndex = this._currentFlightPlanIndex): number | undefined { + const fp = this._flightPlans[flightPlanIndex]; + return fp?.glideslopeIntercept ?? undefined; + } + + private updateActiveArea(): void { + const activeFp = this._flightPlans[FlightPlans.Active]; + if (!activeFp) { + this.activeArea = FlightArea.Terminal; + return; + } + + this.activeArea = this.calculateActiveArea(activeFp); + } + + private calculateActiveArea(activeFp: ManagedFlightPlan): FlightArea { + const activeIndex = activeFp.activeWaypointIndex; + + const appr = activeFp.getSegment(SegmentType.Approach); + const arrival = activeFp.getSegment(SegmentType.Arrival); + const departure = activeFp.getSegment(SegmentType.Departure); + + if (departure !== FlightPlanSegment.Empty && activeIndex < (departure.offset + departure.waypoints.length)) { + return FlightArea.Terminal; + } + + if (arrival !== FlightPlanSegment.Empty + && activeIndex >= arrival.offset + && activeIndex < (arrival.offset + arrival.waypoints.length)) { + return FlightArea.Terminal; + } + + if (appr !== FlightPlanSegment.Empty + && activeIndex >= appr.offset + && activeIndex < (appr.offset + appr.waypoints.length) + && activeFp.finalApproachActive) { + const apprType = activeFp.procedureDetails.approachType; + switch (apprType) { + case ApproachType.APPROACH_TYPE_ILS: + return FlightArea.PrecisionApproach; + case ApproachType.APPROACH_TYPE_GPS: + case ApproachType.APPROACH_TYPE_RNAV: + return FlightArea.GpsApproach; + case ApproachType.APPROACH_TYPE_VOR: + case ApproachType.APPROACH_TYPE_VORDME: + return FlightArea.VorApproach; + default: + return FlightArea.NonPrecisionApproach; + } + } + + return FlightArea.Enroute; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanSegment.ts new file mode 100644 index 00000000000..8d0f7706cf1 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/FlightPlanSegment.ts @@ -0,0 +1,68 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +/** + * A segment of a flight plan. + */ +export class FlightPlanSegment { + /** + * Creates a new FlightPlanSegment. + * @param type The type of the flight plan segment. + * @param offset The offset within the original flight plan that + * the segment starts at. + * @param waypoints The waypoints in the flight plan segment. + */ + constructor(public type: SegmentType, public offset: number, public waypoints: WayPoint[]) { + this.type = type; + this.offset = offset; + this.waypoints = waypoints; + } + + /** An empty flight plan segment. */ + public static Empty: FlightPlanSegment = new FlightPlanSegment(-1, -1, []); +} + +/** Types of flight plan segments. */ +export enum SegmentType { + + /** The origin airfield segment. */ + Origin, + + /** The departure segment. */ + Departure, + + /** The enroute segment. */ + Enroute, + + /** The arrival segment. */ + Arrival, + + /** The approach segment. */ + Approach, + + /** The missed approach segment. */ + Missed, + + /** The destination airfield segment. */ + Destination +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/GPS.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/GPS.ts new file mode 100644 index 00000000000..1073ada676d --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/GPS.ts @@ -0,0 +1,105 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Methods for interacting with the FS9GPS subsystem. + */ +export class GPS { + /** + * Clears the FS9GPS flight plan. + */ + public static async clearPlan(): Promise { + const totalGpsWaypoints = SimVar.GetSimVarValue('C:fs9gps:FlightPlanWaypointsNumber', 'number'); + for (let i = 0; i < totalGpsWaypoints; i++) { + // Always remove waypoint 0 here, which shifts the rest of the waypoints down one + GPS.deleteWaypoint(0).catch(console.error); + } + } + + /** + * Adds a waypoint to the FS9GPS flight plan by ICAO designation. + * @param icao The MSFS ICAO to add to the flight plan. + * @param index The index of the waypoint to add in the flight plan. + */ + public static async addIcaoWaypoint(icao: string, index: number): Promise { + await SimVar.SetSimVarValue('C:fs9gps:FlightPlanNewWaypointICAO', 'string', icao).catch(console.error); + await SimVar.SetSimVarValue('C:fs9gps:FlightPlanAddWaypoint', 'number', index).catch(console.error); + } + + /** + * Adds a user waypoint to the FS9GPS flight plan. + * @param lat The latitude of the user waypoint. + * @param lon The longitude of the user waypoint. + * @param index The index of the waypoint to add in the flight plan. + * @param ident The ident of the waypoint. + */ + public static async addUserWaypoint(lat: number, lon: number, index: number, ident: string): Promise { + await SimVar.SetSimVarValue('C:fs9gps:FlightPlanNewWaypointLatitude', 'degrees', lat).catch(console.error); + await SimVar.SetSimVarValue('C:fs9gps:FlightPlanNewWaypointLongitude', 'degrees', lon).catch(console.error); + + if (ident) { + await SimVar.SetSimVarValue('C:fs9gps:FlightPlanNewWaypointIdent', 'string', ident).catch(console.error); + } + + await SimVar.SetSimVarValue('C:fs9gps:FlightPlanAddWaypoint', 'number', index).catch(console.error); + } + + /** + * Deletes a waypoint from the FS9GPS flight plan. + * @param index The index of the waypoint in the flight plan to delete. + */ + public static async deleteWaypoint(index: number): Promise { + await SimVar.SetSimVarValue('C:fs9gps:FlightPlanDeleteWaypoint', 'number', index).catch(console.error); + } + + /** + * Sets the active FS9GPS waypoint. + * @param {Number} index The index of the waypoint to set active. + */ + public static async setActiveWaypoint(index: number): Promise { + await SimVar.SetSimVarValue('C:fs9gps:FlightPlanActiveWaypoint', 'number', index).catch(console.error); + } + + /** + * Gets the active FS9GPS waypoint. + */ + public static getActiveWaypoint(): number { + return SimVar.GetSimVarValue('C:fs9gps:FlightPlanActiveWaypoint', 'number'); + } + + /** + * Logs the current FS9GPS flight plan. + */ + public static async logCurrentPlan(): Promise { + const waypointIdents = []; + const totalGpsWaypoints = SimVar.GetSimVarValue('C:fs9gps:FlightPlanWaypointsNumber', 'number'); + + for (let i = 0; i < totalGpsWaypoints; i++) { + SimVar.SetSimVarValue('C:fs9gps:FlightPlanWaypointIndex', 'number', i); + waypointIdents.push(SimVar.GetSimVarValue('C:fs9gps:FlightPlanWaypointIdent', 'string')); + } + + console.log(`GPS Plan: ${waypointIdents.join(' ')}`); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/GeoMath.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/GeoMath.ts new file mode 100644 index 00000000000..8fefa740ac5 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/GeoMath.ts @@ -0,0 +1,113 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { WorldMagneticModel } from './WorldMagneticModel'; + +/** A class for geographical mathematics. */ +export class GeoMath { + private static magneticModel = new WorldMagneticModel(); + + /** + * Gets coordinates at a relative bearing and distance from a set of coordinates. + * @param course The course, in degrees, from the reference coordinates. + * @param distanceInNM The distance, in nautical miles, from the reference coordinates. + * @param referenceCoordinates The reference coordinates to calculate from. + * @returns The calculated coordinates. + */ + public static relativeBearingDistanceToCoords(course: number, distanceInNM: number, referenceCoordinates: LatLongAlt): LatLongAlt { + const courseRadians = course * Avionics.Utils.DEG2RAD; + const distanceRadians = (Math.PI / (180 * 60)) * distanceInNM; + + const refLat = referenceCoordinates.lat * Avionics.Utils.DEG2RAD; + const refLon = -(referenceCoordinates.long * Avionics.Utils.DEG2RAD); + + const lat = Math.asin(Math.sin(refLat) * Math.cos(distanceRadians) + Math.cos(refLat) * Math.sin(distanceRadians) * Math.cos(courseRadians)); + const dlon = Math.atan2(Math.sin(courseRadians) * Math.sin(distanceRadians) * Math.cos(refLat), Math.cos(distanceRadians) - Math.sin(refLat) * Math.sin(lat)); + const lon = Avionics.Utils.fmod(refLon - dlon + Math.PI, 2 * Math.PI) - Math.PI; + + return new LatLongAlt(lat * Avionics.Utils.RAD2DEG, -(lon * Avionics.Utils.RAD2DEG)); + } + + /** + * Gets a magnetic heading given a true course and a magnetic variation. + * @param trueCourse The true course to correct. + * @param magneticVariation The measured magnetic variation. + * @returns The magnetic heading, corrected for magnetic variation. + */ + public static correctMagvar(trueCourse: number, magneticVariation: number): number { + return trueCourse - GeoMath.normalizeMagVar(magneticVariation); + } + + /** + * Gets a true course given a magnetic heading and a magnetic variation. + * @param headingMagnetic The magnetic heading to correct. + * @param magneticVariation The measured magnetic variation. + * @returns The true course, corrected for magnetic variation. + */ + public static removeMagvar(headingMagnetic: number, magneticVariation: number): number { + return headingMagnetic + GeoMath.normalizeMagVar(magneticVariation); + } + + /** + * Gets a magnetic variation difference in 0-360 degrees. + * @param magneticVariation The magnetic variation to normalize. + * @returns A normalized magnetic variation. + */ + private static normalizeMagVar(magneticVariation: number): number { + let normalizedMagVar: number; + if (magneticVariation <= 180) { + normalizedMagVar = magneticVariation; + } else { + normalizedMagVar = magneticVariation - 360; + } + + return normalizedMagVar; + } + + /** + * Gets the magnetic variation for a given latitude and longitude. + * @param lat The latitude to get a magvar for. + * @param lon The longitude to get a magvar for. + * @returns The magnetic variation at the specific latitude and longitude. + */ + public static getMagvar(lat: number, lon: number): number { + return GeoMath.magneticModel.declination(0, lat, lon, 2020); + } + + public static directedDistanceToGo(from: Coordinates, to: Coordinates, acDirectedLineBearing: number): NauticalMiles { + const absDtg = Avionics.Utils.computeGreatCircleDistance(from, to); + + // @todo should be abeam distance + if (acDirectedLineBearing >= 90 && acDirectedLineBearing <= 270) { + // Since a line perpendicular to the leg is formed by two 90 degree angles, an aircraftLegBearing outside + // (North - 90) and (North + 90) is in the lower quadrants of a plane centered at the TO fix. This means + // the aircraft is NOT past the TO fix, and DTG must be positive. + + return absDtg; + } + + return -absDtg; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/LegsProcedure.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/LegsProcedure.ts new file mode 100644 index 00000000000..2ff0d911eb9 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/LegsProcedure.ts @@ -0,0 +1,697 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { HoldData, HoldType } from '@fmgc/flightplanning/data/flightplan'; +import { firstSmallCircleIntersection } from 'msfs-geo'; +import { AltitudeDescriptor, FixTypeFlags, LegType } from '../types/fstypes/FSEnums'; +import { FixNamingScheme } from './FixNamingScheme'; +import { GeoMath } from './GeoMath'; +import { RawDataMapper } from './RawDataMapper'; + +/** + * Creates a collection of waypoints from a legs procedure. + */ +export class LegsProcedure { + /** The current index in the procedure. */ + private _currentIndex = 0; + + /** Whether or not there is a discontinuity pending to be mapped. */ + private _isDiscontinuityPending = false; + + /** A collection of the loaded facilities needed for this procedure. */ + private _facilities = new Map(); + + /** Whether or not the facilities have completed loading. */ + private _facilitiesLoaded = false; + + /** The collection of facility promises to await on first load. */ + private _facilitiesToLoad = new Map(); + + /** Whether or not a non initial-fix procedure start has been added to the procedure. */ + private _addedProcedureStart = false; + + /** A normalization factor for calculating distances from triangular ratios. */ + public static distanceNormalFactorNM = (21639 / 2) * Math.PI; + + /** A collection of filtering rules for filtering ICAO data to pre-load for the procedure. */ + private legFilteringRules: ((icao: string) => boolean)[] = [ + (icao) => icao.trim() !== '', // Icao is not empty + (icao) => icao[0] !== 'R', // Icao is not runway icao, which is not searchable + (icao) => icao[0] !== 'A', // Icao is not airport icao, which can be skipped + (icao) => icao.substr(1, 2) !== ' ', // Icao is not missing a region code + (icao) => !this._facilitiesToLoad.has(icao), // Icao is not already being loaded + ]; + + /** + * Creates an instance of a LegsProcedure. + * @param legs The legs that are part of the procedure. + * @param startingPoint The starting point for the procedure. + * @param instrument The instrument that is attached to the flight plan. + * @param approachType The approach type if this is an approach procedure + */ + constructor( + private _legs: RawProcedureLeg[], + private _previousFix: WayPoint, + private _instrument: BaseInstrument, + private airportMagVar: number, + private approachType?: ApproachType, + private legAnnotations?: string[], + ) { + for (const leg of this._legs) { + if (this.isIcaoValid(leg.fixIcao)) { + this._facilitiesToLoad.set(leg.fixIcao, this._instrument.facilityLoader.getFacilityRaw(leg.fixIcao, 2000)); + } + + if (this.isIcaoValid(leg.originIcao)) { + this._facilitiesToLoad.set(leg.originIcao, this._instrument.facilityLoader.getFacilityRaw(leg.originIcao, 2000)); + } + + if (this.isIcaoValid(leg.arcCenterFixIcao)) { + this._facilitiesToLoad.set(leg.arcCenterFixIcao, this._instrument.facilityLoader.getFacilityRaw(leg.arcCenterFixIcao, 2000)); + } + } + } + + /** + * Checks whether or not there are any legs remaining in the procedure. + * @returns True if there is a next leg, false otherwise. + */ + public hasNext(): boolean { + return this._currentIndex < this._legs.length || this._isDiscontinuityPending; + } + + private async ensureFacilitiesLoaded(): Promise { + if (!this._facilitiesLoaded) { + const facilityResults = await Promise.all(this._facilitiesToLoad.values()); + for (const facility of facilityResults.filter((f) => f !== undefined)) { + this._facilities.set(facility.icao, facility); + } + + this._facilitiesLoaded = true; + } + } + + /** + * Gets the next mapped leg from the procedure. + * @returns The mapped waypoint from the leg of the procedure. + */ + public async getNext(): Promise { + let isLegMappable = false; + let mappedLeg: WayPoint; + + await this.ensureFacilitiesLoaded(); + + while (!isLegMappable && this._currentIndex < this._legs.length) { + const currentLeg = this._legs[this._currentIndex]; + const currentAnnotation = this.legAnnotations[this._currentIndex]; + isLegMappable = true; + + // Some procedures don't start with 15 (initial fix) but instead start with a heading and distance from + // a fix: the procedure then starts with the fix exactly + if (this._currentIndex === 0 && currentLeg.type === 10 && !this._addedProcedureStart) { + mappedLeg = this.mapExactFix(currentLeg); + this._addedProcedureStart = true; + } else { + try { + switch (currentLeg.type) { + case LegType.AF: + mappedLeg = this.mapExactFix(currentLeg); + break; + case LegType.CD: + case LegType.VD: + mappedLeg = this.mapHeadingUntilDistanceFromOrigin(currentLeg, this._previousFix); + break; + case LegType.CF: + // Only map if the fix is itself not a runway fix to avoid double + // adding runway fixes + if (currentLeg.fixIcao === '' || currentLeg.fixIcao[0] !== 'R') { + mappedLeg = this.mapOriginRadialForDistance(currentLeg, this._previousFix); + } else { + isLegMappable = false; + } + break; + case LegType.CI: + case LegType.VI: + mappedLeg = this.mapHeadingToInterceptNextLeg(currentLeg, this._previousFix, this._legs[this._currentIndex + 1]); + break; + case LegType.CR: + case LegType.VR: + mappedLeg = this.mapHeadingUntilRadialCrossing(currentLeg, this._previousFix); + break; + case LegType.FC: + case LegType.FD: + mappedLeg = this.mapBearingAndDistanceFromOrigin(currentLeg); + break; + case LegType.FM: + case LegType.VM: + mappedLeg = this.mapVectors(currentLeg, this._previousFix); + break; + case LegType.IF: + if (currentLeg.fixIcao[0] !== 'A') { + const leg = this.mapExactFix(currentLeg); + const prevLeg = this._previousFix; + + // If a type 15 (initial fix) comes up in the middle of a plan + if (leg.icao === prevLeg.icao && leg.infos.coordinates.lat === prevLeg.infos.coordinates.lat + && leg.infos.coordinates.long === prevLeg.infos.coordinates.long) { + isLegMappable = false; + } else { + mappedLeg = leg; + } + } else { + // If type 15 is an airport itself, we don't need to map it (and the data is generally wrong) + isLegMappable = false; + } + break; + case LegType.DF: + case LegType.TF: + // Only map if the fix is itself not a runway fix to avoid double + // adding runway fixes + if (currentLeg.fixIcao === '' || currentLeg.fixIcao[0] !== 'R') { + mappedLeg = this.mapExactFix(currentLeg); + } else { + isLegMappable = false; + } + break; + case LegType.RF: + mappedLeg = this.mapRadiusToFix(currentLeg); + break; + case LegType.CA: + case LegType.VA: + mappedLeg = this.mapHeadingUntilAltitude(currentLeg, this._previousFix); + break; + case LegType.HA: + case LegType.HF: + case LegType.HM: + mappedLeg = this.mapHold(currentLeg); + break; + default: + isLegMappable = false; + break; + } + } catch (err) { + console.log(`LegsProcedure: Unexpected unmappable leg: ${err}`); + } + + if (mappedLeg !== undefined) { + const magCorrection = this.getMagCorrection(currentLeg); + + if (this.approachType === ApproachType.APPROACH_TYPE_ILS && (currentLeg.fixTypeFlags & FixTypeFlags.FAF) > 0) { + if (currentLeg.altDesc === AltitudeDescriptor.At) { + mappedLeg.legAltitudeDescription = AltitudeDescriptor.G; + } else { + mappedLeg.legAltitudeDescription = AltitudeDescriptor.H; + } + } else { + mappedLeg.legAltitudeDescription = currentLeg.altDesc; + } + mappedLeg.legAltitude1 = currentLeg.altitude1 * 3.28084; + mappedLeg.legAltitude2 = currentLeg.altitude2 * 3.28084; + mappedLeg.speedConstraint = currentLeg.speedRestriction; + mappedLeg.turnDirection = currentLeg.turnDirection; + + const recNavaid: RawVor | RawNdb | undefined = this._facilities.get(currentLeg.originIcao); + + mappedLeg.additionalData.legType = currentLeg.type; + mappedLeg.additionalData.overfly = currentLeg.flyOver; + mappedLeg.additionalData.fixTypeFlags = currentLeg.fixTypeFlags; + mappedLeg.additionalData.distance = currentLeg.distanceMinutes ? undefined : currentLeg.distance / 1852; + mappedLeg.additionalData.distanceInMinutes = currentLeg.distanceMinutes ? currentLeg.distance : undefined; + mappedLeg.additionalData.course = currentLeg.trueDegrees ? currentLeg.course : A32NX_Util.magneticToTrue(currentLeg.course, magCorrection); + mappedLeg.additionalData.recommendedIcao = currentLeg.originIcao.trim().length > 0 ? currentLeg.originIcao : undefined; + mappedLeg.additionalData.recommendedFrequency = recNavaid ? recNavaid.freqMHz : undefined; + mappedLeg.additionalData.recommendedLocation = recNavaid ? { lat: recNavaid.lat, long: recNavaid.lon } : undefined; + mappedLeg.additionalData.rho = currentLeg.rho / 1852; + mappedLeg.additionalData.theta = currentLeg.theta; + mappedLeg.additionalData.thetaTrue = A32NX_Util.magneticToTrue(currentLeg.theta, magCorrection); + mappedLeg.additionalData.annotation = currentAnnotation; + } + + this._currentIndex++; + } + } + + if (mappedLeg !== undefined) { + this._previousFix = mappedLeg; + return mappedLeg; + } + + return undefined; + } + + private getMagCorrection(currentLeg: RawProcedureLeg): number { + // we try to interpret PANS OPs as accurately as possible within the limits of available data + + // magnetic tracks to/from a VOR always use VOR station declination + if (currentLeg.fixIcao.charAt(0) === 'V') { + const vor: RawVor = this._facilities.get(currentLeg.fixIcao); + if (!vor || vor.magneticVariation === undefined) { + console.warn('Leg coded incorrectly (missing vor fix or station declination)', currentLeg, vor); + return this.airportMagVar; + } + return 360 - vor.magneticVariation; + } + + // we use station declination for VOR/DME approaches + if (this.approachType === ApproachType.APPROACH_TYPE_VORDME) { + // find a leg with the reference navaid for the procedure + for (let i = this._legs.length - 1; i >= 0; i--) { + if (this._legs[i].originIcao.trim().length > 0) { + const recNavaid: RawVor = this._facilities.get(currentLeg.originIcao); + if (recNavaid && recNavaid.magneticVariation !== undefined) { + return 360 - recNavaid.magneticVariation; + } + } + } + console.warn('VOR/DME approach coded incorrectly (missing recommended navaid or station declination)', currentLeg); + return this.airportMagVar; + } + + // for RNAV procedures use recommended navaid station declination for these leg types + let useStationDeclination = (currentLeg.type === LegType.CF || currentLeg.type === LegType.FA || currentLeg.type === LegType.FM); + + // for localiser bearings (i.e. at or beyond FACF), always use airport value + if (this.approachType === ApproachType.APPROACH_TYPE_ILS || this.approachType === ApproachType.APPROACH_TYPE_LOCALIZER) { + useStationDeclination = useStationDeclination && this._legs.indexOf(currentLeg) < this.getFacfIndex(); + } + + if (useStationDeclination) { + const recNavaid: RawVor = this._facilities.get(currentLeg.originIcao); + if (!recNavaid || recNavaid.magneticVariation === undefined) { + console.warn('Leg coded incorrectly (missing recommended navaid or station declination)', currentLeg, recNavaid); + return this.airportMagVar; + } + return 360 - recNavaid.magneticVariation; + } + + // for all other terminal procedure legs we use airport magnetic variation + return this.airportMagVar; + } + + private getFacfIndex(): number { + if (this.approachType !== undefined) { + for (let i = this._legs.length - 1; i >= 0; i--) { + if (this._legs[i].fixTypeFlags & FixTypeFlags.IF) { + return i; + } + } + } + + return undefined; + } + + /** + * Maps a heading until distance from origin leg. + * @param leg The procedure leg to map. + * @param prevLeg The previously mapped waypoint in the procedure. + * @returns The mapped leg. + */ + public mapHeadingUntilDistanceFromOrigin(leg: RawProcedureLeg, prevLeg: WayPoint): WayPoint { + const origin = this._facilities.get(leg.originIcao); + const originIdent = origin.icao.substring(7, 12).trim(); + + const bearingToOrigin = Avionics.Utils.computeGreatCircleHeading(prevLeg.infos.coordinates, new LatLongAlt(origin.lat, origin.lon)); + const distanceToOrigin = Avionics.Utils.computeGreatCircleDistance(prevLeg.infos.coordinates, new LatLongAlt(origin.lat, origin.lon)) / LegsProcedure.distanceNormalFactorNM; + + const deltaAngle = this.deltaAngleRadians(bearingToOrigin, leg.course); + const targetDistance = (leg.distance / 1852) / LegsProcedure.distanceNormalFactorNM; + + const distanceAngle = Math.asin((Math.sin(distanceToOrigin) * Math.sin(deltaAngle)) / Math.sin(targetDistance)); + const inverseDistanceAngle = Math.PI - distanceAngle; + + const legDistance1 = 2 * Math.atan(Math.tan(0.5 * (targetDistance - distanceToOrigin)) * (Math.sin(0.5 * (deltaAngle + distanceAngle)) + / Math.sin(0.5 * (deltaAngle - distanceAngle)))); + + const legDistance2 = 2 * Math.atan(Math.tan(0.5 * (targetDistance - distanceToOrigin)) * (Math.sin(0.5 * (deltaAngle + inverseDistanceAngle)) + / Math.sin(0.5 * (deltaAngle - inverseDistanceAngle)))); + + const legDistance = targetDistance > distanceToOrigin ? legDistance1 : Math.min(legDistance1, legDistance2); + const course = leg.course + GeoMath.getMagvar(prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long); + const coordinates = Avionics.Utils.bearingDistanceToCoordinates( + course, + legDistance * LegsProcedure.distanceNormalFactorNM, prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long, + ); + + const waypoint = this.buildWaypoint(`${originIdent}${Math.trunc(legDistance * LegsProcedure.distanceNormalFactorNM)}`, coordinates); + + return waypoint; + } + + /** + * Maps an FC or FD leg in the procedure. + * @note FC and FD legs are mapped to CF legs in the real FMS + * @todo move the code into the CF leg (maybe static functions fromFc and fromFd to construct the leg) + * @todo FD should overfly the termination... needs a messy refactor to do that + * @param leg The procedure leg to map. + * @returns The mapped leg. + */ + public mapBearingAndDistanceFromOrigin(leg: RawProcedureLeg): WayPoint { + const origin = this._facilities.get(leg.fixIcao); + const originIdent = origin.icao.substring(7, 12).trim(); + const course = leg.trueDegrees ? leg.course : A32NX_Util.magneticToTrue(leg.course, Facilities.getMagVar(origin.lat, origin.lon)); + // this is the leg length for FC, and the DME distance for FD + const refDistance = leg.distance / 1852; + + let termPoint; + let legLength; + if (leg.type === LegType.FD) { + const recNavaid = this._facilities.get(leg.originIcao); + termPoint = firstSmallCircleIntersection( + { lat: recNavaid.lat, long: recNavaid.lon }, + refDistance, + { lat: origin.lat, long: origin.lon }, + course, + ); + legLength = Avionics.Utils.computeGreatCircleDistance( + { lat: origin.lat, long: origin.lon }, + termPoint, + ); + } else { // FC + termPoint = Avionics.Utils.bearingDistanceToCoordinates( + course, + refDistance, + origin.lat, + origin.lon, + ); + legLength = refDistance; + } + + return this.buildWaypoint(`${originIdent.substring(0, 3)}/${Math.round(legLength).toString().padStart(2, '0')}`, termPoint); + } + + /** + * Maps a radial on the origin for a specified distance leg in the procedure. + * @param leg The procedure leg to map. + * @param prevLeg The previously mapped leg. + * @returns The mapped leg. + */ + public mapOriginRadialForDistance(leg: RawProcedureLeg, prevLeg: WayPoint): WayPoint { + if (leg.fixIcao.trim() !== '') { + return this.mapExactFix(leg); + } + + const origin = this._facilities.get(leg.originIcao); + const originIdent = origin.icao.substring(7, 12).trim(); + + const course = leg.course + GeoMath.getMagvar(prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long); + const coordinates = Avionics.Utils.bearingDistanceToCoordinates(course, leg.distance / 1852, prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long); + + const distanceFromOrigin = Avionics.Utils.computeGreatCircleDistance(new LatLongAlt(origin.lat, origin.lon), coordinates); + return this.buildWaypoint(`${originIdent}${Math.trunc(distanceFromOrigin / 1852)}`, coordinates); + } + + /** + * Maps a heading turn to intercept the next leg in the procedure. + * @param leg The procedure leg to map. + * @param prevLeg The previously mapped leg. + * @param nextLeg The next leg in the procedure to intercept. + * @returns The mapped leg. + */ + public mapHeadingToInterceptNextLeg(leg: RawProcedureLeg, prevLeg: WayPoint, nextLeg: RawProcedureLeg): WayPoint | null { + const magVar = Facilities.getMagVar(prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long); + const course = leg.trueDegrees ? leg.course : A32NX_Util.magneticToTrue(leg.course, magVar); + + const coordinates = GeoMath.relativeBearingDistanceToCoords(course, 1, prevLeg.infos.coordinates); + const waypoint = this.buildWaypoint(FixNamingScheme.courseToIntercept(course), coordinates, prevLeg.infos.magneticVariation); + + return waypoint; + } + + /** + * Maps flying a heading until crossing a radial of a reference fix. + * @param leg The procedure leg to map. + * @param prevLeg The previously mapped leg. + * @returns The mapped leg. + */ + public mapHeadingUntilRadialCrossing(leg: RawProcedureLeg, prevLeg: WayPoint) { + const origin = this._facilities.get(leg.originIcao); + const originCoordinates = new LatLongAlt(origin.lat, origin.lon); + + const originToCoordinates = Avionics.Utils.computeGreatCircleHeading(originCoordinates, prevLeg.infos.coordinates); + const coordinatesToOrigin = Avionics.Utils.computeGreatCircleHeading(prevLeg.infos.coordinates, new LatLongAlt(origin.lat, origin.lon)); + const distanceToOrigin = Avionics.Utils.computeGreatCircleDistance(prevLeg.infos.coordinates, originCoordinates) / LegsProcedure.distanceNormalFactorNM; + + const alpha = this.deltaAngleRadians(coordinatesToOrigin, leg.course); + const beta = this.deltaAngleRadians(originToCoordinates, leg.theta); + + const gamma = Math.acos(Math.sin(alpha) * Math.sin(beta) * Math.cos(distanceToOrigin) - Math.cos(alpha) * Math.cos(beta)); + const legDistance = Math.acos((Math.cos(beta) + Math.cos(alpha) * Math.cos(gamma)) / (Math.sin(alpha) * Math.sin(gamma))); + + const magVar = Facilities.getMagVar(prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long); + const course = leg.trueDegrees ? leg.course : A32NX_Util.magneticToTrue(leg.course, magVar); + + const coordinates = Avionics.Utils.bearingDistanceToCoordinates( + course, + legDistance * LegsProcedure.distanceNormalFactorNM, prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long, + ); + + const waypoint = this.buildWaypoint(`${this.getIdent(origin.icao)}${leg.theta}`, coordinates); + + return waypoint; + } + + /** + * Maps flying a heading until a proscribed altitude. + * @param leg The procedure leg to map. + * @param prevLeg The previous leg in the procedure. + * @returns The mapped leg. + */ + public mapHeadingUntilAltitude(leg: RawProcedureLeg, prevLeg: WayPoint) { + const magVar = Facilities.getMagVar(prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long); + const course = leg.trueDegrees ? leg.course : A32NX_Util.magneticToTrue(leg.course, magVar); + const heading = leg.trueDegrees ? A32NX_Util.trueToMagnetic(leg.course, magVar) : leg.course; + const altitudeFeet = (leg.altitude1 * 3.2808399); + const distanceInNM = altitudeFeet / 500.0; + + const coordinates = GeoMath.relativeBearingDistanceToCoords(course, distanceInNM, prevLeg.infos.coordinates); + const waypoint = this.buildWaypoint(FixNamingScheme.headingUntilAltitude(altitudeFeet), coordinates, prevLeg.infos.magneticVariation); + + waypoint.additionalData.vectorsAltitude = altitudeFeet; + + return waypoint; + } + + /** + * Maps a vectors instruction. + * @param leg The procedure leg to map. + * @param prevLeg The previous leg in the procedure. + * @returns The mapped leg. + */ + public mapVectors(leg: RawProcedureLeg, prevLeg: WayPoint) { + const magVar = Facilities.getMagVar(prevLeg.infos.coordinates.lat, prevLeg.infos.coordinates.long); + const course = leg.trueDegrees ? leg.course : A32NX_Util.magneticToTrue(leg.course, magVar); + const heading = leg.trueDegrees ? A32NX_Util.trueToMagnetic(leg.course, magVar) : leg.course; + const coordinates = GeoMath.relativeBearingDistanceToCoords(course, 1, prevLeg.infos.coordinates); + + const waypoint = this.buildWaypoint(FixNamingScheme.vector(), coordinates); + waypoint.isVectors = true; + waypoint.endsInDiscontinuity = true; + waypoint.discontinuityCanBeCleared = false; + + return waypoint; + } + + /** + * Maps an exact fix leg in the procedure. + * @param leg The procedure leg to map. + * @returns The mapped leg. + */ + public mapExactFix(leg: RawProcedureLeg): WayPoint { + const facility = this._facilities.get(leg.fixIcao); + return RawDataMapper.toWaypoint(facility, this._instrument); + } + + public mapArcToFix(leg: RawProcedureLeg, prevLeg: WayPoint): WayPoint { + const toFix = this._facilities.get(leg.fixIcao); + + const waypoint = RawDataMapper.toWaypoint(toFix, this._instrument); + + return waypoint; + } + + public mapRadiusToFix(leg: RawProcedureLeg): WayPoint { + const arcCentreFix = this._facilities.get(leg.arcCenterFixIcao); + const arcCenterCoordinates = new LatLongAlt(arcCentreFix.lat, arcCentreFix.lon, 0); + + const toFix = this._facilities.get(leg.fixIcao); + const toCoordinates = new LatLongAlt(toFix.lat, toFix.lon, 0); + + const radius = Avionics.Utils.computeGreatCircleDistance(arcCenterCoordinates, toCoordinates); + const waypoint = RawDataMapper.toWaypoint(toFix, this._instrument); + + waypoint.additionalData.radius = radius; + waypoint.additionalData.center = arcCenterCoordinates; + + return waypoint; + } + + public mapHold(leg: RawProcedureLeg): WayPoint { + const facility = this._facilities.get(leg.fixIcao); + const waypoint = RawDataMapper.toWaypoint(facility, this._instrument); + + const magVar = Facilities.getMagVar(facility.lat, facility.lon); + + (waypoint.additionalData.defaultHold as HoldData) = { + inboundMagneticCourse: leg.trueDegrees ? A32NX_Util.trueToMagnetic(leg.course, magVar) : leg.course, + turnDirection: leg.turnDirection, + distance: leg.distanceMinutes ? undefined : leg.distance / 1852, + time: leg.distanceMinutes ? leg.distance : undefined, + type: HoldType.Database, + }; + waypoint.additionalData.modifiedHold = {}; + + return waypoint; + } + + /** + * Gets the difference between two headings in zero north normalized radians. + * @param a The degrees of heading a. + * @param b The degrees of heading b. + * @returns The difference between the two headings in zero north normalized radians. + */ + private deltaAngleRadians(a: number, b: number): number { + return Math.abs((Avionics.Utils.fmod((a - b) + 180, 360) - 180) * Avionics.Utils.DEG2RAD); + } + + /** + * Gets an ident from an ICAO. + * @param icao The icao to pull the ident from. + * @returns The parsed ident. + */ + private getIdent(icao: string): string { + return icao.substring(7, 12).trim(); + } + + /** + * Checks if an ICAO is valid to load. + * @param icao The icao to check. + * @returns Whether or not the ICAO is valid. + */ + private isIcaoValid(icao: string): boolean { + for (const rule of this.legFilteringRules) { + if (!rule(icao)) { + return false; + } + } + + return true; + } + + /** + * Builds a WayPoint from basic data. + * @param ident The ident of the waypoint. + * @param coordinates The coordinates of the waypoint. + * @param magneticVariation The magnetic variation of the waypoint, if any. + * @returns The built waypoint. + */ + public buildWaypoint(ident: string, coordinates: LatLongAlt, magneticVariation?: number): WayPoint { + const waypoint = new WayPoint(this._instrument); + waypoint.type = 'W'; + + waypoint.infos = new IntersectionInfo(this._instrument); + waypoint.infos.coordinates = coordinates; + waypoint.infos.magneticVariation = magneticVariation; + + waypoint.ident = ident; + waypoint.infos.ident = ident; + + waypoint.additionalData = {}; + + return waypoint; + } + + public async calculateApproachData(runway: OneWayRunway): Promise { + await this.ensureFacilitiesLoaded(); + + // our fallback for threshold crossing altitude is threshold + 50 feet + let threshCrossAlt = runway.thresholdElevation + 15.24; + + // see if we have a runway fix, to give us coded TCH + // it can either be the MAP, or be before the MAP (MAP must be last leg of final approach) + // TCH altitude must be coded in altitude1 according to ARINC + for (let i = this._legs.length - 1; i > 0; i--) { + const leg = this._legs[i]; + // TODO check it's the same runway for robustness? + if (leg.fixIcao.charAt(0) === 'R') { + threshCrossAlt = leg.altitude1; + break; + } + } + + // MSFS does not give the coded descent angle + // we do our best to calculate one... + let fafAlt; + let fafIndex; + let fafToTcaDist = 0; + let lastLegPoint; + + for (let i = 0; i < this._legs.length; i++) { + const leg = this._legs[i]; + let termPoint; + if (leg.fixIcao.charAt(0) === 'R') { + termPoint = runway.thresholdCoordinates; + } else { + const fix = this._facilities.get(leg.fixIcao); + termPoint = new LatLongAlt(fix.lat, fix.lon); + } + + if (leg.fixTypeFlags & FixTypeFlags.FAF) { + if (leg.altDesc === AltitudeDescriptor.Empty) { + // this is illegal by ARINC + break; + } + + fafIndex = i; + // MSFS codes the wrong altDesc... but the right data... + fafAlt = leg.altitude2 > 0 ? leg.altitude2 : leg.altitude1; + } else if (fafIndex !== undefined) { + if (leg.distance > 0) { + fafToTcaDist += leg.distance; + } else { + // assume a straight leg + fafToTcaDist += 1852 * Avionics.Utils.computeGreatCircleDistance(lastLegPoint, termPoint); + } + } + + if (leg.fixIcao.charAt(0) === 'R') { + break; + } + + lastLegPoint = termPoint; + } + + if (fafIndex !== undefined && fafAlt > 0 && fafToTcaDist > 0) { + let glideAngle = Math.atan((fafAlt - threshCrossAlt) / fafToTcaDist) * 180 / Math.PI; + // arinc specifics < 3 degrees is rounded up to 3 degrees when calculating glide angle from alt sources + // we do the same if we have invalid data.. + if (!Number.isFinite(glideAngle) || glideAngle < 3 || glideAngle > 10) { + glideAngle = 3; + } + + for (let i = fafIndex + 1; i < this._legs.length; i++) { + this._legs[i].verticalAngle = glideAngle; + } + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/ManagedFlightPlan.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/ManagedFlightPlan.ts new file mode 100644 index 00000000000..a73e0fbfa59 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/ManagedFlightPlan.ts @@ -0,0 +1,1611 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { HoldData, WaypointStats } from '@fmgc/flightplanning/data/flightplan'; +import { AltitudeDescriptor, FixTypeFlags, LegType } from '../types/fstypes/FSEnums'; +import { FlightPlanSegment, SegmentType } from './FlightPlanSegment'; +import { LegsProcedure } from './LegsProcedure'; +import { RawDataMapper } from './RawDataMapper'; +import { GPS } from './GPS'; +import { ProcedureDetails } from './ProcedureDetails'; +import { DirectTo } from './DirectTo'; +import { GeoMath } from './GeoMath'; +import { WaypointBuilder } from './WaypointBuilder'; +import { WaypointConstraintType } from '@fmgc/flightplanning/FlightPlanManager'; + +/** + * A flight plan managed by the FlightPlanManager. + */ +export class ManagedFlightPlan { + /** Whether or not the flight plan has an origin airfield. */ + public originAirfield?: WayPoint; + + // This is the same as originAirfield, but is not cleared when a direct-to occurs + public persistentOriginAirfield?: WayPoint; + + /** Transition altitude for the originAirfield from the nav database */ + public originTransitionAltitudeDb?: number; + + /** Transition altitude for the originAirfield from the pilot */ + public originTransitionAltitudePilot?: number; + + /** Whether or not the flight plan has a destination airfield. */ + public destinationAirfield?: WayPoint; + + /** Transition level for the destinationAirfield from the nav database */ + public destinationTransitionLevelDb?: number; + + /** Transition level for the destinationAirfield from the pilot */ + public destinationTransitionLevelPilot?: number; + + /** The cruise altitude for this flight plan. */ + public cruiseAltitude = 0; + + /** The index of the currently active waypoint. */ + public activeWaypointIndex = 0; + + /** The details for selected procedures on this flight plan. */ + public procedureDetails: ProcedureDetails = new ProcedureDetails(); + + /** The details of any direct-to procedures on this flight plan. */ + public directTo: DirectTo = new DirectTo(); + + private turningPointIndex = 0; + + /** The departure segment of the flight plan. */ + public get departure(): FlightPlanSegment { + return this.getSegment(SegmentType.Departure); + } + + /** The enroute segment of the flight plan. */ + public get enroute(): FlightPlanSegment { + return this.getSegment(SegmentType.Enroute); + } + + /** The arrival segment of the flight plan. */ + public get arrival(): FlightPlanSegment { + return this.getSegment(SegmentType.Arrival); + } + + /** The approach segment of the flight plan. */ + public get approach(): FlightPlanSegment { + return this.getSegment(SegmentType.Approach); + } + + /** The approach segment of the flight plan. */ + public get missed(): FlightPlanSegment { + return this.getSegment(SegmentType.Missed); + } + + /** Whether the flight plan has an origin airfield. */ + public get hasOrigin() { + return this.originAirfield; + } + + /** Whether the flight plan has a persistent origin airfield. */ + public get hasPersistentOrigin() { + return this.persistentOriginAirfield; + } + + /** Whether the flight plan has a destination airfield. */ + public get hasDestination() { + return this.destinationAirfield; + } + + /** The currently active waypoint. */ + public get activeWaypoint(): WayPoint { + return this.waypoints[this.activeWaypointIndex]; + } + + /** + * Returns a list of {@link WaypointStats} for the waypoints in the flight plan + * + * @return {WaypointStats[]} array of statistics for the waypoints in the flight plan, with matching indices to + * flight plan waypoints + */ + public computeWaypointStatistics(ppos: LatLongData): Map { + // TODO this should be moved into its own dedicated module + + const stats = new Map(); + + const firstData = this.computeActiveWaypointStatistics(ppos); + + stats.set(this.activeWaypointIndex, firstData); + + this.waypoints.slice(0).forEach((waypoint, index) => { + // TODO redo when we have a better solution for vector legs + const firstDistFromPpos = firstData?.distanceFromPpos ?? 0; + const activeWpCumulativeDist = this.activeWaypoint?.cumulativeDistanceInFP ?? 0; + const distPpos = (waypoint.isVectors) ? 1 : waypoint.cumulativeDistanceInFP - activeWpCumulativeDist + firstDistFromPpos; + const data = { + ident: waypoint.ident, + bearingInFp: waypoint.bearingInFP, + distanceInFP: waypoint.distanceInFP, + distanceFromPpos: distPpos, + timeFromPpos: this.computeWaypointTime(waypoint.cumulativeDistanceInFP - activeWpCumulativeDist + firstDistFromPpos), + etaFromPpos: this.computeWaypointEta(waypoint.cumulativeDistanceInFP - activeWpCumulativeDist + firstDistFromPpos), + }; + stats.set(index, data); + }); + + return stats; + } + + /** + * Returns info for the currently active waypoint, to be displayed by the Navigation Display + */ + public computeActiveWaypointStatistics(ppos: LatLongData): WaypointStats { + // TODO this should be moved into its own dedicated module + + if (!this.activeWaypoint) { + return undefined; + } + + const bearingInFp = Avionics.Utils.computeGreatCircleHeading(ppos, this.activeWaypoint.infos.coordinates); + + let distanceFromPpos; + if (Number.isNaN(ppos.lat) || Number.isNaN(ppos.long)) { + distanceFromPpos = this.activeWaypoint.distanceInFP; + } else if (this.activeWaypoint.isVectors) { + // TODO redo when we have a better solution for vector legs + distanceFromPpos = 1; + } else { + distanceFromPpos = Avionics.Utils.computeGreatCircleDistance(ppos, this.activeWaypoint.infos.coordinates); + } + const timeFromPpos = this.computeWaypointTime(distanceFromPpos); + const etaFromPpos = this.computeWaypointEta(distanceFromPpos, timeFromPpos); + + return { + ident: this.activeWaypoint.ident, + bearingInFp, + distanceInFP: this.activeWaypoint.distanceInFP, + distanceFromPpos, + timeFromPpos, + etaFromPpos, + magneticVariation: GeoMath.getMagvar(this.activeWaypoint.infos.coordinates.lat, this.activeWaypoint.infos.coordinates.long), + }; + } + + // TODO is this accurate? Logic is same like in the old FPM + private computeWaypointTime(distance: number) { + const groundSpeed = Simplane.getGroundSpeed(); + + if (groundSpeed < 100) { + return (distance / 400) * 3600; + } + + return (distance / groundSpeed) * 3600; + } + + private computeWaypointEta(distance: number, preComputedTime? :number) { + const eta = preComputedTime ?? this.computeWaypointTime(distance); + + const utcTime = SimVar.GetGlobalVarValue('ZULU TIME', 'seconds'); + + // // console.log(`BRUHEGG: ${utcTime}, BRUHHH #2: ${eta}`); + + return eta + utcTime; + } + + /** The parent instrument this flight plan is attached to locally. */ + private _parentInstrument?: BaseInstrument; + + /** The current active segments of the flight plan. */ + private _segments: FlightPlanSegment[] = [new FlightPlanSegment(SegmentType.Enroute, 0, [])]; + + /** The waypoints of the flight plan. */ + public get waypoints(): WayPoint[] { + const waypoints: WayPoint[] = []; + if (this.originAirfield) { + waypoints.push(this.originAirfield); + } + + for (const segment of this._segments) { + waypoints.push(...segment.waypoints); + } + + if (this.destinationAirfield) { + waypoints.push(this.destinationAirfield); + } + + return waypoints; + } + + /** + * Gets all the waypoints that are currently visible and part of the routing. + * + * This is used to obtain the list of waypoints to display after a DIRECT TO. + */ + public get visibleWaypoints(): WayPoint[] { + const allWaypoints = this.waypoints; + + if (this.directTo.isActive) { + const directToWaypointIndex = this.directTo.planWaypointIndex; + + return allWaypoints.slice(Math.max(this.activeWaypointIndex - 1, directToWaypointIndex), allWaypoints.length - 1); + } + + return allWaypoints.slice(this.activeWaypointIndex - 1, allWaypoints.length); + } + + public get activeVisibleWaypointIndex(): number { + if (this.directTo.isActive) { + const directToWaypointIndex = this.directTo.planWaypointIndex; + return (this.activeWaypointIndex - 1) > directToWaypointIndex ? 1 : 0; + } + return 1; + } + + public get segments(): FlightPlanSegment[] { + return this._segments; + } + + /** The length of the flight plan. */ + public get length(): number { + const lastSeg = this._segments[this._segments.length - 1]; + return lastSeg.offset + lastSeg.waypoints.length + (this.hasDestination ? 1 : 0); + } + + public get checksum():number { + let checksum = 0; + const { waypoints } = this; + for (let i = 0; i < waypoints.length; i++) checksum += waypoints[i].infos.coordinates.lat; + return checksum; + } + + /** The non-approach waypoints of the flight plan. */ + public get nonApproachWaypoints(): WayPoint[] { + const waypoints = []; + if (this.originAirfield) { + waypoints.push(this.originAirfield); + } + + for (const segment of this._segments.filter((s) => s.type < SegmentType.Approach)) { + waypoints.push(...segment.waypoints); + } + + if (this.destinationAirfield) { + waypoints.push(this.destinationAirfield); + } + + return waypoints; + } + + /** + * Sets the parent instrument that the flight plan is attached to locally. + * @param instrument The instrument that the flight plan is attached to. + */ + public setParentInstrument(instrument: BaseInstrument): void { + this._parentInstrument = instrument; + } + + /** + * Clears the flight plan. + */ + public async clearPlan(): Promise { + this.originAirfield = undefined; + this.originTransitionAltitudeDb = undefined; + this.originTransitionAltitudePilot = undefined; + this.persistentOriginAirfield = undefined; + this.destinationAirfield = undefined; + this.destinationTransitionLevelDb = undefined; + this.destinationTransitionLevelPilot = undefined; + + this.cruiseAltitude = 0; + this.activeWaypointIndex = 0; + + this.procedureDetails = new ProcedureDetails(); + this.directTo = new DirectTo(); + + await GPS.clearPlan().catch(console.error); + this._segments = [new FlightPlanSegment(SegmentType.Enroute, 0, [])]; + } + + /** + * Syncs the flight plan to FS9GPS. + */ + public async syncToGPS(): Promise { + await GPS.clearPlan().catch(console.error); + for (let i = 0; i < this.waypoints.length; i++) { + const waypoint = this.waypoints[i]; + + if (waypoint.icao && waypoint.icao.trim() !== '') { + GPS.addIcaoWaypoint(waypoint.icao, i).catch(console.error); + } else { + GPS.addUserWaypoint(waypoint.infos.coordinates.lat, waypoint.infos.coordinates.long, i, waypoint.ident).catch(console.error); + } + + if (waypoint.endsInDiscontinuity) { + break; + } + } + + await GPS.setActiveWaypoint(this.activeWaypointIndex).catch(console.error); + await GPS.logCurrentPlan().catch(console.error); + } + + /** + * Adds a waypoint to the flight plan. + * + * @param waypoint The waypoint to add + * + * @param index The index to add the waypoint at. If omitted the waypoint will + * be appended to the end of the flight plan. + * + * @param segmentType The type of segment to add the waypoint to + * @returns The index the waypoint was actually inserted at + */ + public addWaypoint( + waypoint: WayPoint | any, + index?: number | undefined, + segmentType?: SegmentType, + ): number { + console.log('addWaypoint', waypoint, index, SegmentType[segmentType]); + const mappedWaypoint: WayPoint = (waypoint instanceof WayPoint) ? waypoint : RawDataMapper.toWaypoint(waypoint, this._parentInstrument); + + if (mappedWaypoint.type === 'A' && index === 0) { + mappedWaypoint.endsInDiscontinuity = true; + mappedWaypoint.discontinuityCanBeCleared = true; + this.originAirfield = mappedWaypoint; + this.persistentOriginAirfield = mappedWaypoint; + + this.procedureDetails.departureIndex = -1; + this.procedureDetails.departureRunwayIndex = -1; + this.procedureDetails.departureTransitionIndex = -1; + this.procedureDetails.originRunwayIndex = -1; + + this.reflowSegments(); + this.reflowDistances(); + } else if (mappedWaypoint.type === 'A' && index === undefined) { + this.destinationAirfield = mappedWaypoint; + this.procedureDetails.arrivalIndex = -1; + this.procedureDetails.arrivalRunwayIndex = -1; + this.procedureDetails.arrivalTransitionIndex = -1; + this.procedureDetails.approachIndex = -1; + this.procedureDetails.approachTransitionIndex = -1; + + this.reflowSegments(); + this.reflowDistances(); + } else { + let segment; + + if (segmentType !== undefined) { + segment = this.getSegment(segmentType); + if (segment === FlightPlanSegment.Empty) { + segment = this.addSegment(segmentType); + } + } else { + segment = this.findSegmentByWaypointIndex(index); + if (segment === FlightPlanSegment.Empty) { + throw new Error('ManagedFlightPlan::addWaypoint: no segment found!'); + } + } + + // hitting first waypoint in segment > enroute + if (segment.type > SegmentType.Enroute && index === segment.offset) { + const segIdx = this._segments.findIndex((seg) => seg.type === segment.type); + // is prev segment enroute? + const prevSeg = this._segments[segIdx - 1]; + if (prevSeg.type === SegmentType.Enroute) { + segment = prevSeg; + } + } + + if (segment) { + if (index > this.length) { + index = undefined; + } + + if (mappedWaypoint.additionalData.legType === undefined) { + if (segment.waypoints.length < 1) { + mappedWaypoint.additionalData.legType = LegType.IF; + } else { + mappedWaypoint.additionalData.legType = LegType.TF; + } + } + + if (index !== undefined) { + const segmentIndex = index - segment.offset; + if (segmentIndex < segment.waypoints.length) { + segment.waypoints.splice(segmentIndex, 0, mappedWaypoint); + } else { + segment.waypoints.push(mappedWaypoint); + } + } else { + segment.waypoints.push(mappedWaypoint); + } + + this.reflowSegments(); + this.reflowDistances(); + + const finalIndex = this.waypoints.indexOf(mappedWaypoint); + const previousWp = finalIndex > 0 ? this.waypoints[finalIndex - 1] : undefined; + + // Transfer discontinuity forwards if previous waypoint has one and it can be cleared, + // AND the new waypoint isn't the T-P of a direct to + if (previousWp && previousWp.endsInDiscontinuity && !mappedWaypoint.isTurningPoint) { + if (previousWp.discontinuityCanBeCleared === undefined || previousWp.discontinuityCanBeCleared) { + previousWp.endsInDiscontinuity = false; + previousWp.discontinuityCanBeCleared = undefined; + + // Don't mark the mapped waypoint's discontinuity as clearable if this is a MANUAL + // TODO maybe extract this logic since we also use it when building a LegsProcedure + mappedWaypoint.endsInDiscontinuity = true; + if (!mappedWaypoint.isVectors) { + mappedWaypoint.discontinuityCanBeCleared = true; + } + } + } + + if (this.activeWaypointIndex === 0 && this.length > 1) { + this.activeWaypointIndex = 1; + } else if (this.activeWaypointIndex === 1 && waypoint.isRunway && segment.type === SegmentType.Departure) { + this.activeWaypointIndex = 2; + } + + return finalIndex; + } + } + + return -1; + } + + /** + * Removes a waypoint from the flight plan. + * @param index The index of the waypoint to remove. + */ + public removeWaypoint(index: number, noDiscontinuity: boolean = false): void { + let removed = null; + if (this.originAirfield && index === 0) { + removed = this.originAirfield; + this.originAirfield = undefined; + + this.reflowSegments(); + this.reflowDistances(); + } else if (this.destinationAirfield && index === this.length - 1) { + removed = this.destinationAirfield; + this.destinationAirfield = undefined; + } else { + const segment = this.findSegmentByWaypointIndex(index); + if (segment) { + // console.log("--> REMOVING WAYPOINT ", this.getWaypoint(index), ", FROM SEGMENT ", segment); + const spliced = segment.waypoints.splice(index - segment.offset, 1); + removed = spliced[0]; + + if (segment.waypoints.length === 0 && segment.type !== SegmentType.Enroute) { + // console.log("SEGMENT LENGTH is 0, REMOVING..."); + this.removeSegment(segment.type); + } + + this.reflowSegments(); + this.reflowDistances(); + } + } + + // transfer a potential discontinuity backward + const beforeRemoved = this.waypoints[index - 1]; + if (!noDiscontinuity && beforeRemoved && !beforeRemoved.endsInDiscontinuity) { + beforeRemoved.endsInDiscontinuity = true; + beforeRemoved.discontinuityCanBeCleared = true; + } + + if (index < this.activeWaypointIndex || this.activeWaypointIndex === this.waypoints.length) { + this.activeWaypointIndex--; + } + } + + /** + * Gets a waypoint by index from the flight plan. + * @param index The index of the waypoint to get. + */ + public getWaypoint(index: number): WayPoint | null { + if (this.originAirfield && index === 0) { + return this.originAirfield; + } + + if (this.destinationAirfield && index === this.length - 1) { + return this.destinationAirfield; + } + + const segment = this.findSegmentByWaypointIndex(index); + + if (segment) { + return segment.waypoints[index - segment.offset]; + } + + return null; + } + + public setWaypointOverfly(index: number, value: boolean): void { + // FIXME origin airfield isn't necessarily index 0 + if (this.originAirfield && index === 0) { + return; + } + + // FIXME origin airfield isn't necessarily last index (never will be with missed approach) + if (this.destinationAirfield && index === this.length - 1) { + return; + } + + const segment = this.findSegmentByWaypointIndex(index); + + if (segment) { + segment.waypoints[index - segment.offset].additionalData.overfly = value; + } + } + + public addOrEditManualHold( + index: number, + desiredHold: HoldData, + modifiedHold: HoldData, + defaultHold: HoldData, + ): number { + const atWaypoint = this.getWaypoint(index); + + if (!atWaypoint) { + return; + } + + const magVar = Facilities.getMagVar(atWaypoint.infos.coordinates.lat, atWaypoint.infos.coordinates.long); + const trueCourse = A32NX_Util.magneticToTrue(desiredHold.inboundMagneticCourse, magVar); + + if (atWaypoint.additionalData.legType === LegType.HA || atWaypoint.additionalData.legType === LegType.HF || atWaypoint.additionalData.legType === LegType.HM) { + atWaypoint.additionalData.legType = LegType.HM; + atWaypoint.turnDirection = desiredHold.turnDirection; + atWaypoint.additionalData.course = trueCourse; + atWaypoint.additionalData.distance = desiredHold.distance; + atWaypoint.additionalData.distanceInMinutes = desiredHold.time; + + atWaypoint.additionalData.modifiedHold = modifiedHold; + if (atWaypoint.additionalData.defaultHold === undefined) { + atWaypoint.additionalData.defaultHold = defaultHold; + } + return index; + } else { + const manualHoldWaypoint = WaypointBuilder.fromWaypointManualHold(atWaypoint, desiredHold.turnDirection, trueCourse, desiredHold.distance, desiredHold.time, this._parentInstrument); + manualHoldWaypoint.additionalData.modifiedHold = modifiedHold; + manualHoldWaypoint.additionalData.defaultHold = defaultHold; + + this.addWaypoint(manualHoldWaypoint, index + 1); + return index + 1; + } + } + + /** + * Adds a plan segment to the flight plan. + * @param type The type of the segment to add. + */ + public addSegment(type: SegmentType): FlightPlanSegment { + const segment = new FlightPlanSegment(type, 0, []); + this._segments.push(segment); + + this._segments.sort((a, b) => a.type - b.type); + this.reflowSegments(); + + return segment; + } + + /** + * Removes a plan segment from the flight plan. + * @param type The type of plan segment to remove. + */ + public removeSegment(type: SegmentType): void { + const segmentIndex = this._segments.findIndex((s) => s.type === type); + if (segmentIndex > -1) { + this._segments.splice(segmentIndex, 1); + } + } + + /** + * Reflows waypoint index offsets accross plans segments. + */ + public reflowSegments(): void { + let index = 0; + if (this.originAirfield) { + index = 1; + } + + for (const segment of this._segments) { + segment.offset = index; + index += segment.waypoints.length; + } + } + + /** + * Gets a flight plan segment of the specified type. + * @param type The type of flight plan segment to get. + * @returns The found segment, or FlightPlanSegment.Empty if not found. + */ + public getSegment(type: number): FlightPlanSegment { + const segment = this._segments.find((s) => s.type === type); + return segment !== undefined ? segment : FlightPlanSegment.Empty; + } + + /** + * Finds a flight plan segment by waypoint index. + * @param index The index of the waypoint to find the segment for. + * @returns The located segment, if any. + */ + public findSegmentByWaypointIndex(index: number): FlightPlanSegment { + for (let i = 0; i < this._segments.length; i++) { + const segMaxIdx = this._segments[i].offset + this._segments[i].waypoints.length; + if (segMaxIdx > index) { + return this._segments[i]; + } + } + + return this._segments[this._segments.length - 1]; + } + + public isLastWaypointInSegment(fpIndex: number): boolean { + const segment = this.findSegmentByWaypointIndex(fpIndex); + if (fpIndex >= this.waypoints.length) { + return false; + } + if (fpIndex === (segment.offset + segment.waypoints.length - 1)) { + return true; + } + return false; + } + + /** + * Recalculates all waypoint bearings and distances in the flight plan. + */ + public reflowDistances(): void { + let cumulativeDistance = 0; + const { waypoints } = this; + + for (let i = 0; i < waypoints.length; i++) { + if (i > 0) { + // If there's an approach selected and this is the last approach waypoint, use the destination waypoint for coordinates + // Runway waypoints do not have coordinates + const referenceWaypoint = waypoints[i]; + const prevWaypoint = waypoints[i - 1]; + + const trueCourseToWaypoint = Avionics.Utils.computeGreatCircleHeading(prevWaypoint.infos.coordinates, referenceWaypoint.infos.coordinates); + referenceWaypoint.bearingInFP = trueCourseToWaypoint - GeoMath.getMagvar(prevWaypoint.infos.coordinates.lat, prevWaypoint.infos.coordinates.long); + referenceWaypoint.bearingInFP = referenceWaypoint.bearingInFP < 0 ? 360 + referenceWaypoint.bearingInFP : referenceWaypoint.bearingInFP; + if (prevWaypoint.endsInDiscontinuity && !prevWaypoint.discontinuityCanBeCleared) { + referenceWaypoint.distanceInFP = 0; + } else if (referenceWaypoint.additionalData) { + switch (referenceWaypoint.additionalData.legType) { + case 11: + case 22: + referenceWaypoint.distanceInFP = 1; + break; + default: + referenceWaypoint.distanceInFP = Avionics.Utils.computeGreatCircleDistance(prevWaypoint.infos.coordinates, referenceWaypoint.infos.coordinates); + break; + } + } else { + referenceWaypoint.distanceInFP = Avionics.Utils.computeGreatCircleDistance(prevWaypoint.infos.coordinates, referenceWaypoint.infos.coordinates); + } + cumulativeDistance += referenceWaypoint.distanceInFP; + referenceWaypoint.cumulativeDistanceInFP = cumulativeDistance; + } + } + } + + /** + * Copies a sanitized version of the flight plan for shared data storage. + * @returns The sanitized flight plan. + */ + public serialize(): any { + const planCopy = new ManagedFlightPlan(); + const copyWaypoint = (waypoint: WayPoint) => ({ + icao: waypoint.icao, + ident: waypoint.ident, + type: waypoint.type, + legAltitudeDescription: waypoint.legAltitudeDescription, + legAltitude1: waypoint.legAltitude1, + legAltitude2: waypoint.legAltitude2, + speedConstraint: waypoint.speedConstraint, + turnDirection: waypoint.turnDirection, + isVectors: waypoint.isVectors, + endsInDiscontinuity: waypoint.endsInDiscontinuity, + discontinuityCanBeCleared: waypoint.discontinuityCanBeCleared, + distanceInFP: waypoint.distanceInFP, + cumulativeDistanceInFP: waypoint.cumulativeDistanceInFP, + isRunway: waypoint.isRunway, + additionalData: waypoint.additionalData, + infos: { + icao: waypoint.infos.icao, + ident: waypoint.infos.ident, + airwayIn: waypoint.infos.airwayIn, + airwayOut: waypoint.infos.airwayOut, + routes: waypoint.infos.routes, + coordinates: { + lat: waypoint.infos.coordinates.lat, + long: waypoint.infos.coordinates.long, + alt: waypoint.infos.coordinates.alt, + }, + }, + }); + + const copyAirfield = (airfield: WayPoint): WayPoint => { + const copy = Object.assign(new WayPoint(undefined), airfield); + copy.infos = Object.assign(new AirportInfo(undefined), copy.infos); + + delete copy.instrument; + delete copy.infos.instrument; + delete copy._svgElements; + delete copy.infos._svgElements; + + return copy; + }; + + planCopy.activeWaypointIndex = this.activeWaypointIndex; + planCopy.destinationAirfield = this.destinationAirfield && copyAirfield(this.destinationAirfield); + planCopy.originAirfield = this.originAirfield && copyAirfield(this.originAirfield); + planCopy.persistentOriginAirfield = this.persistentOriginAirfield && copyAirfield(this.persistentOriginAirfield); + + planCopy.procedureDetails = { ...this.procedureDetails }; + planCopy.directTo = { ...this.directTo }; + planCopy.directTo.interceptPoints = planCopy.directTo.interceptPoints?.map((w) => copyWaypoint(w) as WayPoint); + + const copySegments = []; + for (const segment of this._segments) { + const copySegment = new FlightPlanSegment(segment.type, segment.offset, []); + for (const waypoint of segment.waypoints) { + copySegment.waypoints.push(copyWaypoint(waypoint) as WayPoint); + } + + copySegments.push(copySegment); + } + + planCopy._segments = copySegments; + return planCopy; + } + + /** + * Copies the flight plan. + * @returns The copied flight plan. + */ + public copy(): ManagedFlightPlan { + const newFlightPlan = Object.assign(new ManagedFlightPlan(), this); + newFlightPlan.setParentInstrument(this._parentInstrument); + + newFlightPlan._segments = []; + 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.map((wp) => { + const clone = new (wp as any).constructor(); + Object.assign(clone, wp); + clone.additionalData = Object.assign({}, wp.additionalData); + return clone; + })]; + } + + newFlightPlan.procedureDetails = Object.assign(new ProcedureDetails(), this.procedureDetails); + newFlightPlan.directTo = Object.assign(new DirectTo(), this.directTo); + newFlightPlan.directTo.interceptPoints = this.directTo.interceptPoints !== undefined ? [...this.directTo.interceptPoints] : undefined; + + return newFlightPlan; + } + + /** + * Reverses the flight plan. + */ + public reverse(): void { + // TODO: Fix flight plan indexes after reversal + // this._waypoints.reverse(); + } + + /** + * Goes direct to the specified waypoint index in the flight plan. + * + * @param waypoint The waypoint to go direct to + */ + public async addDirectTo(waypoint: WayPoint): Promise { + // TODO Replace with FMGC pos + const lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + const long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + const trueTrack = SimVar.GetSimVarValue('GPS GROUND TRUE TRACK', 'degree'); + + const oldToWp = this.waypoints[this.activeWaypointIndex]; + + const turningPoint = WaypointBuilder.fromCoordinates('T-P', new LatLongAlt(lat, long), this._parentInstrument, { legType: LegType.CF, course: trueTrack, dynamicPpos: true }, this.getTurningPointIcao()); + turningPoint.isTurningPoint = true; + + let waypointIndex = this.waypoints.findIndex((w, idx) => idx >= this.activeWaypointIndex && w.icao === waypoint.icao); + if (waypointIndex === -1) { + // in this case the waypoint is not already in the flight plan + // we string it to the start of the flight plan, add a discontinuity after, and then the existing flight plan + waypoint.endsInDiscontinuity = true; + waypoint.discontinuityCanBeCleared = true; + waypoint.additionalData.legType = LegType.DF; + this.addWaypoint(waypoint, this.activeWaypointIndex); + this.activeWaypointIndex = this.addWaypoint(turningPoint, this.activeWaypointIndex) + 1; + + // fix up the old leg that's now after the discont + if (ManagedFlightPlan.isXfLeg(oldToWp)) { + oldToWp.additionalData.legType = LegType.IF; + } + } else { + // in this case the waypoint is already in the flight plan... + // we can skip all the legs before it, and add our dir to + const toWp = this.waypoints[waypointIndex]; + toWp.additionalData.legType = LegType.DF; + toWp.turnDirection = 0; + this.addWaypoint(turningPoint, waypointIndex); + this.activeWaypointIndex = waypointIndex + 1; + } + } + + /** + * + * @param force force updating a turning point even if it's not marked dynamic + */ + public updateTurningPoint(force = false): boolean { + const wp = this.getWaypoint(this.activeWaypointIndex - 1); + if (wp?.additionalData?.dynamicPpos || (force && wp?.isTurningPoint)) { + wp.infos.coordinates.lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + wp.infos.coordinates.long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + wp.additionalData.course = SimVar.GetSimVarValue('GPS GROUND TRUE TRACK', 'degree'); + wp.icao = this.getTurningPointIcao(); + wp.infos.icao = wp.icao; + console.log('updated T-P:', force, wp.additionalData, wp.infos.coordinates); + return true; + } + return false; + } + + private getTurningPointIcao(): string { + this.turningPointIndex = (this.turningPointIndex + 1) % 1000; + return `WXX TP${this.turningPointIndex.toFixed(0).padStart(3, '0')}` + } + + /** + * Builds a departure into the flight plan from indexes in the departure airport information. + */ + public async buildDeparture(): Promise { + const legs = []; + const legAnnotations = []; + const origin = this.originAirfield; + + const { departureIndex } = this.procedureDetails; + const runwayIndex = this.procedureDetails.departureRunwayIndex; + const transitionIndex = this.procedureDetails.departureTransitionIndex; + + const selectedOriginRunwayIndex = this.procedureDetails.originRunwayIndex; + + const airportInfo = origin.infos as AirportInfo; + const airportMagVar = Facilities.getMagVar(airportInfo.coordinates.lat, airportInfo.coordinates.long); + + // Make origin fix an IF leg + if (origin) { + origin.additionalData.legType = LegType.IF; + origin.endsInDiscontinuity = true; + origin.discontinuityCanBeCleared = true; + const departure: RawDeparture = airportInfo.departures[departureIndex]; + if (departure) { + origin.additionalData.annotation = departure.name; + } else { + origin.additionalData.annotation = ''; + } + } + + // Set origin fix coordinates to runway beginning coordinates + if (origin && selectedOriginRunwayIndex !== -1) { + origin.infos.coordinates = airportInfo.oneWayRunways[selectedOriginRunwayIndex].beginningCoordinates; + origin.additionalData.runwayElevation = airportInfo.oneWayRunways[selectedOriginRunwayIndex].elevation * 3.2808399; + origin.additionalData.runwayLength = airportInfo.oneWayRunways[selectedOriginRunwayIndex].length; + } + + if (departureIndex !== -1 && runwayIndex !== -1) { + const runwayTransition: RawRunwayTransition = airportInfo.departures[departureIndex].runwayTransitions[runwayIndex]; + const departure: RawDeparture = airportInfo.departures[departureIndex]; + if (runwayTransition) { + legs.push(...runwayTransition.legs); + legAnnotations.push(...runwayTransition.legs.map(_ => departure.name)); + origin.endsInDiscontinuity = false; + origin.discontinuityCanBeCleared = undefined; + } + } + + if (departureIndex !== -1) { + const departure: RawDeparture = airportInfo.departures[departureIndex]; + legs.push(...departure.commonLegs); + legAnnotations.push(...departure.commonLegs.map(_ => departure.name)); + } + + if (transitionIndex !== -1 && departureIndex !== -1) { + if (airportInfo.departures[departureIndex].enRouteTransitions.length > 0) { + const transition: RawEnRouteTransition = airportInfo.departures[departureIndex].enRouteTransitions[transitionIndex]; + legs.push(...transition.legs); + legAnnotations.push(...transition.legs.map(_ => transition.name)); + } + } + + let segment = this.departure; + if (segment !== FlightPlanSegment.Empty) { + for (let i = 0; i < segment.waypoints.length; i++) { + this.removeWaypoint(segment.offset); + } + + this.removeSegment(segment.type); + } + + if (legs.length > 0 || selectedOriginRunwayIndex !== -1 || (departureIndex !== -1 && runwayIndex !== -1)) { + segment = this.addSegment(SegmentType.Departure); + let procedure = new LegsProcedure(legs, origin, this._parentInstrument, airportMagVar, undefined, legAnnotations); + + const runway: OneWayRunway | null = this.getOriginRunway(); + + if (runway) { + // console.error('bruh'); + // Reference : AMM - 22-71-00 PB001, Page 4 + if (departureIndex === -1 && transitionIndex === -1) { + const TEMPORARY_VERTICAL_SPEED = 2000.0; // ft/min + const TEMPORARY_GROUND_SPEED = 160; // knots + + const altitudeFeet = (runway.elevation * 3.2808399) + 1500; + const distanceInNM = altitudeFeet / TEMPORARY_VERTICAL_SPEED * (TEMPORARY_GROUND_SPEED / 60); + + const coordinates = GeoMath.relativeBearingDistanceToCoords(runway.direction, distanceInNM, runway.endCoordinates); + + const faLeg = procedure.buildWaypoint(`${Math.round(altitudeFeet)}`, coordinates); + // TODO should this check for unclr discont? (probs not) + faLeg.endsInDiscontinuity = true; + faLeg.discontinuityCanBeCleared = true; + + this.addWaypoint(faLeg, undefined, segment.type); + } + } + + let waypointIndex = segment.offset; + while (procedure.hasNext()) { + const waypoint = await procedure.getNext(); + + if (waypoint !== undefined) { + waypoint.additionalData.constraintType = WaypointConstraintType.CLB; + + this.addWaypointAvoidingDuplicates(waypoint, ++waypointIndex, segment); + } + } + } + + this.restringSegmentBoundaries(SegmentType.Departure, SegmentType.Enroute); + } + + /** + * Rebuilds the arrival and approach segment after a change of procedure + */ + public async rebuildArrivalApproach(): Promise { + // remove all legs from these segments to prevent weird stuff + this.truncateSegment(SegmentType.Arrival); + this.truncateSegment(SegmentType.Approach); + this.truncateSegment(SegmentType.Missed); + + await this.buildArrival().catch(console.error); + await this.buildApproach().catch(console.error); + } + + /** + * Builds an arrival into the flight plan from indexes in the arrival airport information. + */ + public async buildArrival(): Promise { + const legs = []; + const legAnnotations = []; + const destination = this.destinationAirfield; + + const { arrivalIndex } = this.procedureDetails; + const { approachTransitionIndex } = this.procedureDetails; + const { arrivalRunwayIndex } = this.procedureDetails; + const { arrivalTransitionIndex } = this.procedureDetails; + + const destinationInfo = destination.infos as AirportInfo; + const airportMagVar = Facilities.getMagVar(destinationInfo.coordinates.lat, destinationInfo.coordinates.long); + + if (arrivalIndex !== -1 && arrivalTransitionIndex !== -1) { + const transition: RawEnRouteTransition = destinationInfo.arrivals[arrivalIndex].enRouteTransitions[arrivalTransitionIndex]; + if (transition !== undefined) { + legs.push(...transition.legs); + legAnnotations.push(...transition.legs.map(_ => transition.name)); + // console.log('MFP: buildArrival - pushing transition legs ->', legs); + } + } + + if (arrivalIndex !== -1) { + const arrival: RawArrival = destinationInfo.arrivals[arrivalIndex]; + legs.push(...arrival.commonLegs); + legAnnotations.push(...arrival.commonLegs.map(_ => arrival.name)); + // console.log('MFP: buildArrival - pushing STAR legs ->', legs); + } + + if (arrivalIndex !== -1 && arrivalRunwayIndex !== -1) { + const arrival: RawArrival = destinationInfo.arrivals[arrivalIndex]; + const runwayTransition: RawRunwayTransition = destinationInfo.arrivals[arrivalIndex].runwayTransitions[arrivalRunwayIndex]; + if (runwayTransition) { + legs.push(...runwayTransition.legs); + legAnnotations.push(...runwayTransition.legs.map(_ => arrival.name)); + } + // console.log('MFP: buildArrival - pushing VIA legs ->', legs); + } + + let { _startIndex, segment } = this.truncateSegment(SegmentType.Arrival); + + if (legs.length > 0) { + if (segment === FlightPlanSegment.Empty) { + segment = this.addSegment(SegmentType.Arrival); + _startIndex = segment.offset; + } + + const procedure = new LegsProcedure(legs, this.getWaypoint(segment.offset - 1), this._parentInstrument, airportMagVar, undefined, legAnnotations); + + let waypointIndex = segment.offset; + // console.log('MFP: buildArrival - ADDING WAYPOINTS ------------------------'); + while (procedure.hasNext()) { + const waypoint = await procedure.getNext(); + + if (waypoint) { + waypoint.additionalData.constraintType = WaypointConstraintType.DES; + + // console.log(' ---- MFP: buildArrival: added waypoint ', waypoint.ident, ' to segment ', segment); + this.addWaypointAvoidingDuplicates(waypoint, ++waypointIndex, segment); + } + } + } + + this.restringSegmentBoundaries(SegmentType.Enroute, SegmentType.Arrival); + this.restringSegmentBoundaries(SegmentType.Arrival, SegmentType.Approach); + } + + /** + * Builds an approach into the flight plan from indexes in the arrival airport information. + */ + public async buildApproach(): Promise { + const legs = []; + const legAnnotations = []; + const missedLegs = []; + const destination = this.destinationAirfield; + this.procedureDetails.approachType = undefined; + + const { approachIndex } = this.procedureDetails; + const { approachTransitionIndex } = this.procedureDetails; + const { destinationRunwayIndex } = this.procedureDetails; + + const destinationInfo = destination.infos as AirportInfo; + const airportMagVar = Facilities.getMagVar(destinationInfo.coordinates.lat, destinationInfo.coordinates.long); + + const approach: RawApproach = destinationInfo.approaches[approachIndex]; + const approachName = approach && approach.approachType !== ApproachType.APPROACH_TYPE_UNKNOWN ? approach.name : ''; + + if (approachIndex !== -1 && approachTransitionIndex !== -1) { + const transition: RawApproachTransition = destinationInfo.approaches[approachIndex].transitions[approachTransitionIndex]; + legs.push(...transition.legs); + legAnnotations.push(...transition.legs.map(_ => transition.name)); + // console.log('MFP: buildApproach - pushing approachTransition legs ->', legs); + } + + if (approachIndex !== -1) { + this.procedureDetails.approachType = approach.approachType; + legs.push(...approach.finalLegs); + legAnnotations.push(...approach.finalLegs.map(_ => approachName)); + missedLegs.push(...approach.missedLegs); + } + + let { _startIndex, segment } = this.truncateSegment(SegmentType.Approach); + + if (legs.length > 0 || approachIndex !== -1 || destinationRunwayIndex !== -1) { + if (segment === FlightPlanSegment.Empty) { + segment = this.addSegment(SegmentType.Approach); + _startIndex = segment.offset; + + const prevWaypointIndex = segment.offset - 1; + if (prevWaypointIndex > 0) { + const prevWaypoint = this.getWaypoint(segment.offset - 1); + if (!prevWaypoint.endsInDiscontinuity) { + prevWaypoint.endsInDiscontinuity = true; + prevWaypoint.discontinuityCanBeCleared = true; + } + } + } + + const runway: OneWayRunway | null = this.getDestinationRunway(); + + const procedure = new LegsProcedure(legs, this.getWaypoint(_startIndex - 1), this._parentInstrument, airportMagVar, this.procedureDetails.approachType, legAnnotations); + + if (runway) { + procedure.calculateApproachData(runway); + } + + let waypointIndex = _startIndex; + // console.log('MFP: buildApproach - ADDING WAYPOINTS ------------------------'); + while (procedure.hasNext()) { + const waypoint = await procedure.getNext(); + + if (waypoint !== undefined) { + waypoint.additionalData.constraintType = WaypointConstraintType.DES; + + // console.log(' ---- MFP: buildApproach: added waypoint', waypoint.ident, ' to segment ', segment); + this.addWaypointAvoidingDuplicates(waypoint, ++waypointIndex, segment); + } + } + + if (runway) { + const selectedRunwayMod = runway.designation.slice(-1); + let selectedRunwayOutput; + if (selectedRunwayMod === 'L' || selectedRunwayMod === 'C' || selectedRunwayMod === 'R') { + if (runway.designation.length === 2) { + selectedRunwayOutput = `0${runway.designation}`; + } else { + selectedRunwayOutput = runway.designation; + } + } else if (runway.designation.length === 2) { + selectedRunwayOutput = runway.designation; + } else { + selectedRunwayOutput = `0${runway.designation}`; + } + if (approachIndex === -1 && destinationRunwayIndex !== -1 && destinationRunwayExtension !== -1) { + const runwayExtensionWaypoint = procedure.buildWaypoint(`RX${selectedRunwayOutput}`, + Avionics.Utils.bearingDistanceToCoordinates(runway.direction + 180, destinationRunwayExtension, runway.beginningCoordinates.lat, runway.beginningCoordinates.long)); + this.addWaypoint(runwayExtensionWaypoint); + } + + // When adding approach, edit destination waypoint + this.destinationAirfield.infos.coordinates = runway.beginningCoordinates; + this.destinationAirfield.legAltitudeDescription = 1; + this.destinationAirfield.legAltitude1 = Math.round((runway.elevation * 3.28084 + 50) / 10) * 10; + this.destinationAirfield.isRunway = true; + if (approachIndex !== -1) { + const lastLeg = approach.finalLegs[approach.finalLegs.length - 1]; + if (lastLeg.type === LegType.CF) { + const magCourse = lastLeg.trueDegrees ? A32NX_Util.trueToMagnetic(lastLeg.course, Facilities.getMagVar(runway.beginningCoordinates.lat, runway.beginningCoordinates.long)) : lastLeg.course; + this.destinationAirfield.additionalData.annotation = `C${magCourse.toFixed(0).padStart(3, '0')}°`; + } else { + this.destinationAirfield.additionalData.annotation = approachName; + } + } + + // Clear discontinuity before destination, if any + const wpBeforeDestIdx = this.waypoints.indexOf(this.destinationAirfield) - 1; + if (wpBeforeDestIdx >= 0) { + const wpBeforeDest = this.getWaypoint(wpBeforeDestIdx); + if (wpBeforeDest.endsInDiscontinuity && wpBeforeDest.discontinuityCanBeCleared) { + wpBeforeDest.endsInDiscontinuity = false; + } + } + } + } + + this.restringSegmentBoundaries(SegmentType.Arrival, SegmentType.Approach); + + /* if (missedLegs.length > 0) { + let { _startIndex, segment } = this.truncateSegment(SegmentType.Missed); + + if (segment === FlightPlanSegment.Empty) { + segment = this.addSegment(SegmentType.Missed); + _startIndex = segment.offset; + } + + let waypointIndex = _startIndex; + + const missedProcedure = new LegsProcedure(missedLegs, this.getWaypoint(_startIndex - 1), this._parentInstrument, airportMagVar); + while (missedProcedure.hasNext()) { + // eslint-disable-next-line no-await-in-loop + const waypoint = await missedProcedure.getNext().catch(console.error); + + if (waypoint !== undefined) { + // console.log(' ---- MFP: buildApproach: added waypoint', waypoint.ident, ' to segment ', segment); + this.addWaypoint(waypoint, ++waypointIndex, segment.type); + } + } + } */ + } + + private static isXfLeg(leg: WayPoint): boolean { + switch (leg?.additionalData?.legType) { + case LegType.CF: + case LegType.DF: + case LegType.IF: + case LegType.RF: + case LegType.TF: + return true; + default: + return false; + } + } + + private static isFxLeg(leg: WayPoint): boolean { + switch (leg?.additionalData?.legType) { + case LegType.FA: + case LegType.FC: + case LegType.FD: + case LegType.FM: + return true; + default: + return false; + } + } + + private static legsStartOrEndAtSameFix(legA: WayPoint, legB: WayPoint): boolean { + return legA.icao === legB.icao && ((ManagedFlightPlan.isXfLeg(legA) && ManagedFlightPlan.isXfLeg(legB)) || (ManagedFlightPlan.isFxLeg(legA) && ManagedFlightPlan.isFxLeg(legB))); + } + + private static climbConstraint(leg: WayPoint): number { + switch (leg.legAltitudeDescription) { + case AltitudeDescriptor.At: + case AltitudeDescriptor.AtOrBelow: + return leg.legAltitude1; + case AltitudeDescriptor.Between: + return leg.legAltitude2; + } + return Infinity; + } + + private static descentConstraint(leg: WayPoint): number { + switch (leg.legAltitudeDescription) { + case AltitudeDescriptor.At: + case AltitudeDescriptor.AtOrAbove: + case AltitudeDescriptor.Between: + return leg.legAltitude1; + } + return -Infinity; + } + + private static mergeConstraints(legA: WayPoint, legB: WayPoint): { legAltitudeDescription: AltitudeDescriptor, legAltitude1: number, legAltitude2: number, speedConstraint: number } { + let legAltitudeDescription = AltitudeDescriptor.Empty; + let legAltitude1 = 0; + let legAltitude2 = 0; + if (legA.legAltitudeDescription === AltitudeDescriptor.At) { + legAltitudeDescription = AltitudeDescriptor.At; + if (legB.legAltitudeDescription === AltitudeDescriptor.At) { + legAltitude1 = Math.min(legA.legAltitude1, legB.legAltitude1); + } else { + legAltitude1 = legA.legAltitude1; + } + } else if (legB.legAltitudeDescription === AltitudeDescriptor.At) { + legAltitudeDescription = AltitudeDescriptor.At; + legAltitude1 = legB.legAltitude1; + } else if (legA.legAltitudeDescription > 0 || legB.legAltitudeDescription > 0) { + const maxAlt = Math.min(ManagedFlightPlan.climbConstraint(legA), ManagedFlightPlan.climbConstraint(legB)); + const minAlt = Math.max(ManagedFlightPlan.descentConstraint(legA), ManagedFlightPlan.descentConstraint(legB)); + + if (Number.isFinite(maxAlt)) { + if (Number.isFinite(minAlt)) { + if (Math.abs(minAlt - maxAlt) < 1) { + legAltitudeDescription = AltitudeDescriptor.At; + legAltitude1 = minAlt; + } else { + legAltitudeDescription = AltitudeDescriptor.Between; + legAltitude1 = minAlt; + legAltitude2 = maxAlt; + } + } else { + legAltitudeDescription = AltitudeDescriptor.AtOrBelow; + legAltitude1 = maxAlt; + } + } else if (Number.isFinite(minAlt)) { + legAltitudeDescription = AltitudeDescriptor.AtOrAbove; + legAltitude1 = minAlt; + } + } + + const speed = Math.min((legA.speedConstraint > 0) ? legA.speedConstraint : Infinity, (legB.speedConstraint > 0) ? legB.speedConstraint : Infinity); + + return { + legAltitudeDescription, + legAltitude1, + legAltitude2, + speedConstraint: Number.isFinite(speed) ? speed : 0, + } + } + + /** + * Check for common waypoints at the boundaries of segments, and merge them if found + * segmentA must be before segmentB in the plan! + */ + private restringSegmentBoundaries(segmentTypeA: SegmentType, segmentTypeB: SegmentType) { + if (segmentTypeB < segmentTypeA) { + throw new Error('restringSegmentBoundaries: segmentTypeA must be before segmentTypeB'); + } + + const segmentA = this.getSegment(segmentTypeA); + const segmentB = this.getSegment(segmentTypeB); + + if (segmentA?.waypoints.length < 1 || segmentB?.waypoints.length < 1) { + return; + } + + const lastLegIndexA = segmentA.offset + segmentA.waypoints.length - 1; + const lastLegA = segmentA.waypoints[segmentA.waypoints.length - 1]; + const firstLegIndexB = segmentB.offset; + const firstLegB = segmentB.waypoints[0]; + + if (ManagedFlightPlan.legsStartOrEndAtSameFix(lastLegA, firstLegB)) { + const constraints = ManagedFlightPlan.mergeConstraints(lastLegA, firstLegB); + if (segmentA.type === SegmentType.Departure) { + this.removeWaypoint(firstLegIndexB, true); + Object.assign(lastLegA, constraints); + lastLegA.endsInDiscontinuity = false; + lastLegA.discontinuityCanBeCleared = undefined; + } else { + this.removeWaypoint(lastLegIndexA, true); + Object.assign(firstLegB, constraints); + firstLegB.endsInDiscontinuity = false; + firstLegB.discontinuityCanBeCleared = undefined; + } + } else if (segmentTypeA === SegmentType.Arrival && segmentTypeB === SegmentType.Approach) { + let toDeleteFromB = 0; + for (let i = 0; i < segmentB.waypoints.length; i++) { + if (ManagedFlightPlan.legsStartOrEndAtSameFix(lastLegA, segmentB.waypoints[i])) { + const constraints = ManagedFlightPlan.mergeConstraints(lastLegA, firstLegB); + Object.assign(lastLegA, constraints); + toDeleteFromB = i + 1; + break; + } + } + for (let i = 0; i < toDeleteFromB; i++) { + this.removeWaypoint(segmentB.offset, true); + } + if (toDeleteFromB === 0 && firstLegB.additionalData.legType === LegType.IF) { + lastLegA.endsInDiscontinuity = true; + lastLegA.discontinuityCanBeCleared = true; + } + } + } + + /** + * Truncates a flight plan segment. If the active waypoint index is current in the segment, + * a discontinuity will be added at the end of the active waypoint and the startIndex will + * point to the next waypoint in the segment after the active. + * @param type The type of segment to truncate. + * @returns A segment to add to and a starting waypoint index. + */ + public truncateSegment(type: SegmentType): { _startIndex: number, segment: FlightPlanSegment } { + let segment = this.getSegment(type); + // const startIndex = this.findSegmentByWaypointIndex(this.activeWaypointIndex) === segment + // ? this.activeWaypointIndex + 1 + // : segment.offset; + const startIndex = segment.offset; + + if (segment !== FlightPlanSegment.Empty) { + const finalIndex = segment.offset + segment.waypoints.length; + if (startIndex < finalIndex) { + for (let i = startIndex; i < finalIndex; i++) { + // console.log(' MFP ---> truncateSegment: removing waypoint ', this.getWaypoint(startIndex).ident); + this.removeWaypoint(startIndex); + } + } + } + + if (segment.waypoints.length === 0) { + this.removeSegment(segment.type); + segment = FlightPlanSegment.Empty; + } else { + const waypoint = segment.waypoints[Math.max((startIndex - 1) - segment.offset, 0)]; + waypoint.endsInDiscontinuity = true; + waypoint.discontinuityCanBeCleared = true; + } + + return { _startIndex: startIndex, segment }; + } + + /** + * Converts a plain object into a ManagedFlightPlan. + * @param flightPlanObject The object to convert. + * @param parentInstrument The parent instrument attached to this flight plan. + * @returns The converted ManagedFlightPlan. + */ + public static fromObject(flightPlanObject: any, parentInstrument: BaseInstrument): ManagedFlightPlan { + const plan = Object.assign(new ManagedFlightPlan(), flightPlanObject); + plan.setParentInstrument(parentInstrument); + + plan.directTo = Object.assign(new DirectTo(), plan.directTo); + + const mapObject = (obj: any, parentType?: string): any => { + if (obj && obj.infos) { + obj = Object.assign(new WayPoint(parentInstrument), obj); + } + + if (obj && obj.coordinates) { + switch (parentType) { + case 'A': + obj = Object.assign(new AirportInfo(parentInstrument), obj); + break; + case 'W': + obj = Object.assign(new IntersectionInfo(parentInstrument), obj); + break; + case 'V': + obj = Object.assign(new VORInfo(parentInstrument), obj); + break; + case 'N': + obj = Object.assign(new NDBInfo(parentInstrument), obj); + break; + default: + obj = Object.assign(new WayPointInfo(parentInstrument), obj); + } + + obj.coordinates = Object.assign(new LatLongAlt(), obj.coordinates); + } + + return obj; + }; + + const visitObject = (obj: any): any => { + for (const key in obj) { + if (typeof obj[key] === 'object' && obj[key] && obj[key].scroll === undefined) { + if (Array.isArray(obj[key])) { + visitArray(obj[key]); + } else { + visitObject(obj[key]); + } + + obj[key] = mapObject(obj[key], obj.type); + } + } + }; + + const visitArray = (array) => { + array.forEach((item, index) => { + if (Array.isArray(item)) { + visitArray(item); + } else if (typeof item === 'object') { + visitObject(item); + } + + array[index] = mapObject(item); + }); + }; + + visitObject(plan); + return plan; + } + + private legDataMatches(a: WayPoint, b: WayPoint, fields: string[]) { + return fields.every((field) => a.additionalData[field] === b.additionalData[field]); + } + + private isLegDuplicate(a: WayPoint, b: WayPoint): boolean { + if (a.additionalData.legType === b.additionalData.legType) { + switch (a.additionalData.legType) { + case LegType.AF: + case LegType.CR: + case LegType.VR: + return this.legDataMatches(a, b, ['course', 'theta', 'recommendedIcao']); + case LegType.CA: + case LegType.VA: + return this.legDataMatches(a, b, ['course']) && a.legAltitude1 === b.legAltitude1; + case LegType.CD: + case LegType.VD: + return this.legDataMatches(a, b, ['course', 'distance', 'recommendedIcao']); + case LegType.CF: + return this.legDataMatches(a, b, ['course']) && a.icao === b.icao; + case LegType.CI: + case LegType.VI: + case LegType.VM: + return this.legDataMatches(a, b, ['course']); + case LegType.DF: + case LegType.IF: + case LegType.TF: + return a.icao === b.icao; + case LegType.FA: + return a.icao === b.icao && a.legAltitude1 === b.legAltitude1; + case LegType.FC: + return this.legDataMatches(a, b, ['course', 'distance']) && a.icao === b.icao; + case LegType.FD: + return this.legDataMatches(a, b, ['course', 'distance', 'recommendedIcao']) && a.icao === b.icao; + case LegType.FM: + return this.legDataMatches(a, b, ['course']) && a.icao === b.icao; + case LegType.HA: + return this.legDataMatches(a, b, ['course', 'distance', 'distanceInMinutes']) && a.icao === b.icao && a.legAltitude1 === b.legAltitude1; + case LegType.HF: + case LegType.HM: + case LegType.PI: + return this.legDataMatches(a, b, ['course', 'distance', 'distanceInMinutes']) && a.icao === b.icao; + case LegType.RF: + return this.legDataMatches(a, b, ['center', 'radius']) && a.icao === b.icao; + default: + } + } else if (ManagedFlightPlan.isXfLeg(a) && ManagedFlightPlan.isXfLeg(b) + || ManagedFlightPlan.isFxLeg(a) && ManagedFlightPlan.isFxLeg(b)) + { + return a.icao === b.icao; + } + + return false; + } + + private addWaypointAvoidingDuplicates(waypoint: WayPoint, waypointIndex: number, segment: FlightPlanSegment): void { + const index = this.waypoints.findIndex((wp) => this.isLegDuplicate(waypoint, wp)); + + // FIXME this should collapse any legs between the old position and the newly inserted position + const wptDist = Math.abs(index - waypointIndex); + + if (index !== -1 && wptDist <= 2) { + // console.log(' -------> MFP: addWaypointAvoidingDuplicates: removing duplicate waypoint ', this.getWaypoint(index).ident); + const removedWp = this.getWaypoint(index); + if (waypoint.legAltitudeDescription === AltitudeDescriptor.Empty && removedWp.legAltitudeDescription !== AltitudeDescriptor.Empty) { + waypoint.legAltitudeDescription = removedWp.legAltitudeDescription; + waypoint.legAltitude1 = removedWp.legAltitude1; + waypoint.legAltitude2 = removedWp.legAltitude2; + } + if (waypoint.speedConstraint <= 0 && removedWp.speedConstraint > 0) { + waypoint.speedConstraint = removedWp.speedConstraint; + } + this.removeWaypoint(index); + } + this.addWaypoint(waypoint, waypointIndex, segment.type); + } + + public getOriginRunway(): OneWayRunway | null { + if (this.originAirfield) { + if (this.procedureDetails.originRunwayIndex !== -1) { + return this.originAirfield.infos.oneWayRunways[this.procedureDetails.originRunwayIndex]; + } + } + return null; + } + + public getDestinationRunway(): OneWayRunway | null { + if (this.destinationAirfield) { + if (this.procedureDetails.destinationRunwayIndex !== -1) { + return this.destinationAirfield.infos.oneWayRunways[this.procedureDetails.destinationRunwayIndex]; + } + } + return null; + } + + get manualHoldActive(): boolean { + return this.waypoints[this.activeWaypointIndex]?.additionalData?.legType === LegType.HM; + } + + get glideslopeIntercept(): number | undefined { + const appr = this.getSegment(SegmentType.Approach); + for (const wp of appr.waypoints) { + if (wp.additionalData.fixTypeFlags & FixTypeFlags.FAF && (wp.legAltitudeDescription === AltitudeDescriptor.G || wp.legAltitudeDescription === AltitudeDescriptor.H)) { + return wp.legAltitude1; + } + } + } + + get destinationIndex(): number { + const appr = this.getSegment(SegmentType.Approach); + const index = appr.offset + appr.waypoints.length; + if (this.destinationAirfield) { + return index + 1; + } + return -1; + } + + get finalApproachActive(): boolean { + const appr = this.getSegment(SegmentType.Approach); + if (appr === FlightPlanSegment.Empty) { + return false; + } + + const offset = this.activeWaypointIndex - appr.offset; + if (offset >= 0 && offset < appr.waypoints.length) { + for (const [index, wp] of appr.waypoints.entries()) { + if (wp.additionalData.fixTypeFlags & FixTypeFlags.FAF) { + return offset >= index; + } + } + } + + return false; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/ProcedureDetails.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/ProcedureDetails.ts new file mode 100644 index 00000000000..501b79569b1 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/ProcedureDetails.ts @@ -0,0 +1,64 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * The details of procedures selected in the flight plan. + */ +export class ProcedureDetails { + /** The index of the origin runway in the origin runway information. */ + public originRunwayIndex = -1; + + /** The index of the departure in the origin airport information. */ + public departureIndex = -1; + + /** The index of the departure transition in the origin airport departure information. */ + public departureTransitionIndex = -1; + + /** The index of the selected runway in the original airport departure information. */ + public departureRunwayIndex = -1; + + /** The index of the arrival in the destination airport information. */ + public arrivalIndex = -1; + + /** The index of the arrival transition in the destination airport arrival information. */ + public arrivalTransitionIndex = -1; + + /** The index of the selected runway transition at the destination airport arrival information. */ + public arrivalRunwayIndex = -1; + + /** The index of the apporach in the destination airport information. */ + public approachIndex = -1; + + /** The index of the approach transition in the destination airport approach information. */ + public approachTransitionIndex = -1; + + /** The index of the destination runway in the destination runway information. */ + public destinationRunwayIndex = -1; + + /** The length from the threshold of the runway extension fix. */ + public destinationRunwayExtension = -1; + + /** The type of approach selected */ + public approachType?: ApproachType; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/RawDataMapper.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/RawDataMapper.ts new file mode 100644 index 00000000000..7aa0c33c4d1 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/RawDataMapper.ts @@ -0,0 +1,168 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { normaliseApproachName } from '@shared/flightplan'; + +/** + * A class for mapping raw facility data to WayPoints. + */ +export class RawDataMapper { + /** + * Maps a raw facility record to a WayPoint. + * @param facility The facility record to map. + * @param instrument The instrument to attach to the WayPoint. + * @returns The mapped waypoint. + */ + public static toWaypoint(facility: any, instrument: BaseInstrument): WayPoint { + const waypoint = new WayPoint(instrument); + + waypoint.ident = WayPoint.formatIdentFromIcao(facility.icao); + waypoint.icao = facility.icao; + waypoint.type = facility.icao[0]; + + let alt = 0; + + switch (waypoint.type) { + case 'A': { + const info = new AirportInfo(instrument); + info.CopyBaseInfosFrom(waypoint); + info.UpdateNamedFrequencies(); + + alt = 3.28084 * facility.runways.reduce((sum, r) => sum + r.elevation, 0) / facility.runways.length; + + info.approaches = facility.approaches; + info.approaches.forEach((approach) => approach.name = normaliseApproachName(approach.name)); + info.approaches.forEach((approach) => approach.transitions.forEach((trans) => trans.name = trans.legs[0].fixIcao.substring(7, 12).trim())); + info.approaches.forEach((approach) => approach.runway = approach.runway.trim()); + + info.departures = facility.departures; + info.departures.forEach((departure) => departure.runwayTransitions.forEach((trans) => trans.name = RawDataMapper.generateRunwayTransitionName(trans))); + info.departures.forEach((departure) => departure.enRouteTransitions.forEach((trans) => trans.name = RawDataMapper.generateDepartureEnRouteTransitionName(trans))); + + info.arrivals = facility.arrivals; + info.arrivals.forEach((arrival) => arrival.runwayTransitions.forEach((trans) => trans.name = RawDataMapper.generateRunwayTransitionName(trans))); + info.arrivals.forEach((arrival) => arrival.enRouteTransitions.forEach((trans) => trans.name = RawDataMapper.generateArrivalTransitionName(trans))); + + info.runways = facility.runways; + + info.oneWayRunways = []; + facility.runways.forEach((runway) => info.oneWayRunways.push(...Object.assign(new Runway(), runway).splitIfTwoWays())); + + info.oneWayRunways.sort(RawDataMapper.sortRunways); + waypoint.infos = info; + } + break; + case 'V': + waypoint.infos = new VORInfo(instrument); + break; + case 'N': + waypoint.infos = new NDBInfo(instrument); + break; + case 'W': + waypoint.infos = new IntersectionInfo(instrument); + break; + default: + waypoint.infos = new WayPointInfo(instrument); + break; + } + if (waypoint.type !== 'A') { + waypoint.infos.CopyBaseInfosFrom(waypoint); + waypoint.infos.routes = facility.routes; + } + + waypoint.infos.coordinates = new LatLongAlt(facility.lat, facility.lon, alt); + waypoint.additionalData = {}; + return waypoint; + } + + /** + * A comparer for sorting runways by number, and then by L, C, and R. + * @param r1 The first runway to compare. + * @param r2 The second runway to compare. + * @returns -1 if the first is before, 0 if equal, 1 if the first is after. + */ + public static sortRunways(r1: OneWayRunway, r2: OneWayRunway): number { + if (parseInt(r1.designation) === parseInt(r2.designation)) { + let v1 = 0; + if (r1.designation.indexOf('L') !== -1) { + v1 = 1; + } else if (r1.designation.indexOf('C') !== -1) { + v1 = 2; + } else if (r1.designation.indexOf('R') !== -1) { + v1 = 3; + } + let v2 = 0; + if (r2.designation.indexOf('L') !== -1) { + v2 = 1; + } else if (r2.designation.indexOf('C') !== -1) { + v2 = 2; + } else if (r2.designation.indexOf('R') !== -1) { + v2 = 3; + } + return v1 - v2; + } + return parseInt(r1.designation) - parseInt(r2.designation); + } + + /** + * Generates a runway transition name from the designated runway in the transition data. + * @param runwayTransition The runway transition to generate the name for. + * @returns The runway transition name. + */ + public static generateRunwayTransitionName(runwayTransition: RunwayTransition): string { + let name = `RW${runwayTransition.runwayNumber}`; + + switch (runwayTransition.runwayDesignation) { + case 1: + name += 'L'; + break; + case 2: + name += 'R'; + break; + case 3: + name += 'C'; + break; + } + + return name; + } + + /** + * Generates an arrival transition name from a provided arrival enroute transition. + * @param enrouteTransition The enroute transition to generate a name for. + * @returns The generated transition name. + */ + public static generateArrivalTransitionName(enrouteTransition: EnrouteTransition): string { + return enrouteTransition.legs[0].fixIcao.substring(7, 12).trim(); + } + + /** + * Generates a departure transition name from a provided departure enroute transition. + * @param enrouteTransition The enroute transition to generate a name for. + * @returns The generated transition name. + */ + public static generateDepartureEnRouteTransitionName(enrouteTransition: EnrouteTransition): string { + return enrouteTransition.legs[enrouteTransition.legs.length - 1].fixIcao.substring(7, 12).trim(); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/SegmentedFlightPlan.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/SegmentedFlightPlan.ts new file mode 100644 index 00000000000..b0840100a34 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/SegmentedFlightPlan.ts @@ -0,0 +1,147 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ManagedFlightPlan } from './ManagedFlightPlan'; +import { FlightPlanSegment } from './FlightPlanSegment'; + +/** + * A segmented flight plan as defined by the reference. + * + * This includes two additional properties - {@link originAirport} and {@link destinationAirport} - that are not present + * in the reference. This is purely done for convenience reasons. + * + * For now, {@link enrouteSegments} is inaccurate - since the flight plan manager only outputs waypoints and not airways. + * + * @see getSegmentedFlightPlan + * + * ## Reference + * + * AMM - 22-71-00 PB001, page 4 + */ +type SegmentedFlightPlan = { + originAirport: string, + departureSegment?: DepartureSegment, + enrouteSegments: WayPoint[], + arrivalSegment: ArrivalSegment, + destinationAirport: string +}; + +/** + * The "Departure" segment of a segmented flight plan + */ +type DepartureSegment = Partial<{ + sid: FlightPlanSegment, + sidEnrouteTransitionIndex: number, +}>; + +/** + * An "Enroute" segment of a segmented flight plan + */ +type EnrouteSegment = Partial<{ + /** + * Only exists if there is no departure segment + */ + initialFix: WayPoint, + airway: WayPoint[], + direct: WayPoint, +}>; + +/** + * The "Arrival" segment of a segmented flight plan + */ +type ArrivalSegment = Partial<{ + starEnrouteTransitionIndex: number, + star: FlightPlanSegment, + approachTransitionIndex: number, + approach: FlightPlanSegment, + missedApproach: FlightPlanSegment, +}>; + +/** + * Discontinuity + */ +type Discontinuity = null; + +/** + * Element of a strung flight plan + */ +type StringItem = WayPoint | Discontinuity; + +/** + * List of strung elements from a segmented flight plan + */ +type StrungSegments = StringItem[]; + +/** + * Constructs a {@link SegmentedFlightPlan} from a {@link ManagedFlightPlan} + * + * @return a {@link SegmentedFlightPlan} if it could be constructed with the info in the {@link ManagedFlightPlan}, `null` otherwise. + */ +export function getSegmentedFlightPlan(flightPlan: ManagedFlightPlan): SegmentedFlightPlan | null { + if (!flightPlan.originAirfield || !flightPlan.destinationAirfield) { + return null; + } + + const segmented: Partial = {}; + + const planDeparture = flightPlan.departure; + + if (planDeparture !== FlightPlanSegment.Empty) { + // We have a departure - add a DepartureSegment + segmented.departureSegment = { + sid: planDeparture, + sidEnrouteTransitionIndex: flightPlan.procedureDetails.departureTransitionIndex, + }; + } + + // TODO actually implemented enroute segments - for now this only adds fixes + // Add our "enroute segments" + segmented.enrouteSegments = flightPlan.enroute.waypoints; + + // Add an ArrivalSegment + segmented.arrivalSegment = { + star: flightPlan.arrival, + starEnrouteTransitionIndex: flightPlan.procedureDetails.arrivalTransitionIndex, + missedApproach: flightPlan.missed, + }; + + return { + originAirport: flightPlan.originAirfield.ident, + departureSegment: segmented.departureSegment, + enrouteSegments: segmented.enrouteSegments, + arrivalSegment: segmented.arrivalSegment, + destinationAirport: flightPlan.destinationAirfield.ident, + }; +} + +/** + * Strings a {@link SegmentedFlightPlan} according to the logic defined in the referencce + * + * ## Reference + * + * AMM - 22-71-00 PB001, page 4-5 + */ +export function stringSegmentedFlightPlan(segmentedFlightPlan: SegmentedFlightPlan): StrungSegments { + return null; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/WaypointBuilder.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/WaypointBuilder.ts new file mode 100644 index 00000000000..8404354327d --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/WaypointBuilder.ts @@ -0,0 +1,130 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { LegType, TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { Minutes } from 'msfs-geo'; +import { FlightPlanManager } from './FlightPlanManager'; +import { GeoMath } from './GeoMath'; + +/** + * Creating a new waypoint to be added to a flight plan. + */ +export class WaypointBuilder { + /** + * Builds a WayPoint from basic data. + * @param ident The ident of the waypoint to be created. + * @param coordinates The coordinates of the waypoint. + * @param instrument The base instrument instance. + * @returns The built waypoint. + */ + public static fromCoordinates(ident: string, coordinates: LatLongAlt, instrument: BaseInstrument, additionalData?: Record, icao?: string): WayPoint { + const waypoint = new WayPoint(instrument); + waypoint.type = 'W'; + + waypoint.infos = new IntersectionInfo(instrument); + waypoint.infos.coordinates = coordinates; + + waypoint.ident = ident; + waypoint.infos.ident = ident; + + waypoint.icao = icao ?? `W ${ident}`; + waypoint.infos.icao = waypoint.icao; + + waypoint.additionalData = additionalData ?? {}; + + return waypoint; + } + + /** + * Builds a WayPoint from a refrence waypoint. + * @param ident The ident of the waypoint to be created. + * @param placeCoordinates The coordinates of the reference waypoint. + * @param bearing The bearing from the reference waypoint. + * @param distance The distance from the reference waypoint. + * @param instrument The base instrument instance. + * @returns The built waypoint. + */ + public static fromPlaceBearingDistance(ident: string, placeCoordinates: LatLongAlt, bearing: number, distance: number, instrument: BaseInstrument): WayPoint { + let magneticBearing = bearing + GeoMath.getMagvar(placeCoordinates.lat, placeCoordinates.long); + magneticBearing = magneticBearing < 0 ? 360 + magneticBearing : magneticBearing; + + const coordinates = Avionics.Utils.bearingDistanceToCoordinates(magneticBearing, distance, placeCoordinates.lat, placeCoordinates.long); + + return WaypointBuilder.fromCoordinates(ident, coordinates, instrument); + } + + /** + * Builds a WayPoint at a distance from an existing waypoint along the flight plan. + * @param ident The ident of the waypoint to be created. + * @param placeIndex The index of the reference waypoint in the flight plan. + * @param distance The distance from the reference waypoint. + * @param instrument The base instrument instance. + * @param fpm The flightplanmanager instance. + * @returns The built waypoint. + */ + public static fromPlaceAlongFlightPlan(ident: string, placeIndex: number, distance: number, instrument: BaseInstrument, fpm: FlightPlanManager): WayPoint { + console.log('running fromPlaceAlongFlightPlan'); + console.log(`destination? ${fpm.getDestination()}` ? 'True' : 'False'); + const destinationDistanceInFlightplan = fpm.getDestination().cumulativeDistanceInFP; + console.log(`destinationDistanceInFlightplan ${destinationDistanceInFlightplan}`); + + const placeDistanceFromDestination = fpm.getWaypoint(placeIndex, 0, true).cumulativeDistanceInFP; + console.log(`placeDistanceFromDestination ${placeDistanceFromDestination}`); + + const distanceFromDestination = destinationDistanceInFlightplan - placeDistanceFromDestination - distance; + console.log(`distanceFromDestination ${distanceFromDestination}`); + + const coordinates = fpm.getCoordinatesAtNMFromDestinationAlongFlightPlan(distanceFromDestination); + + return WaypointBuilder.fromCoordinates(ident, coordinates, instrument); + } + + public static fromWaypointManualHold( + waypoint: WayPoint, + holdDirection: TurnDirection, + inboundCourse: Degrees, + holdLength: NauticalMiles | undefined, + holdTime: Minutes | undefined, + instrument: BaseInstrument, + ): WayPoint { + const newWaypoint = WaypointBuilder.fromCoordinates(waypoint.ident, waypoint.infos.coordinates, instrument); + + newWaypoint.icao = waypoint.icao; + newWaypoint.infos = waypoint.infos; + + newWaypoint.additionalData.legType = LegType.HM; + newWaypoint.turnDirection = holdDirection; + newWaypoint.additionalData.course = inboundCourse; + newWaypoint.additionalData.distance = holdLength; + newWaypoint.additionalData.distanceInMinutes = holdTime; + + newWaypoint.speedConstraint = waypoint.speedConstraint; + newWaypoint.legAltitudeDescription = waypoint.legAltitudeDescription; + newWaypoint.legAltitude1 = waypoint.legAltitude1; + newWaypoint.legAltitude2 = waypoint.legAltitude2; + newWaypoint.additionalData.constraintType = waypoint.additionalData.constraintType; + + return newWaypoint; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/WorldMagneticModel.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/WorldMagneticModel.ts new file mode 100644 index 00000000000..90555b8342c --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/WorldMagneticModel.ts @@ -0,0 +1,695 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export function WorldMagneticModel() { + this.coff = [ + ' 1, 0, -29404.5 , 0.0 , 6.7 , 0.0', + ' 1, 1, -1450.7 , 4652.9 , 7.7 , -25.1', + ' 2, 0, -2500.0 , 0.0 , -11.5 , 0.0', + ' 2, 1, 2982.0 , -2991.6 , -7.1 , -30.2', + ' 2, 2, 1676.8 , -734.8 , -2.2 , -23.9', + ' 3, 0, 1363.9 , 0.0 , 2.8 , 0.0', + ' 3, 1, -2381.0 , -82.2 , -6.2 , 5.7', + ' 3, 2, 1236.2 , 241.8 , 3.4 , -1.0', + ' 3, 3, 525.7 , -542.9 , -12.2 , 1.1', + ' 4, 0, 903.1 , 0.0 , -1.1 , 0.0', + ' 4, 1, 809.4 , 282.0 , -1.6 , 0.2', + ' 4, 2, 86.2 , -158.4 , -6.0 , 6.9', + ' 4, 3, -309.4 , 199.8 , 5.4 , 3.7', + ' 4, 4, 47.9 , -350.1 , -5.5 , -5.6', + ' 5, 0, -234.4 , 0.0 , -0.3 , 0.0', + ' 5, 1, 363.1 , 47.7 , 0.6 , 0.1', + ' 5, 2, 187.8 , 208.4 , -0.7 , 2.5', + ' 5, 3, -140.7 , -121.3 , 0.1 , -0.9', + ' 5, 4, -151.2 , 32.2 , 1.2 , 3.0', + ' 5, 5, 13.7 , 99.1 , 1.0 , 0.5', + ' 6, 0, 65.9 , 0.0 , -0.6 , 0.0', + ' 6, 1, 65.6 , -19.1 , -0.4 , 0.1', + ' 6, 2, 73.0 , 25.0 , 0.5 , -1.8', + ' 6, 3, -121.5 , 52.7 , 1.4 , -1.4', + ' 6, 4, -36.2 , -64.4 , -1.4 , 0.9', + ' 6, 5, 13.5 , 9.0 , -0.0 , 0.1', + ' 6, 6, -64.7 , 68.1 , 0.8 , 1.0', + ' 7, 0, 80.6 , 0.0 , -0.1 , 0.0', + ' 7, 1, -76.8 , -51.4 , -0.3 , 0.5', + ' 7, 2, -8.3 , -16.8 , -0.1 , 0.6', + ' 7, 3, 56.5 , 2.3 , 0.7 , -0.7', + ' 7, 4, 15.8 , 23.5 , 0.2 , -0.2', + ' 7, 5, 6.4 , -2.2 , -0.5 , -1.2', + ' 7, 6, -7.2 , -27.2 , -0.8 , 0.2', + ' 7, 7, 9.8 , -1.9 , 1.0 , 0.3', + ' 8, 0, 23.6 , 0.0 , -0.1 , 0.0', + ' 8, 1, 9.8 , 8.4 , 0.1 , -0.3', + ' 8, 2, -17.5 , -15.3 , -0.1 , 0.7', + ' 8, 3, -0.4 , 12.8 , 0.5 , -0.2', + ' 8, 4, -21.1 , -11.8 , -0.1 , 0.5', + ' 8, 5, 15.3 , 14.9 , 0.4 , -0.3', + ' 8, 6, 13.7 , 3.6 , 0.5 , -0.5', + ' 8, 7, -16.5 , -6.9 , 0.0 , 0.4', + ' 8, 8, -0.3 , 2.8 , 0.4 , 0.1', + ' 9, 0, 5.0 , 0.0 , -0.1 , 0.0', + ' 9, 1, 8.2 , -23.3 , -0.2 , -0.3', + ' 9, 2, 2.9 , 11.1 , -0.0 , 0.2', + ' 9, 3, -1.4 , 9.8 , 0.4 , -0.4', + ' 9, 4, -1.1 , -5.1 , -0.3 , 0.4', + ' 9, 5, -13.3 , -6.2 , -0.0 , 0.1', + ' 9, 6, 1.1 , 7.8 , 0.3 , -0.0', + ' 9, 7, 8.9 , 0.4 , -0.0 , -0.2', + ' 9, 8, -9.3 , -1.5 , -0.0 , 0.5', + ' 9, 9, -11.9 , 9.7 , -0.4 , 0.2', + ' 10, 0, -1.9 , 0.0 , 0.0 , 0.0', + ' 10, 1, -6.2 , 3.4 , -0.0 , -0.0', + ' 10, 2, -0.1 , -0.2 , -0.0 , 0.1', + ' 10, 3, 1.7 , 3.5 , 0.2 , -0.3', + ' 10, 4, -0.9 , 4.8 , -0.1 , 0.1', + ' 10, 5, 0.6 , -8.6 , -0.2 , -0.2', + ' 10, 6, -0.9 , -0.1 , -0.0 , 0.1', + ' 10, 7, 1.9 , -4.2 , -0.1 , -0.0', + ' 10, 8, 1.4 , -3.4 , -0.2 , -0.1', + ' 10, 9, -2.4 , -0.1 , -0.1 , 0.2', + ' 10, 10, -3.9 , -8.8 , -0.0 , -0.0', + ' 11, 0, 3.0 , 0.0 , -0.0 , 0.0', + ' 11, 1, -1.4 , -0.0 , -0.1 , -0.0', + ' 11, 2, -2.5 , 2.6 , -0.0 , 0.1', + ' 11, 3, 2.4 , -0.5 , 0.0 , 0.0', + ' 11, 4, -0.9 , -0.4 , -0.0 , 0.2', + ' 11, 5, 0.3 , 0.6 , -0.1 , -0.0', + ' 11, 6, -0.7 , -0.2 , 0.0 , 0.0', + ' 11, 7, -0.1 , -1.7 , -0.0 , 0.1', + ' 11, 8, 1.4 , -1.6 , -0.1 , -0.0', + ' 11, 9, -0.6 , -3.0 , -0.1 , -0.1', + ' 11, 10, 0.2 , -2.0 , -0.1 , 0.0', + ' 11, 11, 3.1 , -2.6 , -0.1 , -0.0', + ' 12, 0, -2.0 , 0.0 , 0.0 , 0.0', + ' 12, 1, -0.1 , -1.2 , -0.0 , -0.0', + ' 12, 2, 0.5 , 0.5 , -0.0 , 0.0', + ' 12, 3, 1.3 , 1.3 , 0.0 , -0.1', + ' 12, 4, -1.2 , -1.8 , -0.0 , 0.1', + ' 12, 5, 0.7 , 0.1 , -0.0 , -0.0', + ' 12, 6, 0.3 , 0.7 , 0.0 , 0.0', + ' 12, 7, 0.5 , -0.1 , -0.0 , -0.0', + ' 12, 8, -0.2 , 0.6 , 0.0 , 0.1', + ' 12, 9, -0.5 , 0.2 , -0.0 , -0.0', + ' 12, 10, 0.1 , -0.9 , -0.0 , -0.0', + ' 12, 11, -1.1 , -0.0 , -0.0 , 0.0', + ' 12, 12, -0.3 , 0.5 , -0.1 , -0.1', + ]; + + /* static variables */ + + /* some 13x13 2D arrays */ + this.c = new Array(13); + this.cd = new Array(13); + this.tc = new Array(13); + this.dp = new Array(13); + this.k = new Array(13); + + for (var i: any = 0; i < 13; i++) { + this.c[i] = new Array(13); + this.cd[i] = new Array(13); + this.tc[i] = new Array(13); + this.dp[i] = new Array(13); + this.k[i] = new Array(13); + } + + /* some 1D arrays */ + this.snorm = new Array(169); + this.sp = new Array(13); + this.cp = new Array(13); + this.fn = new Array(13); + this.fm = new Array(13); + this.pp = new Array(13); + + /* locals */ + + const maxdeg = 12; + let maxord; + var i; let j; let D1; let D2; let n; let m; + let a; let b; let a2; let b2; let c2; let a4; let b4; let c4; let re; + let gnm; let hnm; let dgnm; let dhnm; let flnmj; + let c_str; + let c_flds; + + /* INITIALIZE CONSTANTS */ + + maxord = maxdeg; + this.sp[0] = 0.0; + this.cp[0] = this.snorm[0] = this.pp[0] = 1.0; + this.dp[0][0] = 0.0; + a = 6378.137; + b = 6356.7523142; + re = 6371.2; + a2 = a * a; + b2 = b * b; + c2 = a2 - b2; + a4 = a2 * a2; + b4 = b2 * b2; + c4 = a4 - b4; + + /* READ WORLD MAGNETIC MODEL SPHERICAL HARMONIC COEFFICIENTS */ + this.c[0][0] = 0.0; + this.cd[0][0] = 0.0; + + for (i = 0; i < this.coff.length; i++) { + c_str = this.coff[i]; + c_flds = c_str.split(','); + + n = parseInt(c_flds[0], 10); + m = parseInt(c_flds[1], 10); + gnm = parseFloat(c_flds[2]); + hnm = parseFloat(c_flds[3]); + dgnm = parseFloat(c_flds[4]); + dhnm = parseFloat(c_flds[5]); + + if (m <= n) { + this.c[m][n] = gnm; + this.cd[m][n] = dgnm; + + if (m != 0) { + this.c[n][m - 1] = hnm; + this.cd[n][m - 1] = dhnm; + } + } + } + + /* CONVERT SCHMIDT NORMALIZED GAUSS COEFFICIENTS TO UNNORMALIZED */ + + this.snorm[0] = 1.0; + for (n = 1; n <= maxord; n++) { + this.snorm[n] = this.snorm[n - 1] * (2 * n - 1) / n; + j = 2; + for (m = 0, D1 = 1, D2 = (n - m + D1) / D1; D2 > 0; D2--, m += D1) { + this.k[m][n] = (((n - 1) * (n - 1)) - (m * m)) / ((2 * n - 1) * (2 * n - 3)); + if (m > 0) { + flnmj = ((n - m + 1) * j) / (n + m); + this.snorm[n + m * 13] = this.snorm[n + (m - 1) * 13] * Math.sqrt(flnmj); + j = 1; + this.c[n][m - 1] = this.snorm[n + m * 13] * this.c[n][m - 1]; + this.cd[n][m - 1] = this.snorm[n + m * 13] * this.cd[n][m - 1]; + } + this.c[m][n] = this.snorm[n + m * 13] * this.c[m][n]; + this.cd[m][n] = this.snorm[n + m * 13] * this.cd[m][n]; + } + this.fn[n] = (n + 1); + this.fm[n] = n; + } + this.k[1][1] = 0.0; + this.fm[0] = 0.0;// !!!!!! WMM C and Fortran both have a bug in that fm[0] is not initialised +} + +WorldMagneticModel.prototype.declination = function (altitudeKm, latitudeDegrees, longitudeDegrees, yearFloat) { + /* locals */ + + const a = 6378.137; + const b = 6356.7523142; + const re = 6371.2; + const a2 = a * a; + const b2 = b * b; + const c2 = a2 - b2; + const a4 = a2 * a2; + const b4 = b2 * b2; + const c4 = a4 - b4; + let D3; let + D4; + let dip; let ti; let gv; let + dec; + let n; let m; + + let pi; let dt; let rlon; let rlat; let srlon; let srlat; let crlon; let crlat; let srlat2; + let crlat2; let q; let q1; let q2; let ct; let d; let aor; let ar; let br; let r; let r2; let bpp; let par; + let temp1; let parp; let temp2; let bx; let by; let bz; let bh; let dtr; let bp; let bt; let st; let ca; let sa; + + const maxord = 12; + const alt = altitudeKm; + const glon = longitudeDegrees; + const glat = latitudeDegrees; + + /** ********************************************************************** */ + + dt = yearFloat - 2020.0; + // if more then 5 years has passed since last epoch update then return invalid + if ((dt < 0.0) || (dt > 5.0)) return -999; + + pi = 3.14159265359; + dtr = pi / 180.0; + rlon = glon * dtr; + rlat = glat * dtr; + srlon = Math.sin(rlon); + srlat = Math.sin(rlat); + crlon = Math.cos(rlon); + crlat = Math.cos(rlat); + srlat2 = srlat * srlat; + crlat2 = crlat * crlat; + this.sp[1] = srlon; + this.cp[1] = crlon; + + /* CONVERT FROM GEODETIC COORDS. TO SPHERICAL COORDS. */ + + q = Math.sqrt(a2 - c2 * srlat2); + q1 = alt * q; + q2 = ((q1 + a2) / (q1 + b2)) * ((q1 + a2) / (q1 + b2)); + ct = srlat / Math.sqrt(q2 * crlat2 + srlat2); + st = Math.sqrt(1.0 - (ct * ct)); + r2 = (alt * alt) + 2.0 * q1 + (a4 - c4 * srlat2) / (q * q); + r = Math.sqrt(r2); + d = Math.sqrt(a2 * crlat2 + b2 * srlat2); + ca = (alt + d) / r; + sa = c2 * crlat * srlat / (r * d); + + for (m = 2; m <= maxord; m++) { + this.sp[m] = this.sp[1] * this.cp[m - 1] + this.cp[1] * this.sp[m - 1]; + this.cp[m] = this.cp[1] * this.cp[m - 1] - this.sp[1] * this.sp[m - 1]; + } + + aor = re / r; + ar = aor * aor; + br = bt = bp = bpp = 0.0; + + for (n = 1; n <= maxord; n++) { + ar *= aor; + for (m = 0, D3 = 1, D4 = (n + m + D3) / D3; D4 > 0; D4--, m += D3) { + /* + COMPUTE UNNORMALIZED ASSOCIATED LEGENDRE POLYNOMIALS + AND DERIVATIVES VIA RECURSION RELATIONS + */ + + if (n == m) { + this.snorm[n + m * 13] = st * this.snorm[n - 1 + (m - 1) * 13]; + this.dp[m][n] = st * this.dp[m - 1][n - 1] + ct * this.snorm[n - 1 + (m - 1) * 13]; + } else if (n == 1 && m == 0) { + this.snorm[n + m * 13] = ct * this.snorm[n - 1 + m * 13]; + this.dp[m][n] = ct * this.dp[m][n - 1] - st * this.snorm[n - 1 + m * 13]; + } else if (n > 1 && n != m) { + if (m > n - 2) this.snorm[n - 2 + m * 13] = 0.0; + if (m > n - 2) this.dp[m][n - 2] = 0.0; + this.snorm[n + m * 13] = ct * this.snorm[n - 1 + m * 13] - this.k[m][n] * this.snorm[n - 2 + m * 13]; + this.dp[m][n] = ct * this.dp[m][n - 1] - st * this.snorm[n - 1 + m * 13] - this.k[m][n] * this.dp[m][n - 2]; + } + + /* + TIME ADJUST THE GAUSS COEFFICIENTS + */ + this.tc[m][n] = this.c[m][n] + dt * this.cd[m][n]; + if (m != 0) this.tc[n][m - 1] = this.c[n][m - 1] + dt * this.cd[n][m - 1]; + + /* + ACCUMULATE TERMS OF THE SPHERICAL HARMONIC EXPANSIONS + */ + par = ar * this.snorm[n + m * 13]; + if (m == 0) { + temp1 = this.tc[m][n] * this.cp[m]; + temp2 = this.tc[m][n] * this.sp[m]; + } else { + temp1 = this.tc[m][n] * this.cp[m] + this.tc[n][m - 1] * this.sp[m]; + temp2 = this.tc[m][n] * this.sp[m] - this.tc[n][m - 1] * this.cp[m]; + } + bt -= ar * temp1 * this.dp[m][n]; + bp += (this.fm[m] * temp2 * par); + br += (this.fn[n] * temp1 * par); + /* + SPECIAL CASE: NORTH/SOUTH GEOGRAPHIC POLES + */ + if (st == 0.0 && m == 1) { + if (n == 1) this.pp[n] = this.pp[n - 1]; + else this.pp[n] = this.ct * this.pp[n - 1] - this.k[m][n] * this.pp[n - 2]; + parp = ar * this.pp[n]; + bpp += (this.fm[m] * temp2 * parp); + } + } + } + + if (st == 0.0) bp = bpp; + else bp /= st; + + /* + ROTATE MAGNETIC VECTOR COMPONENTS FROM SPHERICAL TO + GEODETIC COORDINATES + */ + bx = -bt * ca - br * sa; + by = bp; + bz = bt * sa - br * ca; + /* + COMPUTE DECLINATION (DEC), INCLINATION (DIP) AND + TOTAL INTENSITY (TI) + */ + bh = Math.sqrt((bx * bx) + (by * by)); + ti = Math.sqrt((bh * bh) + (bz * bz)); + dec = Math.atan2(by, bx) / dtr; + dip = Math.atan2(bz, bh) / dtr; + /* + COMPUTE MAGNETIC GRID VARIATION IF THE CURRENT + GEODETIC POSITION IS IN THE ARCTIC OR ANTARCTIC + (I.E. GLAT > +55 DEGREES OR GLAT < -55 DEGREES) + + OTHERWISE, SET MAGNETIC GRID VARIATION TO -999.0 + */ + gv = -999.0; + if (Math.abs(glat) >= 55.0) { + if (glat > 0.0 && glon >= 0.0) gv = dec - glon; + if (glat > 0.0 && glon < 0.0) gv = dec + Math.abs(glon); + if (glat < 0.0 && glon >= 0.0) gv = dec + glon; + if (glat < 0.0 && glon < 0.0) gv = dec - Math.abs(glon); + if (gv > +180.0) gv -= 360.0; + if (gv < -180.0) gv += 360.0; + } + + return dec; +}; + +WorldMagneticModel.prototype.knownAnswerTest = function () { + /* http://www.ngdc.noaa.gov/geomag/WMM WMM2010testvalues.pdf */ + + /* Lat Lon Dec */ + /* Lon 240 = 120W, Lon 300 = 60W */ + + /* Alt 0 km */ + const kat2010 = [ + '80.00 ,0.00 ,-6.13 ', + '0.00 ,120.00 ,0.97 ', + '-80.00 ,240.00 ,70.21 ', + ]; + + const kat2012p5 = [ + '80.00 ,0.00 ,-5.21 ', + '0.00 ,120.00 ,0.88 ', + '-80.00 ,240.00 ,70.04 ', + ]; + + let maxErr = 0.0; + + for (var i = 0; i < kat2010.length; i++) { + var c_str = kat2010[i]; + var c_flds = c_str.split(','); + + var lat = parseFloat(c_flds[0]); + var lon = parseFloat(c_flds[1]); + var exp = parseFloat(c_flds[2]); + var maxExp; + + var dec = this.declination(0, lat, lon, 2010.0); + if (Math.abs(dec - exp) > maxErr) { + maxErr = Math.abs(dec - exp); + maxExp = exp; + } + } + + for (var i = 0; i < kat2012p5.length; i++) { + var c_str = kat2012p5[i]; + var c_flds = c_str.split(','); + + var lat = parseFloat(c_flds[0]); + var lon = parseFloat(c_flds[1]); + var exp = parseFloat(c_flds[2]); + var maxExp; + + var dec = this.declination(0, lat, lon, 2012.5); + if (Math.abs(dec - exp) > maxErr) { + maxErr = Math.abs(dec - exp); + maxExp = exp; + } + } + + return maxErr * 100 / maxExp;// max % error +}; + +/* + + C*********************************************************************** + C + C + C SUBROUTINE GEOMAG (GEOMAGNETIC FIELD COMPUTATION) + C + C + C*********************************************************************** + C + C GEOMAG IS A NATIONAL GEOSPATIAL INTELLIGENCE AGENCY (NGA) STANDARD + C PRODUCT. IT IS COVERED UNDER NGA MILITARY SPECIFICATION: + C MIL-W-89500 (1993). + C + C*********************************************************************** + C Contact Information + C + C Software and Model Support + C National Geophysical Data Center + C NOAA EGC/2 + C 325 Broadway + C Boulder, CO 80303 USA + C Attn: Susan McLean or Stefan Maus + C Phone: (303) 497-6478 or -6522 + C Email: Susan.McLean@noaa.gov or Stefan.Maus@noaa.gov + C Web: http://www.ngdc.noaa.gov/seg/WMM/ + C + C Sponsoring Government Agency + C National Geospatial-Intelligence Agency + C PRG / CSAT, M.S. L-41 + C 3838 Vogel Road + C Arnold, MO 63010 + C Attn: Craig Rollins + C Phone: (314) 263-4186 + C Email: Craig.M.Rollins@Nga.Mil + C + C Original Program By: + C Dr. John Quinn + C FLEET PRODUCTS DIVISION, CODE N342 + C NAVAL OCEANOGRAPHIC OFFICE (NAVOCEANO) + C STENNIS SPACE CENTER (SSC), MS 39522-5001 + C + C*********************************************************************** + C + C PURPOSE: THIS ROUTINE COMPUTES THE DECLINATION (DEC), + C INCLINATION (DIP), TOTAL INTENSITY (TI) AND + C GRID VARIATION (GV - POLAR REGIONS ONLY, REFERENCED + C TO GRID NORTH OF A STEREOGRAPHIC PROJECTION) OF THE + C EARTH'S MAGNETIC FIELD IN GEODETIC COORDINATES + C FROM THE COEFFICIENTS OF THE CURRENT OFFICIAL + C DEPARTMENT OF DEFENSE (DOD) SPHERICAL HARMONIC WORLD + C MAGNETIC MODEL (WMM.COF). THE WMM SERIES OF MODELS IS + C UPDATED EVERY 5 YEARS ON JANUARY 1ST OF THOSE YEARS + C WHICH ARE DIVISIBLE BY 5 (I.E. 2000, 2005, 2010 ETC.) + C BY NOAA'S NATIONAL GEOPHYSICAL DATA CENTER IN + C COOPERATION WITH THE BRITISH GEOLOGICAL SURVEY (BGS). + C THE MODEL IS BASED ON GEOMAGNETIC FIELD MEASUREMENTS + C FROM SATELLITE AND GROUND OBSERVATORIES. + C + C*********************************************************************** + C + C MODEL: THE WMM SERIES GEOMAGNETIC MODELS ARE COMPOSED + C OF TWO PARTS: THE MAIN FIELD MODEL, WHICH IS + C VALID AT THE BASE EPOCH OF THE CURRENT MODEL AND + C A SECULAR VARIATION MODEL, WHICH ACCOUNTS FOR SLOW + C TEMPORAL VARIATIONS IN THE MAIN GEOMAGNETIC FIELD + C FROM THE BASE EPOCH TO A MAXIMUM OF 5 YEARS BEYOND + C THE BASE EPOCH. FOR EXAMPLE, THE BASE EPOCH OF + C THE WMM-2005 MODEL IS 2005.0. THIS MODEL IS THEREFORE + C CONSIDERED VALID BETWEEN 2005.0 AND 2010.0. THE + C COMPUTED MAGNETIC PARAMETERS ARE REFERENCED TO THE + C WGS-84 ELLIPSOID. + C + C*********************************************************************** + C + C ACCURACY: IN OCEAN AREAS AT THE EARTH'S SURFACE OVER THE + C ENTIRE 5 YEAR LIFE OF THE DEGREE AND ORDER 12 + C SPHERICAL HARMONIC MODEL WMM-2005, THE ESTIMATED + C MAXIMUM RMS ERRORS FOR THE VARIOUS MAGNETIC COMPONENTS + C ARE: + C + C DEC - 0.5 Degrees + C DIP - 0.5 Degrees + C TI - 280.0 nanoTeslas (nT) + C GV - 0.5 Degrees + C + C OTHER MAGNETIC COMPONENTS THAT CAN BE DERIVED FROM + C THESE FOUR BY SIMPLE TRIGONOMETRIC RELATIONS WILL + C HAVE THE FOLLOWING APPROXIMATE ERRORS OVER OCEAN AREAS: + C + C X - 140 nT (North) + C Y - 140 nT (East) + C Z - 200 nT (Vertical) Positive is down + C H - 200 nT (Horizontal) + C + C OVER LAND THE MAXIMUM RMS ERRORS ARE EXPECTED TO BE + C HIGHER, ALTHOUGH THE RMS ERRORS FOR DEC, DIP, AND GV + C ARE STILL ESTIMATED TO BE LESS THAN 1.0 DEGREE, FOR + C THE ENTIRE 5-YEAR LIFE OF THE MODEL AT THE EARTH's + C SURFACE. THE OTHER COMPONENT ERRORS OVER LAND ARE + C MORE DIFFICULT TO ESTIMATE AND SO ARE NOT GIVEN. + C + C THE ACCURACY AT ANY GIVEN TIME FOR ALL OF THESE + C GEOMAGNETIC PARAMETERS DEPENDS ON THE GEOMAGNETIC + C LATITUDE. THE ERRORS ARE LEAST FROM THE EQUATOR TO + C MID-LATITUDES AND GREATEST NEAR THE MAGNETIC POLES. + C + C IT IS VERY IMPORTANT TO NOTE THAT A DEGREE AND + C ORDER 12 MODEL, SUCH AS WMM-2005, DESCRIBES ONLY + C THE LONG WAVELENGTH SPATIAL MAGNETIC FLUCTUATIONS + C DUE TO EARTH'S CORE. NOT INCLUDED IN THE WMM SERIES + C MODELS ARE INTERMEDIATE AND SHORT WAVELENGTH + C SPATIAL FLUCTUATIONS OF THE GEOMAGNETIC FIELD + C WHICH ORIGINATE IN THE EARTH'S MANTLE AND CRUST. + C CONSEQUENTLY, ISOLATED ANGULAR ERRORS AT VARIOUS + C POSITIONS ON THE SURFACE (PRIMARILY OVER LAND, IN + C CONTINENTAL MARGINS AND OVER OCEANIC SEAMOUNTS, + C RIDGES AND TRENCHES) OF SEVERAL DEGREES MAY BE + C EXPECTED. ALSO NOT INCLUDED IN THE MODEL ARE + C NONSECULAR TEMPORAL FLUCTUATIONS OF THE GEOMAGNETIC + C FIELD OF MAGNETOSPHERIC AND IONOSPHERIC ORIGIN. + C DURING MAGNETIC STORMS, TEMPORAL FLUCTUATIONS CAN + C CAUSE SUBSTANTIAL DEVIATIONS OF THE GEOMAGNETIC + C FIELD FROM MODEL VALUES. IN ARCTIC AND ANTARCTIC + C REGIONS, AS WELL AS IN EQUATORIAL REGIONS, DEVIATIONS + C FROM MODEL VALUES ARE BOTH FREQUENT AND PERSISTENT. + C + C IF THE REQUIRED DECLINATION ACCURACY IS MORE + C STRINGENT THAN THE WMM SERIES OF MODELS PROVIDE, THEN + C THE USER IS ADVISED TO REQUEST SPECIAL (REGIONAL OR + C LOCAL) SURVEYS BE PERFORMED AND MODELS PREPARED. + C REQUESTS OF THIS NATURE SHOULD BE MADE TO NIMA + C AT THE ADDRESS ABOVE. + C + C*********************************************************************** + C + C USAGE: THIS ROUTINE IS BROKEN UP INTO TWO PARTS: + C + C A) AN INITIALIZATION MODULE, WHICH IS CALLED ONLY + C ONCE AT THE BEGINNING OF THE MAIN (CALLING) + C PROGRAM + C B) A PROCESSING MODULE, WHICH COMPUTES THE MAGNETIC + C FIELD PARAMETERS FOR EACH SPECIFIED GEODETIC + C POSITION (ALTITUDE, LATITUDE, LONGITUDE) AND TIME + C + C INITIALIZATION IS MADE VIA A SINGLE CALL TO THE MAIN + C ENTRY POINT (GEOMAG), WHILE SUBSEQUENT PROCESSING + C CALLS ARE MADE THROUGH THE SECOND ENTRY POINT (GEOMG1). + C ONE CALL TO THE PROCESSING MODULE IS REQUIRED FOR EACH + C POSITION AND TIME. + C + C THE VARIABLE MAXDEG IN THE INITIALIZATION CALL IS THE + C MAXIMUM DEGREE TO WHICH THE SPHERICAL HARMONIC MODEL + C IS TO BE COMPUTED. IT MUST BE SPECIFIED BY THE USER + C IN THE CALLING ROUTINE. NORMALLY IT IS 12 BUT IT MAY + C BE SET LESS THAN 12 TO INCREASE COMPUTATIONAL SPEED AT + C THE EXPENSE OF REDUCED ACCURACY. + C + C THE PC VERSION OF THIS SUBROUTINE MUST BE COMPILED + C WITH A FORTRAN 77 COMPATIBLE COMPILER SUCH AS THE + C MICROSOFT OPTIMIZING FORTRAN COMPILER VERSION 4.1 + C OR LATER. + C + C********************************************************************** + C + C REFERENCES: + C + C JOHN M. QUINN, DAVID J. KERRIDGE AND DAVID R. BARRACLOUGH, + C WORLD MAGNETIC CHARTS FOR 1985 - SPHERICAL HARMONIC + C MODELS OF THE GEOMAGNETIC FIELD AND ITS SECULAR + C VARIATION, GEOPHYS. J. R. ASTR. SOC. (1986) 87, + C PP 1143-1157 + C + C DEFENSE MAPPING AGENCY TECHNICAL REPORT, TR 8350.2: + C DEPARTMENT OF DEFENSE WORLD GEODETIC SYSTEM 1984, + C SEPT. 30 (1987) + C + C JOHN M. QUINN, RACHEL J. COLEMAN, MICHAEL R. PECK, AND + C STEPHEN E. LAUBER; THE JOINT US/UK 1990 EPOCH + C WORLD MAGNETIC MODEL, TECHNICAL REPORT NO. 304, + C NAVAL OCEANOGRAPHIC OFFICE (1991) + C + C JOHN M. QUINN, RACHEL J. COLEMAN, DONALD L. SHIEL, AND + C JOHN M. NIGRO; THE JOINT US/UK 1995 EPOCH WORLD + C MAGNETIC MODEL, TECHNICAL REPORT NO. 314, NAVAL + C OCEANOGRAPHIC OFFICE (1995) + C + C SUSAN AMCMILLAN, DAVID R. BARRACLOUGH, JOHN M. QUINN, AND + C RACHEL J. COLEMAN; THE 1995 REVISION OF THE JOINT US/UK + C GEOMAGNETIC FIELD MODELS - I. SECULAR VARIATION, JOURNAL OF + C GEOMAGNETISM AND GEOELECTRICITY, VOL. 49, PP. 229-243 + C (1997) + C + C JOHN M. QUINN, RACHEL J. COELMAN, SUSAM MACMILLAN, AND + C DAVID R. BARRACLOUGH; THE 1995 REVISION OF THE JOINT + C US/UK GEOMAGNETIC FIELD MODELS: II. MAIN FIELD,JOURNAL OF + C GEOMAGNETISM AND GEOELECTRICITY, VOL. 49, PP. 245 - 261 + C (1997) + C + C*********************************************************************** + C + C PARAMETER DESCRIPTIONS: + C + C A - SEMIMAJOR AXIS OF WGS-84 ELLIPSOID (KM) + C B - SEMIMINOR AXIS OF WGS-84 ELLIPSOID (KM) + C RE - MEAN RADIUS OF IAU-66 ELLIPSOID (KM) + C SNORM - SCHMIDT NORMALIZATION FACTORS + C C - GAUSS COEFFICIENTS OF MAIN GEOMAGNETIC MODEL (NT) + C CD - GAUSS COEFFICIENTS OF SECULAR GEOMAGNETIC MODEL (NT/YR) + C TC - TIME ADJUSTED GEOMAGNETIC GAUSS COEFFICIENTS (NT) + C OTIME - TIME ON PREVIOUS CALL TO GEOMAG (YRS) + C OALT - GEODETIC ALTITUDE ON PREVIOUS CALL TO GEOMAG (YRS) + C OLAT - GEODETIC LATITUDE ON PREVIOUS CALL TO GEOMAG (DEG.) + C TIME - COMPUTATION TIME (YRS) (INPUT) + C (EG. 1 JULY 1995 = 1995.500) + C ALT - GEODETIC ALTITUDE (KM) (INPUT) + C GLAT - GEODETIC LATITUDE (DEG.) (INPUT) + C GLON - GEODETIC LONGITUDE (DEG.) (INPUT) + C EPOCH - BASE TIME OF GEOMAGNETIC MODEL (YRS) + C DTR - DEGREE TO RADIAN CONVERSION + C SP(M) - SINE OF (M*SPHERICAL COORD. LONGITUDE) + C CP(M) - COSINE OF (M*SPHERICAL COORD. LONGITUDE) + C ST - SINE OF (SPHERICAL COORD. LATITUDE) + C CT - COSINE OF (SPHERICAL COORD. LATITUDE) + C R - SPHERICAL COORDINATE RADIAL POSITION (KM) + C CA - COSINE OF SPHERICAL TO GEODETIC VECTOR ROTATION ANGLE + C SA - SINE OF SPHERICAL TO GEODETIC VECTOR ROTATION ANGLE + C BR - RADIAL COMPONENT OF GEOMAGNETIC FIELD (NT) + C BT - THETA COMPONENT OF GEOMAGNETIC FIELD (NT) + C BP - PHI COMPONENT OF GEOMAGNETIC FIELD (NT) + C P(N,M) - ASSOCIATED LEGENDRE POLYNOMIALS (UNNORMALIZED) + C PP(N) - ASSOCIATED LEGENDRE POLYNOMIALS FOR M=1 (UNNORMALIZED) + C DP(N,M)- THETA DERIVATIVE OF P(N,M) (UNNORMALIZED) + C BX - NORTH GEOMAGNETIC COMPONENT (NT) + C BY - EAST GEOMAGNETIC COMPONENT (NT) + C BZ - VERTICALLY DOWN GEOMAGNETIC COMPONENT (NT) + C BH - HORIZONTAL GEOMAGNETIC COMPONENT (NT) + C DEC - GEOMAGNETIC DECLINATION (DEG.) (OUTPUT) + C EAST=POSITIVE ANGLES + C WEST=NEGATIVE ANGLES + C DIP - GEOMAGNETIC INCLINATION (DEG.) (OUTPUT) + C DOWN=POSITIVE ANGLES + C UP=NEGATIVE ANGLES + C TI - GEOMAGNETIC TOTAL INTENSITY (NT) (OUTPUT) + C GV - GEOMAGNETIC GRID VARIATION (DEG.) (OUTPUT) + C REFERENCED TO GRID NORTH + C GRID NORTH REFERENCED TO 0 MERIDIAN + C OF A POLAR STEREOGRAPHIC PROJECTION + C (ARCTIC/ANTARCTIC ONLY) + C MAXDEG - MAXIMUM DEGREE OF SPHERICAL HARMONIC MODEL (INPUT) + C MOXORD - MAXIMUM ORDER OF SPHERICAL HARMONIC MODEL + C + C*********************************************************************** + C + C NOTE: THIS VERSION OF GEOMAG USES A WMM SERIES GEOMAGNETIC + C FIELS MODEL REFERENCED TO THE WGS-84 GRAVITY MODEL + C ELLIPSOID + C + + */ diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/data/flightplan.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/data/flightplan.ts new file mode 100644 index 00000000000..7a1e9c3ec33 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/data/flightplan.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; + +export interface WaypointStats { + /** + * Waypoint ident + */ + ident: string; + + /** + * Bearing from previous waypoint in degrees + */ + bearingInFp: number; + + /** + * Distance from previous waypoint in flight plan in nautical miles + */ + distanceInFP: number; + + /** + * Distance from PPOS in nautical miles + */ + distanceFromPpos: number; + + /** + * Predicted time from PPOS in seconds + */ + timeFromPpos: number; + + /** + * Predicted ETA from PPOS in seconds + */ + etaFromPpos: number; + + /** + * Magnetic variation in degrees + */ + magneticVariation: number; +} + +export interface ApproachStats { + name: string; + + /** + * Distance from PPOS in nautical miles + */ + distanceFromPpos: number; +} + +export interface HoldData { + inboundMagneticCourse?: number, + + turnDirection?: TurnDirection, + + distance?: number, + + time?: number, + + type: HoldType, +} + +export enum HoldType { + Computed = 0, + Database = 1, + Pilot = 2, +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/data/geo.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/data/geo.ts new file mode 100644 index 00000000000..b37b46370bb --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/data/geo.ts @@ -0,0 +1,9 @@ +/** + * MSFS API compatible lla object + */ +export type Coordinates = { + lat: Degrees, + long: Degrees, +} + +export type Xy = [x: number, y: number]; diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/DataLoading.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/DataLoading.ts new file mode 100644 index 00000000000..fc98dd6d484 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/DataLoading.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Airport, Approach, Arrival, Departure, Runway } from 'msfs-navdata'; +import { NavigationDatabaseService } from '@fmgc/flightplanning/new/NavigationDatabaseService'; + +/** + * Loads an airport from the navigation database + * + * @param icao airport icao code + * + * @throws if the airport is not found + */ +export async function loadAirport(icao: string): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const airports = await db.getAirports([icao]); + const matchingAirport = airports.find((a) => a.ident === icao); + + if (!matchingAirport) { + throw new Error(`[FMS/FPM] Can't find airport with ICAO '${icao}'`); + } + + return matchingAirport; +} + +/** + * Loads all runways for an airport + * + * @param airport Airport object + */ +export async function loadAllRunways(airport: Airport): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const runways = await db.getRunways(airport.ident); + + return runways; +} + +/** + * Loads a runway from the navigation database + * + * @param airport Airport object + * @param runwayIdent runway identifier + * + * @throws if the runway is not found + */ +export async function loadRunway(airport: Airport, runwayIdent: string): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const runways = await db.getRunways(airport.ident); + const matchingRunway = runways.find((runway) => runway.ident === runwayIdent); + + if (!matchingRunway) { + throw new Error(`[FMS/FPM] Can't find runway '${runwayIdent}' at ${airport.ident}`); + } + + return matchingRunway; +} + +/** + * Loads all SIDs for an airport + * + * @param airport Airport object + */ +export async function loadAllDepartures(airport: Airport): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const proceduresAtAirport = await db.getDepartures(airport.ident); + + return proceduresAtAirport; +} + +/** + * Loads all STARs for an airport + * + * @param airport Airport object + */ +export async function loadAllArrivals(airport: Airport): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const proceduresAtAirport = await db.getArrivals(airport.ident); + + return proceduresAtAirport; +} + +/** + * Loads all approaches for an airport + * + * @param airport Airport object + */ +export async function loadAllApproaches(airport: Airport): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const proceduresAtAirport = await db.getApproaches(airport.ident); + + return proceduresAtAirport; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanDefinition.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanDefinition.ts new file mode 100644 index 00000000000..c50a5c3b9c9 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanDefinition.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +export interface FlightPlanDefinition { + + originIcao: string, + + originRunwayIdent?: string, + + originDepartureIdent?: string, + + originEnrouteTransitionIdent?: string, + + enrouteSegments?: EnrouteSegmentDefinition[], + + arrivalEnrouteTransitionIDent?: string, + + arrivalIdent?: string, + + arrivalRunwayTransitionIdent?: string, + + approachIdent?: string, + + destinationRunwayIdent?: string, + + destinationIcao: string, + +} + +export interface EnrouteSegmentDefinition { + airwayIdent?: string, + + via?: string, +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanEditor.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanEditor.ts new file mode 100644 index 00000000000..4e840203298 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanEditor.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { FlightPlanIndex, FlightPlanManager } from '@fmgc/flightplanning/new/FlightPlanManager'; + +/** + * Allows high-level operations to be made to a flight plan + */ +export class FlightPlanEditor { + /** + * Creates a flight plan editor for the active flight plan + */ + static forActive(fpm: FlightPlanManager) { + return new FlightPlanEditor(false, undefined, fpm); + } + + /** + * Crates a flight plan editor for a secondary flight plan. Index is only used on A380. + */ + static forSecondary(fpm: FlightPlanManager, index = 0) { + return new FlightPlanEditor(true, index, fpm); + } + + private constructor( + private secondary: boolean, + private secondaryIndex: number | undefined, + private fpm: FlightPlanManager, + ) { + } + + private get fpmIndex(): number { + return this.secondary ? FlightPlanIndex.FirstSecondary + this.secondaryIndex : FlightPlanIndex.Temporary; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanManager.spec.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanManager.spec.ts new file mode 100644 index 00000000000..b78367320d9 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanManager.spec.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import fetch from 'node-fetch'; +import { setupNavigraphDatabase } from '@fmgc/flightplanning/new/test/Database'; +import { FlightPlanManager } from './FlightPlanManager'; + +if (!globalThis.fetch) { + globalThis.fetch = fetch; +} + +describe('FlightPlanManager', () => { + beforeEach(() => { + setupNavigraphDatabase(); + }); + + it('can create a flight plan', () => { + const fpm = new FlightPlanManager(); + + fpm.create(1); + + expect(fpm.get(1)).not.toBeNull(); + }); + + it('can delete a flight plan', () => { + const fpm = new FlightPlanManager(); + + fpm.create(1); + fpm.delete(1); + + expect(() => fpm.get(1)).toThrow(); + }); + + it('can copy a flight plan', async () => { + const fpm = new FlightPlanManager(); + + fpm.create(1); + + const flightPlan = fpm.get(1); + + await flightPlan.setOriginAirport('CYYZ'); + await flightPlan.setOriginRunway('RW06R'); + + fpm.copy(1, 2); + + const copied = fpm.get(2); + + expect(copied.originAirport).toEqual(expect.objectContaining({ ident: 'CYYZ' })); + expect(copied.originRunway).toEqual(expect.objectContaining({ ident: 'RW06R' })); + }); + + it('can swap two flight plans', async () => { + const fpm = new FlightPlanManager(); + + fpm.create(1); + + const flightPlanA = fpm.get(1); + + await flightPlanA.setOriginAirport('CYYZ'); + await flightPlanA.setOriginRunway('RW06R'); + + fpm.create(2); + + const flightPlanB = fpm.get(2); + + await flightPlanB.setOriginAirport('LOWI'); + await flightPlanB.setOriginRunway('RW26'); + + fpm.swap(1, 2); + + const newA = fpm.get(2); + + expect(newA.originAirport).toEqual(expect.objectContaining({ ident: 'CYYZ' })); + expect(newA.originRunway).toEqual(expect.objectContaining({ ident: 'RW06R' })); + + const newB = fpm.get(1); + + expect(newB.originAirport).toEqual(expect.objectContaining({ ident: 'LOWI' })); + expect(newB.originRunway).toEqual(expect.objectContaining({ ident: 'RW26' })); + }); +}); diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanManager.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanManager.ts new file mode 100644 index 00000000000..ee55dde3176 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanManager.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { FlightPlan } from '@fmgc/flightplanning/new/plans/FlightPlan'; +import { FlightPlanDefinition } from '@fmgc/flightplanning/new/FlightPlanDefinition'; + +export enum FlightPlanIndex { + Active, + Temporary, + FirstSecondary, +} + +export class FlightPlanManager { + private plans: FlightPlan[] = [] + + has(index: number) { + return this.plans[index] !== undefined; + } + + get(index: number) { + this.assertFlightPlanExists(index); + + return this.plans[index]; + } + + private set(index: number, flightPlan: FlightPlan) { + this.plans[index] = flightPlan; + } + + create(index: number, definition?: FlightPlanDefinition) { + this.assertFlightPlanDoesntExist(index); + + const flightPlan = definition ? FlightPlan.fromDefinition(definition) : FlightPlan.empty(); + + this.plans[index] = flightPlan; + } + + delete(index: number) { + this.assertFlightPlanExists(index); + + this.plans[index] = undefined; + } + + deleteAll() { + this.plans.length = 0; + this.plans.push(FlightPlan.empty()); + } + + copy(from: number, to: number) { + this.assertFlightPlanExists(from); + + const newPlan = this.get(from).clone(); + + this.set(to, newPlan); + } + + swap(a: number, b: number) { + this.assertFlightPlanExists(a); + this.assertFlightPlanExists(b); + + const planA = this.get(a); + const planB = this.get(b); + + this.delete(a); + this.delete(b); + + this.set(a, planB); + this.set(b, planA); + } + + private assertFlightPlanExists(index: number) { + if (!this.plans[index]) { + throw new Error(`[FMS/FlightPlanManager] Tried to access non-existent flight plan at index #${index}`); + } + } + + private assertFlightPlanDoesntExist(index: number) { + if (this.plans[index]) { + throw new Error(`[FMS/FlightPlanManager] Tried to create existent flight plan at index #${index}`); + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanService.spec.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanService.spec.ts new file mode 100644 index 00000000000..219df350fe4 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanService.spec.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import fetch from 'node-fetch'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { FlightPlanIndex } from '@fmgc/flightplanning/new/FlightPlanManager'; +import { loadSingleWaypoint } from '@fmgc/flightplanning/new/segments/enroute/WaypointLoading'; +import { assertDiscontinuity, assertNotDiscontinuity } from '@fmgc/flightplanning/new/test/LegUtils'; +import { setupNavigraphDatabase } from '@fmgc/flightplanning/new/test/Database'; +import { placeBearingDistance } from 'msfs-geo'; +import { dumpFlightPlan } from '@fmgc/flightplanning/new/test/FlightPlan'; +import { FmgcFlightPhase } from '@shared/flightphase'; + +if (!globalThis.fetch) { + globalThis.fetch = fetch; +} + +describe('the flight plan service', () => { + beforeEach(() => { + FlightPlanService.reset(); + setupNavigraphDatabase(); + }); + + it('deletes the temporary flight plan properly', async () => { + await FlightPlanService.newCityPair('CYUL', 'LOWI', 'LOWG'); + + await FlightPlanService.setOriginRunway('RW06R'); + + FlightPlanService.temporaryDelete(); + + expect(FlightPlanService.hasTemporary).toBeFalsy(); + expect(FlightPlanService.activeOrTemporary.originRunway).toBeUndefined(); + }); + + it('inserts the temporary flight plan properly', async () => { + await FlightPlanService.newCityPair('CYUL', 'LOWI', 'LOWG'); + + await FlightPlanService.setOriginRunway('RW06R'); + + FlightPlanService.temporaryInsert(); + + expect(FlightPlanService.hasTemporary).toBeFalsy(); + expect(FlightPlanService.activeOrTemporary.originRunway).toEqual(expect.objectContaining({ ident: 'RW06R' })); + }); + + describe('performing revisions', () => { + describe('next waypoint', () => { + beforeEach(async () => { + await FlightPlanService.newCityPair('CYUL', 'CYYZ'); + + await FlightPlanService.setOriginRunway('RW06R'); + await FlightPlanService.setDepartureProcedure('CYUL1'); + + await FlightPlanService.setDestinationRunway('RW05'); + await FlightPlanService.setApproach('I05'); + }); + + it('with duplicate', async () => { + await FlightPlanService.setArrival('BOXUM5'); + + const waypoint = await loadSingleWaypoint('ERBUS', 'WCYCYYZERBUS'); + + await FlightPlanService.nextWaypoint(3, waypoint); + + FlightPlanService.temporaryInsert(); + + const leg4 = assertNotDiscontinuity(FlightPlanService.active.allLegs[4]); + + expect(leg4.ident).toEqual('ERBUS'); + + const leg5 = assertNotDiscontinuity(FlightPlanService.active.allLegs[5]); + + expect(leg5.ident).toEqual('SELAP'); + }); + + it('without duplicate', async () => { + const waypoint = await loadSingleWaypoint('ERBUS', 'WCYCYYZERBUS'); + + await FlightPlanService.nextWaypoint(3, waypoint); + + FlightPlanService.temporaryInsert(); + + const leg4 = assertNotDiscontinuity(FlightPlanService.active.allLegs[4]); + + expect(leg4.ident).toEqual('ERBUS'); + + assertDiscontinuity(FlightPlanService.active.allLegs[5]); + + const leg6 = assertNotDiscontinuity(FlightPlanService.active.allLegs[6]); + + expect(leg6.ident).toEqual('DULPA'); + }); + }); + + describe('direct to', () => { + beforeEach(async () => { + await FlightPlanService.newCityPair('CYYZ', 'CYVR'); + + await FlightPlanService.setOriginRunway('RW06R'); + await FlightPlanService.setDepartureProcedure('AVSEP6'); + + FlightPlanService.temporaryInsert(); + }); + + test('a normal direct to', async () => { + const runway = FlightPlanService.active.originRunway; + const runwayLeg = assertNotDiscontinuity(FlightPlanService.active.originSegment.allLegs[0]); + + const ppos = placeBearingDistance(runwayLeg.definition.waypoint.location, runway.bearing, 0.5); + + const targetWaypoint = await loadSingleWaypoint('NUGOP', 'WCY NUGOP'); + + FlightPlanService.directTo({ lat: ppos.lat, lon: ppos.long }, runway.bearing, targetWaypoint); + + FlightPlanService.temporaryInsert(); + + console.log(dumpFlightPlan(FlightPlanService.active)); + }); + }); + }); + + describe('editing the active flight plan', () => { + it('correctly accepts a city pair', async () => { + await FlightPlanService.newCityPair('CYUL', 'LOWI', 'LOWG'); + + expect(FlightPlanService.hasTemporary).toBeFalsy(); + + expect(FlightPlanService.activeOrTemporary.originAirport).toEqual(expect.objectContaining({ ident: 'CYUL' })); + expect(FlightPlanService.activeOrTemporary.destinationAirport).toEqual(expect.objectContaining({ ident: 'LOWI' })); + expect(FlightPlanService.activeOrTemporary.alternateDestinationAirport).toEqual(expect.objectContaining({ ident: 'LOWG' })); + }); + + it('does create a temporary flight plan when changing procedure details', async () => { + await FlightPlanService.newCityPair('CYYZ', 'LGKR', 'LGKO'); + + await FlightPlanService.setOriginRunway('RW06R'); + expect(FlightPlanService.hasTemporary).toBeTruthy(); + FlightPlanService.temporaryInsert(); + + await FlightPlanService.setDepartureProcedure('AVSEP6'); + expect(FlightPlanService.hasTemporary).toBeTruthy(); + FlightPlanService.temporaryInsert(); + + await FlightPlanService.setDepartureEnrouteTransition('OTNIK'); + expect(FlightPlanService.hasTemporary).toBeTruthy(); + FlightPlanService.temporaryInsert(); + + await FlightPlanService.setDestinationRunway('RW34'); + expect(FlightPlanService.hasTemporary).toBeTruthy(); + FlightPlanService.temporaryInsert(); + + await FlightPlanService.setArrival('PARA1J'); + expect(FlightPlanService.hasTemporary).toBeTruthy(); + FlightPlanService.temporaryInsert(); + + await FlightPlanService.setApproach('R34'); + expect(FlightPlanService.hasTemporary).toBeTruthy(); + FlightPlanService.temporaryInsert(); + + await FlightPlanService.setApproachVia('BEDEX'); + expect(FlightPlanService.hasTemporary).toBeTruthy(); + FlightPlanService.temporaryInsert(); + }); + }); + + describe('editing a secondary flight plan', () => { + it('correctly accepts a city pair', async () => { + await FlightPlanService.newCityPair('CYUL', 'LOWI', 'LOWG', FlightPlanIndex.FirstSecondary); + + expect(FlightPlanService.secondary(1).originAirport).toEqual(expect.objectContaining({ ident: 'CYUL' })); + expect(FlightPlanService.secondary(1).destinationAirport).toEqual(expect.objectContaining({ ident: 'LOWI' })); + expect(FlightPlanService.secondary(1).alternateDestinationAirport).toEqual(expect.objectContaining({ ident: 'LOWG' })); + }); + + it('does not create a temporary flight plan when changing procedure details', async () => { + await FlightPlanService.newCityPair('CYYZ', 'LGKR', 'LGKO', FlightPlanIndex.FirstSecondary); + + await FlightPlanService.setOriginRunway('RW06R', FlightPlanIndex.FirstSecondary); + expect(FlightPlanService.hasTemporary).toBeFalsy(); + + await FlightPlanService.setDepartureProcedure('AVSEP6', FlightPlanIndex.FirstSecondary); + expect(FlightPlanService.hasTemporary).toBeFalsy(); + + await FlightPlanService.setDepartureEnrouteTransition('OTNIK', FlightPlanIndex.FirstSecondary); + expect(FlightPlanService.hasTemporary).toBeFalsy(); + + await FlightPlanService.setDestinationRunway('RW34', FlightPlanIndex.FirstSecondary); + expect(FlightPlanService.hasTemporary).toBeFalsy(); + + await FlightPlanService.setArrival('PARA1J', FlightPlanIndex.FirstSecondary); + expect(FlightPlanService.hasTemporary).toBeFalsy(); + + await FlightPlanService.setApproach('R34', FlightPlanIndex.FirstSecondary); + expect(FlightPlanService.hasTemporary).toBeFalsy(); + + await FlightPlanService.setApproachVia('BEDEX', FlightPlanIndex.FirstSecondary); + expect(FlightPlanService.hasTemporary).toBeFalsy(); + }); + }); +}); diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanService.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanService.ts new file mode 100644 index 00000000000..8362db4799f --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FlightPlanService.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { FlightPlanIndex, FlightPlanManager } from '@fmgc/flightplanning/new/FlightPlanManager'; +import { A380FpmConfig, FpmConfig } from '@fmgc/flightplanning/new/FpmConfig'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { Airway, Waypoint } from 'msfs-navdata'; +import { NavigationDatabase } from '@fmgc/NavigationDatabase'; +import { Coordinates } from 'msfs-geo'; + +export class FlightPlanService { + private constructor() { + } + + private static flightPlanManager = new FlightPlanManager(); + + private static config: FpmConfig = A380FpmConfig + + static navigationDatabase: NavigationDatabase + + static version = 0; + + static createFlightPlans() { + this.flightPlanManager.create(0); + this.flightPlanManager.create(1); + this.flightPlanManager.create(2); + } + + static has(index: number) { + return this.flightPlanManager.has(index); + } + + static get active() { + return this.flightPlanManager.get(FlightPlanIndex.Active); + } + + static get temporary() { + return this.flightPlanManager.get(FlightPlanIndex.Temporary); + } + + static get activeOrTemporary() { + if (this.hasTemporary) { + return this.flightPlanManager.get(FlightPlanIndex.Temporary); + } + return this.flightPlanManager.get(FlightPlanIndex.Active); + } + + /** + * Obtains the specified secondary flight plan, 1-indexed + */ + static secondary(index: number) { + return this.flightPlanManager.get(FlightPlanIndex.FirstSecondary + index - 1); + } + + static get hasActive() { + return this.flightPlanManager.has(FlightPlanIndex.Active); + } + + static get hasTemporary() { + return this.flightPlanManager.has(FlightPlanIndex.Temporary); + } + + static temporaryInsert() { + const temporaryPlan = this.flightPlanManager.get(FlightPlanIndex.Temporary); + + if (temporaryPlan.pendingAirways) { + temporaryPlan.pendingAirways.finalize(); + } + + this.flightPlanManager.copy(FlightPlanIndex.Temporary, FlightPlanIndex.Active); + this.flightPlanManager.delete(FlightPlanIndex.Temporary); + } + + static temporaryDelete() { + if (!this.hasTemporary) { + throw new Error('[FMS/FPS] Cannot delete temporary flight plan if none exists'); + } + + this.flightPlanManager.delete(FlightPlanIndex.Temporary); + } + + static reset() { + this.flightPlanManager.deleteAll(); + } + + private static prepareDestructiveModification(planIndex: FlightPlanIndex) { + let finalIndex = planIndex; + if (planIndex < FlightPlanIndex.FirstSecondary) { + this.ensureTemporaryExists(); + + finalIndex = FlightPlanIndex.Temporary; + } + + return finalIndex; + } + + /** + * Resets the flight plan with a new FROM/TO/ALTN city pair + * + * @param fromIcao ICAO of the FROM airport + * @param toIcao ICAO of the TO airport + * @param altnIcao ICAO of the ALTN airport + * @param planIndex which flight plan (excluding temporary) to make the change on + */ + static async newCityPair(fromIcao: string, toIcao: string, altnIcao?: string, planIndex = FlightPlanIndex.Active) { + if (planIndex === FlightPlanIndex.Temporary) { + throw new Error('[FMS/FPM] Cannot enter new city pair on temporary flight plan'); + } + + if (planIndex === FlightPlanIndex.Active && this.flightPlanManager.has(FlightPlanIndex.Temporary)) { + this.flightPlanManager.delete(FlightPlanIndex.Temporary); + } + + if (this.flightPlanManager.has(planIndex)) { + this.flightPlanManager.delete(planIndex); + } + this.flightPlanManager.create(planIndex); + + await this.flightPlanManager.get(planIndex).setOriginAirport(fromIcao); + await this.flightPlanManager.get(planIndex).setDestinationAirport(toIcao); + if (altnIcao) { + await this.flightPlanManager.get(planIndex).setAlternateDestinationAirport(altnIcao); + } + } + + /** + * Sets the origin runway in the flight plan. Creates a temporary flight plan if target is active. + * + * @param runwayIdent the runway identifier (e.g., RW27C) + * @param planIndex which flight plan to make the change on + */ + static setOriginRunway(runwayIdent: string, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + return this.flightPlanManager.get(finalIndex).setOriginRunway(runwayIdent); + } + + /** + * Sets the departure procedure in the flight plan. Creates a temporary flight plan if target is active. + * + * @param procedureIdent the procedure identifier (e.g., BAVE6P) + * @param planIndex which flight plan to make the change on + */ + static setDepartureProcedure(procedureIdent: string | undefined, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + return this.flightPlanManager.get(finalIndex).setDeparture(procedureIdent); + } + + /** + * Sets the departure enroute transition procedure in the flight plan. Creates a temporary flight plan if target is active. + * + * @param transitionIdent the enroute transition identifier (e.g., KABIN) + * @param planIndex which flight plan to make the change on + */ + static setDepartureEnrouteTransition(transitionIdent: string | undefined, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + return this.flightPlanManager.get(finalIndex).setDepartureEnrouteTransition(transitionIdent); + } + + /** + * Sets the arrival enroute transition procedure in the flight plan. Creates a temporary flight plan if target is active. + * + * @param transitionIdent the enroute transition identifier (e.g., PLYMM) + * @param planIndex which flight plan to make the change on + */ + static setArrivalEnrouteTransition(transitionIdent: string | undefined, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + return this.flightPlanManager.get(finalIndex).setArrivalEnrouteTransition(transitionIdent); + } + + /** + * Sets the arrival procedure in the flight plan. Creates a temporary flight plan if target is active. + * + * @param procedureIdent the procedure identifier (e.g., BOXUM5) + * @param planIndex which flight plan to make the change on + */ + static setArrival(procedureIdent: string | undefined, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + return this.flightPlanManager.get(finalIndex).setArrival(procedureIdent); + } + + /** + * Sets the approach via in the flight plan. Creates a temporary flight plan if target is active. + * + * @param procedureIdent the procedure identifier (e.g., DIREX) + * @param planIndex which flight plan to make the change on + */ + static setApproachVia(procedureIdent: string | undefined, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + return this.flightPlanManager.get(finalIndex).setApproachVia(procedureIdent); + } + + /** + * Sets the approach procedure in the flight plan. Creates a temporary flight plan if target is active. + * + * @param procedureIdent the procedure identifier (e.g., R05-X) + * @param planIndex which flight plan to make the change on + */ + static setApproach(procedureIdent: string | undefined, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + return this.flightPlanManager.get(finalIndex).setApproach(procedureIdent); + } + + /** + * Sets the origin runway in the flight plan. Creates a temporary flight plan if target is active. + * + * @param runwayIdent the runway identifier (e.g., RW27C) + * @param planIndex which flight plan to make the change on + */ + static setDestinationRunway(runwayIdent: string, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + return this.flightPlanManager.get(finalIndex).setDestinationRunway(runwayIdent); + } + + /** + * Deletes an element (leg or discontinuity) at the specified index. Depending on the {@link FpmConfig} in use, + * this can create a temporary flight plan if target is active. + * + * @param index the index of the element to delete + * @param planIndex which flight plan to make the change on + * + * @returns `true` if the element could be removed, `false` if removal is not allowed + */ + static deleteElementAt(index: number, planIndex = FlightPlanIndex.Active): boolean { + if (!this.config.ALLOW_REVISIONS_ON_TMPY && planIndex === FlightPlanIndex.Temporary) { + throw new Error('[FMS/FPS] Cannot delete element in temporary flight plan'); + } + + let finalIndex: number = planIndex; + if (this.config.TMPY_ON_DELETE_WAYPOINT) { + finalIndex = this.prepareDestructiveModification(planIndex); + } + + return this.flightPlanManager.get(finalIndex).removeElementAt(index); + } + + static nextWaypoint(atIndex: number, waypoint: Waypoint, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + const plan = this.flightPlanManager.get(finalIndex); + + const leg = FlightPlanLeg.fromEnrouteWaypoint(plan.enrouteSegment, waypoint); + + this.flightPlanManager.get(finalIndex).insertElementAfter(atIndex, leg); + } + + static startAirwayEntry(at: number, planIndex = FlightPlanIndex.Active) { + const finalIndex = this.prepareDestructiveModification(planIndex); + + const plan = this.flightPlanManager.get(finalIndex) + + plan.startAirwayEntry(at); + } + + static directTo(ppos: Coordinates, trueTrack: Degrees, waypoint: Waypoint, planIndex = FlightPlanIndex.Active) { + const magVar = Facilities.getMagVar(ppos.lat, ppos.long); + + const finalIndex = this.prepareDestructiveModification(planIndex); + + const plan = this.flightPlanManager.get(finalIndex); + + const targetLeg = plan.allLegs.find((it) => it.isDiscontinuity === false && it.terminatesWithWaypoint(waypoint)); + const targetLegIndex = plan.allLegs.findIndex((it) => it === targetLeg); + const turningPoint = FlightPlanLeg.turningPoint(plan.enrouteSegment, ppos); + const turnStart = FlightPlanLeg.directToTurnStart(plan.enrouteSegment, ppos, (720 + trueTrack - (magVar)) % 360); + + // Remove all legs before target + + // TODO maybe encapsulate this behaviour in BaseFlightPlan + plan.removeRange(this.activeLegIndex, targetLegIndex); + + plan.insertElementAfter(this.activeLegIndex - 1, turningPoint); + plan.insertElementAfter(this.activeLegIndex, turnStart); + } + + static get activeLegIndex(): number { + return this.active.activeLegIndex; + } + + private static ensureTemporaryExists() { + if (this.hasTemporary) { + return; + } + + this.flightPlanManager.copy(FlightPlanIndex.Active, FlightPlanIndex.Temporary); + } + + // static insertDirectTo(directTo: DirectTo): Promise { + // if (!this.hasActive) { + // throw new Error('[FMS/FPM] DirectTo cannot be done without active flight plan'); + // } + // + // if ((directTo.flightPlanLegIndex === undefined || directTo.flightPlanLegIndex === null) && !directTo.nonFlightPlanWaypoint) { + // throw new Error('[FMS/FPM] DirectTo must have either flightPlanLegIndex or nonFlightPlanWaypoint'); + // } + // + // if (directTo.flightPlanLegIndex !== undefined && directTo.flightPlanLegIndex !== null && directTo.nonFlightPlanWaypoint) { + // throw new Error('[FMS/FPM] DirectTo cannot have both flightPlanLegIndex and nonFlightPlanWaypoint'); + // } + // + // if (directTo.nonFlightPlanWaypoint) { + // const dfLeg = FlightPlanLeg.fromEnrouteWaypoint(this.active.enrouteSegment, directTo.nonFlightPlanWaypoint); + // dfLeg.type = LegType.DF; + // + // this.active.insertWaypointAfter(this.active.activeLegIndex, directTo.nonFlightPlanWaypoint); + // } + // } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FpmConfig.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FpmConfig.ts new file mode 100644 index 00000000000..a429f921101 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/FpmConfig.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +export interface FpmConfig { + + /** + * Whether deleting a waypoint or a discontinuity creates a temporary flight plan + */ + TMPY_ON_DELETE_WAYPOINT: boolean, + + /** + * Whether further changes can be made to the temporary flight plan after it is created from a revision + */ + ALLOW_REVISIONS_ON_TMPY: boolean, + + /** + * Maximum number of flight plan legs + */ + MAX_NUM_LEGS: number, + + /** + * Whether to advertise only VIAs that are compatible with the selected STAR (first waypoint of VIA is in STAR) + */ + CHECK_VIA_COMPATIBILITY: boolean, + + /** + * Whether the next abeam point of a DIR TO WITH ABEAM is considered as the TO waypoint emitted by the FPm + */ + DIR_TO_ABEAM_POINT_IS_TO_WPT: boolean, + +} + +export const A380FpmConfig: FpmConfig = { + TMPY_ON_DELETE_WAYPOINT: true, + ALLOW_REVISIONS_ON_TMPY: true, + MAX_NUM_LEGS: 200, + CHECK_VIA_COMPATIBILITY: true, + DIR_TO_ABEAM_POINT_IS_TO_WPT: true, +}; + +export const A320H3FpmConfig: FpmConfig = { + TMPY_ON_DELETE_WAYPOINT: false, + ALLOW_REVISIONS_ON_TMPY: false, + MAX_NUM_LEGS: 250, + CHECK_VIA_COMPATIBILITY: false, + DIR_TO_ABEAM_POINT_IS_TO_WPT: false, +}; diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/LegUtils.spec.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/LegUtils.spec.ts new file mode 100644 index 00000000000..d9f14aca21e --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/LegUtils.spec.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { mergeLegSets } from '@fmgc/flightplanning/new/LegUtils'; + +describe('Leg Utilities', () => { + describe('downstream leg set merging', () => { + it('should merge legs with a matching waypoint correctly', () => { + const existing: { icaoCode: string }[] = [ + { icaoCode: 'Z' }, + { icaoCode: 'C' }, + { icaoCode: 'E' }, + { icaoCode: 'F' }, + { icaoCode: 'G' }, + ]; + + const incoming: { icaoCode: string }[] = [ + { icaoCode: 'A' }, + { icaoCode: 'B' }, + { icaoCode: 'C' }, + { icaoCode: 'G' }, + { icaoCode: 'H' }, + ]; + + const result = mergeLegSets(existing, incoming, true); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ icaoCode: 'A' }), + expect.objectContaining({ icaoCode: 'B' }), + expect.objectContaining({ icaoCode: 'C' }), + expect.objectContaining({ icaoCode: 'E' }), + expect.objectContaining({ icaoCode: 'F' }), + expect.objectContaining({ icaoCode: 'G' }), + ]), + ); + }); + + it('should not merge legs without a matching waypoint', () => { + const existing: { icaoCode: string }[] = [ + { icaoCode: 'A' }, + { icaoCode: 'B' }, + ]; + + const incoming: { icaoCode: string }[] = [ + { icaoCode: 'C' }, + { icaoCode: 'D' }, + ]; + + const result = mergeLegSets(existing, incoming, true); + + expect(result).toBeUndefined(); + }); + }); + + describe('upstream leg set merging', () => { + it('should merge legs with a matching waypoint correctly', () => { + const existing: { icaoCode: string }[] = [ + { icaoCode: 'A' }, + { icaoCode: 'B' }, + { icaoCode: 'C' }, + { icaoCode: 'D' }, + ]; + + const incoming: { icaoCode: string }[] = [ + { icaoCode: 'B' }, + { icaoCode: 'E' }, + { icaoCode: 'F' }, + ]; + + const result = mergeLegSets(existing, incoming, false); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ icaoCode: 'A' }), + expect.objectContaining({ icaoCode: 'B' }), + expect.objectContaining({ icaoCode: 'E' }), + expect.objectContaining({ icaoCode: 'F' }), + ]), + ); + }); + + it('should not merge legs without a matching waypoint', () => { + const existing: { icaoCode: string }[] = [ + { icaoCode: 'A' }, + { icaoCode: 'B' }, + ]; + + const incoming: { icaoCode: string }[] = [ + { icaoCode: 'C' }, + { icaoCode: 'D' }, + ]; + + const result = mergeLegSets(existing, incoming, false); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/LegUtils.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/LegUtils.ts new file mode 100644 index 00000000000..5abe0efb02b --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/LegUtils.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +/** + * Merges sets of legs together, either going forward (downstream) on the incoming legs or backwards (upstream) + * + * Returns an array of final merged legs if a matching waypoint is found, or undefined if one isn't (discontinuity) + */ +export function mergeLegSets( + existingLegs: { icaoCode: string }[], + incomingLegs: { icaoCode: string }[], + downstream: boolean, +): { icaoCode: string }[] { + let finalLegs: { icaoCode: string }[]; + + if (downstream) { + let connectionFound = false; + for (let i = 0; i < existingLegs.length; i++) { + const existingLeg = existingLegs[i]; + + if (connectionFound && finalLegs) { + finalLegs.push(existingLeg); + continue; + } + + const matchingLegIndex = incomingLegs.findIndex((leg) => leg.icaoCode === existingLeg.icaoCode); + + if (matchingLegIndex !== -1) { + finalLegs = [...incomingLegs]; + + connectionFound = true; + + finalLegs.splice(matchingLegIndex); + + finalLegs.push(existingLeg); + } + } + } else { + const connectionFound = false; + for (let i = existingLegs.length - 1; i >= 0; i--) { + const existingLeg = existingLegs[i]; + + if (connectionFound && finalLegs) { + finalLegs.push(existingLeg); + continue; + } + + const matchingLegIndex = incomingLegs.findIndex((leg) => leg.icaoCode === existingLeg.icaoCode); + + if (matchingLegIndex !== -1) { + finalLegs = [...existingLegs]; + + finalLegs.splice(i + 1); + + finalLegs.push(...incomingLegs.slice(matchingLegIndex + 1)); + + break; + } + } + } + + return finalLegs; +} + +/* + * A B C D E + * *------*------*------*------* + * ^ + * C G H I + * *------*------*------* + * + * Becomes + * + * A B C G H I + * *------*------*------*------*------* + */ diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/NavigationDatabaseService.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/NavigationDatabaseService.ts new file mode 100644 index 00000000000..20c272e3cde --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/NavigationDatabaseService.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { NavigationDatabase } from '@fmgc/NavigationDatabase'; + +export class NavigationDatabaseService { + static version = 0; + + static _activeDatabase: NavigationDatabase; + + static get activeDatabase(): NavigationDatabase { + return this._activeDatabase + } + + static set activeDatabase(db: NavigationDatabase) { + this._activeDatabase = db; + this.version++; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLeg.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLeg.ts new file mode 100644 index 00000000000..492ee4cdcca --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLeg.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { + Airport, + LegType, + Location, + ProcedureLeg, + Runway, + Waypoint, + WaypointArea, + WaypointDescriptor, +} from 'msfs-navdata'; +import { FlightPlanLegDefinition } from '@fmgc/flightplanning/new/legs/FlightPlanLegDefinition'; +import { procedureLegIdentAndAnnotation } from '@fmgc/flightplanning/new/legs/FlightPlanLegNaming'; +import { WaypointFactory } from '@fmgc/flightplanning/new/waypoints/WaypointFactory'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { MathUtils } from '@shared/MathUtils'; +import { EnrouteSegment } from '@fmgc/flightplanning/new/segments/EnrouteSegment'; + +/** + * A leg in a flight plan. Not to be confused with a geometry leg or a procedure leg + */ +export class FlightPlanLeg { + type: LegType; + + private constructor( + public segment: FlightPlanSegment, + public readonly definition: FlightPlanLegDefinition, + public ident: string, + public annotation: string, + public readonly airwayIdent: string | undefined, + public readonly rnp: number | undefined, + public readonly overfly: boolean, + ) { + this.type = definition.type; + } + + isDiscontinuity: false = false + + get waypointDescriptor() { + return this.definition.waypointDescriptor; + } + + /** + * Determines whether this leg is a fix-terminating leg (AF, CF, IF, DF, RF, TF, HF) + */ + isXF() { + const legType = this.definition.type; + + return legType === LegType.AF + || legType === LegType.CF + || legType === LegType.IF + || legType === LegType.DF + || legType === LegType.RF + || legType === LegType.TF + || legType === LegType.HF; + } + + isFX() { + const legType = this.definition.type; + + return legType === LegType.FA || legType === LegType.FC || legType === LegType.FD || legType === LegType.FM; + } + + isHX() { + const legType = this.definition.type; + + return legType === LegType.HA || legType === LegType.HF || legType === LegType.HM; + } + + /** + * Returns the termination waypoint is this is an XF leg, `null` otherwise + */ + terminationWaypoint(): Waypoint | null { + if (!this.isXF() && !this.isFX() && !this.isHX()) { + return null; + } + + return this.definition.waypoint; + } + + /** + * Determines whether the leg terminates with a specified waypoint + * + * @param waypoint the specified waypoint + */ + terminatesWithWaypoint(waypoint: Waypoint) { + if (!this.isXF()) { + return false; + } + + // FIXME use databaseId when tracer fixes it + return this.definition.waypoint.ident === waypoint.ident && this.definition.waypoint.icaoCode === waypoint.icaoCode; + } + + static turningPoint(segment: EnrouteSegment, location: Location): FlightPlanLeg { + return new FlightPlanLeg(segment, { + type: LegType.IF, + overfly: false, + waypoint: WaypointFactory.fromLocation('T-P', location), + }, 'T-P', '', undefined, undefined, false); + } + + static directToTurnStart(segment: EnrouteSegment, location: Location, bearing: DegreesTrue): FlightPlanLeg { + return new FlightPlanLeg(segment, { + type: LegType.FD, + overfly: false, + waypoint: WaypointFactory.fromWaypointLocationAndDistanceBearing('', location, 0.1, bearing), + length: 0.1 * 1852, // 0.1 NM in metres + }, '', '', undefined, undefined, false); + } + + static fromProcedureLeg(segment: FlightPlanSegment, procedureLeg: ProcedureLeg, procedureIdent: string): FlightPlanLeg { + const [ident, annotation] = procedureLegIdentAndAnnotation(procedureLeg, procedureIdent); + + // TODO somehow we need to also return a discont for legs combinations that always have a discontinuity between them + return new FlightPlanLeg(segment, procedureLeg, ident, annotation, undefined, procedureLeg.rnp, procedureLeg.overfly); + } + + static fromAirportAndRunway(segment: FlightPlanSegment, procedureIdent: string, airport: Airport, runway?: Runway): FlightPlanLeg { + if (runway) { + return new FlightPlanLeg(segment, { + type: LegType.IF, + overfly: false, + waypoint: WaypointFactory.fromAirportAndRunway(airport, runway), + waypointDescriptor: WaypointDescriptor.Runway, + magneticCourse: runway?.magneticBearing, + }, `${airport.ident}${runway ? runway.ident.replace('RW', '') : ''}`, procedureIdent, undefined, undefined, false); + } + + return new FlightPlanLeg(segment, { + type: LegType.IF, + overfly: false, + waypoint: { ...airport, area: WaypointArea.Terminal }, + waypointDescriptor: WaypointDescriptor.Airport, + magneticCourse: runway?.magneticBearing, + }, `${airport.ident}${runway ? runway.ident.replace('RW', '') : ''}`, procedureIdent, undefined, undefined, false); + } + + static originExtendedCenterline(segment: FlightPlanSegment, runwayLeg: FlightPlanLeg): FlightPlanLeg { + const altitude = runwayLeg.definition.waypoint.location.alt + 1500; + + // TODO magvar + const annotation = runwayLeg.ident.substring(0, 3) + Math.round(runwayLeg.definition.magneticCourse).toString().padStart(3, '0'); + const ident = Math.round(altitude).toString().substring(0, 4); + + return new FlightPlanLeg(segment, { + type: LegType.FA, + overfly: false, + waypoint: runwayLeg.terminationWaypoint(), + magneticCourse: runwayLeg.definition.magneticCourse, + altitude1: altitude, + }, ident, annotation, undefined, undefined, false); + } + + static destinationExtendedCenterline(segment: FlightPlanSegment, airport: Airport, runway?: Runway): FlightPlanLeg { + const waypoint = WaypointFactory.fromWaypointLocationAndDistanceBearing( + 'CF', + airport.location, + 5, + MathUtils.clampAngle(runway.bearing + 180), + ); + + return new FlightPlanLeg(segment, { + type: LegType.IF, + overfly: false, + waypoint, + }, waypoint.ident, '', undefined, undefined, false); + } + + static fromEnrouteWaypoint(segment: FlightPlanSegment, waypoint: Waypoint, airwayIdent?: string): FlightPlanLeg { + return new FlightPlanLeg(segment, { + type: LegType.TF, + overfly: false, + waypoint, + }, waypoint.ident, airwayIdent ?? '', airwayIdent, undefined, false); + } +} + +export interface Discontinuity { + isDiscontinuity: true +} + +export type FlightPlanElement = FlightPlanLeg | Discontinuity diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLegDefinition.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLegDefinition.ts new file mode 100644 index 00000000000..24cb02b0696 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLegDefinition.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { + AltitudeDescriptor, ApproachWaypointDescriptor, + LegType, + SpeedDescriptor, + TurnDirection, WaypointDescriptor, +} from 'msfs-navdata/dist/shared/types/ProcedureLeg'; +import { Waypoint } from 'msfs-navdata'; +import { VhfNavaid } from 'msfs-navdata/dist/shared/types/VhfNavaid'; +import { NdbNavaid } from 'msfs-navdata/dist/shared/types/NdbNavaid'; +import { DegreesMagnetic, Feet, Knots, Minutes, NauticalMiles } from 'msfs-navdata/dist/shared/types/Common'; +import { Degrees } from 'msfs-navdata/dist/shared'; + +export interface FlightPlanLegDefinition { + /** + * Leg termination type according to ARICN424 + */ + type: LegType; + + /** + * Should the termination of this leg be overflown (not flown by in a turn) + */ + overfly: boolean; + + /** + * The waypoint assocaited with the termination of this leg + * For VM legs at the end of a STAR, this shall be the airport reference point + */ + waypoint?: Waypoint; + + /** + * Radio navaid to be used for this leg + */ + recommendedNavaid?: VhfNavaid | NdbNavaid | Waypoint; + + /** + * Distance from the recommended navaid, to the waypoint + */ + rho?: NauticalMiles; + + /** + * Magnetic bearing from the recommended navaid, to the waypoint + * For AF legs this is the fix radial + */ + theta?: DegreesMagnetic; + + /** + * Defines the arc for RF legs + */ + arcCentreFix?: Waypoint; + + /** + * Defines the radius for RF legs + */ + arcRadius?: NauticalMiles; + + /** + * length if it is specified in distance + * exact meaning depends on the leg type + * mutually exclusive with lengthTime + * For PI legs, the excursion distance from the waypoint + */ + length?: NauticalMiles; + + /** + * length if it is specified in time + * exact meaning depends on the leg type + * mutually exclusive with length + */ + lengthTime?: Minutes; + + /** + * Required Navigation Performance for this leg + */ + rnp?: NauticalMiles; + + /** + * Transition altitude + * Should be specified on the first leg of each procedure, or default 18000 feet if not specified + */ + transitionAltitude?: Feet; + + /** + * Specifies the meaning of the altitude1 and altitude2 properties + */ + altitudeDescriptor?: AltitudeDescriptor; + + /** + * altitudeDescriptor property specifies the meaning of this property + */ + altitude1?: Feet; + + /** + * altitudeDescriptor property specifies the meaning of this property + */ + altitude2?: Feet; + + /** + * On SIDS the speed limit applies backwards from termination of this leg, + * to either the previous speed limit terminator, or the start of the procedure. + * On STARs and approaches, the speed limit applies forwards until either + * the end of the procedure, or the next speed limit + * The exact meaning is coded in the speedDescriptor property + */ + speed?: Knots; + + /** + * Specifies the meaning of the speed property + */ + speedDescriptor?: SpeedDescriptor; + + /** + * Specifies the direction of the turn to capture this leg (the start of the leg) + * Should be specified for any track change > 135° + * Assume valid if defined as L or R + */ + turnDirection?: TurnDirection; + + /** + * Specifies the outbound magnetic course associated with the termination of this leg + * For AF legs this is the boundary radial + * For CF legs this is the course to the specified fix + */ + magneticCourse?: DegreesMagnetic; + + /** + * Specifies the descent vertical angle (negative) referenced to the terminating fix + * Should be projected back up to the last coded altitude + */ + verticalAngle?: Degrees; + + /** + * Approach-specific waypoint type + */ + approachWaypointDescriptor?: ApproachWaypointDescriptor; + + /** + * General waypoint type + */ + waypointDescriptor?: WaypointDescriptor; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLegNaming.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLegNaming.ts new file mode 100644 index 00000000000..7fda2da3db0 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/legs/FlightPlanLegNaming.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { LegType, Runway, TurnDirection } from 'msfs-navdata'; +import { FlightPlanLegDefinition } from '@fmgc/flightplanning/new/legs/FlightPlanLegDefinition'; + +export function runwayIdent(runway: Runway) { + return runway.ident.substring(2); +} + +export function procedureLegIdentAndAnnotation(procedureLeg: FlightPlanLegDefinition, procedureIdent?: string): [ident: string, annotation: string] { + const legType = procedureLeg.type; + + switch (legType) { + case LegType.AF: + return [ + procedureLeg.waypoint.ident, + `${Math.round(procedureLeg.rho).toString().padStart(2, ' ')} ${procedureLeg.recommendedNavaid.ident.substring(0, 3)}`, + ]; + case LegType.CF: + return [ + procedureLeg.waypoint.ident, + `C${Math.round(procedureLeg.magneticCourse).toString().padStart(3, '0')}°`, + ]; + case LegType.IF: + case LegType.DF: + case LegType.TF: + return [procedureLeg.waypoint.ident, procedureIdent ?? null]; + case LegType.RF: + return [ + procedureLeg.waypoint.ident, + `${Math.round(procedureLeg.length).toString().padStart(2, ' ')} ARC`, + ]; + case LegType.CA: + case LegType.FA: + case LegType.VA: + return [ + Math.round(procedureLeg.altitude1).toString().substring(0, 9), + `${legType === LegType.VA ? 'H' : 'C'}${Math.round(procedureLeg.magneticCourse).toString().padStart(3, '0')}°`, + ]; // TODO fix for VA + case LegType.CD: + case LegType.FC: + case LegType.FD: + const targetFix = procedureLeg.waypoint ?? procedureLeg.recommendedNavaid; + + return [ + `${targetFix.ident.substring(0, 3)}/${Math.round(procedureLeg.length).toString().padStart(2, '0')}`, + `C${Math.round(procedureLeg.magneticCourse).toString().padStart(3, '0')}°`, + ]; + case LegType.CI: + case LegType.VI: + return [ + 'INTCPT', + `${legType === LegType.CI ? 'C' : 'H'}${Math.round(procedureLeg.magneticCourse).toString().padStart(3, '0')}°`, + ]; // TODO fix for VI + case LegType.CR: + case LegType.VR: + return [ + `${procedureLeg.recommendedNavaid.ident.substring(0, 3)}${Math.round(procedureLeg.theta).toString().padStart(3, '0')}`, + `${legType === LegType.VR ? 'H' : 'C'}${Math.round(procedureLeg.magneticCourse).toString().padStart(3, '0')}°`, + ]; // TODO fix for VR + case LegType.HA: + return [ + Math.round(procedureLeg.altitude1).toString(), + `HOLD ${procedureLeg.turnDirection === TurnDirection.Left ? 'L' : 'R'}`, + ]; + case LegType.HF: + return [ + procedureLeg.waypoint.ident, + `HOLD ${procedureLeg.turnDirection === TurnDirection.Left ? 'L' : 'R'}`, + ]; + case LegType.HM: + return [ // TODO leg before + procedureLeg.waypoint.ident, + `C${Math.round(procedureLeg.magneticCourse).toString().padStart(3, '0')}°`, + ]; + case LegType.PI: + break; + case LegType.VD: + break; + case LegType.FM: + case LegType.VM: + return [ + 'MANUAL', + `${legType === LegType.FM ? 'C' : 'H'}${Math.round(procedureLeg.magneticCourse).toString().padStart(3, '0')}°`, + ]; // TODO fix for VM + default: + break; + } + + return [`(UNKN ${LegType[legType]})`, 'UNKNOWN']; +} + +export const pposPointIDent = 'PPOS'; + +export const turningPointIdent = 'T-P'; + +export const inboundPointIdent = 'IN-BND'; + +export const outboundPointIdent = 'OUT-BND'; diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/AlternateFlightPlan.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/AlternateFlightPlan.ts new file mode 100644 index 00000000000..1c68c8f4478 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/AlternateFlightPlan.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Airport } from 'msfs-navdata'; +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { DestinationSegment } from '@fmgc/flightplanning/new/segments/DestinationSegment'; +import { FlightPlanElement } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { OriginSegment } from '@fmgc/flightplanning/new/segments/OriginSegment'; + +/** + * An alternate flight plan shares its origin with the destination of a regular flight plan + */ +export class AlternateFlightPlan extends BaseFlightPlan { + constructor( + private mainFlightPlan: BaseFlightPlan, + ) { + super(); + + this.originSegment = new AlternateOriginSegment(this, this.mainFlightPlan.destinationSegment); + } + + get originAirport(): Airport { + return this.mainFlightPlan.destinationAirport; + } + + get allLegs(): FlightPlanElement[] { + return [ + ...this.originSegment.allLegs, + ...this.destinationSegment.allLegs, + ]; + } + + clone(fromMainFlightPlan: BaseFlightPlan): AlternateFlightPlan { + const newPlan = new AlternateFlightPlan(fromMainFlightPlan); + + newPlan.departureRunwayTransitionSegment = this.departureRunwayTransitionSegment.clone(newPlan); + newPlan.departureSegment = this.departureSegment.clone(newPlan); + newPlan.departureEnrouteTransitionSegment = this.departureEnrouteTransitionSegment.clone(newPlan); + newPlan.enrouteSegment = this.enrouteSegment.clone(newPlan); + newPlan.arrivalEnrouteTransitionSegment = this.arrivalEnrouteTransitionSegment.clone(newPlan); + newPlan.arrivalSegment = this.arrivalSegment.clone(newPlan); + newPlan.arrivalRunwayTransitionSegment = this.arrivalRunwayTransitionSegment.clone(newPlan); + newPlan.approachViaSegment = this.approachViaSegment.clone(newPlan); + newPlan.approachSegment = this.approachSegment.clone(newPlan); + newPlan.destinationSegment = this.destinationSegment.clone(newPlan); + newPlan.missedApproachSegment = this.missedApproachSegment.clone(newPlan); + + return newPlan; + } +} + +export class AlternateOriginSegment extends OriginSegment { + constructor( + flightPlan: BaseFlightPlan, + private readonly mainDestinationSegment: DestinationSegment, + ) { + super(flightPlan); + } + + get originAirport(): Airport { + return this.mainDestinationSegment.destinationAirport; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/BaseFlightPlan.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/BaseFlightPlan.ts new file mode 100644 index 00000000000..fabde771d60 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/BaseFlightPlan.ts @@ -0,0 +1,904 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { + Airport, + Approach, + Arrival, + Departure, + LegType, + ProcedureTransition, + Runway, Waypoint, + WaypointDescriptor, +} from 'msfs-navdata'; +import { OriginSegment } from '@fmgc/flightplanning/new/segments/OriginSegment'; +import { FlightPlanElement } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { DepartureSegment } from '@fmgc/flightplanning/new/segments/DepartureSegment'; +import { ArrivalSegment } from '@fmgc/flightplanning/new/segments/ArrivalSegment'; +import { ApproachSegment } from '@fmgc/flightplanning/new/segments/ApproachSegment'; +import { DestinationSegment } from '@fmgc/flightplanning/new/segments/DestinationSegment'; +import { DepartureEnrouteTransitionSegment } from '@fmgc/flightplanning/new/segments/DepartureEnrouteTransitionSegment'; +import { DepartureRunwayTransitionSegment } from '@fmgc/flightplanning/new/segments/DepartureRunwayTransitionSegment'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { EnrouteSegment } from '@fmgc/flightplanning/new/segments/EnrouteSegment'; +import { ArrivalEnrouteTransitionSegment } from '@fmgc/flightplanning/new/segments/ArrivalEnrouteTransitionSegment'; +import { MissedApproachSegment } from '@fmgc/flightplanning/new/segments/MissedApproachSegment'; +import { ArrivalRunwayTransitionSegment } from '@fmgc/flightplanning/new/segments/ArrivalRunwayTransitionSegment'; +import { ApproachViaSegment } from '@fmgc/flightplanning/new/segments/ApproachViaSegment'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { WaypointStats } from '@fmgc/flightplanning/data/flightplan'; +import { procedureLegIdentAndAnnotation } from '@fmgc/flightplanning/new/legs/FlightPlanLegNaming'; + +export enum FlightPlanQueuedOperation { + Restring, + RebuildArrivalAndApproach, +} + +export abstract class BaseFlightPlan { + get legCount() { + return this.allLegs.length; + } + + get lastIndex() { + return Math.max(0, this.legCount - 1); + } + + get firstMissedApproachLeg() { + return this.allLegs.length - this.missedApproachSegment.allLegs.length; + } + + activeLegIndex = 1; + + get activeLeg(): FlightPlanElement { + return this.allLegs[this.activeLegIndex]; + } + + sequence() { + this.activeLegIndex++; + } + + version = 0; + + incrementVersion() { + this.version++; + } + + queuedOperations: FlightPlanQueuedOperation[] = []; + + enqueueOperation(op: FlightPlanQueuedOperation): void { + const existing = this.queuedOperations.find((it) => it === op); + + if (!existing) { + this.queuedOperations.push(op); + } + } + + async flushOperationQueue() { + for (const operation of this.queuedOperations) { + switch (operation) { + case FlightPlanQueuedOperation.Restring: + this.restring(); + break; + case FlightPlanQueuedOperation.RebuildArrivalAndApproach: + // eslint-disable-next-line no-await-in-loop + await this.rebuildArrivalAndApproachSegments(); + break; + default: + console.error(`Unknown queue operation: ${operation}`); + } + } + + this.queuedOperations.length = 0; + } + + originSegment = new OriginSegment(this); + + departureRunwayTransitionSegment = new DepartureRunwayTransitionSegment(this); + + departureSegment = new DepartureSegment(this); + + departureEnrouteTransitionSegment = new DepartureEnrouteTransitionSegment(this) + + enrouteSegment = new EnrouteSegment(this); + + arrivalEnrouteTransitionSegment = new ArrivalEnrouteTransitionSegment(this); + + arrivalSegment = new ArrivalSegment(this); + + arrivalRunwayTransitionSegment = new ArrivalRunwayTransitionSegment(this); + + approachViaSegment = new ApproachViaSegment(this); + + approachSegment = new ApproachSegment(this); + + destinationSegment = new DestinationSegment(this); + + missedApproachSegment = new MissedApproachSegment(this); + + availableOriginRunways: Runway[] = []; + + availableDepartures: Departure[] = []; + + availableDestinationRunways: Runway[] = []; + + availableArrivals: Arrival[] = []; + + availableApproaches: Approach[] = []; + + availableApproachVias: ProcedureTransition[] = []; + + get originLeg() { + return this.originSegment.allLegs[0]; + } + + get destinationLeg() { + return this.elementAt(this.destinationLegIndex); + } + + get endsAtRunway() { + if (this.approachSegment.allLegs.length === 0) { + return true; + } + + const lastApproachLeg = this.approachSegment.allLegs[this.approachSegment.allLegs.length - 1]; + + return lastApproachLeg && lastApproachLeg.isDiscontinuity === false && lastApproachLeg.definition.waypointDescriptor === WaypointDescriptor.Runway; + } + + get destinationLegIndex() { + let targetSegment; + + if (this.destinationSegment.allLegs.length > 0) { + targetSegment = this.destinationSegment; + } else if (this.approachSegment.allLegs.length > 0) { + targetSegment = this.approachSegment; + } else { + return -1; + } + + let accumulator = 0; + for (const segment of this.orderedSegments) { + accumulator += segment.allLegs.length; + + if (segment === targetSegment) { + break; + } + } + + return accumulator - 1; + } + + get lastLegIndex() { + return this.legCount - 1; + } + + hasElement(index: number): boolean { + return index >= 0 && index < this.allLegs.length; + } + + elementAt(index: number): FlightPlanElement { + const legs = this.allLegs; + + if (index < 0 || index > legs.length) { + throw new Error('[FMS/FPM] leg index out of bounds'); + } + + return legs[index]; + } + + maybeElementAt(index: number): FlightPlanElement { + const legs = this.allLegs; + + return legs[index]; + } + + private lastAllLegsVersion = -1; + + private cachedAllLegs = []; + + get allLegs(): FlightPlanElement[] { + if (this.lastAllLegsVersion !== this.version) { + this.lastAllLegsVersion = this.version; + + this.cachedAllLegs = [ + ...this.originSegment.allLegs, + ...this.departureRunwayTransitionSegment.allLegs, + ...this.departureSegment.allLegs, + ...this.departureEnrouteTransitionSegment.allLegs, + ...this.enrouteSegment.allLegs, + ...this.arrivalEnrouteTransitionSegment.allLegs, + ...this.arrivalSegment.allLegs, + ...this.arrivalRunwayTransitionSegment.allLegs, + ...this.approachViaSegment.allLegs, + ...this.approachSegment.allLegs, + ...(this.endsAtRunway ? (this.destinationSegment.allLegs) : []), + ...this.missedApproachSegment.allLegs, + ]; + } + + return this.cachedAllLegs; + } + + public computeWaypointStatistics(): Map { + const stats = new Map(); + + for (let i = 0; i < this.allLegs.length; i++) { + const element = this.allLegs[i]; + + if (element.isDiscontinuity === true) { + continue; + } + + const data = { + ident: element.ident, + bearingInFp: 0, + distanceInFP: 0, + distanceFromPpos: 0, + timeFromPpos: 0, + etaFromPpos: 0, + magneticVariation: 0, + }; + + stats.set(i, data); + } + + return stats; + } + + protected get orderedSegments() { + return [ + this.originSegment, + this.departureRunwayTransitionSegment, + this.departureSegment, + this.departureEnrouteTransitionSegment, + this.enrouteSegment, + this.arrivalEnrouteTransitionSegment, + this.arrivalSegment, + this.arrivalRunwayTransitionSegment, + this.approachViaSegment, + this.approachSegment, + this.destinationSegment, + this.missedApproachSegment, + ]; + } + + /** + * Returns the last flight plan segment containing at least one leg + * + * @param before the segment + */ + public previousSegment(before: FlightPlanSegment) { + const segments = this.orderedSegments; + const segmentIndex = segments.findIndex((s) => s === before); + + if (segmentIndex === -1) { + throw new Error('[FMS/FPM] Invalid segment passed to prevSegment'); + } + + let prevSegmentIndex = segmentIndex - 1; + let prevSegment = segments[prevSegmentIndex]; + + if (!prevSegment) { + return undefined; + } + + while (prevSegment && prevSegment.allLegs.length === 0 && prevSegmentIndex > 0) { + prevSegmentIndex--; + prevSegment = segments[prevSegmentIndex]; + } + + if (prevSegment && prevSegment.allLegs.length > 0) { + return prevSegment; + } + + return undefined; + } + + /** + * Returns the next flight plan segment containing at least one leg + * + * @param after the segment + */ + public nextSegment(after: FlightPlanSegment) { + const segments = this.orderedSegments; + const segmentIndex = segments.findIndex((s) => s === after); + + if (segmentIndex === -1) { + throw new Error('[FMS/FPM] Invalid segment passed to nextSegment'); + } + + let nextSegmentIndex = segmentIndex + 1; + let nextSegment = segments[nextSegmentIndex]; + + if (!nextSegment) { + return undefined; + } + + while (nextSegment && nextSegment.allLegs.length === 0 && nextSegmentIndex < segments.length) { + nextSegmentIndex++; + nextSegment = segments[nextSegmentIndex]; + } + + if (nextSegment && nextSegment.allLegs.length > 0) { + return nextSegment; + } + + return undefined; + } + + get originAirport(): Airport { + return this.originSegment.originAirport; + } + + async setOriginAirport(icao: string) { + await this.originSegment.setOriginIcao(icao); + await this.departureSegment.setDepartureProcedure(undefined); + this.enrouteSegment.allLegs.length = 0; + await this.arrivalSegment.setArrivalProcedure(undefined); + await this.approachSegment.setApproachProcedure(undefined); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get originRunway(): Runway { + return this.originSegment.originRunway; + } + + async setOriginRunway(runwayIdent: string) { + await this.originSegment.setOriginRunway(runwayIdent); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get departureRunwayTransition(): ProcedureTransition { + return this.departureRunwayTransitionSegment.departureRunwayTransitionProcedure; + } + + get originDeparture(): Departure { + return this.departureSegment.originDeparture; + } + + async setDeparture(procedureIdent: string | undefined) { + await this.departureSegment.setDepartureProcedure(procedureIdent).then(() => this.incrementVersion()); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get departureEnrouteTransition(): ProcedureTransition { + return this.departureEnrouteTransitionSegment.departureEnrouteTransitionProcedure; + } + + /** + * Sets the departure enroute transition + * + * @param transitionIdent the transition ident or `undefined` for NONE + */ + async setDepartureEnrouteTransition(transitionIdent: string | undefined) { + this.departureEnrouteTransitionSegment.setDepartureEnrouteTransition(transitionIdent); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get arrivalEnrouteTransition(): ProcedureTransition { + return this.arrivalEnrouteTransitionSegment.arrivalEnrouteTransitionProcedure; + } + + /** + * Sets the arrival enroute transition + * + * @param transitionIdent the transition ident or `undefined` for NONE + */ + async setArrivalEnrouteTransition(transitionIdent: string | undefined) { + await this.arrivalEnrouteTransitionSegment.setArrivalEnrouteTransition(transitionIdent); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get arrival() { + return this.arrivalSegment.arrivalProcedure; + } + + async setArrival(procedureIdent: string | undefined) { + await this.arrivalSegment.setArrivalProcedure(procedureIdent).then(() => this.incrementVersion()); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get arrivalRunwayTransition() { + return this.arrivalRunwayTransitionSegment.arrivalRunwayTransitionProcedure; + } + + get approachVia() { + return this.approachViaSegment.approachViaProcedure; + } + + /** + * Sets the approach via + * + * @param transitionIdent the transition ident or `undefined` for NONE + */ + async setApproachVia(transitionIdent: string | undefined) { + await this.approachViaSegment.setApproachVia(transitionIdent); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get approach() { + return this.approachSegment.approachProcedure; + } + + async setApproach(procedureIdent: string | undefined) { + await this.approachSegment.setApproachProcedure(procedureIdent).then(() => this.incrementVersion()); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get destinationAirport(): Airport { + return this.destinationSegment.destinationAirport; + } + + async setDestinationAirport(icao: string) { + await this.destinationSegment.setDestinationIcao(icao).then(() => this.incrementVersion()); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + get destinationRunway(): Runway { + return this.destinationSegment.destinationRunway; + } + + async setDestinationRunway(runwayIdent: string) { + await this.destinationSegment.setDestinationRunway(runwayIdent).then(() => this.incrementVersion()); + + await this.flushOperationQueue(); + this.incrementVersion(); + } + + removeElementAt(index: number, insertDiscontinuity = false): boolean { + if (index < 0) { + throw new Error('[FMS/FPM] Tried to remove element for out-of-bounds index'); + } + + const [segment, indexInSegment] = this.segmentPositionForIndex(index); + + // TODO if clear leg before a hold, delete hold too? some other legs like this too.. + + if (insertDiscontinuity && index > 0) { + const previousElement = this.elementAt(index - 1); + + if (previousElement.isDiscontinuity === false) { + segment.allLegs.splice(indexInSegment, 1, { isDiscontinuity: true }); + } else { + segment.allLegs.splice(indexInSegment, 1); + } + } else { + segment.allLegs.splice(indexInSegment, 1); + } + + this.incrementVersion(); + + this.adjustIFLegs(); + this.redistributeLegsAt(index); + + this.incrementVersion(); + + return true; + } + + /** + * Finds the segment and index in segment of a given flight plan index + * + * @param index the given index + * + * @private + */ + segmentPositionForIndex(index: number): [segment: FlightPlanSegment, indexInSegment: number] { + if (index < 0) { + throw new Error('[FMS/FPM] Tried to get segment for out-of-bounds index'); + } + + let accumulator = 0; + for (const segment of this.orderedSegments) { + accumulator += segment.allLegs.length; + + if (accumulator > index) { + return [segment, index - (accumulator - segment.allLegs.length)]; + } + } + + throw new Error('[FMS/FPM] Tried to get segment for out-of-bounds index'); + } + + insertElementAfter(index: number, element: FlightPlanElement, insertDiscontinuity = false) { + if (index < 0 || index > this.allLegs.length) { + throw new Error(`[FMS/FPM] Tried to insert waypoint out of bounds (index=${index})`); + } + + let startSegment; + let indexInStartSegment; + + if (this.legCount > 0) { + [startSegment, indexInStartSegment] = this.segmentPositionForIndex(index); + } else { + startSegment = this.enrouteSegment; + indexInStartSegment = 0; + } + + startSegment.insertAfter(indexInStartSegment, element); + + if (insertDiscontinuity) { + startSegment.insertAfter(indexInStartSegment + 1, { isDiscontinuity: true }); + + this.incrementVersion(); + return; + } + + if (element.isDiscontinuity === false && element.isXF()) { + const duplicate = this.findDuplicate(element.terminationWaypoint(), index + 1); + + if (duplicate) { + const [,, duplicatePlanIndex] = duplicate; + + this.removeRange(index + 2, duplicatePlanIndex + 1); + } + } + + this.incrementVersion(); + this.redistributeLegsAt(index + 1); + } + + private findDuplicate(waypoint: Waypoint, afterIndex?: number): [FlightPlanSegment, number, number] | null { + // There is never gonna be a duplicate in the origin + + let indexAccumulator = 0; + + for (const segment of this.orderedSegments) { + indexAccumulator += segment.allLegs.length; + + if (indexAccumulator > afterIndex) { + const dupeIndexInSegment = segment.findIndexOfWaypoint(waypoint, afterIndex - (indexAccumulator - segment.allLegs.length)); + + const planIndex = indexAccumulator - segment.allLegs.length + dupeIndexInSegment; + + if (planIndex <= afterIndex) { + continue; + } + + if (dupeIndexInSegment !== -1) { + return [segment, dupeIndexInSegment, planIndex]; + } + } + } + + return null; + } + + /** + * Redistributes flight plan elements at a point, either moving previous or next non-enroute legs into the enroute, depending on the index + * + * @param index point at which to redistribute + */ + redistributeLegsAt(index: number) { + if (!this.hasElement(index)) { + return; + } + + const [segment, indexInSegment] = this.segmentPositionForIndex(index); + + if (segment.class === SegmentClass.Departure) { + const toInsertInEnroute: FlightPlanElement[] = []; + + let emptyAllNext = false; + + if (segment === this.departureRunwayTransitionSegment) { + emptyAllNext = true; + + toInsertInEnroute.push(...this.departureRunwayTransitionSegment.truncate(indexInSegment)); + } + + if (segment === this.departureSegment) { + emptyAllNext = true; + + toInsertInEnroute.push(...this.departureSegment.truncate(indexInSegment)); + } else if (emptyAllNext) { + const removed = this.departureSegment.allLegs.slice(); + this.departureSegment.allLegs.length = 0; + + toInsertInEnroute.push(...removed); + } + + if (segment === this.departureEnrouteTransitionSegment) { + toInsertInEnroute.push(...this.departureEnrouteTransitionSegment.truncate(indexInSegment)); + } else if (emptyAllNext) { + const removed = this.departureEnrouteTransitionSegment.allLegs.slice(); + this.departureEnrouteTransitionSegment.allLegs.length = 0; + + toInsertInEnroute.push(...removed); + } + + for (const element of toInsertInEnroute) { + if (element.isDiscontinuity === false) { + element.annotation = 'TRUNC D'; + element.segment = this.enrouteSegment; + } + } + + this.enrouteSegment.allLegs.unshift(...toInsertInEnroute); + } else if (segment.class === SegmentClass.Arrival) { + const toInsertInEnroute: FlightPlanElement[] = []; + + let emptyAllNext = false; + + if (segment === this.approachSegment) { + emptyAllNext = true; + + toInsertInEnroute.unshift(...this.approachSegment.truncate(indexInSegment)); + } + + if (segment === this.approachViaSegment) { + emptyAllNext = true; + + toInsertInEnroute.unshift(...this.approachViaSegment.truncate(indexInSegment)); + } else if (emptyAllNext) { + const removed = this.approachViaSegment.allLegs.slice(); + this.approachViaSegment.allLegs.length = 0; + + toInsertInEnroute.unshift(...removed); + } + + if (segment === this.arrivalRunwayTransitionSegment) { + emptyAllNext = true; + + toInsertInEnroute.unshift(...this.arrivalRunwayTransitionSegment.truncate(indexInSegment)); + } else if (emptyAllNext) { + const removed = this.arrivalRunwayTransitionSegment.allLegs.slice(); + this.arrivalRunwayTransitionSegment.allLegs.length = 0; + + toInsertInEnroute.unshift(...removed); + } + + if (segment === this.arrivalSegment) { + emptyAllNext = true; + + toInsertInEnroute.unshift(...this.arrivalSegment.truncate(indexInSegment)); + } else if (emptyAllNext) { + const removed = this.arrivalSegment.allLegs.slice(); + this.arrivalSegment.allLegs.length = 0; + + toInsertInEnroute.unshift(...removed); + } + + if (segment === this.arrivalEnrouteTransitionSegment) { + toInsertInEnroute.unshift(...this.arrivalEnrouteTransitionSegment.truncate(indexInSegment)); + } else if (emptyAllNext) { + const removed = this.arrivalEnrouteTransitionSegment.allLegs.slice(); + this.arrivalEnrouteTransitionSegment.allLegs.length = 0; + + toInsertInEnroute.unshift(...removed); + } + + for (const element of toInsertInEnroute) { + if (element.isDiscontinuity === false) { + element.annotation = 'TRUNC A'; + element.segment = this.enrouteSegment; + } + } + + this.enrouteSegment.allLegs.push(...toInsertInEnroute); + } else { + // Do nothing + } + } + + private restring() { + const segments = this.orderedSegments; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const prevSegment = this.previousSegment(segment); + const nextSegment = this.nextSegment(segment); + + this.stringSegmentsForwards(prevSegment, segment); + this.stringSegmentsForwards(segment, nextSegment); + + segment.insertNecessaryDiscontinuities(); + } + + this.adjustIFLegs(); + this.incrementVersion(); + } + + private stringSegmentsForwards(first: FlightPlanSegment, second: FlightPlanSegment) { + if (!first || !second || first.strung || first.allLegs.length === 0 || second.allLegs.length === 0) { + return; + } + + const lastElementInFirst = first.allLegs[first.allLegs.length - 1]; + let lastLegInFirst = lastElementInFirst; + + if (lastLegInFirst?.isDiscontinuity === true) { + lastLegInFirst = first.allLegs[first.allLegs.length - 2]; + + if (!lastLegInFirst || lastLegInFirst?.isDiscontinuity === true) { + throw new Error('[FMS/FPM] Segment legs only contained a discontinuity'); + } + } + + if (first instanceof ApproachSegment && second instanceof DestinationSegment) { + // Always string approach to destination + first.strung = true; + return; + } + + if ((first instanceof DestinationSegment || first instanceof ApproachSegment) && second instanceof MissedApproachSegment) { + // Always string approach to missed + first.strung = true; + return; + } + + if (lastLegInFirst.type === LegType.IF) { + // Always connect if first segment end with an IF leg + first.strung = true; + return; + } + + let cutBefore = -1; + for (let i = 0; i < second.allLegs.length; i++) { + const element = second.allLegs[i]; + + if (element.isDiscontinuity === true) { + continue; + } + + const bothXf = lastLegInFirst.isXF() && element.isXF(); + + if (bothXf) { + if (element.terminatesWithWaypoint(lastLegInFirst.terminationWaypoint())) { + // Transfer leg type from lastLegInFirst definition onto element + element.type = lastLegInFirst.definition.type; + Object.assign(element.definition, lastLegInFirst.definition); + + // FIXME carry procedure ident from second segment + [element.ident, element.annotation] = procedureLegIdentAndAnnotation(element.definition, ''); + + first.allLegs.pop(); + cutBefore = i; + break; + } + } + + const xfToFx = lastLegInFirst.isXF() && element.isFX(); + + if (xfToFx && lastLegInFirst.terminatesWithWaypoint(element.terminationWaypoint())) { + cutBefore = i; + break; + } + } + + // If no matching leg is found, insert a discontinuity (if there isn't one already) at the end of the first segment + if (cutBefore === -1) { + if (lastElementInFirst.isDiscontinuity === false) { + first.allLegs.push({ isDiscontinuity: true }); + } + + first.strung = false; + return; + } + + // Otherwise, clear a possible discontinuity and remove all elements before the matching leg and the last leg of the first segment + if (lastElementInFirst.isDiscontinuity === true) { + first.allLegs.pop(); + } + + for (let i = 0; i < cutBefore; i++) { + second.allLegs.shift(); + } + + first.strung = true; + } + + private adjustIFLegs() { + const elements = this.allLegs; + + for (let i = 0; i < elements.length; i++) { + if (i === 0) { + continue; + } + + const prevElement = elements[i - 1]; + const element = elements[i]; + + // IF -> XX if no discontinuity before, and element present + if (element && element.isDiscontinuity === false && element.type === LegType.IF) { + if (prevElement && prevElement.isDiscontinuity === true) { + continue; + } + + if (element.definition.type === LegType.IF) { + element.type = LegType.TF; + } else { + element.type = element.definition.type; + } + } + + // XX -> IF if no element, or discontinuity before + if (element && element.isDiscontinuity === false && element.type !== LegType.IF) { + if (!prevElement || (prevElement && prevElement.isDiscontinuity === true)) { + element.type = LegType.IF; + } + } + } + } + + arrivalAndApproachSegmentsBeingRebuilt = false; + + private async rebuildArrivalAndApproachSegments() { + // We call the segment functions here, otherwise we infinitely enqueue restrings and rebuilds since calling + // the methods on BaseFlightPlan flush the op queue + + this.arrivalAndApproachSegmentsBeingRebuilt = true; + + if (this.approach) { + await this.approachSegment.setApproachProcedure(this.approach.ident); + } + + if (this.approachVia) { + await this.approachViaSegment.setApproachVia(this.approachVia.ident); + } + + if (this.arrival) { + await this.arrivalSegment.setArrivalProcedure(this.arrival.ident); + } + + if (this.arrivalEnrouteTransition) { + await this.arrivalEnrouteTransitionSegment.setArrivalEnrouteTransition(this.arrivalEnrouteTransition.ident); + } + + await this.destinationSegment.refresh(false); + } + + public removeRange(start: number, end: number) { + const [startSegment, indexInStartSegment] = this.segmentPositionForIndex(start); + const [endSegment, indexInEndSegment] = this.segmentPositionForIndex(end); + + if (!startSegment || !endSegment) { + throw new Error('[FMS/FPM] Range out of bounds'); + } + + if (startSegment === endSegment) { + startSegment.removeRange(indexInStartSegment, indexInEndSegment); + } else { + let startFound = false; + for (const segment of this.orderedSegments) { + if (!startFound && segment !== startSegment) { + continue; + } + + if (segment === startSegment) { + startFound = true; + + segment.removeAfter(indexInStartSegment); + continue; + } + + if (segment === endSegment) { + segment.removeBefore(indexInEndSegment); + return; + } + + segment.allLegs.length = 0; + } + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/FlightPlan.spec.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/FlightPlan.spec.ts new file mode 100644 index 00000000000..389b47c97f9 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/FlightPlan.spec.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import fetch from 'node-fetch'; + +import { setupNavigraphDatabase } from '@fmgc/flightplanning/new/test/Database'; +import { FlightPlan } from '@fmgc/flightplanning/new/plans/FlightPlan'; +import { loadSingleWaypoint } from '@fmgc/flightplanning/new/segments/enroute/WaypointLoading'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { assertDiscontinuity, assertNotDiscontinuity } from '@fmgc/flightplanning/new/test/LegUtils'; +import { LegType, WaypointDescriptor } from 'msfs-navdata'; +import { loadAirwayLegs } from '@fmgc/flightplanning/new/segments/enroute/AirwayLoading'; + +if (!globalThis.fetch) { + globalThis.fetch = fetch; +} + +describe('a base flight plan', () => { + beforeAll(() => { + setupNavigraphDatabase(); + }); + + it('can insert a leg', async () => { + const fp = FlightPlan.empty(); + + await fp.setOriginAirport('CYUL'); + await fp.setOriginRunway('RW06R'); + await fp.setDeparture('CYUL1'); + + await fp.setDestinationAirport('CYYZ'); + await fp.setDestinationRunway('RW05'); + await fp.setApproach('I05'); + await fp.setArrival('BOXUM5'); + + const waypoint = await loadSingleWaypoint('NOSUS', 'WCYCYULNOSUS'); + + const leg = FlightPlanLeg.fromEnrouteWaypoint(fp.enrouteSegment, waypoint); + + fp.insertElementAfter(3, leg, true); + + const fpLeg = assertNotDiscontinuity(fp.allLegs[4]); + + expect(fpLeg.ident).toEqual('NOSUS'); + expect(fp.allLegs[5].isDiscontinuity).toBeTruthy(); + + expect(fp.allLegs).toHaveLength(23); + }); + + describe('deleting legs', () => { + it('without inserting a discontinuity', async () => { + const fp = FlightPlan.empty(); + + await fp.setOriginAirport('CYYZ'); + await fp.setOriginRunway('RW06R'); + await fp.setDeparture('AVSEP6'); + + fp.removeElementAt(5, false); + + expect(assertNotDiscontinuity(fp.elementAt(5)).ident).toBe('AVSEP'); + }); + + it('inserting a discontinuity', async () => { + const fp = FlightPlan.empty(); + + await fp.setOriginAirport('CYYZ'); + await fp.setOriginRunway('RW06R'); + await fp.setDeparture('AVSEP6'); + + fp.removeElementAt(5, true); + + assertDiscontinuity(fp.elementAt(5)); + expect(assertNotDiscontinuity(fp.elementAt(6)).ident).toBe('AVSEP'); + }); + + it('not duplicating a discontinuity', async () => { + const fp = FlightPlan.empty(); + + await fp.setOriginAirport('CYYZ'); + await fp.setOriginRunway('RW06R'); + await fp.setDeparture('AVSEP6'); + + fp.removeElementAt(4, true); + + expect(assertNotDiscontinuity(fp.elementAt(4)).ident).toBe('DUVKO'); + }); + }); + + describe('editing the departure or arrival', () => { + it('should truncate departure segment after it is edited', async () => { + const flightPlan = FlightPlan.empty(); + + await flightPlan.setOriginAirport('CYYZ'); + await flightPlan.setOriginRunway('RW06R'); + await flightPlan.setDeparture('AVSEP6'); + + flightPlan.removeElementAt(4); + + expect(flightPlan.departureRunwayTransitionSegment.allLegs).toHaveLength(4); + + const lastLegOfTruncatedDeparture = assertNotDiscontinuity(flightPlan.departureRunwayTransitionSegment.allLegs[3]); + expect(lastLegOfTruncatedDeparture.ident).toEqual('DUVKO'); + + expect(flightPlan.departureSegment.allLegs).toHaveLength(0); + + expect(flightPlan.departureEnrouteTransitionSegment.allLegs).toHaveLength(0); + + expect(flightPlan.enrouteSegment.allLegs).toHaveLength(2); + + const firstLegOfEnroute = assertNotDiscontinuity(flightPlan.enrouteSegment.allLegs[0]); + expect(firstLegOfEnroute.ident).toEqual('AVSEP'); + }); + + it('should insert a discontinuity when deleting a leg', async () => { + const flightPlan = FlightPlan.empty(); + + await flightPlan.setOriginAirport('CYYZ'); + await flightPlan.setOriginRunway('RW06R'); + await flightPlan.setDeparture('AVSEP6'); + + flightPlan.removeElementAt(5, true); + + expect(assertNotDiscontinuity(flightPlan.elementAt(4)).ident).toBe('KEDSI'); + assertDiscontinuity(flightPlan.elementAt(5)); + expect(assertNotDiscontinuity(flightPlan.elementAt(6)).ident).toBe('AVSEP'); + }); + }); + + describe('collapsing waypoints', () => { + it('should collapse waypoints within one segment', async () => { + const flightPlan = FlightPlan.empty(); + const segment = flightPlan.enrouteSegment; + + const w1 = await loadSingleWaypoint('NOSUS', 'WCYCYULNOSUS'); + const w2 = await loadSingleWaypoint('NAPEE', 'WCY NAPEE'); + const w3 = await loadSingleWaypoint('PBERG', 'WK6 PBERG'); + const w4 = await loadSingleWaypoint('HOVOB', 'WK6 HOVOB'); + + flightPlan.insertElementAfter(flightPlan.lastIndex, FlightPlanLeg.fromEnrouteWaypoint(segment, w1)); + flightPlan.insertElementAfter(flightPlan.lastIndex, FlightPlanLeg.fromEnrouteWaypoint(segment, w2)); + flightPlan.insertElementAfter(flightPlan.lastIndex, FlightPlanLeg.fromEnrouteWaypoint(segment, w3)); + flightPlan.insertElementAfter(flightPlan.lastIndex, FlightPlanLeg.fromEnrouteWaypoint(segment, w4)); + + const l1 = assertNotDiscontinuity(flightPlan.allLegs[0]); + const l2 = assertNotDiscontinuity(flightPlan.allLegs[1]); + const l3 = assertNotDiscontinuity(flightPlan.allLegs[2]); + const l4 = assertNotDiscontinuity(flightPlan.allLegs[3]); + + expect(l1.ident).toEqual('NOSUS'); + expect(l2.ident).toEqual('NAPEE'); + expect(l3.ident).toEqual('PBERG'); + expect(l4.ident).toEqual('HOVOB'); + + flightPlan.insertElementAfter(0, FlightPlanLeg.fromEnrouteWaypoint(segment, w4)); + + expect(flightPlan.allLegs).toHaveLength(2); + expect(assertNotDiscontinuity(flightPlan.allLegs[1]).ident).toEqual('HOVOB'); + }); + + it('should collapse waypoints across segments', async () => { + const flightPlan = FlightPlan.empty(); + const departure = flightPlan.departureSegment; + + await flightPlan.setOriginAirport('NZQN'); + await flightPlan.setOriginRunway('RW05'); + await departure.setDepartureProcedure('ANPO3A'); + + await flightPlan.setDepartureEnrouteTransition('SAVLA'); + + const enroute = flightPlan.enrouteSegment; + + const airwayLegs = await loadAirwayLegs(enroute, 'Y569', 'ENZ Y569', 'WNZ SAVLA', 'WNZ PEDPO'); + + enroute.insertLegs(...airwayLegs); + + expect(flightPlan.allLegs).toHaveLength(14); + + const w1 = await loadSingleWaypoint('PEDPO', 'WNZ PEDPO'); + + flightPlan.insertElementAfter(4, FlightPlanLeg.fromEnrouteWaypoint(enroute, w1)); + + expect(flightPlan.allLegs).toHaveLength(6); + expect(assertNotDiscontinuity(flightPlan.allLegs[4]).ident).toEqual('QN852'); + expect(assertNotDiscontinuity(flightPlan.allLegs[5]).ident).toEqual('PEDPO'); + }); + }); + + it('connects segments by merging TF -> FX legs with the same waypoint', async () => { + const fp = FlightPlan.empty(); + + await fp.setDestinationAirport('EGLL'); + await fp.setDestinationRunway('RW27R'); + await fp.setApproach('I27R'); + await fp.setApproachVia('LAM'); + await fp.setArrival('LOGA2H'); + + const leg4 = fp.allLegs[4]; + const leg5 = fp.allLegs[5]; + + expect(assertNotDiscontinuity(leg4).ident).toBe('LAM'); + expect(assertNotDiscontinuity(leg4).type).toBe(LegType.TF); + expect(assertNotDiscontinuity(leg5).ident).toBe('LAM/11'); + expect(assertNotDiscontinuity(leg5).type).toBe(LegType.FD); + }); + + it('does not connect segments by merging TF -> FX legs with a different waypoint', async () => { + const fp = FlightPlan.empty(); + + await fp.setOriginAirport('EGLL'); + await fp.setOriginRunway('RW09L'); + await fp.setDeparture('CPT4K'); + + await fp.setDestinationAirport('EGCC'); + await fp.setDestinationRunway('RW23R'); + await fp.setApproach('D23R'); + + await fp.setApproachVia('MCT'); + + const leg5 = fp.allLegs[5]; + const leg6 = fp.allLegs[6]; + const leg7 = fp.allLegs[7]; + + // This approach has an FC leg on MCT - we must not connect TF(CPT) to it + + expect(assertNotDiscontinuity(leg5).ident).toBe('CPT'); + expect(assertNotDiscontinuity(leg5).type).toBe(LegType.TF); + assertDiscontinuity(leg6); + expect(assertNotDiscontinuity(leg7).ident).toBe('MCT'); + expect(assertNotDiscontinuity(leg7).type).toBe(LegType.IF); + }); + + describe('plan info', () => { + describe('destination leg', () => { + it('returns the right leg for an approach ending at the runway', async () => { + const fp = FlightPlan.empty(); + + await fp.setDestinationAirport('CYYZ'); + await fp.setDestinationRunway('RW05'); + await fp.setApproach('I05'); + + const destinationLeg = assertNotDiscontinuity(fp.destinationLeg); + + expect(destinationLeg.ident).toBe('CYYZ05'); + expect(destinationLeg.definition.waypointDescriptor).toEqual(WaypointDescriptor.Runway); + }); + + it('returns the right leg for an approach not ending at the runway', async () => { + const fp = FlightPlan.empty(); + + await fp.setDestinationAirport('NZQN'); + await fp.setDestinationRunway('RW05'); + await fp.setApproach('D05-B'); + + const destinationLeg = assertNotDiscontinuity(fp.destinationLeg); + + expect(destinationLeg.ident).toBe('MA260'); + expect(destinationLeg.definition.waypointDescriptor).not.toEqual(WaypointDescriptor.Runway); + }); + }); + }); +}); diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/FlightPlan.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/FlightPlan.ts new file mode 100644 index 00000000000..02d1252f345 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/FlightPlan.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Airport, Airway, Waypoint } from 'msfs-navdata'; +import { FlightPlanDefinition } from '@fmgc/flightplanning/new/FlightPlanDefinition'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { AlternateFlightPlan } from '@fmgc/flightplanning/new/plans/AlternateFlightPlan'; +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { FlightPlanElement } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { PendingAirways } from "@fmgc/flightplanning/new/plans/PendingAirways"; +// import { FlightPlanPerformanceData } from '@fmgc/flightplanning/new/plans/performance/FlightPlanPerformanceData'; + +export class FlightPlan extends BaseFlightPlan { + static empty(): FlightPlan { + return new FlightPlan(); + } + + static fromDefinition(definition: FlightPlanDefinition): FlightPlan { + return new FlightPlan(); + } + + /** + * Alternate flight plan associated with this flight plan + */ + alternateFlightPlan = new AlternateFlightPlan(this); + + pendingAirways: PendingAirways | undefined; + + /** + * Performance data for this flight plan + */ + // performanceData = new FlightPlanPerformanceData(); + + clone(): FlightPlan { + const newPlan = FlightPlan.empty(); + + newPlan.originSegment = this.originSegment.clone(newPlan); + newPlan.departureRunwayTransitionSegment = this.departureRunwayTransitionSegment.clone(newPlan); + newPlan.departureSegment = this.departureSegment.clone(newPlan); + newPlan.departureEnrouteTransitionSegment = this.departureEnrouteTransitionSegment.clone(newPlan); + newPlan.enrouteSegment = this.enrouteSegment.clone(newPlan); + newPlan.arrivalEnrouteTransitionSegment = this.arrivalEnrouteTransitionSegment.clone(newPlan); + newPlan.arrivalSegment = this.arrivalSegment.clone(newPlan); + newPlan.arrivalRunwayTransitionSegment = this.arrivalRunwayTransitionSegment.clone(newPlan); + newPlan.approachViaSegment = this.approachViaSegment.clone(newPlan); + newPlan.approachSegment = this.approachSegment.clone(newPlan); + newPlan.destinationSegment = this.destinationSegment.clone(newPlan); + newPlan.missedApproachSegment = this.missedApproachSegment.clone(newPlan); + newPlan.alternateFlightPlan = this.alternateFlightPlan.clone(newPlan); + + newPlan.availableOriginRunways = [...this.availableOriginRunways]; + newPlan.availableDepartures = [...this.availableDepartures]; + newPlan.availableDestinationRunways = [...this.availableDestinationRunways]; + newPlan.availableArrivals = [...this.availableArrivals]; + newPlan.availableApproaches = [...this.availableApproaches]; + newPlan.availableApproachVias = [...this.availableApproachVias]; + + newPlan.activeLegIndex = this.activeLegIndex; + // TODO copy performance data as well (only for SEC F-PLN) + + return newPlan; + } + + get alternateDestinationAirport(): Airport { + return this.alternateFlightPlan.destinationAirport; + } + + async setAlternateDestinationAirport(icao: string) { + return this.alternateFlightPlan.setDestinationAirport(icao); + } + + startAirwayEntry(revisedLegIndex: number) { + const leg = this.elementAt(revisedLegIndex); + + if (leg.isDiscontinuity === true) { + throw new Error('Cannot start airway entry at a discontinuity'); + } + + if (!leg.isXF() && !leg.isHX()) { + throw new Error('Cannot create a pending airways entry from a non XF or HX leg'); + } + + this.pendingAirways = new PendingAirways(this, revisedLegIndex, leg); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/PendingAirways.spec.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/PendingAirways.spec.ts new file mode 100644 index 00000000000..f60c48186d2 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/PendingAirways.spec.ts @@ -0,0 +1,121 @@ +import fetch from "node-fetch"; +import { FlightPlanService } from "@fmgc/flightplanning/new/FlightPlanService"; +import { setupNavigraphDatabase } from "@fmgc/flightplanning/new/test/Database"; +import { NavigationDatabaseService } from "@fmgc/flightplanning/new/NavigationDatabaseService"; +import { loadSingleFix, loadSingleWaypoint } from "@fmgc/flightplanning/new/segments/enroute/WaypointLoading"; +import { FlightPlanPerformanceData } from "@fmgc/flightplanning/new/plans/performance/FlightPlanPerformanceData"; +import { dumpFlightPlan } from "@fmgc/flightplanning/new/test/FlightPlan"; + +if (!globalThis.fetch) { + globalThis.fetch = fetch; +} + +describe('pending airways entry', () => { + beforeEach(() => { + FlightPlanService.reset(); + setupNavigraphDatabase(); + }); + + it('inserts an airway', async () => { + const db = NavigationDatabaseService.activeDatabase; + + const fp = FlightPlanService.active; + + await FlightPlanService.newCityPair('CYUL', 'KBOS', 'KJFK'); + await FlightPlanService.setOriginRunway('RW06R'); + await FlightPlanService.setDepartureProcedure('CYUL1'); + + const wp = await loadSingleWaypoint('DUNUP', 'WCY DUNUP'); + + FlightPlanService.nextWaypoint(2, wp); + + FlightPlanService.temporaryInsert(); + + FlightPlanService.active.startAirwayEntry(3); + + const airway = (await db.searchAirway('Q903'))[0]; + + FlightPlanService.active.pendingAirways.thenAirway(airway); + + const endWp = await loadSingleWaypoint('NOSUT', 'WCY NOSUT'); + + FlightPlanService.active.pendingAirways.thenTo(endWp); + + const airway2 = (await db.searchAirway('Q878'))[0]; + + FlightPlanService.active.pendingAirways.thenAirway(airway2); + + const endWp2 = await loadSingleWaypoint('UDBAM', 'WCY UDBAM'); + + FlightPlanService.active.pendingAirways.thenTo(endWp2); + + console.log(dumpFlightPlan(FlightPlanService.active)); + console.log(FlightPlanService.active.pendingAirways.elements); + }); + + it('automatically connects two airways at a matching point', async () => { + const db = NavigationDatabaseService.activeDatabase; + + const fp = FlightPlanService.active; + + await FlightPlanService.newCityPair('EGLL', 'OMDB', 'OMAA'); + await FlightPlanService.setOriginRunway('RW27R'); + await FlightPlanService.setDepartureProcedure('DET2F'); + + FlightPlanService.temporaryInsert(); + + FlightPlanService.active.startAirwayEntry(7); + + const airway = (await db.searchAirway('L6'))[0]; + + FlightPlanService.active.pendingAirways.thenAirway(airway); + + const to = (await db.searchFix('DVR'))[0]; + + FlightPlanService.active.pendingAirways.thenTo(to); + + const airway2 = (await db.searchAirway('UL9'))[0]; + + FlightPlanService.active.pendingAirways.thenAirway(airway2); + + const to2 = (await db.searchFix('KONAN'))[1]; + + FlightPlanService.active.pendingAirways.thenTo(to2); + + const airway3 = (await db.searchAirway('UL607'))[1]; + + FlightPlanService.active.pendingAirways.thenAirway(airway3); + + const to3 = (await db.searchFix('FERDI'))[0]; + + FlightPlanService.active.pendingAirways.thenTo(to3); + + const to5 = (await db.searchFix('MATUG'))[0]; + const to6 = (await db.searchFix('GUBAX'))[0]; + const to7 = (await db.searchFix('BOREP'))[0]; + const to8 = (await db.searchFix('ENITA'))[0]; + const to9 = (await db.searchFix('PEPIK'))[0]; + const to10 = (await db.searchFix('BALAP'))[0]; + const to11 = (await db.searchFix('ARGES'))[0]; + const to12 = (await db.searchFix('ARTAT'))[0]; + + FlightPlanService.active.pendingAirways.thenTo(to5); + FlightPlanService.active.pendingAirways.thenTo(to6); + FlightPlanService.active.pendingAirways.thenTo(to7); + FlightPlanService.active.pendingAirways.thenTo(to8); + FlightPlanService.active.pendingAirways.thenTo(to9); + FlightPlanService.active.pendingAirways.thenTo(to10); + FlightPlanService.active.pendingAirways.thenTo(to11); + FlightPlanService.active.pendingAirways.thenTo(to12); + + const airway4 = (await db.searchAirway('UP975'))[0]; + + FlightPlanService.active.pendingAirways.thenAirway(airway4); + + const to13 = (await db.searchFix('ERGUN'))[0]; + + FlightPlanService.active.pendingAirways.thenTo(to13); + + debugger; + }) +}); diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/PendingAirways.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/PendingAirways.ts new file mode 100644 index 00000000000..728332e0336 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/PendingAirways.ts @@ -0,0 +1,166 @@ +import { Airway, AirwayDirection, Waypoint } from 'msfs-navdata'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { EnrouteSegment } from '@fmgc/flightplanning/new/segments/EnrouteSegment'; + +export interface PendingAirwayEntry { + fromIndex?: number, + airway?: Airway, + to?: Waypoint, + isDct?: true, + isAutoConnected?: true, +} + +export class PendingAirways { + elements: PendingAirwayEntry[] = []; + + legs: FlightPlanLeg[] = []; + + revisedWaypoint: Waypoint; + + constructor( + private readonly flightPlan: BaseFlightPlan, + private readonly revisedLegIndex: number, + private readonly revisedLeg: FlightPlanLeg, + ) { + if (!revisedLeg.isXF() && !revisedLeg.isHX()) { + throw new Error('Cannot create a pending airways entry from a non XF or HX leg'); + } + + this.revisedWaypoint = revisedLeg.terminationWaypoint(); + } + + private get tailElement(): PendingAirwayEntry | undefined { + if (this.elements.length === 0) { + return undefined; + } + + return this.elements[this.elements.length - 1]; + } + + thenAirway(airway: Airway) { + if (airway.direction === AirwayDirection.Backward) { + airway.fixes.reverse(); + } + + const taiLElement = this.tailElement; + + let startWaypoint: Waypoint; + let startWaypointIndex: number; + done: if (!taiLElement || taiLElement.to) { + // No airways have been entered. We consider the revised waypoint to be the start of the new entry. + // OR + // An airway is entered and has a TO. + + const startDatabaseID = taiLElement ? taiLElement.to.databaseId : this.revisedWaypoint.databaseId; + const startIdent = taiLElement ? taiLElement.to.ident : this.revisedWaypoint.ident; + const startIcaoCode = taiLElement ? taiLElement.to.icaoCode : this.revisedWaypoint.icaoCode; + for (let i = 0; i < airway.fixes.length; i++) { + const fix = airway.fixes[i]; + + if (startIdent === fix.ident && startIcaoCode === fix.icaoCode) { + startWaypoint = fix; + startWaypointIndex = i; + break done; + } + } + + return false; + } else { + // We do not have an end waypoint defined as part of the previous entry. We find an automatic or geographic intersection. + + for (let i = 0; i < taiLElement.airway.fixes.length; i++) { + const fix = taiLElement.airway.fixes[i]; + + const matchInCurrentIndex = airway.fixes.findIndex((it) => it.ident === fix.ident && it.icaoCode === fix.icaoCode); + const matchInCurrent = airway.fixes[matchInCurrentIndex]; + + if (matchInCurrent && matchInCurrentIndex < airway.fixes.length) { + taiLElement.to = matchInCurrent; + taiLElement.isAutoConnected = true; + + const splitLegs = taiLElement.airway.fixes.slice(taiLElement.fromIndex, i + 1); + const mappedSplitLegs = splitLegs.map((it) => FlightPlanLeg.fromEnrouteWaypoint(this.flightPlan.enrouteSegment, it, taiLElement.airway.ident)); + + this.legs.push(...mappedSplitLegs); + + startWaypointIndex = matchInCurrentIndex + 1; + break done; + } + } + + // No automatic intersection is found, let's try to find a geographic one + + // TODO + + console.error(`No automatic airway intersection found between last airway id=${taiLElement.airway.databaseId} and airway id=${airway.databaseId} - geographic intersections not yet implemented`); + return false; + } + + // Insert the entry + this.elements.push({ + fromIndex: startWaypointIndex, + airway, + }); + + this.flightPlan.incrementVersion(); + return true; + } + + thenTo(waypoint: Waypoint) { + const taiLElement = this.tailElement; + + if (taiLElement.to) { + // The tail element is already complete, so we do a DCT entry + + this.elements.push({ to: waypoint, isDct: true }); + + const mappedLeg = FlightPlanLeg.fromEnrouteWaypoint(this.flightPlan.enrouteSegment, waypoint); + + this.legs.push(mappedLeg); + + return true; + } + + const tailAirway = taiLElement.airway; + + let endWaypointIndex; + for (let i = 0; i < tailAirway.fixes.length; i++) { + const fix = tailAirway.fixes[i]; + + if (waypoint.ident === fix.ident && waypoint.icaoCode === fix.icaoCode) { + endWaypointIndex = i; + break; + } + } + + if (endWaypointIndex === undefined) { + return false; + } + + const splitLegs = tailAirway.fixes.slice(taiLElement.fromIndex, endWaypointIndex + 1); + const mappedSplitLegs = splitLegs.map((it) => FlightPlanLeg.fromEnrouteWaypoint(this.flightPlan.enrouteSegment, it, tailAirway.ident)); + + this.legs.push(...mappedSplitLegs); + + taiLElement.to = waypoint; + + this.flightPlan.incrementVersion(); + + return true; + } + + finalize() { + this.flightPlan.redistributeLegsAt(this.revisedLegIndex); + + const [segment, indexInSegment] = this.flightPlan.segmentPositionForIndex(this.revisedLegIndex); + + if (!(segment instanceof EnrouteSegment)) { + throw new Error('Finalizing pending airways into a segment that isn\'t enroute is not yet supported'); + } + + this.flightPlan.enrouteSegment.allLegs.splice(indexInSegment, 1, ...this.legs); + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + this.flightPlan.flushOperationQueue(); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/performance/FlightPlanPerformanceData.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/performance/FlightPlanPerformanceData.ts new file mode 100644 index 00000000000..92e2f3aed14 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/plans/performance/FlightPlanPerformanceData.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { MappedSubject, Subject } from '@microsoft/msfs-sdk'; + +type VSpeedValue = 0 | undefined; + +type AltitudeValue = 0 | undefined; + +export class FlightPlanPerformanceData { + /** + * V1 speed + */ + readonly v1 = Subject.create(undefined); + + /** + * VR speed + */ + readonly vr = Subject.create(undefined); + + /** + * V2 speed + */ + readonly v2 = Subject.create(undefined); + + /** + * TRANS ALT from NAV database + */ + readonly databaseTransitionAltitude = Subject.create(undefined); + + /** + * TRANS ALT from pilot entry + */ + readonly pilotTransitionAltitude = Subject.create(undefined); + + /** + * TRANS ALT from pilot if entered, otherwise from database + */ + readonly transitionAltitude = MappedSubject.create(([db, pilot]) => pilot ?? db, this.databaseTransitionAltitude, this.pilotTransitionAltitude); + + /** + * Whether TRANS ALT is from the database + */ + readonly transitionAltitudeIsFromDatabase = this.pilotTransitionAltitude.map((it) => it !== undefined); +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ApproachSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ApproachSegment.ts new file mode 100644 index 00000000000..6d9f7265127 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ApproachSegment.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Approach, Runway, WaypointDescriptor } from 'msfs-navdata'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { NavigationDatabaseService } from '../NavigationDatabaseService'; + +export class ApproachSegment extends FlightPlanSegment { + class = SegmentClass.Arrival + + allLegs: FlightPlanElement[] = [] + + private approach: Approach | undefined + + get approachProcedure() { + return this.approach; + } + + async setApproachProcedure(procedureIdent: string | undefined) { + const oldApproachName = this.flightPlan.approach?.ident; + + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + if (procedureIdent === undefined) { + this.flightPlan.approachViaSegment.setApproachVia(undefined); + this.approach = undefined; + this.allLegs = this.createLegSet([]); + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + + return; + } + + const { destinationAirport } = this.flightPlan.destinationSegment; + + if (!destinationAirport) { + throw new Error('[FMS/FPM] Cannot set approach without destination airport'); + } + + const approaches = await db.getApproaches(destinationAirport.ident); + + const matchingProcedure = approaches.find((approach) => approach.ident === procedureIdent); + + if (!matchingProcedure) { + throw new Error(`[FMS/FPM] Can't find approach procedure '${procedureIdent}' for ${destinationAirport.ident}`); + } + + this.approach = matchingProcedure; + this.allLegs = this.createLegSet(matchingProcedure.legs.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, matchingProcedure.ident))); + this.strung = false; + + // Set plan destination runway + const procedureRunwayIdent = matchingProcedure.runwayIdent; + + if (procedureRunwayIdent) { + // TODO temporary workaround for bug in msfs backend + await this.flightPlan.destinationSegment.setDestinationRunway(procedureRunwayIdent.startsWith('R') ? procedureRunwayIdent : `RW${procedureRunwayIdent}`, true); + } + + const mappedMissedApproachLegs = matchingProcedure.missedLegs.map((leg) => FlightPlanLeg.fromProcedureLeg(this.flightPlan.missedApproachSegment, leg, matchingProcedure.ident)); + this.flightPlan.missedApproachSegment.setMissedApproachLegs(mappedMissedApproachLegs); + + // Clear flight plan approach via if the new approach is different + if (oldApproachName !== matchingProcedure.ident) { + await this.flightPlan.approachViaSegment.setApproachVia(undefined); + } + + this.flightPlan.availableApproachVias = matchingProcedure.transitions; + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.RebuildArrivalAndApproach); + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + } + + createLegSet(approachLegs: FlightPlanElement[]): FlightPlanElement[] { + const legs = []; + + const airport = this.flightPlan.destinationAirport; + const runway = this.flightPlan.destinationRunway; + + if (approachLegs.length === 0 && this.flightPlan.destinationAirport && this.flightPlan.destinationSegment.destinationRunway) { + const cf = FlightPlanLeg.destinationExtendedCenterline( + this, + airport, + runway, + ); + + legs.push(cf); + legs.push(FlightPlanLeg.fromAirportAndRunway(this, '', airport, runway)); + } else { + const lastLeg = approachLegs[approachLegs.length - 1]; + + if (lastLeg && lastLeg.isDiscontinuity === false && lastLeg.waypointDescriptor === WaypointDescriptor.Runway) { + legs.push(...approachLegs.slice(0, approachLegs.length - 1)); + + const runway = this.findRunwayFromRunwayLeg(lastLeg); + + if (lastLeg?.isDiscontinuity === false && lastLeg.waypointDescriptor === WaypointDescriptor.Runway) { + const mappedLeg = FlightPlanLeg.fromAirportAndRunway(this, this.approachProcedure?.ident ?? '', airport, runway); + + if (approachLegs.length > 1) { + mappedLeg.type = lastLeg.type; + Object.assign(lastLeg.definition, lastLeg.definition); + } + + legs.push(mappedLeg); + } + } else { + legs.push(...approachLegs); + } + } + + return legs; + } + + private findRunwayFromRunwayLeg(leg: FlightPlanLeg): Runway | undefined { + return this.flightPlan.availableDestinationRunways.find((it) => it.ident === leg.ident); + } + + private findRunwayFromApproachIdent(ident: string, runwaySet: Runway[]): Runway | undefined { + const runwaySpecificApproachPrefixes = /[ILDRV]/; + + const ident0 = ident.substring(0, 1); + const ident1 = ident.substring(1, 2); + if (ident0.match(runwaySpecificApproachPrefixes) && ident1.match(/\d/)) { + const rwyNumber = ident.substring(1, 3); + + return runwaySet.find((it) => it.ident === `RW${rwyNumber}`); + } + + return undefined; + } + + clone(forPlan: BaseFlightPlan): ApproachSegment { + const newSegment = new ApproachSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.approach = this.approach; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ApproachViaSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ApproachViaSegment.ts new file mode 100644 index 00000000000..a34513b1873 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ApproachViaSegment.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { ProcedureTransition } from 'msfs-navdata'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; + +export class ApproachViaSegment extends FlightPlanSegment { + class = SegmentClass.Arrival + + allLegs: FlightPlanElement[] = [] + + private approachVia: ProcedureTransition | undefined = undefined + + get approachViaProcedure() { + return this.approachVia; + } + + async setApproachVia(transitionIdent: string | undefined): Promise { + if (transitionIdent === undefined) { + this.approachVia = undefined; + this.allLegs.length = 0; + return; + } + + const { approach } = this.flightPlan; + + if (!approach) { + throw new Error('[FMS/FPM] Cannot set approach via without approach'); + } + + const approachVias = approach.transitions; + + const matchingApproachVia = approachVias.find((transition) => transition.ident === transitionIdent); + + if (!matchingApproachVia) { + throw new Error(`[FMS/FPM] Can't find arrival approach via '${transitionIdent}' for ${approach.ident}`); + } + + this.approachVia = matchingApproachVia; + this.allLegs.length = 0; + + const mappedApproachViaLegs = matchingApproachVia.legs.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, matchingApproachVia.ident)); + this.allLegs.push(...mappedApproachViaLegs); + this.strung = false; + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.RebuildArrivalAndApproach); + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + } + + clone(forPlan: BaseFlightPlan): ApproachViaSegment { + const newSegment = new ApproachViaSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.approachVia = this.approachVia; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalEnrouteTransitionSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalEnrouteTransitionSegment.ts new file mode 100644 index 00000000000..7daa718367d --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalEnrouteTransitionSegment.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { ProcedureTransition } from 'msfs-navdata'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; + +export class ArrivalEnrouteTransitionSegment extends FlightPlanSegment { + class = SegmentClass.Arrival + + allLegs: FlightPlanElement[] = [] + + private arrivalEnrouteTransition: ProcedureTransition | undefined = undefined + + get arrivalEnrouteTransitionProcedure() { + return this.arrivalEnrouteTransition; + } + + setArrivalEnrouteTransition(transitionIdent: string | undefined) { + if (transitionIdent === undefined) { + this.arrivalEnrouteTransition = undefined; + this.allLegs.length = 0; + return; + } + + const { destinationAirport, destinationRunway, arrival } = this.flightPlan; + + if (!destinationAirport || !destinationRunway || !arrival) { + throw new Error('[FMS/FPM] Cannot set arrival enroute transition without destination airport, runway and STAR'); + } + + const arrivalEnrouteTransitions = arrival.enrouteTransitions; + + const matchingArrivalEnrouteTransition = arrivalEnrouteTransitions.find((transition) => transition.ident === transitionIdent); + + if (!matchingArrivalEnrouteTransition) { + throw new Error(`[FMS/FPM] Can't find arrival enroute transition '${transitionIdent}' for ${destinationAirport.ident} ${arrival.ident}`); + } + + this.arrivalEnrouteTransition = matchingArrivalEnrouteTransition; + this.allLegs.length = 0; + + const mappedArrivalEnrouteTransitionLegs = matchingArrivalEnrouteTransition.legs.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, matchingArrivalEnrouteTransition.ident)); + this.allLegs.push(...mappedArrivalEnrouteTransitionLegs); + this.strung = false; + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + } + + clone(forPlan: BaseFlightPlan): ArrivalEnrouteTransitionSegment { + const newSegment = new ArrivalEnrouteTransitionSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.arrivalEnrouteTransition = this.arrivalEnrouteTransition; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalRunwayTransitionSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalRunwayTransitionSegment.ts new file mode 100644 index 00000000000..1da332c3978 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalRunwayTransitionSegment.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { ProcedureTransition } from 'msfs-navdata'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { FlightPlanElement } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; + +export class ArrivalRunwayTransitionSegment extends FlightPlanSegment { + class = SegmentClass.Arrival + + allLegs: FlightPlanElement[] = [] + + private arrivalRunwayTransition: ProcedureTransition | undefined = undefined + + get arrivalRunwayTransitionProcedure() { + return this.arrivalRunwayTransition; + } + + setArrivalRunwayTransition(transition: ProcedureTransition, legs: FlightPlanElement[]) { + this.allLegs.length = 0; + this.allLegs.push(...legs); + this.strung = false; + + this.arrivalRunwayTransition = transition; + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.RebuildArrivalAndApproach); + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + } + + clone(forPlan: BaseFlightPlan): ArrivalRunwayTransitionSegment { + const newSegment = new ArrivalRunwayTransitionSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.arrivalRunwayTransition = this.arrivalRunwayTransition; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalSegment.ts new file mode 100644 index 00000000000..d172825d0ec --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/ArrivalSegment.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Arrival } from 'msfs-navdata'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { NavigationDatabaseService } from '../NavigationDatabaseService'; + +export class ArrivalSegment extends FlightPlanSegment { + class = SegmentClass.Arrival + + allLegs: FlightPlanElement[] = [] + + private arrival: Arrival | undefined + + get arrivalProcedure() { + return this.arrival; + } + + async setArrivalProcedure(procedureIdent: string | undefined) { + if (procedureIdent === undefined) { + this.arrival = undefined; + this.allLegs.length = 0; + this.flightPlan.arrivalEnrouteTransitionSegment.setArrivalEnrouteTransition(undefined); + return; + } + + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const { destinationAirport, destinationRunway } = this.flightPlan.destinationSegment; + + if (!destinationAirport || !destinationRunway) { + throw new Error('[FMS/FPM] Cannot set approach without destination airport and runway'); + } + + const arrivals = await db.getArrivals(destinationAirport.ident); + + const matchingArrival = arrivals.find((arrival) => arrival.ident === procedureIdent); + + if (!matchingArrival) { + throw new Error(`[FMS/FPM] Can't find arrival procedure '${procedureIdent}' for ${destinationAirport.ident}`); + } + + const legs = [...matchingArrival.commonLegs]; + + this.arrival = matchingArrival; + this.allLegs.length = 0; + + const mappedArrivalLegs = legs.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, matchingArrival.ident)); + this.allLegs.push(...mappedArrivalLegs); + + const matchingRunwayTransition = matchingArrival.runwayTransitions.find((transition) => transition.ident === destinationRunway.ident); + + const mappedRunwayTransitionLegs = matchingRunwayTransition?.legs?.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, matchingArrival.ident)) ?? []; + this.flightPlan.arrivalRunwayTransitionSegment.setArrivalRunwayTransition(matchingRunwayTransition, mappedRunwayTransitionLegs); + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.RebuildArrivalAndApproach); + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + } + + clone(forPlan: BaseFlightPlan): ArrivalSegment { + const newSegment = new ArrivalSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.arrival = this.arrival; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureEnrouteTransitionSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureEnrouteTransitionSegment.ts new file mode 100644 index 00000000000..f8bdf3aa4db --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureEnrouteTransitionSegment.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { ProcedureTransition } from 'msfs-navdata'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; + +export class DepartureEnrouteTransitionSegment extends FlightPlanSegment { + class = SegmentClass.Departure + + allLegs: FlightPlanElement[] = [] + + private departureEnrouteTransition: ProcedureTransition | undefined = undefined + + get departureEnrouteTransitionProcedure() { + return this.departureEnrouteTransition; + } + + setDepartureEnrouteTransition(transitionIdent: string | undefined) { + if (transitionIdent === undefined) { + this.departureEnrouteTransition = undefined; + this.allLegs.length = 0; + return; + } + + const { originAirport, originRunway, originDeparture } = this.flightPlan; + + if (!originAirport || !originRunway || !originDeparture) { + throw new Error('[FMS/FPM] Cannot set origin enroute transition without destination airport, runway and SID'); + } + + const originEnrouteTransitions = originDeparture.enrouteTransitions; + + const matchingOriginEnrouteTransition = originEnrouteTransitions.find((transition) => transition.ident === transitionIdent); + + if (!matchingOriginEnrouteTransition) { + throw new Error(`[FMS/FPM] Can't find origin enroute transition '${transitionIdent}' for ${originAirport.ident} ${originDeparture.ident}`); + } + + this.departureEnrouteTransition = matchingOriginEnrouteTransition; + this.allLegs.length = 0; + + const mappedOriginEnrouteTransitionLegs = matchingOriginEnrouteTransition.legs.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, matchingOriginEnrouteTransition.ident)); + this.allLegs.push(...mappedOriginEnrouteTransitionLegs); + this.strung = false; + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + } + + clone(forPlan: BaseFlightPlan): DepartureEnrouteTransitionSegment { + const newSegment = new DepartureEnrouteTransitionSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.departureEnrouteTransition = this.departureEnrouteTransition; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureRunwayTransitionSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureRunwayTransitionSegment.ts new file mode 100644 index 00000000000..d5989892f78 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureRunwayTransitionSegment.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { FlightPlanElement } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { ProcedureTransition } from 'msfs-navdata'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; + +export class DepartureRunwayTransitionSegment extends FlightPlanSegment { + class = SegmentClass.Departure + + allLegs: FlightPlanElement[] = [] + + private departureRunwayTransition: ProcedureTransition | undefined = undefined + + get departureRunwayTransitionProcedure() { + return this.departureRunwayTransition; + } + + async setOriginRunwayTransitionSegment(transition: ProcedureTransition | undefined, legs: FlightPlanElement[]) { + this.allLegs.length = 0; + this.allLegs.push(...legs); + this.strung = false; + + this.departureRunwayTransition = transition; + + await this.flightPlan.originSegment.refreshOriginLegs(); + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + } + + clone(forPlan: BaseFlightPlan): DepartureRunwayTransitionSegment { + const newSegment = new DepartureRunwayTransitionSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.departureRunwayTransition = this.departureRunwayTransition; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureSegment.ts new file mode 100644 index 00000000000..d4367ddee8a --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DepartureSegment.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Departure } from 'msfs-navdata'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { BaseFlightPlan, FlightPlanQueuedOperation } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { NavigationDatabaseService } from '../NavigationDatabaseService'; + +export class DepartureSegment extends FlightPlanSegment { + class = SegmentClass.Departure + + originDeparture: Departure + + allLegs: FlightPlanLeg[] = [] + + async setDepartureProcedure(procedureIdent: string | undefined) { + if (procedureIdent === undefined) { + this.originDeparture = undefined; + this.allLegs.length = 0; + + await this.flightPlan.departureRunwayTransitionSegment.setOriginRunwayTransitionSegment(undefined, []); + await this.flightPlan.setDepartureEnrouteTransition(undefined); + await this.flightPlan.originSegment.refreshOriginLegs(); + + return; + } + + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + if (!this.flightPlan.originAirport || !this.flightPlan.originRunway) { + throw new Error('[FMS/FPM] Cannot set departure procedure without origin airport and runway'); + } + + const proceduresAtAirport = await db.getDepartures(this.flightPlan.originAirport.ident); + + if (proceduresAtAirport.length === 0) { + throw new Error(`[FMS/FPM] Cannot find procedures at ${this.flightPlan.originAirport.ident}`); + } + + const matchingProcedure = proceduresAtAirport.find((proc) => proc.ident === procedureIdent); + + if (!matchingProcedure) { + throw new Error(`[FMS/FPM] Can't find procedure '${procedureIdent}' for ${this.flightPlan.originAirport.ident}`); + } + + const runwayTransition = matchingProcedure.runwayTransitions.find((transition) => transition.ident === this.flightPlan.originRunway.ident); + + this.originDeparture = matchingProcedure; + + this.allLegs = matchingProcedure.commonLegs.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, matchingProcedure.ident)); + this.strung = false; + + const mappedRunwayTransitionLegs = runwayTransition?.legs?.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, matchingProcedure.ident)) ?? []; + await this.flightPlan.departureRunwayTransitionSegment.setOriginRunwayTransitionSegment(runwayTransition, mappedRunwayTransitionLegs); + + this.flightPlan.enqueueOperation(FlightPlanQueuedOperation.Restring); + } + + clone(forPlan: BaseFlightPlan): DepartureSegment { + const newSegment = new DepartureSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.originDeparture = this.originDeparture; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DestinationSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DestinationSegment.ts new file mode 100644 index 00000000000..eb2b2d555ad --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/DestinationSegment.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Airport, Runway, WaypointDescriptor } from 'msfs-navdata'; +import { FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { loadAllApproaches, loadAllArrivals, loadAllRunways } from '@fmgc/flightplanning/new/DataLoading'; +import { FlightPlanSegment } from './FlightPlanSegment'; +import { NavigationDatabaseService } from '../NavigationDatabaseService'; + +export class DestinationSegment extends FlightPlanSegment { + class = SegmentClass.Arrival + + allLegs: FlightPlanElement[] = [] + + private airport: Airport; + + public get destinationAirport() { + return this.airport; + } + + public async setDestinationIcao(icao: string) { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const airports = await db.getAirports([icao]); + const airport = airports.find((a) => a.ident === icao); + + if (!airport) { + throw new Error(`[FMS/FPM] Can't find airport with ICAO '${icao}'`); + } + + this.airport = airport; + + this.flightPlan.availableDestinationRunways = await loadAllRunways(this.destinationAirport); + + // TODO do we clear arrival/via/approach ...? + await this.refresh(); + + this.flightPlan.availableArrivals = await loadAllArrivals(this.destinationAirport); + this.flightPlan.availableApproaches = await loadAllApproaches(this.destinationAirport); + } + + private runway?: Runway; + + public get destinationRunway() { + return this.runway; + } + + public async setDestinationRunway(runwayIdent: string, setByApproach = false) { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + if (!this.airport) { + throw new Error('[FMS/FPM] Cannot set destination runway without destination airport'); + } + + const runways = await db.getRunways(this.airport.ident); + + const matchingRunway = runways.find((runway) => runway.ident === runwayIdent); + + if (!matchingRunway) { + throw new Error(`[FMS/FPM] Can't find runway '${runwayIdent}' at ${this.airport.ident}`); + } + + const oldRunwayIdent = this.runway?.ident; + + this.runway = matchingRunway; + + await this.refresh(oldRunwayIdent !== this.runway?.ident && !setByApproach); + } + + async refresh(doRemoveApproach = true) { + this.allLegs.length = 0; + + const { approachSegment } = this.flightPlan; + + // We remove the approach if the runway ident changed and the runway was not set by the approach + if (doRemoveApproach) { + await approachSegment.setApproachProcedure(undefined); + } + + if (this.airport && approachSegment.allLegs.length === 0) { + this.allLegs.push(FlightPlanLeg.fromAirportAndRunway(this, '', this.airport)); + } else { + this.allLegs.length = 0; + } + + this.flightPlan.availableApproaches = await loadAllApproaches(this.destinationAirport); + } + + clone(forPlan: BaseFlightPlan): DestinationSegment { + const newSegment = new DestinationSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.airport = this.airport; + newSegment.runway = this.runway; + + return newSegment; + } + + removeRange(_from: number, _to: number) { + throw new Error('Not implemented'); + } + + removeAfter(_from: number) { + throw new Error('Not implemented'); + } + + removeBefore(_before: number) { + throw new Error('Not implemented'); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/EnrouteSegment.spec.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/EnrouteSegment.spec.ts new file mode 100644 index 00000000000..283a0f87515 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/EnrouteSegment.spec.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import fetch from 'node-fetch'; +import { loadSingleWaypoint } from '@fmgc/flightplanning/new/segments/enroute/WaypointLoading'; +import { loadAirwayLegs } from '@fmgc/flightplanning/new/segments/enroute/AirwayLoading'; +import { FlightPlan } from '@fmgc/flightplanning/new/plans/FlightPlan'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { setupNavigraphDatabase } from '@fmgc/flightplanning/new/test/Database'; + +if (!globalThis.fetch) { + globalThis.fetch = fetch; +} + +describe('an enroute segment', () => { + beforeAll(() => { + setupNavigraphDatabase(); + }); + + it('should insert waypoint sequentially', async () => { + const segment = FlightPlan.empty().enrouteSegment; + + const w1 = await loadSingleWaypoint('NOSUS', 'WCYCYULNOSUS'); + const w2 = await loadSingleWaypoint('NAPEE', 'WCY NAPEE'); + const w3 = await loadSingleWaypoint('PBERG', 'WK6 PBERG'); + + segment.insertLeg(FlightPlanLeg.fromEnrouteWaypoint(this, w1)); + segment.insertLeg(FlightPlanLeg.fromEnrouteWaypoint(this, w2)); + segment.insertLeg(FlightPlanLeg.fromEnrouteWaypoint(this, w3)); + + const e0 = segment.allLegs[0]; + expect(e0.isDiscontinuity).toBeFalsy(); + + const e1 = segment.allLegs[1]; + expect(e1.isDiscontinuity).toBeFalsy(); + + const e2 = segment.allLegs[2]; + expect(e2.isDiscontinuity).toBeFalsy(); + + expect((e0 as FlightPlanLeg).ident).toEqual('NOSUS'); + expect((e1 as FlightPlanLeg).ident).toEqual('NAPEE'); + expect((e2 as FlightPlanLeg).ident).toEqual('PBERG'); + }); + + it('should insert airway', async () => { + const segment = FlightPlan.empty().enrouteSegment; + + const airwayLegs = await loadAirwayLegs( + segment, + 'Q935', + 'EK5 Q935', + 'WK5 CFRCN', + 'WK6 PONCT', + ); + + expect(airwayLegs).toHaveLength(10); + + const endLeg = airwayLegs[airwayLegs.length - 1]; + + expect(endLeg.ident).toEqual('PONCT'); + + segment.insertLegs(...airwayLegs); + + expect(segment.allLegs).toHaveLength(10); + }); +}); diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/EnrouteSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/EnrouteSegment.ts new file mode 100644 index 00000000000..e7ff965ccbd --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/EnrouteSegment.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Waypoint } from 'msfs-navdata'; +import { FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { FlightPlanSegment } from './FlightPlanSegment'; + +export class EnrouteSegment extends FlightPlanSegment { + class = SegmentClass.Enroute + + allLegs: FlightPlanElement[] = [] + + insertLeg(leg: FlightPlanLeg) { + this.allLegs.push(leg); + } + + insertLegs(...elements: FlightPlanLeg[]) { + this.allLegs.push(...elements); + } + + clone(forPlan: BaseFlightPlan): EnrouteSegment { + const newSegment = new EnrouteSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + + return newSegment; + } +} + +export interface EnrouteElement { + airwayIdent?: string, + waypoint: Waypoint, +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/FlightPlanSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/FlightPlanSegment.ts new file mode 100644 index 00000000000..e652d3cb3bf --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/FlightPlanSegment.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { LegType, Waypoint } from 'msfs-navdata'; +import { FlightPlanElement } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; + +export abstract class FlightPlanSegment { + abstract class: SegmentClass + + /** + * All the leg contained in this segment + */ + abstract get allLegs(): FlightPlanElement[] + + get legCount() { + return this.allLegs.length; + } + + /** + * Whether the segment has already been strung + */ + strung = false + + constructor( + protected readonly flightPlan: BaseFlightPlan, + ) { + } + + /** + * Creates an identical copy of this segment + * + * @param forPlan the (new) flight plan for which the segment is being cloned + */ + abstract clone(forPlan: BaseFlightPlan): FlightPlanSegment + + /** + * Inserts an element at a specified index, not checking for duplicates + * + * @param index the index to insert the element at + * @param element the element to insert + */ + insertAfter(index: number, element: FlightPlanElement) { + this.allLegs.splice(index + 1, 0, element); + } + + /** + * Removes all legs including and after `fromIndex` from the segment and merges them into the enroute segment + * + * @param atPoint + */ + truncate(atPoint: number): FlightPlanElement[] { + if (this.class === SegmentClass.Departure) { + // Move legs after cut to enroute + const removed = this.allLegs.splice(atPoint); + + return removed; + } + + if (this.class === SegmentClass.Arrival) { + // Move legs before cut to enroute + const removed = []; + for (let i = 0; i < atPoint; i++) { + removed.push(this.allLegs.shift()); + } + + return removed; + } + + throw new Error(`[FMS/FPM] Cannot truncate segment of class '${SegmentClass[this.class]}'`); + } + + /** + * Removes all legs between from and to + * + * @param from start of the range, inclusive + * @param to end of the range, exclusive + */ + removeRange(from: number, to: number) { + this.allLegs.splice(from, to - from); + } + + /** + * Removes all legs before to + * + * @param before end of the range, exclusive + */ + removeBefore(before: number) { + for (let i = 0; i < before; i++) { + this.allLegs.shift(); + } + } + + /** + * Removes all legs after from + * + * @param from start of the range, inclusive + */ + removeAfter(from: number) { + this.allLegs.splice(from); + } + + insertNecessaryDiscontinuities() { + // We do not consider the last leg as we do not want to insert a discontinuity at the end of the flight plan + for (let i = 0; i < this.allLegs.length - 1; i++) { + const element = this.allLegs[i]; + const nextElement = this.allLegs[i + 1]; + + if (element.isDiscontinuity === true) { + continue; + } + + if ((nextElement?.isDiscontinuity ?? false) === false && (element.type === LegType.VM || element.type === LegType.FM)) { + this.allLegs.splice(i + 1, 0, { isDiscontinuity: true }); + i++; + } + } + } + + /** + * Returns the index of a leg in the segment that terminates at the specified waypoint, or -1 if none is found + * + * @param waypoint the waypoint to look for + */ + findIndexOfWaypoint(waypoint: Waypoint, afterIndex? :number): number { + for (let i = 0; i < this.allLegs.length; i++) { + if (i <= afterIndex) { + continue; + } + + const leg = this.allLegs[i]; + + if (leg.isDiscontinuity === false && leg.terminatesWithWaypoint(waypoint)) { + return i; + } + } + + return -1; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/MissedApproachSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/MissedApproachSegment.ts new file mode 100644 index 00000000000..2fe795960b4 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/MissedApproachSegment.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { FlightPlanElement } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; + +export class MissedApproachSegment extends FlightPlanSegment { + class = SegmentClass.Arrival + + allLegs: FlightPlanElement[] = [] + + setMissedApproachLegs(legs: FlightPlanElement[]) { + this.allLegs.length = 0; + this.allLegs.push(...legs); + + this.insertNecessaryDiscontinuities(); + } + + clone(forPlan: BaseFlightPlan): MissedApproachSegment { + const newSegment = new MissedApproachSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/OriginSegment.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/OriginSegment.ts new file mode 100644 index 00000000000..72923e8708b --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/OriginSegment.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Airport, Runway } from 'msfs-navdata'; +import { FlightPlanSegment } from '@fmgc/flightplanning/new/segments/FlightPlanSegment'; +import { loadAirport, loadAllDepartures, loadAllRunways, loadRunway } from '@fmgc/flightplanning/new/DataLoading'; +import { SegmentClass } from '@fmgc/flightplanning/new/segments/SegmentClass'; +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { FlightPlanElement, FlightPlanLeg } from '../legs/FlightPlanLeg'; +import { NavigationDatabaseService } from '../NavigationDatabaseService'; + +export class OriginSegment extends FlightPlanSegment { + class = SegmentClass.Departure + + allLegs: FlightPlanElement[] = [] + + private airport: Airport; + + public runway?: Runway; + + get originAirport() { + return this.airport; + } + + public async setOriginIcao(icao: string) { + this.airport = await loadAirport(icao); + + await this.refreshOriginLegs(); + + this.flightPlan.availableOriginRunways = await loadAllRunways(this.originAirport); + this.flightPlan.availableDepartures = await loadAllDepartures(this.originAirport); + } + + get originRunway() { + return this.runway; + } + + public async setOriginRunway(runwayIdent: string) { + if (!this.originAirport) { + throw new Error('[FMS/FPM] Cannot set origin runway with no origin airport'); + } + + this.runway = await loadRunway(this.originAirport, runwayIdent); + + await this.refreshOriginLegs(); + + this.insertNecessaryDiscontinuities(); + } + + async refreshOriginLegs() { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + this.allLegs.length = 0; + this.allLegs.push(FlightPlanLeg.fromAirportAndRunway(this, this.flightPlan.departureSegment.originDeparture?.ident ?? '', this.originAirport, this.runway)); + + if (this.runway) { + const newRunwayCompatibleSids = await db.getDepartures(this.runway.airportIdent, this.runway.ident); + + const currentSidCompatibleWithNewRunway = newRunwayCompatibleSids.some((departure) => departure.ident === this.flightPlan.originDeparture?.ident); + + if (currentSidCompatibleWithNewRunway) { + const currentSidNewRunwayTransition = this.flightPlan.originDeparture.runwayTransitions.find((transition) => transition.ident === this.runway.ident); + + if (currentSidNewRunwayTransition && this.flightPlan.departureRunwayTransition.ident !== currentSidNewRunwayTransition.ident) { + const mappedTransitionLegs = currentSidNewRunwayTransition.legs.map((leg) => FlightPlanLeg.fromProcedureLeg(this, leg, this.flightPlan.originDeparture.ident)); + + await this.flightPlan.departureRunwayTransitionSegment.setOriginRunwayTransitionSegment(currentSidNewRunwayTransition, mappedTransitionLegs); + + this.strung = true; + } + } else { + const runwayLeg = this.allLegs[this.allLegs.length - 1]; + + if (runwayLeg.isDiscontinuity === true) { + throw new Error('[FMS/FPM] Runway leg was discontinuity'); + } + + this.allLegs.push(FlightPlanLeg.originExtendedCenterline(this, runwayLeg)); + this.allLegs.push({ isDiscontinuity: true }); + } + + this.flightPlan.availableDepartures = newRunwayCompatibleSids; + } else { + this.allLegs.push({ isDiscontinuity: true }); + } + } + + clone(forPlan: BaseFlightPlan): OriginSegment { + const newSegment = new OriginSegment(forPlan); + + newSegment.allLegs = [...this.allLegs]; + newSegment.airport = this.airport; + newSegment.runway = this.runway; + + return newSegment; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/SegmentClass.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/SegmentClass.ts new file mode 100644 index 00000000000..c4651fc3816 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/SegmentClass.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +export enum SegmentClass { + Departure, + Enroute, + Arrival, +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/AirwayLoading.spec.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/AirwayLoading.spec.ts new file mode 100644 index 00000000000..a9c598bce0e --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/AirwayLoading.spec.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import fetch from 'node-fetch'; + +import { setupNavigraphDatabase } from '@fmgc/flightplanning/new/test/Database'; + +if (!globalThis.fetch) { + globalThis.fetch = fetch; +} + +describe('airway loading', () => { + beforeAll(() => { + setupNavigraphDatabase(); + }); +}); diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/AirwayLoading.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/AirwayLoading.ts new file mode 100644 index 00000000000..25b32d70013 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/AirwayLoading.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Waypoint } from 'msfs-navdata'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { EnrouteSegment } from '@fmgc/flightplanning/new/segments/EnrouteSegment'; +import { NavigationDatabaseService } from '../../NavigationDatabaseService'; + +/** + * Loads legs from a specified airway, starting and ending at specified waypoints + * + * @param inSegment the enroute segment the legs are being loaded into + * @param airwayIdent the identifier of the airway + * @param databaseId the database id of the airway to load + * @param startWaypointDatabaseId the database id of the starting waypoint on the airway to start loading legs at + * @param viaDatabaseId the database id of the ending waypoint on the airway to stop loading legs at + */ +export async function loadAirwayLegs(inSegment: EnrouteSegment, airwayIdent: string, databaseId: string, startWaypointDatabaseId: string, viaDatabaseId: string): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const airways = await db.getAirways([airwayIdent]); + + const matchingAirway = airways.find((airway) => airway.databaseId === databaseId); + + if (!matchingAirway) { + throw new Error(`[FMS/FPM] Can't find airway with database ID '${databaseId}'`); + } + + const finalLegs: Waypoint[] = []; + + let startInserting = false; + for (const leg of matchingAirway.fixes) { + if (!startInserting && leg.databaseId !== startWaypointDatabaseId) { + continue; + } else if (leg.databaseId === startWaypointDatabaseId) { + startInserting = true; + continue; + } + + finalLegs.push(leg); + + if (leg.databaseId === viaDatabaseId) { + break; + } + } + + return finalLegs.map((waypoint) => FlightPlanLeg.fromEnrouteWaypoint(inSegment, waypoint, airwayIdent)); +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/WaypointLoading.spec.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/WaypointLoading.spec.ts new file mode 100644 index 00000000000..084db1d2697 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/WaypointLoading.spec.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import fetch from 'node-fetch'; + +import { loadFixes, loadSingleWaypoint } from '@fmgc/flightplanning/new/segments/enroute/WaypointLoading'; +import { VhfNavaid } from 'msfs-navdata'; +import { setupNavigraphDatabase } from '@fmgc/flightplanning/new/test/Database'; + +if (!globalThis.fetch) { + globalThis.fetch = fetch; +} + +describe('waypoint loading', () => { + beforeAll(() => { + setupNavigraphDatabase(); + }); + + it('can load waypoint NOSUS', async () => { + const element = await loadSingleWaypoint('NOSUS', 'WCYCYULNOSUS'); + + expect(element).not.toBeNull(); + expect(element.ident).toEqual('NOSUS'); + expect(element.icaoCode).toEqual('CY'); + }); + + it('can load ALB (ALBANY) VOR', async () => { + const elements = await loadFixes('ALB'); + + expect(elements).toHaveLength(4); + + const albanyVor = elements.find((it) => it.icaoCode === 'K6'); + + expect(albanyVor).not.toBeNull(); + expect((albanyVor as VhfNavaid).name).toEqual('ALBANY'); + }); +}); diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/WaypointLoading.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/WaypointLoading.ts new file mode 100644 index 00000000000..fa12fa61522 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/segments/enroute/WaypointLoading.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { GlsNavaid, IlsNavaid, NdbNavaid, VhfNavaid, Waypoint } from 'msfs-navdata'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { NavigationDatabaseService } from '../../NavigationDatabaseService'; + +/** + * Loads waypoints with a specified ident from the nav database, returning all matches + */ +export async function loadWaypoints(waypointIdent: string): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const waypoints = await db.getWaypoints([waypointIdent]); + + return waypoints; +} + +/** + * Loads a particular waypoint with a certain database ID from the nav database + * + * @throws if no results are found or none have the specified database ID + */ +export async function loadSingleWaypoint(waypointIdent: string, databaseId: string): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const waypoints = await db.getWaypoints([waypointIdent]); + + if (waypoints.length === 0) { + throw new Error(`[FMS/FPM] Found no waypoints with ident '${waypointIdent}'`); + } + + const matchingWaypoint = waypoints.find((waypoint) => waypoint.databaseId === databaseId); + + if (!matchingWaypoint) { + throw new Error(`[FMS/FPM] None of the waypoints with ident '${waypointIdent}' had database id '${databaseId}'`); + } + + return matchingWaypoint; +} + +export type Fix = Waypoint | VhfNavaid | NdbNavaid | IlsNavaid | GlsNavaid + +/** + * Loads fixes (either a waypoint, VHF navaid, NDB, ILS or GLS) with a specified ident from the nav database, returning all matches + */ +export async function loadFixes(fixIDent: string): Promise { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; + + const navaids = await db.getNavaids([fixIDent]); + const ndbs = await db.getNDBs([fixIDent]); + const waypoints = await db.getWaypoints([fixIDent]); + + return [...navaids, ...ndbs, ...waypoints]; +} + +/** + * Loads a particular fix (either a waypoint, VHF navaid, NDB, ILS or GLS) with a certain database ID from the nav database + * + * @throws if no results are found or none have the specified database ID + */ +export async function loadSingleFix(fixIdent: string, databaseId: string): Promise { + const results = await loadFixes(fixIdent); + + if (results.length === 0) { + throw new Error(`[FMS/FPM] Found no fixes with ident '${fixIdent}'`); + } + + const matchingFix = results.find((fix) => fix.databaseId === databaseId); + + if (!matchingFix) { + throw new Error(`[FMS/FPM] None of the fixes with ident '${fixIdent}' had database id '${databaseId}'`); + } + + return matchingFix; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/Database.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/Database.ts new file mode 100644 index 00000000000..b4ce63d6afa --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/Database.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { NavigationDatabase, NavigationDatabaseBackend } from '@fmgc/NavigationDatabase'; +import { NavigationDatabaseService } from '@fmgc/flightplanning/new/NavigationDatabaseService'; + +export function setupNavigraphDatabase() { + NavigationDatabaseService.activeDatabase = new NavigationDatabase(NavigationDatabaseBackend.Navigraph); +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/FlightPlan.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/FlightPlan.ts new file mode 100644 index 00000000000..52d7dbb58c5 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/FlightPlan.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; + +export function dumpFlightPlan(plan: BaseFlightPlan): string { + const string = plan.allLegs.map((it) => { + if (it.isDiscontinuity === true) { + return '---F-PLN-DISCONTINUITY--'; + } + + return ` ${it.annotation}\n${it.ident}`; + }).join('\n'); + + return string; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/LegUtils.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/LegUtils.ts new file mode 100644 index 00000000000..8015e819558 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/test/LegUtils.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Discontinuity, FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; + +export function assertDiscontinuity(element: FlightPlanElement) { + expect(element?.isDiscontinuity ?? false).toBeTruthy(); + + return element as Discontinuity; +} + +export function assertNotDiscontinuity(element: FlightPlanElement) { + expect(element?.isDiscontinuity ?? true).toBeFalsy(); + + return element as FlightPlanLeg; +} diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/types/DirectTo.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/types/DirectTo.ts new file mode 100644 index 00000000000..afa595b47f1 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/types/DirectTo.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Waypoint } from 'msfs-navdata'; + +interface FlightPlanDirectTo { + flightPlanLegIndex?: number, + nonFlightPlanWaypoint?: Waypoint, +} + +type CourseInFlightPlanDirectTo = FlightPlanDirectTo & { + courseIn: DegreesTrue, +} + +type CourseOutFlightPlanDirectTo = FlightPlanDirectTo & { + courseOut: DegreesTrue, +} + +type WithAbeamFlightPlanDirectTo = FlightPlanDirectTo & { + withAbeam: true, +} + +export type DirectTo = FlightPlanDirectTo | CourseInFlightPlanDirectTo | CourseOutFlightPlanDirectTo | WithAbeamFlightPlanDirectTo diff --git a/fbw-a380x/src/systems/fmgc/src/flightplanning/new/waypoints/WaypointFactory.ts b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/waypoints/WaypointFactory.ts new file mode 100644 index 00000000000..d8774360ca3 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/flightplanning/new/waypoints/WaypointFactory.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Airport, Location, Runway, Waypoint, WaypointArea } from 'msfs-navdata'; +import { placeBearingDistance } from 'msfs-geo'; +import { runwayIdent } from '@fmgc/flightplanning/new/legs/FlightPlanLegNaming'; + +export namespace WaypointFactory { + + export function fromLocation( + ident: string, + location: Location, + ): Waypoint { + return { + databaseId: `X ${ident.padEnd(5, ' ')}`, + icaoCode: ' ', + area: WaypointArea.Enroute, + ident, + location, + }; + } + + export function fromWaypointLocationAndDistanceBearing( + ident: string, + location: Location, + distance: NauticalMiles, + bearing: DegreesTrue, + ): Waypoint { + const loc = placeBearingDistance(location, bearing, distance); + + const point: Location = { lat: loc.lat, lon: loc.long }; + + return { + databaseId: 'X CF ', + icaoCode: ' ', + area: WaypointArea.Enroute, + ident, + location: point, + }; + } + + export function fromAirportAndRunway(airport: Airport, runway: Runway): Waypoint { + return { + ...runway, + ident: `${airport.ident + runwayIdent(runway)}`, + location: runway.thresholdLocation, + area: WaypointArea.Terminal, + }; + } + +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/ControlLaws.ts b/fbw-a380x/src/systems/fmgc/src/guidance/ControlLaws.ts new file mode 100644 index 00000000000..36ad2c2f8b3 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/ControlLaws.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +/** + * This enum represents a Control Law selected by the guidance system. + */ +export enum ControlLaw { + /** + * The only parameter for the Heading law is the desired heading. + */ + HEADING = 1, + /** + * The only parameter for the Track law is the desired course. + */ + TRACK = 2, + /** + * The lateral path law allows for complex lateral path traversal. It requires three parameters: + * - Crosstrack Error (XTE) + * - Track Angle Error (TAE) + * - Roll Angle (Phi) + */ + LATERAL_PATH = 3, +} + +export type HeadingGuidance = { + law: ControlLaw.HEADING, + heading: Degrees; + + /** Only for RAD */ + phiCommand?: Degrees; +} + +export type TrackGuidance = { + law: ControlLaw.TRACK, + course: Degrees; + + /** Only for RAD */ + phiCommand?: Degrees; +} + +export type LateralPathGuidance = { + law: ControlLaw.LATERAL_PATH, + crossTrackError: NauticalMiles; + trackAngleError: Degrees; + phiCommand: Degrees; +} + +export type GuidanceParameters = HeadingGuidance | TrackGuidance | LateralPathGuidance; + +export type CompletedGuidanceParameters = GuidanceParameters & { + phiLimit: Degrees; +}; + +export enum RequestedVerticalMode { + None = 0, + SpeedThrust = 1, + VpathThrust = 2, + VpathSpeed = 3, + FpaSpeed = 4, + VsSpeed = 5, +} + +export type TargetAltitude = Feet; + +export type TargetVerticalSpeed = FeetPerMinute | Degrees diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/FmsState.ts b/fbw-a380x/src/systems/fmgc/src/guidance/FmsState.ts new file mode 100644 index 00000000000..21b92970451 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/FmsState.ts @@ -0,0 +1,17 @@ +import { Mode, RangeSetting } from '@shared/NavigationDisplay'; + +export interface FmsState { + leftEfisState: EfisState, + + rightEfisState: EfisState, +} + +export interface EfisState { + mode: Mode, + + range: RangeSetting, + + dataLimitReached: boolean, + + legsCulled: boolean, +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/Geometry.ts b/fbw-a380x/src/systems/fmgc/src/guidance/Geometry.ts new file mode 100644 index 00000000000..52958e15f33 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/Geometry.ts @@ -0,0 +1,538 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { Guidable } from '@fmgc/guidance/Guidable'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { CourseCaptureTransition } from '@fmgc/guidance/lnav/transitions/CourseCaptureTransition'; +import { DirectToFixTransitionGuidanceState, DirectToFixTransition } from '@fmgc/guidance/lnav/transitions/DirectToFixTransition'; +import { PathVector } from '@fmgc/guidance/lnav/PathVector'; +import { CALeg } from '@fmgc/guidance/lnav/legs/CA'; +import { isCourseReversalLeg, isHold } from '@fmgc/guidance/lnav/legs'; +import { maxBank } from '@fmgc/guidance/lnav/CommonGeometry'; +import { CILeg } from '@fmgc/guidance/lnav/legs/CI'; +import { CRLeg } from '@fmgc/guidance/lnav/legs/CR'; +import { VMLeg } from '@fmgc/guidance/lnav/legs/VM'; +import { TransitionPicker } from '@fmgc/guidance/lnav/TransitionPicker'; +import { ControlLaw, CompletedGuidanceParameters, LateralPathGuidance } from './ControlLaws'; + +function isGuidableCapturingPath(guidable: Guidable): boolean { + return !( + guidable instanceof CALeg + || guidable instanceof CILeg + || guidable instanceof CRLeg + || guidable instanceof VMLeg + || guidable instanceof CourseCaptureTransition + ); +} + +export class Geometry { + constructor( + /** + * The list of transitions between legs. + * - entry n: transition after leg n + */ + public transitions: Map, + + /** + * The list of legs in this geometry, possibly connected through transitions: + * - entry n: nth leg, before transition n + */ + public legs: Map, + + /** + * Whether this geometry is for a temporary flight plan + */ + private temp: boolean, + ) { + } + + public version = 0; + + private listener = RegisterViewListener('JS_LISTENER_SIMVARS', null, true); + + public isComputed = false; + + private cachedVectors = []; + + private missedCachedVectors = []; + + public cachedVectorsVersion = 0; + + public missedCachedVectorsVersion = 0; + + public getAllPathVectors(activeLegIndex?: number, missedApproach = false): PathVector[] { + if (missedApproach) { + if (this.version === this.missedCachedVectorsVersion) { + return this.missedCachedVectors; + } + } else if (this.version === this.cachedVectorsVersion) { + return this.cachedVectors; + } + + const transmitHoldEntry = !this.temp; + + const ret = []; + + for (const [index, leg] of this.legs.entries()) { + if ((!missedApproach && leg.metadata.isInMissedApproach) || (missedApproach && !leg.metadata.isInMissedApproach)) { + continue; + } + + if (leg.isNull) { + continue; + } + + // TODO don't transmit any course reversals when this side range >= 160 + const transmitCourseReversal = LnavConfig.DEBUG_FORCE_INCLUDE_COURSE_REVERSAL_VECTORS || index === activeLegIndex || index === (activeLegIndex + 1); + + if (activeLegIndex !== undefined) { + if (isCourseReversalLeg(leg) && !transmitCourseReversal) { + continue; + } + if (index < activeLegIndex) { + continue; + } + } + const legInboundTransition = leg.inboundGuidable instanceof Transition ? leg.inboundGuidable : null; + + if (legInboundTransition && !legInboundTransition.isNull && (!isHold(leg) || transmitHoldEntry)) { + ret.push(...legInboundTransition.predictedPath); + } + + if (leg) { + ret.push(...leg.predictedPath); + } + } + + if (missedApproach) { + this.missedCachedVectors = ret; + this.missedCachedVectorsVersion = this.version; + } else { + this.cachedVectors = ret; + this.cachedVectorsVersion = this.version; + } + + return ret; + } + + /** + * Recomputes the guidable using new parameters + * + * @param tas predicted true airspeed speed of the current leg (for a leg) or the next leg (for a transition) in knots + * @param gs predicted ground speed of the current leg + * @param ppos present position coordinates + * @param trueTrack present true track + * @param activeLegIdx current active leg index + * @param activeTransIdx current active transition index + */ + recomputeWithParameters(tas: Knots, gs: Knots, ppos: Coordinates, trueTrack: DegreesTrue, activeLegIdx: number, _activeTransIdx: number) { + this.version++; + + if (LnavConfig.DEBUG_GEOMETRY) { + console.log(`[FMS/Geometry] Recomputing geometry with current_tas: ${tas}kts`); + console.time('geometry_recompute'); + } + + for (let i = activeLegIdx ?? 0; this.legs.get(i) || this.legs.get(i + 1); i++) { + if (!this.legs.has(i)) { + continue; + } + + const leg = this.legs.get(i); + const wasNull = leg.isNull; + + this.computeLeg(i, activeLegIdx, ppos, trueTrack, tas, gs); + + // If a leg became null/not null, we immediately recompute it to calculate the new guidables and transitions + if (!wasNull && leg.isNull || wasNull && !leg.isNull) { + this.computeLeg(i, activeLegIdx, ppos, trueTrack, tas, gs); + } + } + + if (LnavConfig.DEBUG_GEOMETRY) { + console.timeEnd('geometry_recompute'); + } + } + + static getLegPredictedTas(leg: Leg, currentTas: number) { + return Math.max(LnavConfig.DEFAULT_MIN_PREDICTED_TAS, leg.predictedTas ?? currentTas); + } + + static getLegPredictedGs(leg: Leg, currentGs: number) { + return Math.max(LnavConfig.DEFAULT_MIN_PREDICTED_TAS, leg.predictedGs ?? currentGs); + } + + private computeLeg(index: number, activeLegIdx: number, ppos: Coordinates, trueTrack: DegreesTrue, tas: Knots, gs: Knots) { + const prevLeg = this.legs.get(index - 1); + const leg = this.legs.get(index); + const nextLeg = this.legs.get(index + 1); + const nextNextLeg = this.legs.get(index + 2); + + const inboundTransition = this.transitions.get(index - 1); + const outboundTransition = this.transitions.get(index); + + const legPredictedTas = Geometry.getLegPredictedTas(leg, tas); + const legPredictedGs = Geometry.getLegPredictedGs(leg, gs); + + // If the leg is null, we compute the following: + // - transition from prevLeg to nextLeg + // - nextLeg + // - transition from nextLeg to nextNextLeg (in order to compute nextLeg) + if (leg?.isNull) { + if (nextLeg) { + let newInboundTransition: Transition; + if ((LnavConfig.NUM_COMPUTED_TRANSITIONS_AFTER_ACTIVE === -1) || index - activeLegIdx < LnavConfig.NUM_COMPUTED_TRANSITIONS_AFTER_ACTIVE) { + newInboundTransition = TransitionPicker.forLegs(prevLeg, nextLeg); + } + + let newOutboundTransition: Transition; + if (nextNextLeg && (LnavConfig.NUM_COMPUTED_TRANSITIONS_AFTER_ACTIVE === -1) || (index + 1) - activeLegIdx < LnavConfig.NUM_COMPUTED_TRANSITIONS_AFTER_ACTIVE) { + newOutboundTransition = TransitionPicker.forLegs(nextLeg, nextNextLeg); + } + + if (newInboundTransition && prevLeg) { + const prevLegPredictedLegTas = Geometry.getLegPredictedTas(prevLeg, tas); + const prevLegPredictedLegGs = Geometry.getLegPredictedGs(prevLeg, gs); + + newInboundTransition.setNeighboringGuidables(prevLeg, nextLeg); + newInboundTransition.recomputeWithParameters( + activeLegIdx === index, + prevLegPredictedLegTas, + prevLegPredictedLegGs, + ppos, + trueTrack, + ); + } + + const nextLegPredictedLegTas = Geometry.getLegPredictedTas(nextLeg, tas); + const nextLegPredictedLegGs = Geometry.getLegPredictedGs(nextLeg, gs); + + nextLeg.setNeighboringGuidables(newInboundTransition ?? prevLeg, newOutboundTransition ?? nextNextLeg); + nextLeg.recomputeWithParameters( + activeLegIdx === index, + nextLegPredictedLegTas, + nextLegPredictedLegGs, + ppos, + trueTrack, + ); + + if (newOutboundTransition) { + newOutboundTransition.setNeighboringGuidables(nextLeg, nextNextLeg); + newOutboundTransition.recomputeWithParameters( + activeLegIdx === index + 1, + nextLegPredictedLegTas, + nextLegPredictedLegGs, + ppos, + trueTrack, + ); + + nextLeg.recomputeWithParameters( + activeLegIdx === index, + nextLegPredictedLegTas, + nextLegPredictedLegGs, + ppos, + trueTrack, + ); + } + } + } + + if (inboundTransition && prevLeg) { + const prevLegPredictedLegTas = Geometry.getLegPredictedTas(prevLeg, tas); + const prevLegPredictedLegGs = Geometry.getLegPredictedGs(prevLeg, gs); + + inboundTransition.setNeighboringGuidables(prevLeg, leg); + inboundTransition.setNeighboringLegs(prevLeg, leg); + inboundTransition.recomputeWithParameters( + activeLegIdx === index, + prevLegPredictedLegTas, + prevLegPredictedLegGs, + ppos, + trueTrack, + ); + } + + // Compute leg and outbound if previous leg isn't null (we already computed 1 leg forward the previous iteration) + if (!(prevLeg && prevLeg.isNull)) { + leg.setNeighboringGuidables(inboundTransition ?? prevLeg, outboundTransition ?? nextLeg); + leg.recomputeWithParameters( + activeLegIdx === index, + legPredictedTas, + legPredictedGs, + ppos, + trueTrack, + ); + + if (outboundTransition && nextLeg) { + outboundTransition.setNeighboringGuidables(leg, nextLeg); + outboundTransition.setNeighboringLegs(leg, nextLeg); + outboundTransition.recomputeWithParameters( + activeLegIdx === index + 1, + legPredictedTas, + legPredictedGs, + ppos, + trueTrack, + ); + + // Since the outbound transition can have TAD, we recompute the leg again to make sure the end point is at the right place for this cycle + leg.setNeighboringGuidables(inboundTransition ?? prevLeg, outboundTransition); + leg.recomputeWithParameters( + activeLegIdx === index, + legPredictedTas, + legPredictedGs, + ppos, + trueTrack, + ); + } + } + } + + /** + * @param activeLegIdx + * @param ppos + * @param trueTrack + * @param gs + * @param tas + */ + getGuidanceParameters(activeLegIdx: number, ppos: Coordinates, trueTrack: DegreesTrue, gs: Knots, tas: Knots): CompletedGuidanceParameters | undefined { + const activeLeg = this.legs.get(activeLegIdx); + const nextLeg = this.legs.get(activeLegIdx + 1); + + // TODO handle in guidance controller state + const autoSequencing = !activeLeg?.disableAutomaticSequencing; + + let activeGuidable: Guidable | null = null; + let nextGuidable: Guidable | null = null; + + // first, check if we're abeam with one of the transitions (start or end) + const fromTransition = this.transitions.get(activeLegIdx - 1); + const toTransition = this.transitions.get(activeLegIdx); + if (fromTransition && !fromTransition.isNull && fromTransition.isAbeam(ppos)) { + if (!fromTransition.isFrozen) { + fromTransition.freeze(); + } + + // Since CA leg CourseCaptureTransition inbound starts at PPOS, we always consider the CA leg as the active guidable + if (fromTransition instanceof CourseCaptureTransition && activeLeg instanceof CALeg) { + activeGuidable = activeLeg; + nextGuidable = toTransition; + } else { + activeGuidable = fromTransition; + nextGuidable = activeLeg; + } + } else if (toTransition && !toTransition.isNull && autoSequencing) { + // TODO need to check that the previous leg is actually flown first... + if (toTransition.isAbeam(ppos)) { + if (toTransition instanceof FixedRadiusTransition && !toTransition.isFrozen) { + toTransition.freeze(); + } + + activeGuidable = toTransition; + nextGuidable = nextLeg; + } else if (activeLeg) { + activeGuidable = activeLeg; + nextGuidable = toTransition; + } + } else if (activeLeg) { + activeGuidable = activeLeg; + if (nextLeg && autoSequencing) { + nextGuidable = nextLeg; + } + } + + // figure out guidance params and roll anticipation + let guidanceParams: CompletedGuidanceParameters; + let rad; + let dtg; + if (activeGuidable) { + const phiLimit = maxBank(tas, isGuidableCapturingPath(activeGuidable)); + guidanceParams = { + ...activeGuidable.getGuidanceParameters(ppos, trueTrack, tas, gs), + phiLimit, + }; + dtg = activeGuidable.getDistanceToGo(ppos); + + if (activeGuidable && nextGuidable) { + rad = this.getGuidableRollAnticipationDistance(gs, activeGuidable, nextGuidable); + if (rad > 0 && dtg <= rad) { + const nextGuidanceParams = nextGuidable.getGuidanceParameters(ppos, trueTrack, tas, gs); + + if (nextGuidanceParams.law === ControlLaw.LATERAL_PATH) { + (guidanceParams as LateralPathGuidance).phiCommand = nextGuidanceParams?.phiCommand ?? 0; + } + } + } + } + + if (LnavConfig.DEBUG_GUIDANCE) { + this.listener.triggerToAllSubscribers('A32NX_FM_DEBUG_LNAV_STATUS', + // eslint-disable-next-line prefer-template + 'A32NX FMS LNAV STATUS\n' + + `XTE ${(guidanceParams as LateralPathGuidance).crossTrackError?.toFixed(3) ?? '(NO DATA)'}\n` + + `TAE ${(guidanceParams as LateralPathGuidance).trackAngleError?.toFixed(3) ?? '(NO DATA)'}\n` + + `PHI ${(guidanceParams as LateralPathGuidance).phiCommand?.toFixed(5) ?? '(NO DATA)'}\n` + + '---\n' + + `CURR GUIDABLE ${activeGuidable?.repr ?? '---'}\n` + + `CURR GUIDABLE DTG ${dtg?.toFixed(3) ?? '---'}\n` + + ((activeGuidable instanceof DirectToFixTransition) ? `DFX STATE ${DirectToFixTransitionGuidanceState[(activeGuidable as DirectToFixTransition).state]}\n` : '') + + '---\n' + + `RAD GUIDABLE ${nextGuidable?.repr ?? '---'}\n` + + `RAD DISTANCE ${rad?.toFixed(3) ?? '---'}\n` + + '---\n' + + `L0 ${this.legs.get(activeLegIdx - 1)?.repr ?? '---'}\n` + + `T0 ${this.transitions.get(activeLegIdx - 1)?.repr ?? '---'}\n` + + `L1 ${this.legs.get(activeLegIdx)?.repr ?? '---'}\n` + + `T1 ${this.transitions.get(activeLegIdx)?.repr ?? '---'}\n` + + `L2 ${this.legs.get(activeLegIdx + 1)?.repr ?? '---'}\n`); + } + + return guidanceParams; + } + + getGuidableRollAnticipationDistance(gs: Knots, from: Guidable, to: Guidable) { + if (!from.endsInCircularArc && !to.startsInCircularArc) { + return 0; + } + + // get nominal phi from previous and next leg + const phiNominalFrom = from.endsInCircularArc ? from.getNominalRollAngle(gs) : 0; + const phiNominalTo = to.startsInCircularArc ? to.getNominalRollAngle(gs) : 0; + + // TODO consider case where RAD > transition distance + + return Geometry.getRollAnticipationDistance(gs, phiNominalFrom, phiNominalTo); + } + + static getRollAnticipationDistance(gs: Knots, bankA: Degrees, bankB: Degrees): NauticalMiles { + // calculate delta phi + const deltaPhi = Math.abs(bankA - bankB); + + // calculate RAD + const maxRollRate = 5; // deg / s, TODO picked off the wind + const k2 = 0.0038; + const rad = gs / 3600 * (Math.sqrt(1 + 2 * k2 * 9.81 * deltaPhi / maxRollRate) - 1) / (k2 * 9.81); + + return rad; + } + + getDistanceToGo(activeLegIdx: number, ppos: LatLongAlt): number | null { + const activeLeg = this.legs.get(activeLegIdx); + if (activeLeg) { + return activeLeg.getDistanceToGo(ppos); + } + + return null; + } + + shouldSequenceLeg(activeLegIdx: number, ppos: LatLongAlt): boolean { + const activeLeg = this.legs.get(activeLegIdx); + const inboundTransition = this.transitions.get(activeLegIdx - 1); + + // Restrict sequencing in cases where we are still in inbound transition. Make an exception for very short legs as the transition could be overshooting. + if (!inboundTransition?.isNull && inboundTransition?.isAbeam(ppos) && activeLeg.distance > 0.01) { + return false; + } + + const dtg = activeLeg.getDistanceToGo(ppos); + + if (dtg <= 0 || activeLeg.isNull) { + return true; + } + + if (activeLeg) { + return activeLeg.getDistanceToGo(ppos) < 0.001; + } + + return false; + } + + onLegSequenced(_sequencedLeg: Leg, nextLeg: Leg, followingLeg: Leg): void { + if (isCourseReversalLeg(nextLeg) || isCourseReversalLeg(followingLeg)) { + this.version++; + } + } + + legsInSegment(segmentType: SegmentType): Map { + const newMap = new Map(); + + for (const entry of this.legs.entries()) { + if (entry[1].segment === segmentType) { + newMap.set(...entry); + } + } + + return newMap; + } + + /** + * Returns DTG for a complete leg path, taking into account transitions (including split FXR) + * + * @param ppos present position + * @param leg the leg guidable + * @param inbound the inbound transition guidable, if present + * @param outbound the outbound transition guidable, if present + */ + static completeLegPathDistanceToGo( + ppos: LatLongData, + leg: Leg, + inbound?: Transition, + outbound?: Transition, + ) { + const [, legPartLength, outboundTransLength] = Geometry.completeLegPathLengths( + leg, + inbound, + outbound, + ); + + if (outbound && outbound.isAbeam(ppos)) { + return outbound.getDistanceToGo(ppos) - outbound.distance / 2; // Remove half of the transition length, since it is split (Type I) + } + + if (inbound && inbound.isAbeam(ppos)) { + return inbound.getDistanceToGo(ppos) + legPartLength + outboundTransLength; + } + + return (leg.getDistanceToGo(ppos) - (outbound && outbound instanceof FixedRadiusTransition ? outbound.unflownDistance : 0)) + outboundTransLength; + } + + /** + * Returns lengths of the different segments of a leg, taking into account transitions (including split FXR) + * + * @param leg the leg guidable + * @param inbound the inbound transition guidable, if present + * @param outbound the outbound transition guidable, if present + */ + static completeLegPathLengths( + leg: Leg, + inbound?: Transition, + outbound?: Transition, + ): [number, number, number] { + let inboundLength = 0; + let outboundLength = 0; + + if (outbound) { + if (outbound instanceof FixedRadiusTransition) { + // Type I transitions are split between the prev and next legs + outboundLength = outbound.distance / 2; + } + } + + if (inbound) { + if (inbound instanceof FixedRadiusTransition) { + // Type I transitions are split between the prev and next legs + inboundLength = inbound.distance / 2; + } else { + inboundLength = inbound.distance; + } + } + + return [inboundLength, leg.distance, outboundLength]; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/Guidable.ts b/fbw-a380x/src/systems/fmgc/src/guidance/Guidable.ts new file mode 100644 index 00000000000..4e0ef3d8f2a --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/Guidable.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { PathVector, pathVectorLength, pathVectorPoint, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; + +/** + * A `Guidable` is a part of an LNAV path. It can be either a leg or a transition. + */ +export abstract class Guidable { + /** + * Whether the guidable should be considered for map display, guidance and sequencing + * + * For a transition, this indicates that the transition between the legs is selected but has no geometry. + * For a leg, this indicates that geometry conditions cause the leg to be skipped. + */ + isNull = false + + /** + * The first valid guidable that precedes this one. This takes into account the `isNull` property, meaning other + * guidables can exist before this one but would not be referred to by this property if they were to be null. + */ + inboundGuidable: Guidable; + + /** + * The first valid guidable that succeeds this one. This takes into account the `isNull` property, meaning other + * guidables can exist after this one but would not be referred to by this property if they were to be null. + */ + outboundGuidable: Guidable; + + protected constructor() { + } + + /** + * Used to update the {@link inboundGuidable} and {@link outboundGuidable} properties. + */ + setNeighboringGuidables(inbound: Guidable, outbound: Guidable) { + this.inboundGuidable = inbound; + this.outboundGuidable = outbound; + } + + abstract getPathStartPoint(): Coordinates | undefined; + + getPathEndPoint(): Coordinates | undefined { + if (this.isNull) { + return this.inboundGuidable.getPathEndPoint(); + } + + if (this.predictedPath) { + for (let i = this.predictedPath.length - 1; i >= 0; i--) { + const vector = this.predictedPath[i]; + + if (vector.type === PathVectorType.DebugPoint) { + continue; + } + + if (vector.endPoint) { + return vector.endPoint; + } + } + } + + return undefined; + } + + isComputed = false; + + /** + * Recomputes the guidable using new parameters + * + * @param isActive whether the guidable is being flown + * @param tas predicted true airspeed speed of the current leg (for a leg) or the next leg (for a transition) in knots + * @param gs predicted ground speed of the current leg + * @param ppos the current position of the aircraft + * @param trueTrack true ground track + * @param previousGuidable previous guidable before leg + * @param nextGuidable next guidable after leg + */ + abstract recomputeWithParameters(isActive: boolean, tas: Knots, gs: Knots, ppos: Coordinates, trueTrack: DegreesTrue, previousGuidable: Guidable, nextGuidable: Guidable): void; + + /** + * Obtains guidance parameters that will be sent to the FG when this guidable is active (or being captured by a previous guidable) + * + * @param ppos the current position of the aircraft + * @param trueTrack true ground track + * @param tas true air speed + * @param gs ground speed + */ + abstract getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, tas: Knots, gs: Knots): GuidanceParameters | undefined; + + /** + * Calculates directed DTG parameter + * + * @param ppos the current position of the aircraft + */ + abstract getDistanceToGo(ppos: Coordinates): NauticalMiles | undefined; + + abstract isAbeam(ppos: Coordinates): boolean; + + /** + * Obtains the location of a pseudo-waypoint on the guidable (does NOT include inbound or outbound + * transitions for legs; see {@link PseudoWaypoints.pointFromEndOfPath} for a function that includes those). + * + * @param distanceBeforeTerminator + */ + getPseudoWaypointLocation(distanceBeforeTerminator: NauticalMiles): Coordinates | undefined { + for (const vector of [...this.predictedPath].reverse()) { + const length = pathVectorLength(vector); + + if (length > distanceBeforeTerminator) { + return pathVectorPoint(vector, distanceBeforeTerminator); + } + } + return undefined; + } + + /** + * Path vectors for the predicted path. + * + * This path always represents what is being drawn on the ND, and is used for the vast majority of prediction computations. It is + * however not always representative of guidance, for example in case of path capture or course capture transitions or CX/VX legs. + */ + abstract get predictedPath(): PathVector[] | undefined; + + /** + * Whether the path ends in a curved arc - for entry roll anticipation + */ + get startsInCircularArc(): boolean { + return false; + } + + /** + * Whether the path ends in a curved arc - for exit roll anticipation + */ + get endsInCircularArc(): boolean { + return false; + } + + /** + * Obtain the nominal roll angle for the curved portion of the path + */ + abstract getNominalRollAngle(gs: MetresPerSecond): Degrees | undefined; + + abstract get repr(): string; +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceComponent.ts b/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceComponent.ts new file mode 100644 index 00000000000..b356c5b67dc --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceComponent.ts @@ -0,0 +1,14 @@ +import { Geometry } from '@fmgc/guidance/Geometry'; + +export interface GuidanceComponent { + init(): void; + + update(deltaTime: number): void; + + /** + * Callback invoked when the FMS decides to generate new multiple leg geometry + * + * @param geometry the new multiple leg geometry + */ + acceptMultipleLegGeometry?(geometry: Geometry): void; +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceConstants.ts b/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceConstants.ts new file mode 100644 index 00000000000..f5fc9acbf50 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceConstants.ts @@ -0,0 +1,8 @@ +export class GuidanceConstants { + static k2 = 0.0045; + + /** + * TKAE threshold for exiting a forced turn state + */ + static FORCED_TURN_TKAE_THRESHOLD = 150; +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceController.ts b/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceController.ts new file mode 100644 index 00000000000..fdcdbd965ef --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceController.ts @@ -0,0 +1,471 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Geometry } from '@fmgc/guidance/Geometry'; +import { PseudoWaypoint } from '@fmgc/guidance/PsuedoWaypoint'; +import { PseudoWaypoints } from '@fmgc/guidance/lnav/PseudoWaypoints'; +import { EfisVectors } from '@fmgc/efis/EfisVectors'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { EfisState } from '@fmgc/guidance/FmsState'; +import { EfisSide, Mode, rangeSettings } from '@shared/NavigationDisplay'; +import { TaskCategory, TaskQueue } from '@fmgc/guidance/TaskQueue'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { GeometryFactory } from '@fmgc/guidance/geometry/GeometryFactory'; +import { FlightPlanIndex } from '@fmgc/flightplanning/new/FlightPlanManager'; +import { HMLeg } from '@fmgc/guidance/lnav/legs/HX'; +import { SimVarString } from '@shared/simvar'; +import { getFlightPhaseManager } from '@fmgc/flightphase'; +import { FmgcFlightPhase } from '@shared/flightphase'; +import { ApproachType } from 'msfs-navdata'; +import { NavigationDatabase } from '@fmgc/NavigationDatabase'; +import { LnavDriver } from './lnav/LnavDriver'; +import { FlightPlanManager } from '../flightplanning/FlightPlanManager'; +import { VnavDriver } from './vnav/VnavDriver'; + +// How often the (milliseconds) +const GEOMETRY_RECOMPUTATION_TIMER = 5_000; + +export class GuidanceController { + lnavDriver: LnavDriver; + + vnavDriver: VnavDriver; + + pseudoWaypoints: PseudoWaypoints; + + efisVectors: EfisVectors; + + activeGeometry: Geometry | null; + + temporaryGeometry: Geometry | null; + + secondaryGeometry: Geometry | null; + + activeLegIndex: number; + + temporaryLegIndex: number = -1; + + activeTransIndex: number; + + activeLegDtg: NauticalMiles; + + activeLegCompleteLegPathDtg: NauticalMiles; + + displayActiveLegCompleteLegPathDtg: NauticalMiles; + + focusedWaypointCoordinates: Coordinates = { lat: 0, long: 0 }; + + currentPseudoWaypoints: PseudoWaypoint[] = []; + + automaticSequencing: boolean = true; + + leftEfisState: EfisState + + rightEfisState: EfisState + + efisStateForSide: { L: EfisState, R: EfisState } + + private approachMessage: string = '' + + taskQueue = new TaskQueue(); + + viewListener = RegisterViewListener('JS_LISTENER_SIMVARS', null, true); + + get hasTemporaryFlightPlan() { + return FlightPlanService.hasTemporary; + } + + private updateEfisState(side: EfisSide, state: EfisState): void { + const ndMode = SimVar.GetSimVarValue(`L:A32NX_EFIS_${side}_ND_MODE`, 'Enum') as Mode; + const ndRange = rangeSettings[SimVar.GetSimVarValue(`L:A32NX_EFIS_${side}_ND_RANGE`, 'Enum')]; + + if (state?.mode !== ndMode || state?.range !== ndRange) { + this.taskQueue.cancelAllInCategory(TaskCategory.EfisVectors); + this.efisVectors.forceUpdate(); + } + + state.mode = ndMode; + state.range = ndRange; + + this.updateEfisApproachMessage(); + } + + private lastFocusedWpIndex = -1; + + // FIXME only considers the case where F-PLN is shown on the MCDU + private updateMrpState() { + if (!FlightPlanService.hasActive) { + return; // TODO secondary + } + + // PLAN mode center + + const focusedWpIndex = SimVar.GetSimVarValue('L:A32NX_SELECTED_WAYPOINT', 'number'); + + // FIXME plans other than active primary + if (!FlightPlanService.active.hasElement(focusedWpIndex)) { + return; + } + + const matchingLeg = FlightPlanService.active.elementAt(focusedWpIndex); + + if (!matchingLeg || matchingLeg.isDiscontinuity === true || !matchingLeg.isXF()) { + return; + } + + // FIXME HAX + const matchingGeometryLeg = Array.from(this.activeGeometry.legs.values()).find((leg) => leg.ident === matchingLeg.ident); + + if (!matchingGeometryLeg) { + // throw new Error('[FMS/MRP] Could not find matching geometry leg'); + SimVar.SetSimVarValue('L:A32NX_SELECTED_WAYPOINT_LAT', 'Degrees', SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude')); + SimVar.SetSimVarValue('L:A32NX_SELECTED_WAYPOINT_LONG', 'Degrees', SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude')); + return; + } + + if (this.lastFocusedWpIndex !== focusedWpIndex) { + this.lastFocusedWpIndex = focusedWpIndex; + + this.efisVectors.forceUpdate(); + } + + let termination: Coordinates; + if ('lat' in matchingGeometryLeg.terminationWaypoint) { + termination = matchingGeometryLeg.terminationWaypoint; + } else { + termination = matchingGeometryLeg.terminationWaypoint.location; + } + + this.focusedWaypointCoordinates.lat = termination.lat; + this.focusedWaypointCoordinates.long = termination.long; + + SimVar.SetSimVarValue('L:A32NX_SELECTED_WAYPOINT_LAT', 'Degrees', this.focusedWaypointCoordinates.lat); + SimVar.SetSimVarValue('L:A32NX_SELECTED_WAYPOINT_LONG', 'Degrees', this.focusedWaypointCoordinates.long); + } + + private updateMapPartlyDisplayed() { + if (this.efisStateForSide.L.dataLimitReached || this.efisStateForSide.L.legsCulled) { + SimVar.SetSimVarValue('L:A32NX_EFIS_L_MAP_PARTLY_DISPLAYED', 'boolean', true); + } else { + SimVar.SetSimVarValue('L:A32NX_EFIS_L_MAP_PARTLY_DISPLAYED', 'boolean', false); + } + + if (this.efisStateForSide.R.dataLimitReached || this.efisStateForSide.R.legsCulled) { + SimVar.SetSimVarValue('L:A32NX_EFIS_R_MAP_PARTLY_DISPLAYED', 'boolean', true); + } else { + SimVar.SetSimVarValue('L:A32NX_EFIS_R_MAP_PARTLY_DISPLAYED', 'boolean', false); + } + } + + private updateEfisIdent() { + // Update EFIS ident + + const efisIdent = this.activeGeometry.legs.get(this.activeLegIndex)?.ident ?? 'PPOS'; + + const efisVars = SimVarString.pack(efisIdent, 9); + // setting the simvar as a number greater than about 16 million causes precision error > 1... but this works.. + SimVar.SetSimVarValue('L:A32NX_EFIS_L_TO_WPT_IDENT_0', 'string', efisVars[0].toString()); + SimVar.SetSimVarValue('L:A32NX_EFIS_L_TO_WPT_IDENT_1', 'string', efisVars[1].toString()); + SimVar.SetSimVarValue('L:A32NX_EFIS_R_TO_WPT_IDENT_0', 'string', efisVars[0].toString()); + SimVar.SetSimVarValue('L:A32NX_EFIS_R_TO_WPT_IDENT_1', 'string', efisVars[1].toString()); + } + + private updateEfisApproachMessage() { + let apprMsg = ''; + // const appr = this.flightPlanManager.getApproach(FlightPlans.Active); + const appr = FlightPlanService.active.approach; + if (appr && appr.type !== ApproachType.Unknown) { + const phase = getFlightPhaseManager().phase; + if (phase > FmgcFlightPhase.Cruise || (phase === FmgcFlightPhase.Cruise /* && this.flightPlanManager.getDistanceToDestination(FlightPlans.Active) < 250) */)) { + apprMsg = NavigationDatabase.formatLongApproachIdent(appr); + } + } + + if (apprMsg !== this.approachMessage) { + this.approachMessage = apprMsg; + const apprMsgVars = SimVarString.pack(apprMsg, 9); + // setting the simvar as a number greater than about 16 million causes precision error > 1... but this works.. + SimVar.SetSimVarValue('L:A32NX_EFIS_L_APPR_MSG_0', 'string', apprMsgVars[0].toString()); + SimVar.SetSimVarValue('L:A32NX_EFIS_L_APPR_MSG_1', 'string', apprMsgVars[1].toString()); + SimVar.SetSimVarValue('L:A32NX_EFIS_R_APPR_MSG_0', 'string', apprMsgVars[0].toString()); + SimVar.SetSimVarValue('L:A32NX_EFIS_R_APPR_MSG_1', 'string', apprMsgVars[1].toString()); + } + } + + constructor() { + this.lnavDriver = new LnavDriver(this); + this.vnavDriver = new VnavDriver(this); + this.pseudoWaypoints = new PseudoWaypoints(this); + this.efisVectors = new EfisVectors(this); + } + + init() { + console.log('[FMGC/Guidance] GuidanceController initialized!'); + + this.lnavDriver.ppos.lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + this.lnavDriver.ppos.long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + + this.activeLegIndex = FlightPlanService.activeOrTemporary.activeLegIndex; + + this.updateGeometries(); + + this.leftEfisState = { mode: Mode.ARC, range: 10, dataLimitReached: false, legsCulled: false }; + this.rightEfisState = { mode: Mode.ARC, range: 10, dataLimitReached: false, legsCulled: false }; + this.efisStateForSide = { + L: this.leftEfisState, + R: this.rightEfisState, + }; + + this.updateEfisState('L', this.leftEfisState); + this.updateEfisState('R', this.rightEfisState); + + this.efisStateForSide.L = this.leftEfisState; + this.efisStateForSide.R = this.leftEfisState; + + this.lnavDriver.init(); + this.vnavDriver.init(); + this.pseudoWaypoints.init(); + + Coherent.on('A32NX_IMM_EXIT', (fpIndex, immExit) => { + const leg = this.activeGeometry.legs.get(fpIndex); + const tas = SimVar.GetSimVarValue('AIRSPEED TRUE', 'Knots'); + if (leg instanceof HMLeg) { + leg.setImmediateExit(immExit, this.lnavDriver.ppos, tas); + FlightPlanService.active.incrementVersion(); + this.automaticSequencing = true; + } + }, undefined); + } + + private lastFlightPlanVersion = SimVar.GetSimVarValue(FlightPlanManager.FlightPlanVersionKey, 'number'); + + private geometryRecomputationTimer = GEOMETRY_RECOMPUTATION_TIMER + 1; + + update(deltaTime: number) { + this.geometryRecomputationTimer += deltaTime; + + this.activeLegIndex = FlightPlanService.activeOrTemporary.activeLegIndex; + + this.updateEfisState('L', this.leftEfisState); + this.updateEfisState('R', this.rightEfisState); + + try { + // Generate new geometry when flight plan changes + // const newFlightPlanVersion = FlightPlanService.activeOrTemporary.version; + // if (newFlightPlanVersion !== this.lastFlightPlanVersion) { + // this.lastFlightPlanVersion = newFlightPlanVersion; + // + // this.updateGeometries(); + // this.geometryRecomputationTimer = 0; + // } + + if (this.geometryRecomputationTimer > GEOMETRY_RECOMPUTATION_TIMER) { + this.geometryRecomputationTimer = 0; + + this.updateGeometries(); + // this.recomputeGeometries(); + // + // if (this.activeGeometry) { + // this.vnavDriver.acceptMultipleLegGeometry(this.activeGeometry); + // this.pseudoWaypoints.acceptMultipleLegGeometry(this.activeGeometry); + // } + } + } catch (e) { + console.error('[FMS] Error during LNAV update. See exception below.'); + console.error(e); + } + + try { + this.updateMrpState(); + } catch (e) { + console.error('[FMS] Error during map state computation. See exception below.'); + console.error(e); + } + + try { + this.updateMapPartlyDisplayed(); + } catch (e) { + console.error('[FMS] Error during map partly displayed computation. See exception below.'); + console.error(e); + } + + try { + this.lnavDriver.update(deltaTime); + } catch (e) { + console.error('[FMS] Error during LNAV driver update. See exception below.'); + console.error(e); + } + + try { + this.vnavDriver.update(deltaTime); + } catch (e) { + console.error('[FMS] Error during VNAV driver update. See exception below.'); + console.error(e); + } + + try { + this.pseudoWaypoints.update(deltaTime); + } catch (e) { + console.error('[FMS] Error during pseudo waypoints update. See exception below.'); + console.error(e); + } + + try { + this.efisVectors.update(deltaTime); + } catch (e) { + console.error('[FMS] Error during EFIS vectors update. See exception below.'); + console.error(e); + } + + try { + this.taskQueue.update(deltaTime); + } catch (e) { + console.error('[FMS] Error during task queue update. See exception below.'); + console.error(e); + } + } + + /** + * Called when the lateral flight plan is changed + */ + updateGeometries() { + if (FlightPlanService.has(FlightPlanIndex.Active)) { + this.updateActiveGeometry(); + } + + if (FlightPlanService.hasTemporary) { + this.updateTemporaryGeometry(); + } else { + this.temporaryGeometry = null; + } + + if (FlightPlanService.has(FlightPlanIndex.FirstSecondary)) { + this.updateSecondaryGeometry(); + } else { + this.secondaryGeometry = null; + } + + this.recomputeGeometries(); + + this.updateEfisIdent(); + + this.geometryRecomputationTimer = 0; + this.vnavDriver.acceptMultipleLegGeometry(this.activeGeometry); + this.pseudoWaypoints.acceptMultipleLegGeometry(this.activeGeometry); + } + + private updateActiveGeometry() { + if (this.activeGeometry) { + GeometryFactory.updateFromFlightPlan(this.activeGeometry, FlightPlanService.active); + } else { + this.activeGeometry = GeometryFactory.createFromFlightPlan(FlightPlanService.active); + } + } + + private updateTemporaryGeometry() { + if (this.temporaryGeometry) { + GeometryFactory.updateFromFlightPlan(this.temporaryGeometry, FlightPlanService.temporary); + } else { + this.temporaryGeometry = GeometryFactory.createFromFlightPlan(FlightPlanService.temporary); + } + } + + private updateSecondaryGeometry() { + if (this.secondaryGeometry) { + GeometryFactory.updateFromFlightPlan(this.secondaryGeometry, FlightPlanService.secondary(1), false); + } else { + this.secondaryGeometry = GeometryFactory.createFromFlightPlan(FlightPlanService.secondary(1), false); + } + } + + recomputeGeometries() { + const tas = SimVar.GetSimVarValue('AIRSPEED TRUE', 'Knots'); + const gs = SimVar.GetSimVarValue('GPS GROUND SPEED', 'Knots'); + const trueTrack = SimVar.GetSimVarValue('GPS GROUND TRUE TRACK', 'degree'); + + if (this.activeGeometry) { + this.activeGeometry.recomputeWithParameters( + tas, + gs, + this.lnavDriver.ppos, + trueTrack, + this.activeLegIndex, + this.activeTransIndex, + ); + } + + if (this.temporaryGeometry) { + this.temporaryGeometry.recomputeWithParameters( + tas, + gs, + this.lnavDriver.ppos, + trueTrack, + this.temporaryLegIndex, + this.temporaryLegIndex - 1, + ); + } + + if (this.secondaryGeometry) { + this.secondaryGeometry.recomputeWithParameters( + tas, + gs, + this.lnavDriver.ppos, + trueTrack, + this.activeLegIndex, + this.activeTransIndex, + ); + } + + if (this.secondaryGeometry) { + this.secondaryGeometry.recomputeWithParameters( + tas, + gs, + this.lnavDriver.ppos, + trueTrack, + this.activeLegIndex, + this.activeTransIndex, + ); + } + } + + /** + * Notifies the FMS that a pseudo waypoint must be sequenced. + * + * This is to be sued by {@link LnavDriver} only. + * + * @param pseudoWaypoint the {@link PseudoWaypoint} to sequence. + */ + sequencePseudoWaypoint(pseudoWaypoint: PseudoWaypoint): void { + this.pseudoWaypoints.sequencePseudoWaypoint(pseudoWaypoint); + } + + isManualHoldActive(): boolean { + if (this.activeGeometry) { + const activeLeg = this.activeGeometry.legs.get(this.activeLegIndex); + return activeLeg instanceof HMLeg; + } + return false; + } + + isManualHoldNext(): boolean { + if (this.activeGeometry) { + const nextLeg = this.activeGeometry.legs.get(this.activeLegIndex + 1); + return nextLeg instanceof HMLeg; + } + return false; + } + + setHoldSpeed(tas: Knots) { + let holdLeg: HMLeg; + if (this.isManualHoldActive()) { + holdLeg = this.activeGeometry.legs.get(this.activeLegIndex) as unknown as HMLeg; + } else if (this.isManualHoldNext()) { + holdLeg = this.activeGeometry.legs.get(this.activeLegIndex + 1) as unknown as HMLeg; + } + + if (holdLeg) { + holdLeg.setPredictedTas(tas); + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceManager.ts b/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceManager.ts new file mode 100644 index 00000000000..f9946fa9b16 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/GuidanceManager.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { FlightPlanManager } from '../flightplanning/FlightPlanManager'; + +/** + * This class will guide the aircraft by predicting a flight path and + * calculating the autopilot inputs to follow the predicted flight path. + */ +export class GuidanceManager { + + constructor() { + + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/LnavConfig.ts b/fbw-a380x/src/systems/fmgc/src/guidance/LnavConfig.ts new file mode 100644 index 00000000000..16cd4943de1 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/LnavConfig.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +export const LnavConfig = { + + /* ========== PATHGEN CONFIG ========== */ + + /** + * The minimum TAS we ever compute guidables with + */ + DEFAULT_MIN_PREDICTED_TAS: 160, + + /** + * Coefficient applied to all transition turn radii + */ + TURN_RADIUS_FACTOR: 1.0, + + /** + * The number of transitions to compute after the active leg (-1: no limit, compute all transitions) + */ + NUM_COMPUTED_TRANSITIONS_AFTER_ACTIVE: -1, + + /* ========== DEBUG INFO ========== */ + + /** + * Whether to print geometry generation / update debug info + */ + DEBUG_GEOMETRY: false, + + /** + * Whether to use the L:A32NX_DEBUG_TAS and L:A32NX_DEBUG_GS LVar for prediction speeds + */ + DEBUG_USE_SPEED_LVARS: false, + + /** + * Whether to force the drawing of course reversal (hold, proc turn) vectors at any point in the path + */ + DEBUG_FORCE_INCLUDE_COURSE_REVERSAL_VECTORS: false, + + /** + * Whether to print guidance debug information on the ND + */ + DEBUG_GUIDANCE: false, + + /** + * Whether to print guidable recomputation info + */ + DEBUG_GUIDABLE_RECOMPUTATION: false, + + /** + * Whether to draw path debug points and print them out + */ + DEBUG_PREDICTED_PATH: false, + + /** + * Whether to print SVG path generation debug info + */ + DEBUG_PATH_DRAWING: false, + + /** + * Whether to print FMS timing information + */ + DEBUG_PERF: false, + + /** + * Whether to save the flight plan to local storage (keeps flight plan over instrument reload) + */ + DEBUG_SAVE_FPLN_LOCAL_STORAGE: false, + +}; diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/PsuedoWaypoint.ts b/fbw-a380x/src/systems/fmgc/src/guidance/PsuedoWaypoint.ts new file mode 100644 index 00000000000..2501a818170 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/PsuedoWaypoint.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { WaypointStats } from '@fmgc/flightplanning/data/flightplan'; + +/** + * Types that tie pseudo waypoints to sequencing actions + */ +export enum PseudoWaypointSequencingAction { + + /** + * Used to trigger "DECELERATE / T/D REACHED" message on EFIS (depending on EIS version and standard) eg. (T/D) + */ + TOD_REACHED, + + /** + * Used for approach phase auto-engagement condition eg. (DECEL) + */ + APPROACH_PHASE_AUTO_ENGAGE, + +} + +export interface PseudoWaypoint { + + /** + * The identifier of the PWP, like (T/C) or (DECEL) + */ + ident: string, + + /** + * The sequencing type of the pseudo waypoint, if applicable. This is used to determine what to do when the pseudo + * waypoints is sequenced. + */ + sequencingType?: PseudoWaypointSequencingAction, + + /** + * The index of the leg the pseudo waypoint is on + */ + alongLegIndex: number, + + /** + * The distance from the termination of the leg at index {@link alongLegIndex} the PWP is on + */ + distanceFromLegTermination: NauticalMiles, + + /** + * A bitfield for the EFIS symbol associated with this PWP + */ + efisSymbolFlag: number, + + /** + * lla for the position of the EFIS symbol + */ + efisSymbolLla: Coordinates, + + /** + * Whether the pseudo waypoint is displayed on the MCDU + */ + displayedOnMcdu: boolean, + + /** + * Waypoint stats for the PWP + */ + stats: WaypointStats, + +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/TaskQueue.ts b/fbw-a380x/src/systems/fmgc/src/guidance/TaskQueue.ts new file mode 100644 index 00000000000..1ad01b8ef7c --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/TaskQueue.ts @@ -0,0 +1,54 @@ +export enum TaskCategory { + EfisVectors, +} + +export interface Task { + category: TaskCategory, + tag: string, + executor: () => Generator, +} + +export class TaskQueue { + private taskQueue: Task[] = []; + + private currentTask: Task | null = null; + + private currentTaskExecutor: Generator | null = null; + + update(_deltaTime: number): void { + if (!this.currentTask && this.taskQueue.length > 0) { + const nextTask = this.taskQueue.shift(); + + if (nextTask) { + this.currentTask = nextTask; + this.currentTaskExecutor = nextTask.executor(); + } + } + + if (this.currentTask) { + const done = this.currentTaskExecutor.next().done; + + if (done) { + this.currentTask = null; + this.currentTaskExecutor = null; + } + } + } + + runStepTask(executor: Task) { + this.taskQueue.push(executor); + } + + cancelAllInCategory(category: TaskCategory) { + if (this.currentTask?.category === category) { + this.currentTask = null; + this.currentTaskExecutor = null; + } + + for (const queuedTask of this.taskQueue) { + if (queuedTask.category === category) { + this.taskQueue = this.taskQueue.filter((task) => task === queuedTask); + } + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/geometry/GeometryFactory.ts b/fbw-a380x/src/systems/fmgc/src/guidance/geometry/GeometryFactory.ts new file mode 100644 index 00000000000..fd7561e40d5 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/geometry/GeometryFactory.ts @@ -0,0 +1,309 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Geometry } from '@fmgc/guidance/Geometry'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { BaseFlightPlan } from '@fmgc/flightplanning/new/plans/BaseFlightPlan'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { FlightPlanElement, FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { LegType } from 'msfs-navdata'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { IFLeg } from '@fmgc/guidance/lnav/legs/IF'; +import { CALeg } from '@fmgc/guidance/lnav/legs/CA'; +import { AFLeg } from '@fmgc/guidance/lnav/legs/AF'; +import { CFLeg } from '@fmgc/guidance/lnav/legs/CF'; +import { CILeg } from '@fmgc/guidance/lnav/legs/CI'; +import { TransitionPicker } from '@fmgc/guidance/lnav/TransitionPicker'; +import { DFLeg } from '@fmgc/guidance/lnav/legs/DF'; +import { legMetadataFromFlightPlanLeg } from '@fmgc/guidance/lnav/legs'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { VMLeg } from '@fmgc/guidance/lnav/legs/VM'; +import { RFLeg } from '@fmgc/guidance/lnav/legs/RF'; +import { CRLeg } from '@fmgc/guidance/lnav/legs/CR'; +import { FDLeg } from '@fmgc/guidance/lnav/legs/FD'; +import { CDLeg } from '@fmgc/guidance/lnav/legs/CD'; +import { HALeg, HFLeg, HMLeg } from '../lnav/legs/HX'; + +function getFacilities(): typeof Facilities { + if ('Facilities' in window) { + return Facilities; + } + + return { + getMagVar(_lat: Degrees, _long: Degrees): Degrees { + return 0; + }, + }; +} + +export namespace GeometryFactory { + export function createFromFlightPlan(plan: BaseFlightPlan, doGenerateTransitions = true): Geometry { + const legs = new Map(); + const transitions = new Map(); + + let runningMagvar = 0; + + const planElements = plan.allLegs; + for (let i = 0; i < planElements.length; i++) { + const prevElement = planElements[i - 1]; + const element = planElements[i]; + const nextElement = planElements[i + 1]; + + if (element.isDiscontinuity === true) { + continue; + } + + if (element.isXF()) { + const fixLocation = element.terminationWaypoint().location; + + // TODO very sussy... declination/variation does not work like this for terminal procedures + runningMagvar = getFacilities().getMagVar(fixLocation.lat, fixLocation.long); + } + + let nextGeometryLeg; + if (nextElement?.isDiscontinuity === false && nextElement.type !== LegType.CI && nextElement.type !== LegType.VI) { + nextGeometryLeg = geometryLegFromFlightPlanLeg(runningMagvar, element, nextElement); + } + + const geometryLeg = geometryLegFromFlightPlanLeg(runningMagvar, prevElement, element, nextGeometryLeg); + + const previousGeometryLwg = legs.get(i - 1); + + if (previousGeometryLwg && doGenerateTransitions && doGenerateTransitionsForLeg(geometryLeg, i, plan)) { + const transition = TransitionPicker.forLegs(previousGeometryLwg, geometryLeg); + + transitions.set(i - 1, transition); + } + + legs.set(i, geometryLeg); + } + + return new Geometry(transitions, legs, false); + } + + export function updateFromFlightPlan(geometry: Geometry, flightPlan: BaseFlightPlan, doGenerateTransitions = true) { + if (LnavConfig.DEBUG_GEOMETRY) { + console.log('[Fms/Geometry/Update] Starting geometry update.'); + } + + let runningMagvar = 0; + + for (let i = flightPlan.activeLegIndex - 1; i < flightPlan.legCount; i++) { + const oldLeg = geometry.legs.get(i); + + const previousPlanLeg = flightPlan.allLegs[i - 1]; + const nextPlanLeg = flightPlan.allLegs[i + 1]; + + const planLeg = flightPlan.allLegs[i]; + + if (planLeg.isDiscontinuity === false && planLeg.isXF()) { + const fixLocation = planLeg.terminationWaypoint().location; + + // TODO very sussy... declination/variation does not work like this for terminal procedures + runningMagvar = getFacilities().getMagVar(fixLocation.lat, fixLocation.long); + } + + let nextLeg: Leg; + if (nextPlanLeg?.isDiscontinuity === false && nextPlanLeg.type !== LegType.CI && nextPlanLeg.type !== LegType.VI) { + nextLeg = geometryLegFromFlightPlanLeg(runningMagvar, planLeg, nextPlanLeg); + } + + const newLeg = planLeg?.isDiscontinuity === false ? geometryLegFromFlightPlanLeg(runningMagvar, previousPlanLeg, planLeg, nextLeg) : undefined; + + if (LnavConfig.DEBUG_GEOMETRY) { + console.log(`[FMS/Geometry/Update] Old leg #${i} = ${oldLeg?.repr ?? ''}`); + console.log(`[FMS/Geometry/Update] New leg #${i} = ${newLeg?.repr ?? ''}`); + } + + const legsMatch = oldLeg?.repr === newLeg?.repr; + + if (legsMatch) { + if (LnavConfig.DEBUG_GEOMETRY) { + console.log('[FMS/Geometry/Update] Old and new leg are the same. Keeping old leg.'); + } + + // Sync fixes + + if (oldLeg instanceof XFLeg && newLeg instanceof XFLeg) { + oldLeg.fix = newLeg.fix; + } + + const prevLeg = geometry.legs.get(i - 1); + + const oldInboundTransition = geometry.transitions.get(i - 1); + const newInboundTransition = TransitionPicker.forLegs(prevLeg, newLeg); + + const transitionsMatch = oldInboundTransition?.repr === newInboundTransition?.repr; + + if (!transitionsMatch && doGenerateTransitions && doGenerateTransitionsForLeg(newLeg, i, flightPlan)) { + geometry.transitions.set(i - 1, newInboundTransition); + } + } else { + if (LnavConfig.DEBUG_GEOMETRY) { + if (!oldLeg) console.log('[FMS/Geometry/Update] No old leg. Adding new leg.'); + else if (!newLeg) console.log('[FMS/Geometry/Update] No new leg. Removing old leg.'); + else console.log('[FMS/Geometry/Update] Old and new leg are different. Keeping new leg.'); + } + + if (newLeg) { + geometry.legs.set(i, newLeg); + + const prevLeg = geometry.legs.get(i - 1); + + if (prevLeg && doGenerateTransitions && doGenerateTransitionsForLeg(newLeg, i, flightPlan)) { + const newInboundTransition = TransitionPicker.forLegs(prevLeg, newLeg); + + if (LnavConfig.DEBUG_GEOMETRY) { + console.log(`[FMS/Geometry/Update] Set new inbound transition for new leg (${newInboundTransition?.repr ?? ''})`); + } + + if (newInboundTransition) { + geometry.transitions.set(i - 1, newInboundTransition); + } else { + geometry.transitions.delete(i - 1); + } + } else { + geometry.transitions.delete(i - 1); + } + } else { + geometry.legs.delete(i); + geometry.transitions.delete(i - 1); + geometry.transitions.delete(i); + } + } + } + + // Trim geometry + + for (const [index] of geometry.legs.entries()) { + const legBeforePrev = index < flightPlan.activeLegIndex - 1; + const legAfterLastWpt = index >= flightPlan.legCount; + + if (legBeforePrev || legAfterLastWpt) { + if (LnavConfig.DEBUG_GEOMETRY) { + console.log(`[FMS/Geometry/Update] Removed leg #${index} (${geometry.legs.get(index)?.repr ?? ''}) because of trimming.`); + } + + geometry.legs.delete(index); + geometry.transitions.delete(index - 1); + } + } + + if (LnavConfig.DEBUG_GEOMETRY) { + console.log('[Fms/Geometry/Update] Done with geometry update.'); + } + } +} + +function geometryLegFromFlightPlanLeg(runningMagvar: Degrees, previousFlightPlanLeg: FlightPlanElement | undefined, flightPlanLeg: FlightPlanLeg, nextGeometryLeg?: Leg): Leg { + const legType = flightPlanLeg.type; + + if (previousFlightPlanLeg?.isDiscontinuity === true && legType !== LegType.IF) { + throw new Error('[FMS/Geometry] Cannot create non-IF geometry leg after discontinuity'); + } + + const metadata = legMetadataFromFlightPlanLeg(flightPlanLeg); + + const waypoint = flightPlanLeg.terminationWaypoint(); + const recommendedNavaid = flightPlanLeg.definition.recommendedNavaid; + const trueCourse = flightPlanLeg.definition.magneticCourse + runningMagvar; + const trueTheta = flightPlanLeg.definition.theta + runningMagvar; + const length = flightPlanLeg.definition.length; + + switch (legType) { + case LegType.AF: { + const recommendedNavaid = flightPlanLeg.definition.recommendedNavaid; + const navaid = recommendedNavaid.location; + const rho = flightPlanLeg.definition.rho; + + return new AFLeg(waypoint, navaid, rho, trueTheta, trueCourse, metadata, SegmentType.Departure); + } + case LegType.CA: + case LegType.FA: + case LegType.VA: { // TODO FA, VA legs in geometry + const altitude = flightPlanLeg.definition.altitude1; + + return new CALeg(trueCourse, altitude, metadata, SegmentType.Departure); + } + case LegType.CD: + return new CDLeg(trueCourse, length, recommendedNavaid, metadata, SegmentType.Departure); + case LegType.CF: + return new CFLeg(waypoint, trueCourse, metadata, SegmentType.Departure); + case LegType.CI: + case LegType.VI: { // TODO VI leg in geometry + if (!nextGeometryLeg) { + throw new Error('[FMS/Geometry] Cannot make a CI leg without the next geometry leg being defined'); + } + + return new CILeg(trueCourse, nextGeometryLeg, metadata, SegmentType.Departure); + } + case LegType.CR: + case LegType.VR: // TODO VR leg in geometry + return new CRLeg(trueCourse, { ident: recommendedNavaid.ident, coordinates: recommendedNavaid.location, theta: trueTheta - runningMagvar }, trueTheta, metadata, SegmentType.Departure); + case LegType.HA: + return new HALeg(waypoint, metadata, SegmentType.Departure); + case LegType.HF: + return new HFLeg(waypoint, metadata, SegmentType.Departure); + case LegType.HM: + return new HMLeg(waypoint, metadata, SegmentType.Departure); + case LegType.DF: + return new DFLeg(waypoint, metadata, SegmentType.Departure); + case LegType.FC: + break; + case LegType.FD: + return new FDLeg(trueCourse, length, waypoint, recommendedNavaid, metadata, SegmentType.Departure); + case LegType.IF: + return new IFLeg(waypoint, metadata, SegmentType.Departure); + case LegType.PI: + break; + case LegType.RF: + case LegType.TF: { + const prev = previousFlightPlanLeg as FlightPlanLeg; + + if (!prev.isXF()) { + throw new Error('[FMS/Geometry] Cannot create a TF leg after a non-XF leg'); + } + + const prevWaypoint = prev.terminationWaypoint(); + const waypoint = flightPlanLeg.terminationWaypoint(); + const center = flightPlanLeg.definition.arcCentreFix; + + if (legType === LegType.RF) { + return new RFLeg(prevWaypoint, waypoint, center.location, metadata, SegmentType.Departure); + } + + return new TFLeg(prevWaypoint, waypoint, metadata, SegmentType.Departure); + } + case LegType.VD: + break; + case LegType.FM: + case LegType.VM: { + return new VMLeg(trueCourse, metadata, SegmentType.Departure); + } + default: + break; + } + + throw new Error(`[FMS/Geometry] Could not generate geometry leg for flight plan leg type=${LegType[legType]}`); +} + +function doGenerateTransitionsForLeg(leg: Leg, legIndex: number, plan: BaseFlightPlan) { + const generateAllTransitions = LnavConfig.NUM_COMPUTED_TRANSITIONS_AFTER_ACTIVE === -1; + const positionFromActiveLeg = legIndex - plan.activeLegIndex; + + const inRange = generateAllTransitions || positionFromActiveLeg < 2; + + if (!inRange) { + return false; + } + + if (leg.metadata.isInMissedApproach) { + return legIndex <= plan.firstMissedApproachLeg; + } + + return true; +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/CommonGeometry.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/CommonGeometry.ts new file mode 100644 index 00000000000..4ccf59af84a --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/CommonGeometry.ts @@ -0,0 +1,331 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { ControlLaw, LateralPathGuidance } from '@fmgc/guidance/ControlLaws'; +import { MathUtils } from '@shared/MathUtils'; +import { Constants } from '@shared/Constants'; +import { bearingTo, distanceTo, placeBearingDistance } from 'msfs-geo'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; + +/** + * Compute the remaining distance around an arc + * This is only valid once past the itp + * @param ppos current aircraft position + * @param itp current aircraft track + * @param centreFix centre of the arc + * @param sweepAngle angle swept around the arc, +ve for clockwise + * @returns + */ +export function arcDistanceToGo(ppos: Coordinates, itp: Coordinates, centreFix: Coordinates, sweepAngle: Degrees) { + const itpBearing = bearingTo(centreFix, itp); + const pposBearing = bearingTo(centreFix, ppos); + const radius = distanceTo(centreFix, itp); + + const refFrameOffset = MathUtils.diffAngle(0, itpBearing); + const pposAngle = sweepAngle < 0 ? MathUtils.clampAngle(refFrameOffset - pposBearing) : MathUtils.clampAngle(pposBearing - refFrameOffset); + + // before the arc... this implies max sweep angle is <340, arinc allows less than that anyway + if (pposAngle >= 340) { + return radius * Math.PI * Math.abs(sweepAngle) / 180; + } + + if (pposAngle >= Math.abs(sweepAngle)) { + return 0; + } + + return radius * Math.PI * (Math.abs(sweepAngle) - pposAngle) / 180; +} + +/** + * Compute guidance parameters for an arc path + * + * @param ppos current aircraft position + * @param trueTrack current aircraft track + * @param itp initial turning point for the arc + * @param centreFix centre of the arc + * @param sweepAngle angle swept around the arc, +ve for clockwise + * + * @returns lateral path law params + */ +export function arcGuidance(ppos: Coordinates, trueTrack: Degrees, itp: Coordinates, centreFix: Coordinates, sweepAngle: Degrees): LateralPathGuidance { + const bearingPpos = bearingTo( + centreFix, + ppos, + ); + + const desiredTrack = sweepAngle > 0 ? MathUtils.clampAngle(bearingPpos + 90) : MathUtils.clampAngle(bearingPpos - 90); + const trackAngleError = MathUtils.diffAngle(trueTrack, desiredTrack); + + const radius = distanceTo(centreFix, itp); + const distanceFromCenter = distanceTo(centreFix, ppos); + + const crossTrackError = sweepAngle > 0 + ? distanceFromCenter - radius + : radius - distanceFromCenter; + + const groundSpeed = SimVar.GetSimVarValue('GPS GROUND SPEED', 'meters per second'); + const radiusInMetre = radius * 1852; + const phiCommand = (sweepAngle > 0 ? 1 : -1) * Math.atan((groundSpeed * groundSpeed) / (radiusInMetre * 9.81)) * (180 / Math.PI); + + return { + law: ControlLaw.LATERAL_PATH, + trackAngleError, + crossTrackError, + phiCommand, + }; +} + +/** + * Computes a point along a course to a fix + * + * @param distanceFromEnd distance before end of line + * @param course course of the line to the fix + * @param fix self-explanatory + */ +export function pointOnCourseToFix( + distanceFromEnd: NauticalMiles, + course: DegreesTrue, + fix: Coordinates, +): Coordinates { + return placeBearingDistance( + fix, + reciprocal(course), + distanceFromEnd, + ); +} + +/** + * Computes a point along an arc at a distance before its termination + * + * @param distanceFromFtp distance before end of arc + * @param ftp arc exit point + * @param centreFix arc centre fix + * @param sweepAngle angle swept around the arc, +ve for clockwise + */ +export function pointOnArc( + distanceFromFtp: NauticalMiles, + ftp: Coordinates, + centreFix: Coordinates, + sweepAngle: Degrees, +): Coordinates { + const radius = distanceTo(centreFix, ftp); + const distanceRatio = distanceFromFtp / arcLength(radius, sweepAngle); + const angleFromFtp = -distanceRatio * sweepAngle; + + const centerToTerminationBearing = bearingTo(centreFix, ftp); + + return placeBearingDistance( + centreFix, + MathUtils.clampAngle(centerToTerminationBearing + angleFromFtp), + radius, + ); +} + +export function minBank(segment: SegmentType): Degrees { + return segment === SegmentType.Enroute ? 5 : 10; +} + +/** + * + * @param tas + * @param pathCapture true when the turn is to capture a path or heading, or for curved legs + * @returns + */ +export function maxBank(tas: Knots, pathCapture: boolean): Degrees { + /* + TODO + if (engineOut) { + return 15; + } + */ + + if (pathCapture) { + // roll limit 2 from honeywell doc + if (tas < 100) { + return 15 + (tas / 10); + } + if (tas > 350) { + return 19 + Math.max(0, ((450 - tas) * 6 / 100)); + } + return 25; + } + // roll limit 1 + if (tas < 150) { + return 15 + (tas / 10); + } + if (tas > 300) { + return 19 + Math.max(0, ((450 - tas) * 11 / 150)); + } + return 30; +} + +/** + * Returns the largest acceptable turn anticipation distance for a given true air speed + * + * @param tas the current or predicted true airspeed + */ +export function maxTad(tas: Knots | undefined): NauticalMiles { + if (tas === undefined) { + return 10; + } + + if (tas <= 100) { + return 4; + } if (tas >= 100 && tas <= 400) { + return (tas / 100) * 4; + } + return 16; +} + +export function courseToFixDistanceToGo(ppos: Coordinates, course: Degrees, fix: Coordinates): NauticalMiles { + const pposToFixBearing = bearingTo(ppos, fix); + const pposToFixDist = distanceTo(ppos, fix); + + const pposToFixAngle = MathUtils.diffAngle(pposToFixBearing, course); + + return Math.max(0, pposToFixDist * Math.cos(pposToFixAngle * Math.PI / 180)); +} + +export function courseToFixGuidance(ppos: Coordinates, trueTrack: Degrees, course: Degrees, fix: Coordinates): LateralPathGuidance { + const pposToFixBearing = bearingTo(ppos, fix); + const pposToFixDist = distanceTo(ppos, fix); + + const pposToFixAngle = MathUtils.diffAngle(course, pposToFixBearing); + + const crossTrackError = pposToFixDist * Math.sin(pposToFixAngle * Math.PI / 180); + + const trackAngleError = MathUtils.diffAngle(trueTrack, course); + + return { + law: ControlLaw.LATERAL_PATH, + trackAngleError, + crossTrackError, + phiCommand: 0, + }; +} + +export enum PointSide { + Before, + After, +} + +/** + * Returns the side of a fix (considering a course inbound to that fix) a point is lying on, assuming they lie on the same + * great circle. + * + * @param fix destination fix + * @param course course to the fix + * @param point point to compare with + * + * @returns `-1` if the point is before the fix, `1` if the point is after the fix + */ +export function sideOfPointOnCourseToFix(fix: Coordinates, course: DegreesTrue, point: Coordinates): PointSide { + const bearingFixPoint = bearingTo(fix, point); + + const onOtherSide = Math.abs(MathUtils.diffAngle(bearingFixPoint, course)) < 3; + + if (onOtherSide) { + return PointSide.After; + } + + return PointSide.Before; +} + +function getAlongTrackDistanceTo(start: Coordinates, end: Coordinates, ppos: Coordinates): number { + const R = Constants.EARTH_RADIUS_NM; + + const d13 = distanceTo(start, ppos) / R; + const Theta13 = MathUtils.DEGREES_TO_RADIANS * bearingTo(start, ppos); + const Theta12 = MathUtils.DEGREES_TO_RADIANS * bearingTo(start, end); + + const deltaXt = Math.asin(Math.sin(d13) * Math.sin(Theta13 - Theta12)); + + const deltaAt = Math.acos(Math.cos(d13) / Math.abs(Math.cos(deltaXt))); + + return deltaAt * Math.sign(Math.cos(Theta12 - Theta13)) * R; +} + +export function getIntermediatePoint(start: Coordinates, end: Coordinates, fraction: number): Coordinates { + const Phi1 = start.lat * MathUtils.DEGREES_TO_RADIANS; + const Gamma1 = start.long * MathUtils.DEGREES_TO_RADIANS; + const Phi2 = end.lat * MathUtils.DEGREES_TO_RADIANS; + const Gamma2 = end.long * MathUtils.DEGREES_TO_RADIANS; + + const deltaPhi = Phi2 - Phi1; + const deltaGamma = Gamma2 - Gamma1; + + const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) + Math.cos(Phi1) * Math.cos(Phi2) * Math.sin(deltaGamma / 2) * Math.sin(deltaGamma / 2); + const delta = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + const A = Math.sin((1 - fraction) * delta) / Math.sin(delta); + const B = Math.sin(fraction * delta) / Math.sin(delta); + + const x = A * Math.cos(Phi1) * Math.cos(Gamma1) + B * Math.cos(Phi2) * Math.cos(Gamma2); + const y = A * Math.cos(Phi1) * Math.sin(Gamma1) + B * Math.cos(Phi2) * Math.sin(Gamma2); + const z = A * Math.sin(Phi1) + B * Math.sin(Phi2); + + const Phi3 = Math.atan2(z, Math.sqrt(x * x + y * y)); + const Gamma3 = Math.atan2(y, x); + + return { + lat: Phi3 * MathUtils.RADIANS_TO_DEGREES, + long: Gamma3 * MathUtils.RADIANS_TO_DEGREES, + }; +} + +export function fixToFixGuidance(ppos: Coordinates, trueTrack: DegreesTrue, from: Coordinates, to: Coordinates): LateralPathGuidance { + // Track angle error + const totalTrackDistance = distanceTo(from, to); + const alongTrackDistance = getAlongTrackDistanceTo(from, to, ppos); + + const intermediatePoint = getIntermediatePoint(from, to, Math.min(Math.max(alongTrackDistance / totalTrackDistance, 0.05), 0.95)); + + const desiredTrack = bearingTo(intermediatePoint, to); + const trackAngleError = MathUtils.mod(desiredTrack - trueTrack + 180, 360) - 180; + + // Cross track error + const bearingAC = bearingTo(from, ppos); + const bearingAB = bearingTo(from, to); + const distanceAC = distanceTo(from, ppos); + + const desiredOffset = 0; + const actualOffset = ( + Math.asin( + Math.sin(MathUtils.DEGREES_TO_RADIANS * (distanceAC / Constants.EARTH_RADIUS_NM)) + * Math.sin(MathUtils.DEGREES_TO_RADIANS * (bearingAC - bearingAB)), + ) * MathUtils.RADIANS_TO_DEGREES + ) * Constants.EARTH_RADIUS_NM; + const crossTrackError = desiredOffset - actualOffset; + + return { + law: ControlLaw.LATERAL_PATH, + trackAngleError, + crossTrackError, + phiCommand: 0, + }; +} + +export function arcLength(radius: NauticalMiles, sweepAngle: Degrees): NauticalMiles { + const circumference = 2 * Math.PI * radius; + + return circumference / 360 * Math.abs(sweepAngle); +} + +export function reciprocal(course: Degrees): Degrees { + return MathUtils.clampAngle(course + 180); +} + +export function getRollAnticipationDistance(gs: Knots, bankA: Degrees, bankB: Degrees): NauticalMiles { + // calculate delta phi + const deltaPhi = Math.abs(bankA - bankB); + + // calculate RAD + const maxRollRate = 5; // deg / s, TODO picked off the wind + const k2 = 0.0038; + const rad = gs / 3600 * (Math.sqrt(1 + 2 * k2 * 9.81 * deltaPhi / maxRollRate) - 1) / (k2 * 9.81); + + return rad; +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/LnavDriver.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/LnavDriver.ts new file mode 100644 index 00000000000..af189a7d728 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/LnavDriver.ts @@ -0,0 +1,503 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { ControlLaw, LateralMode, VerticalMode } from '@shared/autopilot'; +import { MathUtils } from '@shared/MathUtils'; +import { Geometry } from '@fmgc/guidance/Geometry'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { maxBank } from '@fmgc/guidance/lnav/CommonGeometry'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { PathCaptureTransition } from '@fmgc/guidance/lnav/transitions/PathCaptureTransition'; +import { CourseCaptureTransition } from '@fmgc/guidance/lnav/transitions/CourseCaptureTransition'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { GuidanceConstants } from '@fmgc/guidance/GuidanceConstants'; +import { VMLeg } from '@fmgc/guidance/lnav/legs/VM'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { FmgcFlightPhase } from '@shared/flightphase'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { bearingTo, distanceTo } from 'msfs-geo'; +import { MagVar } from '@shared/MagVar'; +import { GuidanceController } from '../GuidanceController'; +import { GuidanceComponent } from '../GuidanceComponent'; + +/** + * Represents the current turn state of the LNAV driver + */ +export enum LnavTurnState { + /** + * No turn direction is being forced + */ + Normal, + + /** + * A left turn is being forced using phi_command + */ + ForceLeftTurn, + + /** + * A right turn is being forced using phi_command + */ + ForceRightTurn, +} + +export class LnavDriver implements GuidanceComponent { + private guidanceController: GuidanceController; + + private lastAvail: boolean; + + private lastLaw: ControlLaw; + + private lastXTE: number; + + private lastTAE: number; + + private lastPhi: number; + + public turnState = LnavTurnState.Normal; + + public ppos: LatLongAlt = new LatLongAlt(); + + private listener = RegisterViewListener('JS_LISTENER_SIMVARS', null, true); + + constructor(guidanceController: GuidanceController) { + this.guidanceController = guidanceController; + this.lastAvail = null; + this.lastLaw = null; + this.lastXTE = null; + this.lastTAE = null; + this.lastPhi = null; + } + + init(): void { + console.log('[FMGC/Guidance] LnavDriver initialized!'); + } + + update(_: number): void { + let available = false; + + this.ppos.lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'); + this.ppos.long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'); + + const geometry = this.guidanceController.activeGeometry; + + const activeLegIdx = this.guidanceController.activeLegIndex; + + if (geometry && geometry.legs.size > 0) { + const dtg = geometry.getDistanceToGo(this.guidanceController.activeLegIndex, this.ppos); + + const inboundTrans = geometry.transitions.get(activeLegIdx - 1); + const activeLeg = geometry.legs.get(activeLegIdx); + const outboundTrans = geometry.transitions.get(activeLegIdx) ? geometry.transitions.get(activeLegIdx) : null; + + if (!activeLeg) { + if (LnavConfig.DEBUG_GUIDANCE) { + console.log('[FMS/LNAV] No leg at activeLegIdx!'); + } + + return; + } + + let completeDisplayLegPathDtg; + if (inboundTrans instanceof FixedRadiusTransition && !inboundTrans.isNull) { + if (inboundTrans.isAbeam(this.ppos)) { + const inboundHalfDistance = inboundTrans.distance / 2; + const inboundDtg = inboundTrans.getDistanceToGo(this.ppos); + + if (inboundDtg > inboundHalfDistance) { + completeDisplayLegPathDtg = inboundDtg - inboundHalfDistance; + } + } + } + + const completeLegPathDtg = Geometry.completeLegPathDistanceToGo( + this.ppos, + activeLeg, + inboundTrans, + outboundTrans, + ); + + this.guidanceController.activeLegDtg = dtg; + this.guidanceController.activeLegCompleteLegPathDtg = completeLegPathDtg; + this.guidanceController.displayActiveLegCompleteLegPathDtg = completeDisplayLegPathDtg; + + // Update activeTransIndex in GuidanceController + if (inboundTrans && inboundTrans.isAbeam(this.ppos)) { + this.guidanceController.activeTransIndex = activeLegIdx - 1; + } else if (outboundTrans && outboundTrans.isAbeam(this.ppos)) { + this.guidanceController.activeTransIndex = activeLegIdx; + } else { + this.guidanceController.activeTransIndex = -1; + } + + // Pseudo waypoint sequencing + + // FIXME when we have a path model, we don't have to do any of this business ? + // FIXME see PseudoWaypoints.ts:153 for why we also allow the previous leg + const pseudoWaypointsOnActiveLeg = this.guidanceController.currentPseudoWaypoints + .filter((it) => it.alongLegIndex === activeLegIdx || it.alongLegIndex === activeLegIdx - 1); + + for (const pseudoWaypoint of pseudoWaypointsOnActiveLeg) { + // FIXME as with the hack above, we use the dtg to the intermediate point of the transition instead of + // completeLegPathDtg, since we are pretending the previous leg is still active + let dtgToUse; + if (inboundTrans instanceof FixedRadiusTransition && pseudoWaypoint.alongLegIndex === activeLegIdx - 1) { + const inboundHalfDistance = inboundTrans.distance / 2; + const inboundDtg = inboundTrans.getDistanceToGo(this.ppos); + + if (inboundDtg > inboundHalfDistance) { + dtgToUse = inboundDtg - inboundHalfDistance; + } else { + dtgToUse = completeLegPathDtg; + } + } else { + dtgToUse = completeLegPathDtg; + } + + if (pseudoWaypoint.distanceFromLegTermination >= dtgToUse) { + this.guidanceController.sequencePseudoWaypoint(pseudoWaypoint); + } + } + + // Leg sequencing + + // TODO FIXME: Use FM position + + const trueTrack = SimVar.GetSimVarValue('GPS GROUND TRUE TRACK', 'degree'); + + // this is not the correct groundspeed to use, but it will suffice for now + const tas = SimVar.GetSimVarValue('AIRSPEED TRUE', 'Knots'); + const gs = SimVar.GetSimVarValue('GPS GROUND SPEED', 'knots'); + + const params = geometry.getGuidanceParameters(activeLegIdx, this.ppos, trueTrack, gs, tas); + + if (params) { + if (this.lastLaw !== params.law) { + this.lastLaw = params.law; + + SimVar.SetSimVarValue('L:A32NX_FG_CURRENT_LATERAL_LAW', 'number', params.law); + } + + // Send bank limit to FG + const bankLimit = params?.phiLimit ?? maxBank(tas, false); + + SimVar.SetSimVarValue('L:A32NX_FG_PHI_LIMIT', 'Degrees', bankLimit); + + switch (params.law) { + case ControlLaw.LATERAL_PATH: + let { + crossTrackError, + trackAngleError, + phiCommand, + } = params; + + // Update and take into account turn state; only guide using phi during a forced turn + + if (this.turnState !== LnavTurnState.Normal) { + if (Math.abs(trackAngleError) < GuidanceConstants.FORCED_TURN_TKAE_THRESHOLD) { + // Stop forcing turn + this.turnState = LnavTurnState.Normal; + } + + const forcedTurnPhi = this.turnState === LnavTurnState.ForceLeftTurn ? -maxBank(tas, true) : maxBank(tas, true); + + crossTrackError = 0; + trackAngleError = 0; + phiCommand = forcedTurnPhi; + } + + // Set FG inputs + + if (!this.lastAvail) { + SimVar.SetSimVarValue('L:A32NX_FG_AVAIL', 'Bool', true); + this.lastAvail = true; + } + + if (crossTrackError !== this.lastXTE) { + SimVar.SetSimVarValue('L:A32NX_FG_CROSS_TRACK_ERROR', 'nautical miles', crossTrackError); + this.lastXTE = crossTrackError; + } + + if (trackAngleError !== this.lastTAE) { + SimVar.SetSimVarValue('L:A32NX_FG_TRACK_ANGLE_ERROR', 'degree', trackAngleError); + this.lastTAE = trackAngleError; + } + + if (phiCommand !== this.lastPhi) { + SimVar.SetSimVarValue('L:A32NX_FG_PHI_COMMAND', 'degree', phiCommand); + this.lastPhi = phiCommand; + } + + break; + case ControlLaw.HEADING: + const { heading, phiCommand: forcedPhiHeading } = params; + + if (!this.lastAvail) { + SimVar.SetSimVarValue('L:A32NX_FG_AVAIL', 'Bool', true); + this.lastAvail = true; + } + + if (this.lastXTE !== 0) { + SimVar.SetSimVarValue('L:A32NX_FG_CROSS_TRACK_ERROR', 'nautical miles', 0); + this.lastXTE = 0; + } + + // Track Angle Error + const currentHeading = SimVar.GetSimVarValue('PLANE HEADING DEGREES TRUE', 'Degrees'); + const deltaHeading = MathUtils.diffAngle(currentHeading, heading); + + // Update and take into account turn state; only guide using phi during a forced turn + + if (this.turnState !== LnavTurnState.Normal) { + if (Math.abs(deltaHeading) < GuidanceConstants.FORCED_TURN_TKAE_THRESHOLD) { + // Stop forcing turn + this.turnState = LnavTurnState.Normal; + } + + const forcedTurnPhi = this.turnState === LnavTurnState.ForceLeftTurn ? -maxBank(tas, true) : maxBank(tas, true); + + if (forcedTurnPhi !== this.lastPhi) { + SimVar.SetSimVarValue('L:A32NX_FG_PHI_COMMAND', 'degree', forcedTurnPhi); + this.lastPhi = forcedTurnPhi; + } + + if (this.lastTAE !== 0) { + SimVar.SetSimVarValue('L:A32NX_FG_TRACK_ANGLE_ERROR', 'degree', 0); + this.lastTAE = 0; + } + } else { + if (deltaHeading !== this.lastTAE) { + SimVar.SetSimVarValue('L:A32NX_FG_TRACK_ANGLE_ERROR', 'degree', deltaHeading); + this.lastTAE = deltaHeading; + } + + if (forcedPhiHeading !== undefined) { + if (forcedPhiHeading !== this.lastPhi) { + SimVar.SetSimVarValue('L:A32NX_FG_PHI_COMMAND', 'degree', forcedPhiHeading); + this.lastPhi = forcedPhiHeading; + } + } else if (this.lastPhi !== 0) { + SimVar.SetSimVarValue('L:A32NX_FG_PHI_COMMAND', 'degree', 0); + this.lastPhi = 0; + } + } + + break; + case ControlLaw.TRACK: + const { course, phiCommand: forcedPhiCourse } = params; + + if (!this.lastAvail) { + SimVar.SetSimVarValue('L:A32NX_FG_AVAIL', 'Bool', true); + this.lastAvail = true; + } + + if (this.lastXTE !== 0) { + SimVar.SetSimVarValue('L:A32NX_FG_CROSS_TRACK_ERROR', 'nautical miles', 0); + this.lastXTE = 0; + } + + const deltaCourse = MathUtils.diffAngle(trueTrack, course); + + if (this.turnState !== LnavTurnState.Normal) { + if (Math.abs(deltaCourse) < GuidanceConstants.FORCED_TURN_TKAE_THRESHOLD) { + // Stop forcing turn + this.turnState = LnavTurnState.Normal; + } + + const forcedTurnPhi = this.turnState === LnavTurnState.ForceLeftTurn ? -maxBank(tas, true) : maxBank(tas, true); + + if (forcedTurnPhi !== this.lastPhi) { + SimVar.SetSimVarValue('L:A32NX_FG_PHI_COMMAND', 'degree', forcedTurnPhi); + this.lastPhi = forcedTurnPhi; + } + + if (this.lastTAE !== 0) { + SimVar.SetSimVarValue('L:A32NX_FG_TRACK_ANGLE_ERROR', 'degree', 0); + this.lastTAE = 0; + } + } else { + if (deltaCourse !== this.lastTAE) { + SimVar.SetSimVarValue('L:A32NX_FG_TRACK_ANGLE_ERROR', 'degree', deltaCourse); + this.lastTAE = deltaCourse; + } + + if (forcedPhiCourse !== undefined) { + if (forcedPhiCourse !== this.lastPhi) { + SimVar.SetSimVarValue('L:A32NX_FG_PHI_COMMAND', 'degree', forcedPhiCourse); + this.lastPhi = forcedPhiCourse; + } + } else if (this.lastPhi !== 0) { + SimVar.SetSimVarValue('L:A32NX_FG_PHI_COMMAND', 'degree', 0); + this.lastPhi = 0; + } + } + break; + default: + break; + } + + available = true; + } else if (DEBUG) { + console.error('[FMS/LNAV] Guidance parameters from geometry are null.'); + } + + if (LnavConfig.DEBUG_GUIDANCE) { + SimVar.SetSimVarValue('L:A32NX_FM_TURN_STATE', 'Enum', this.turnState); + } + + SimVar.SetSimVarValue('L:A32NX_GPS_WP_DISTANCE', 'nautical miles', dtg ?? 0); + + // Update EFIS active waypoint info + + this.updateEfisData(activeLeg, gs); + + // Sequencing + + const flightPhase = SimVar.GetSimVarValue('L:A32NX_FMGC_FLIGHT_PHASE', 'Enum') as FmgcFlightPhase; + + const canSequence = !activeLeg.disableAutomaticSequencing && flightPhase >= FmgcFlightPhase.Takeoff; + + let withinSequencingArea = true; + if (params.law === ControlLaw.LATERAL_PATH) { + withinSequencingArea = Math.abs(params.crossTrackError) < 7 && Math.abs(params.trackAngleError) < 90; + } + + if ((canSequence && withinSequencingArea && geometry.shouldSequenceLeg(activeLegIdx, this.ppos)) || activeLeg.isNull) { + const outboundTransition = geometry.transitions.get(activeLegIdx); + const nextLeg = geometry.legs.get(activeLegIdx + 1); + const followingLeg = geometry.legs.get(activeLegIdx + 2); + + if (nextLeg) { + this.sequenceLeg(activeLeg, outboundTransition); + geometry.onLegSequenced(activeLeg, nextLeg, followingLeg); + } else { + this.sequenceDiscontinuity(activeLeg); + geometry.onLegSequenced(activeLeg, nextLeg, followingLeg); + } + } + } + + /* Set FG parameters */ + + if (!available && this.lastAvail !== false) { + SimVar.SetSimVarValue('L:A32NX_FG_AVAIL', 'Bool', false); + SimVar.SetSimVarValue('L:A32NX_FG_CROSS_TRACK_ERROR', 'nautical miles', 0); + SimVar.SetSimVarValue('L:A32NX_FG_TRACK_ANGLE_ERROR', 'degree', 0); + SimVar.SetSimVarValue('L:A32NX_FG_PHI_COMMAND', 'degree', 0); + + this.lastAvail = false; + this.lastTAE = null; + this.lastXTE = null; + this.lastPhi = null; + this.turnState = LnavTurnState.Normal; + } + } + + /** + * Updates the EFIS TO WPT data + * + * @param activeLeg currently active display leg + * @param gs current ground speed in knots + * + * @private + */ + private updateEfisData(activeLeg: Leg, gs: Knots) { + const termination = activeLeg instanceof XFLeg ? activeLeg.fix.location : activeLeg.getPathEndPoint(); + + const efisBearing = termination ? MagVar.trueToMagnetic( + bearingTo(this.ppos, termination), + MagVar.getMagVar(this.ppos), + ) : -1; + + // Don't compute distance and ETA for XM legs + const efisDistance = activeLeg instanceof VMLeg ? -1 : distanceTo(this.ppos, termination); + const efisEta = activeLeg instanceof VMLeg ? -1 : LnavDriver.legEta(this.ppos, gs, termination); + + // FIXME should be NCD if no FM position + + SimVar.SetSimVarValue('L:A32NX_EFIS_L_TO_WPT_BEARING', 'Degrees', efisBearing); + SimVar.SetSimVarValue('L:A32NX_EFIS_L_TO_WPT_DISTANCE', 'Number', efisDistance); + SimVar.SetSimVarValue('L:A32NX_EFIS_L_TO_WPT_ETA', 'Seconds', efisEta); + + SimVar.SetSimVarValue('L:A32NX_EFIS_R_TO_WPT_BEARING', 'Degrees', efisBearing); + SimVar.SetSimVarValue('L:A32NX_EFIS_R_TO_WPT_DISTANCE', 'Number', efisDistance); + SimVar.SetSimVarValue('L:A32NX_EFIS_R_TO_WPT_ETA', 'Seconds', efisEta); + } + + private static legEta(ppos: Coordinates, gs: Knots, termination: Coordinates): number { + // FIXME use a more accurate estimate, calculate in predictions + + const UTC_SECONDS = Math.floor(SimVar.GetGlobalVarValue('ZULU TIME', 'seconds')); + + const nauticalMilesToGo = distanceTo(ppos, termination); + const secondsToGo = (nauticalMilesToGo / Math.max(LnavConfig.DEFAULT_MIN_PREDICTED_TAS, gs)) * 3600; + + const eta = (UTC_SECONDS + secondsToGo) % (3600 * 24); + + return eta; + } + + sequenceLeg(leg?: Leg, outboundTransition?: Transition): void { + FlightPlanService.active.sequence(); + + console.log(`[FMGC/Guidance] LNAV - sequencing leg. [new Index: ${FlightPlanService.active.activeLegIndex}]`); + + outboundTransition?.freeze(); + + // Set turn state based on turn direction + if (outboundTransition && (outboundTransition instanceof PathCaptureTransition || outboundTransition instanceof CourseCaptureTransition)) { + if (outboundTransition.turnDirection === TurnDirection.Left) { + this.turnState = LnavTurnState.ForceLeftTurn; + } else if (outboundTransition.turnDirection === TurnDirection.Right) { + this.turnState = LnavTurnState.ForceRightTurn; + } else { + // Just to be safe + this.turnState = LnavTurnState.Normal; + } + } else { + this.turnState = LnavTurnState.Normal; + } + } + + sequenceDiscontinuity(_leg?: Leg): void { + console.log('[FMGC/Guidance] LNAV - sequencing discontinuity'); + + // Lateral mode is NAV + const lateralModel = SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_MODE', 'Enum'); + const verticalMode = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Enum'); + + let reverted = false; + + if (lateralModel === LateralMode.NAV) { + // Set HDG (current heading) + SimVar.SetSimVarValue('H:A320_Neo_FCU_HDG_PULL', 'number', 0); + SimVar.SetSimVarValue('L:A32NX_AUTOPILOT_HEADING_SELECTED', 'number', Simplane.getHeadingMagnetic()); + reverted = true; + } + + if (verticalMode === VerticalMode.DES) { + // revert to V/S + SimVar.SetSimVarValue('H:A320_Neo_FCU_VS_PULL', 'number', 0); + reverted = true; + } else if (verticalMode === VerticalMode.CLB) { + // revert to OP CLB + SimVar.SetSimVarValue('H:A320_Neo_FCU_ALT_PULL', 'number', 0); + reverted = true; + } + + if (reverted) { + // Triple click + Coherent.call('PLAY_INSTRUMENT_SOUND', '3click').catch(console.error); + } + + this.sequenceLeg(_leg, null); + } + + sequenceManual(_leg?: Leg): void { + console.log('[FMGC/Guidance] LNAV - sequencing MANUAL'); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/PathVector.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/PathVector.ts new file mode 100644 index 00000000000..3617fd56f66 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/PathVector.ts @@ -0,0 +1,79 @@ +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { arcLength, pointOnArc, pointOnCourseToFix } from '@fmgc/guidance/lnav/CommonGeometry'; +import { bearingTo, distanceTo } from 'msfs-geo'; + +export enum PathVectorType { + Line, + Arc, + DebugPoint, +} + +export enum DebugPointColour { + White, + Green, + Yellow, + Cyan, + Magenta, +} + +export interface ArcPathVector { + type: PathVectorType.Arc, + startPoint: Coordinates, + endPoint: Coordinates, + centrePoint: Coordinates, + sweepAngle: Degrees, +} + +export interface LinePathVector { + type: PathVectorType.Line, + startPoint: Coordinates, + endPoint: Coordinates, +} + +export interface DebugPointPathVector { + type: PathVectorType.DebugPoint, + startPoint: Coordinates, + annotation?: string, + colour?: DebugPointColour, +} + +export type PathVector = LinePathVector | ArcPathVector | DebugPointPathVector + +export function pathVectorLength(vector: PathVector) { + if (vector.type === PathVectorType.Line) { + return distanceTo(vector.startPoint, vector.endPoint); + } + + if (vector.type === PathVectorType.Arc) { + const radius = distanceTo(vector.startPoint, vector.centrePoint); + + return arcLength(radius, vector.sweepAngle); + } + + return 0; +} + +export function pathVectorValid(vector: PathVector) { + switch (vector.type) { + case PathVectorType.Line: + return !!(vector.startPoint?.lat && vector.endPoint?.lat); + case PathVectorType.Arc: + return !!(vector.endPoint?.lat && vector.centrePoint?.lat && vector.sweepAngle); + case PathVectorType.DebugPoint: + return !!vector.startPoint?.lat; + default: + return true; + } +} + +export function pathVectorPoint(vector: PathVector, distanceFromEnd: NauticalMiles): Coordinates | undefined { + if (vector.type === PathVectorType.Line) { + return pointOnCourseToFix(distanceFromEnd, bearingTo(vector.startPoint, vector.endPoint), vector.endPoint); + } + + if (vector.type === PathVectorType.Arc) { + return pointOnArc(distanceFromEnd, vector.endPoint, vector.centrePoint, vector.sweepAngle); + } + + return undefined; +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/PseudoWaypoints.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/PseudoWaypoints.ts new file mode 100644 index 00000000000..e2db1d181ec --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/PseudoWaypoints.ts @@ -0,0 +1,324 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { GuidanceComponent } from '@fmgc/guidance/GuidanceComponent'; +import { PseudoWaypoint, PseudoWaypointSequencingAction } from '@fmgc/guidance/PsuedoWaypoint'; +import { VnavConfig, VnavDescentMode } from '@fmgc/guidance/vnav/VnavConfig'; +import { NdSymbolTypeFlags } from '@shared/NavigationDisplay'; +import { Geometry } from '@fmgc/guidance/Geometry'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { WaypointStats } from '@fmgc/flightplanning/data/flightplan'; +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { LateralMode } from '@shared/autopilot'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; + +const PWP_IDENT_TOD = '(T/D)'; +const PWP_IDENT_DECEL = '(DECEL)'; +const PWP_IDENT_FLAP1 = '(FLAP1)'; +const PWP_IDENT_FLAP2 = '(FLAP2)'; + +export class PseudoWaypoints implements GuidanceComponent { + pseudoWaypoints: PseudoWaypoint[] = []; + + constructor(private guidanceController: GuidanceController) { } + + acceptVerticalProfile() { + if (DEBUG) { + console.log('[FMS/PWP] Computed new pseudo waypoints because of new vertical profile.'); + } + this.recompute(); + } + + acceptMultipleLegGeometry(_geometry: Geometry) { + if (DEBUG) { + console.log('[FMS/PWP] Computed new pseudo waypoints because of new lateral geometry.'); + } + this.recompute(); + } + + private recompute() { + const geometry = this.guidanceController.activeGeometry; + const wptCount = FlightPlanService.active.allLegs.length; + + if (!geometry || geometry.legs.size < 1) { + this.pseudoWaypoints.length = 0; + return; + } + + const newPseudoWaypoints: PseudoWaypoint[] = []; + + if (VnavConfig.VNAV_EMIT_TOD) { + const tod = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentDescentProfile.tod, DEBUG && PWP_IDENT_TOD); + + if (tod) { + const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = tod; + + newPseudoWaypoints.push({ + ident: PWP_IDENT_TOD, + sequencingType: PseudoWaypointSequencingAction.TOD_REACHED, + alongLegIndex, + distanceFromLegTermination, + efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent, + efisSymbolLla, + displayedOnMcdu: true, + stats: PseudoWaypoints.computePseudoWaypointStats(PWP_IDENT_TOD, geometry.legs.get(alongLegIndex), distanceFromLegTermination), + }); + } + } + + if (VnavConfig.VNAV_EMIT_DECEL) { + const decel = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentApproachProfile.decel, DEBUG && PWP_IDENT_DECEL); + + if (decel) { + const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = decel; + + newPseudoWaypoints.push({ + ident: PWP_IDENT_DECEL, + sequencingType: PseudoWaypointSequencingAction.APPROACH_PHASE_AUTO_ENGAGE, + alongLegIndex, + distanceFromLegTermination, + efisSymbolFlag: NdSymbolTypeFlags.PwpDecel, + efisSymbolLla, + displayedOnMcdu: true, + stats: PseudoWaypoints.computePseudoWaypointStats(PWP_IDENT_DECEL, geometry.legs.get(alongLegIndex), distanceFromLegTermination), + }); + } + + // for (let i = 0; i < 75; i++) { + // const point = PseudoWaypoints.pointFromEndOfPath(geometry, this.guidanceController.vnavDriver.currentApproachProfile.decel + i / 2, `(BRUH${i}`); + // + // if (point) { + // const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = point; + // + // newPseudoWaypoints.push({ + // ident: `(BRUH${i})`, + // sequencingType: PseudoWaypointSequencingAction.TOD_REACHED, + // alongLegIndex, + // distanceFromLegTermination, + // efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent, + // efisSymbolLla, + // displayedOnMcdu: true, + // stats: PseudoWaypoints.computePseudoWaypointStats(`(BRUH${i})`, geometry.legs.get(alongLegIndex), distanceFromLegTermination), + // }); + // } + // } + } + + if (VnavConfig.VNAV_DESCENT_MODE === VnavDescentMode.CDA && VnavConfig.VNAV_EMIT_CDA_FLAP_PWP) { + const flap1 = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentApproachProfile.flap1, DEBUG && PWP_IDENT_FLAP1); + + if (flap1) { + const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = flap1; + + newPseudoWaypoints.push({ + ident: PWP_IDENT_FLAP1, + alongLegIndex, + distanceFromLegTermination, + efisSymbolFlag: NdSymbolTypeFlags.PwpCdaFlap1, + efisSymbolLla, + displayedOnMcdu: true, + stats: PseudoWaypoints.computePseudoWaypointStats(PWP_IDENT_FLAP1, geometry.legs.get(alongLegIndex), distanceFromLegTermination), + }); + } + + const flap2 = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentApproachProfile.flap2, DEBUG && PWP_IDENT_FLAP2); + + if (flap2) { + const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = flap2; + + newPseudoWaypoints.push({ + ident: PWP_IDENT_FLAP2, + alongLegIndex, + distanceFromLegTermination, + efisSymbolFlag: NdSymbolTypeFlags.PwpCdaFlap2, + efisSymbolLla, + displayedOnMcdu: true, + stats: PseudoWaypoints.computePseudoWaypointStats(PWP_IDENT_FLAP2, geometry.legs.get(alongLegIndex), distanceFromLegTermination), + }); + } + } + + this.pseudoWaypoints = newPseudoWaypoints; + } + + init() { + console.log('[FMGC/Guidance] PseudoWaypoints initialized!'); + } + + update(_: number) { + // Pass our pseudo waypoints to the GuidanceController + this.guidanceController.currentPseudoWaypoints.length = 0; + + let idx = 0; + for (const pseudoWaypoint of this.pseudoWaypoints) { + const onPreviousLeg = pseudoWaypoint.alongLegIndex === this.guidanceController.activeLegIndex - 1; + const onActiveLeg = pseudoWaypoint.alongLegIndex === this.guidanceController.activeLegIndex; + const afterActiveLeg = pseudoWaypoint.alongLegIndex > this.guidanceController.activeLegIndex; + + // TODO we also consider the previous leg as active because we sequence Type I transitions at the same point + // for both guidance and legs list. IRL, the display sequences after the guidance, which means the pseudo-waypoints + // on the first half of the transition are considered on the active leg, whereas without this hack they are + // on the previous leg by the time we try to re-add them to the list. + + // We only want to add the pseudo waypoint if it's after the active leg or it isn't yet passed + if ( + afterActiveLeg + || (onPreviousLeg && this.guidanceController.displayActiveLegCompleteLegPathDtg > pseudoWaypoint.distanceFromLegTermination) + || (onActiveLeg && this.guidanceController.activeLegCompleteLegPathDtg > pseudoWaypoint.distanceFromLegTermination) + ) { + this.guidanceController.currentPseudoWaypoints[++idx] = pseudoWaypoint; + } + } + } + + /** + * Notifies the FMS that a pseudo waypoint must be sequenced. + * + * This is to be sued by {@link GuidanceController} only. + * + * @param pseudoWaypoint the {@link PseudoWaypoint} to sequence. + */ + sequencePseudoWaypoint(pseudoWaypoint: PseudoWaypoint): void { + if (true) { + console.log(`[FMS/PseudoWaypoints] Pseudo-waypoint '${pseudoWaypoint.ident}' sequenced.`); + } + + switch (pseudoWaypoint.sequencingType) { + case PseudoWaypointSequencingAction.TOD_REACHED: + // TODO EFIS message; + break; + case PseudoWaypointSequencingAction.APPROACH_PHASE_AUTO_ENGAGE: + const apLateralMode = SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_MODE', 'Number'); + const agl = Simplane.getAltitudeAboveGround(); + + if (agl < 9500 && (apLateralMode === LateralMode.NAV || apLateralMode === LateralMode.LOC_CPT || apLateralMode === LateralMode.LOC_TRACK)) { + // Request APPROACH phase engagement for 5 seconds + SimVar.SetSimVarValue('L:A32NX_FM_ENABLE_APPROACH_PHASE', 'Bool', true).then(() => [ + setTimeout(() => { + SimVar.SetSimVarValue('L:A32NX_FM_ENABLE_APPROACH_PHASE', 'Bool', false); + }, 5_000), + ]); + } + break; + default: + } + } + + /** + * Computes a {@link WaypointStats} object for a pseudo waypoint + * + * @param ident the text identifier to give to this pseudo waypoint, for display on the MCDU + * @param leg the leg along which this pseudo waypoint is situated + * @param distanceAlongLeg the distance from the termination of the leg to this pseudo waypoint + * + * @private + */ + private static computePseudoWaypointStats(ident: string, leg: Leg, distanceAlongLeg: number): WaypointStats { + // TODO use predictions store to find out altitude, speed and time + return { + ident, + bearingInFp: 0, + distanceInFP: leg.distance - distanceAlongLeg, + distanceFromPpos: 0, + timeFromPpos: 0, + etaFromPpos: 0, + magneticVariation: 0, + }; + } + + private static pointFromEndOfPath( + path: Geometry, + wptCount: number, + distanceFromEnd: NauticalMiles, + debugString?: string, + ): [lla: Coordinates, distanceFromLegTermination: number, legIndex: number] | undefined { + if (distanceFromEnd < 0) { + throw new Error('[FMS/PWP](pointFromEndOfPath) distanceFromEnd was negative'); + } + + let accumulator = 0; + + if (false) { + console.log(`[FMS/PWP] Starting placement of PWP '${debugString}': dist: ${distanceFromEnd.toFixed(2)}nm`); + } + + for (let i = wptCount - 1; i > 0; i--) { + const leg = path.legs.get(i); + + if (!leg || leg.isNull) { + continue; + } + + const inboundTrans = path.transitions.get(i - 1); + const outboundTrans = path.transitions.get(i); + + const [inboundTransLength, legPartLength, outboundTransLength] = Geometry.completeLegPathLengths( + leg, + inboundTrans, + (outboundTrans instanceof FixedRadiusTransition) ? outboundTrans : null, + ); + + const totalLegPathLength = inboundTransLength + legPartLength + outboundTransLength; + accumulator += totalLegPathLength; + + if (DEBUG) { + const inb = inboundTransLength.toFixed(2); + const legd = legPartLength.toFixed(2); + const outb = outboundTransLength.toFixed(2); + const acc = accumulator.toFixed(2); + + console.log(`[FMS/PWP] Trying to place PWP '${debugString}' ${distanceFromEnd.toFixed(2)} along leg #${i}; inb: ${inb}, leg: ${legd}, outb: ${outb}, acc: ${acc}`); + } + + if (accumulator > distanceFromEnd) { + const distanceFromEndOfLeg = distanceFromEnd - (accumulator - totalLegPathLength); + + let lla; + if (distanceFromEndOfLeg < outboundTransLength) { + // Point is in outbound transition segment + const distanceBeforeTerminator = (outboundTrans.distance / 2) + distanceFromEndOfLeg; + + if (DEBUG) { + console.log(`[FMS/PWP] Placed PWP '${debugString}' on leg #${i} outbound segment (${distanceFromEndOfLeg.toFixed(2)}nm before end)`); + } + + lla = outboundTrans.getPseudoWaypointLocation(distanceBeforeTerminator); + } else if (distanceFromEndOfLeg >= outboundTransLength && distanceFromEndOfLeg < (outboundTransLength + legPartLength)) { + // Point is in leg segment + const distanceBeforeTerminator = distanceFromEndOfLeg - outboundTransLength; + + if (DEBUG) { + console.log(`[FMS/PWP] Placed PWP '${debugString}' on leg #${i} leg segment (${distanceBeforeTerminator.toFixed(2)}nm before end)`); + } + + lla = leg.getPseudoWaypointLocation(distanceBeforeTerminator); + } else { + // Point is in inbound transition segment + const distanceBeforeTerminator = distanceFromEndOfLeg - outboundTransLength - legPartLength; + + if (DEBUG) { + console.log(`[FMS/PWP] Placed PWP '${debugString}' on leg #${i} inbound segment (${distanceBeforeTerminator.toFixed(2)}nm before end)`); + } + + lla = inboundTrans.getPseudoWaypointLocation(distanceBeforeTerminator); + } + + if (lla) { + return [lla, distanceFromEndOfLeg, i]; + } + + return undefined; + } + } + + if (DEBUG) { + console.error(`[FMS/PseudoWaypoints] ${distanceFromEnd.toFixed(2)}nm is larger than the total lateral path.`); + } + + return undefined; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/Transition.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/Transition.ts new file mode 100644 index 00000000000..e72e04c8fbb --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/Transition.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { Guidable } from '@fmgc/guidance/Guidable'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; + +export enum TransitionState { + UPCOMING, + OUT_OF_ACTIVE_LEG, + ACTIVE, + INTO_ACTIVE_LEG, + PASSED, +} + +export abstract class Transition extends Guidable { + abstract isAbeam(ppos: LatLongData): boolean; + + protected constructor( + public previousLeg: Leg, + public nextLeg: Leg, + ) { + super(); + + this.inboundGuidable = previousLeg; + this.outboundGuidable = nextLeg; + } + + public isFrozen = false; + + public freeze(): void { + this.isFrozen = true; + } + + /** + * Used to update the {@link previousLeg} and {@link nextLeg} properties. + */ + setNeighboringLegs(previous: Leg, next: Leg) { + this.previousLeg = previous; + this.nextLeg = next; + } + + recomputeWithParameters( + _isActive: boolean, + _tas: Knots, + _gs: MetresPerSecond, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + ) { + // Default impl. + } + + abstract getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, tas: Knots, gs: Knots); + + abstract getDistanceToGo(ppos: Coordinates); + + abstract getTurningPoints(): [Coordinates, Coordinates]; + + abstract get distance(): NauticalMiles; + + abstract get repr(): string; +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/TransitionPicker.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/TransitionPicker.ts new file mode 100644 index 00000000000..da80447923d --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/TransitionPicker.ts @@ -0,0 +1,486 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { CALeg } from '@fmgc/guidance/lnav/legs/CA'; +import { DFLeg } from '@fmgc/guidance/lnav/legs/DF'; +import { HALeg, HFLeg, HMLeg } from '@fmgc/guidance/lnav/legs/HX'; +import { RFLeg } from '@fmgc/guidance/lnav/legs/RF'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { VMLeg } from '@fmgc/guidance/lnav/legs/VM'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { PathCaptureTransition } from '@fmgc/guidance/lnav/transitions/PathCaptureTransition'; +import { CourseCaptureTransition } from '@fmgc/guidance/lnav/transitions/CourseCaptureTransition'; +import { DirectToFixTransition } from '@fmgc/guidance/lnav/transitions/DirectToFixTransition'; +import { HoldEntryTransition } from '@fmgc/guidance/lnav/transitions/HoldEntryTransition'; +import { CFLeg } from '@fmgc/guidance/lnav/legs/CF'; +import { CRLeg } from '@fmgc/guidance/lnav/legs/CR'; +import { CILeg } from '@fmgc/guidance/lnav/legs/CI'; +import { AFLeg } from '@fmgc/guidance/lnav/legs/AF'; +import { DmeArcTransition } from '@fmgc/guidance/lnav/transitions/DmeArcTransition'; +import { CDLeg } from '@fmgc/guidance/lnav/legs/CD'; +import { FDLeg } from '@fmgc/guidance/lnav/legs/FD'; + +export class TransitionPicker { + static forLegs(from: Leg, to: Leg): Transition | null { + if (from instanceof AFLeg) { + return TransitionPicker.fromAF(from, to); + } + if (from instanceof CALeg) { + return TransitionPicker.fromCA(from, to); + } + if (from instanceof CDLeg) { + return TransitionPicker.fromCD(from, to); + } + if (from instanceof CFLeg) { + return TransitionPicker.fromCF(from, to); + } + if (from instanceof CILeg) { + return TransitionPicker.fromCI(from, to); + } + if (from instanceof CRLeg) { + return TransitionPicker.fromCR(from, to); + } + if (from instanceof DFLeg) { + return TransitionPicker.fromDF(from, to); + } + if (from instanceof FDLeg) { + return TransitionPicker.fromFD(from, to); + } + if (from instanceof HALeg || from instanceof HFLeg || from instanceof HMLeg) { + return TransitionPicker.fromHX(from, to); + } + if (from instanceof RFLeg) { + return TransitionPicker.fromRF(from, to); + } + if (from instanceof TFLeg) { + return TransitionPicker.fromTF(from, to); + } + if (from instanceof VMLeg) { + return TransitionPicker.fromVM(from, to); + } + + if (DEBUG) { + console.error(`[FMS/Geometry] Could not pick transition between '${from.repr}' and '${to.repr}'.`); + } + + return null; + } + + private static fromCA(from: CALeg, to: Leg): Transition | null { + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof FDLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof TFLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG) { + console.error(`Illegal sequence CALeg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromCD(from: CDLeg, to: Leg): Transition | null { + if (to instanceof AFLeg) { + return new DmeArcTransition(from, to); + } + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof FDLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + } + + private static fromAF(from: AFLeg, to: Leg): Transition | null { + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + // FIXME fixed radius / revert to path capture + return new PathCaptureTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof FDLeg) { + // TODO here we might wanna do a DmeArcTransition; need to check ARINC 424 and IRL FMS to see if this would actually happen + // (waypoint would need to lie on DME arc) + return new PathCaptureTransition(from, to); + } + if (to instanceof HALeg || to instanceof HFLeg || to instanceof HMLeg) { + return new HoldEntryTransition(from, to); + } + if (to instanceof TFLeg) { + return new DmeArcTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG) { + console.error(`Illegal sequence AFLEg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromCF(from: CFLeg, to: Leg): Transition | null { + if (to instanceof AFLeg) { + return new DmeArcTransition(from, to); + } + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + // FIXME fixed radius / revert to path capture + return new PathCaptureTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof FDLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof HALeg || to instanceof HFLeg || to instanceof HMLeg) { + return new HoldEntryTransition(from, to); + } + if (to instanceof TFLeg) { + return new FixedRadiusTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG) { + console.error(`Illegal sequence CFLeg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromCI(from: CILeg, to: Leg): Transition | null { + if (to instanceof AFLeg) { + return new DmeArcTransition(from, to); + } + if (to instanceof CFLeg) { + return new FixedRadiusTransition(from, to); + } + if (to instanceof FDLeg) { + return new PathCaptureTransition(from, to); + } + + if (DEBUG) { + console.error(`Illegal sequence CILeg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromDF(from: DFLeg, to: Leg): Transition | null { + if (to instanceof AFLeg) { + return new DmeArcTransition(from, to); + } + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + return new FixedRadiusTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof FDLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof HALeg || to instanceof HFLeg || to instanceof HMLeg) { + return new HoldEntryTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof TFLeg) { + return new FixedRadiusTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG && !(to instanceof RFLeg)) { + console.error(`Illegal sequence DFLeg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromHX(from: HALeg | HFLeg |HMLeg, to: Leg): Transition | null { + if (to instanceof AFLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof FDLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof TFLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG && !(to instanceof RFLeg)) { + console.error(`Illegal sequence DFLeg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromRF(from: RFLeg, to: Leg): Transition | null { + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof FDLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof HALeg || to instanceof HFLeg || to instanceof HMLeg) { + return new HoldEntryTransition(from, to); + } + + if (DEBUG && !(to instanceof RFLeg) && !(to instanceof TFLeg)) { + console.error(`Illegal sequence RFLeg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromTF(from: TFLeg, to: Leg): Transition | null { + if (to instanceof AFLeg) { + return new DmeArcTransition(from, to); + } + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + // FIXME / revert to fixed radius + return new FixedRadiusTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof FDLeg) { + const fromLegWaypointID = from.metadata.flightPlanLegDefinition.waypoint.databaseId; + const toLegWaypointID = to.metadata.flightPlanLegDefinition.waypoint.databaseId; + + // If the FD leg starts at the same fix as the one on which the TF leg ends, we can use a fixed radius transition + // instead to get a cleaner turn + if (fromLegWaypointID === toLegWaypointID) { + return new FixedRadiusTransition(from, to); + } + + return new PathCaptureTransition(from, to); + } + if (to instanceof HALeg || to instanceof HFLeg || to instanceof HMLeg) { + return new HoldEntryTransition(from, to); + } + if (to instanceof TFLeg) { + return new FixedRadiusTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG && !(to instanceof RFLeg)) { + console.error(`Illegal sequence TFLeg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromCR(from: CRLeg, to: Leg): Transition | null { + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + // FIXME / revert to fixed radius + return new PathCaptureTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof FDLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG) { + console.error(`Illegal sequence CRLeg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromFD(from: FDLeg, to: Leg) { + if (to instanceof AFLeg) { + return new DmeArcTransition(from, to); + } + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CFLeg) { + return new PathCaptureTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof VMLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG) { + console.error(`Illegal sequence FDLEg -> ${to.constructor.name}`); + } + + return null; + } + + private static fromVM(from: VMLeg, to: Leg): Transition | null { + if (to instanceof CALeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CDLeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof CILeg) { + return new CourseCaptureTransition(from, to); + } + if (to instanceof DFLeg) { + return new DirectToFixTransition(from, to); + } + if (to instanceof CRLeg) { + return new CourseCaptureTransition(from, to); + } + + if (DEBUG) { + console.error(`Illegal sequence VMLeg -> ${to.constructor.name}`); + } + + return null; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/AF.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/AF.ts new file mode 100644 index 00000000000..08fefe2aff6 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/AF.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { arcDistanceToGo, arcGuidance } from '@fmgc/guidance/lnav/CommonGeometry'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { DmeArcTransition } from '@fmgc/guidance/lnav/transitions/DmeArcTransition'; +import { MathUtils } from '@shared/MathUtils'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { bearingTo, distanceTo, placeBearingDistance } from 'msfs-geo'; +import { PathCaptureTransition } from '@fmgc/guidance/lnav/transitions/PathCaptureTransition'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { Waypoint } from 'msfs-navdata'; +import { PathVector, PathVectorType } from '../PathVector'; + +export class AFLeg extends XFLeg { + predictedPath: PathVector[] = []; + + constructor( + fix: Waypoint, + private navaid: Coordinates, + private rho: NauticalMiles, + private theta: NauticalMiles, + public boundaryRadial: NauticalMiles, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(fix); + + this.segment = segment; + + this.centre = navaid; + this.radius = distanceTo(navaid, this.fix.location); + this.terminationRadial = this.theta; + this.bearing = MathUtils.clampAngle(bearingTo(this.centre, this.fix.location) + 90 * this.turnDirectionSign); + this.arcStartPoint = placeBearingDistance(this.centre, this.boundaryRadial, this.radius); + this.arcEndPoint = placeBearingDistance(this.centre, this.terminationRadial, this.radius); + + this.inboundCourse = this.boundaryRadial + 90 * this.turnDirectionSign; + this.outboundCourse = this.terminationRadial + 90 * this.turnDirectionSign; + } + + readonly centre: Coordinates | undefined + + private readonly terminationRadial: DegreesTrue | undefined; + + private readonly bearing: DegreesTrue | undefined + + readonly arcStartPoint: Coordinates | undefined + + readonly arcEndPoint: Coordinates | undefined + + readonly radius: NauticalMiles | undefined + + private sweepAngle: Degrees | undefined + + private clockwise: boolean | undefined + + inboundCourse: DegreesTrue | undefined + + outboundCourse: DegreesTrue | undefined + + getPathStartPoint(): Coordinates | undefined { + return this.inboundGuidable instanceof DmeArcTransition ? this.inboundGuidable.getPathEndPoint() : this.arcStartPoint; + } + + getPathEndPoint(): Coordinates | undefined { + if (this.outboundGuidable instanceof DmeArcTransition && this.outboundGuidable.isComputed) { + return this.outboundGuidable.getPathStartPoint(); + } + + return this.arcEndPoint; + } + + recomputeWithParameters( + _isActive: boolean, + _tas: Knots, + _gs: Knots, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + ) { + this.sweepAngle = MathUtils.diffAngle(bearingTo(this.centre, this.getPathStartPoint()), bearingTo(this.centre, this.getPathEndPoint())); + this.clockwise = this.sweepAngle > 0; + + // We do not consider the path capture end point in this class' getPathEndPoint since that causes a race condition with the path capture + // finding its intercept point onto this leg + const startPoint = this.inboundGuidable instanceof PathCaptureTransition ? this.inboundGuidable.getPathEndPoint() : this.getPathStartPoint(); + + this.predictedPath.length = 0; + this.predictedPath.push({ + type: PathVectorType.Arc, + startPoint, + centrePoint: this.centre, + endPoint: this.getPathEndPoint(), + sweepAngle: this.sweepAngle, + }); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathStartPoint(), + annotation: 'AF ITP', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'AF FTP', + }, + ); + } + } + + public get turnDirectionSign(): 1 | -1 { + if (this.metadata.turnDirection !== TurnDirection.Right && this.metadata.turnDirection !== TurnDirection.Left) { + throw new Error('AFLeg found without specific turnDirection'); + } + + return this.constrainedTurnDirection === TurnDirection.Left ? -1 : 1; + } + + get startsInCircularArc(): boolean { + return true; + } + + get endsInCircularArc(): boolean { + return true; + } + + getNominalRollAngle(gs: MetresPerSecond): Degrees | undefined { + const gsMs = gs * (463 / 900); + return (this.clockwise ? 1 : -1) * Math.atan((gsMs ** 2) / (this.radius * 1852 * 9.81)) * (180 / Math.PI); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees): GuidanceParameters | undefined { + return arcGuidance(ppos, trueTrack, this.getPathStartPoint(), this.centre, this.sweepAngle); + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles | undefined { + return arcDistanceToGo(ppos, this.getPathStartPoint(), this.centre, this.sweepAngle); + } + + isAbeam(ppos: Coordinates): boolean { + const bearingPpos = bearingTo( + this.centre, + ppos, + ); + + const bearingFrom = bearingTo( + this.centre, + this.getPathStartPoint(), + ); + + const trackAngleError = this.clockwise ? MathUtils.diffAngle(bearingFrom, bearingPpos) : MathUtils.diffAngle(bearingPpos, bearingFrom); + + return trackAngleError >= 0; + } + + get repr(): string { + return `AF(${this.radius.toFixed(1)}NM) TO ${this.fix.ident}`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CA.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CA.ts new file mode 100644 index 00000000000..fb86bb6dc8e --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CA.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { courseToFixDistanceToGo, courseToFixGuidance } from '@fmgc/guidance/lnav/CommonGeometry'; +import { IFLeg } from '@fmgc/guidance/lnav/legs/IF'; +import { distanceTo, placeBearingDistance } from 'msfs-geo'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { PathVector, PathVectorType } from '../PathVector'; + +export class CALeg extends Leg { + public estimatedTermination: Coordinates | undefined; + + private computedPath: PathVector[] = []; + + constructor( + public readonly course: Degrees, + public readonly altitude: Feet, + public readonly metadata: Readonly, + segment: SegmentType, + private readonly extraLength?: NauticalMiles, + ) { + super(); + + this.segment = segment; + } + + private start: Coordinates; + + get terminationWaypoint(): Coordinates | undefined { + return this.estimatedTermination; + } + + get ident(): string { + return Math.round(this.altitude).toString(); + } + + getPathStartPoint(): Coordinates | undefined { + return this.inboundGuidable?.getPathEndPoint(); + } + + getPathEndPoint(): Coordinates | undefined { + return this.estimatedTermination; + } + + get predictedPath(): PathVector[] { + return this.computedPath; + } + + private wasMovedByPpos = false; + + recomputeWithParameters( + isActive: boolean, + _tas: Knots, + _gs: Knots, + ppos: Coordinates, + _trueTrack: DegreesTrue, + ) { + // FIXME somehow after reloads the isRunway property is gone, so consider airports as runways for now + const afterRunway = this.inboundGuidable instanceof IFLeg && this.inboundGuidable.fix.databaseId.startsWith('A'); + + // We assign / spread properties here to avoid copying references and causing bugs + if (isActive && !afterRunway) { + this.wasMovedByPpos = true; + + if (!this.start) { + this.start = { ...ppos }; + } else { + this.start.lat = ppos.lat; + this.start.long = ppos.long; + } + + if (!this.estimatedTermination) { + this.recomputeEstimatedTermination(); + } + } else if (!this.wasMovedByPpos) { + const newPreviousGuidableStart = this.inboundGuidable?.getPathEndPoint(); + + if (newPreviousGuidableStart) { + if (!this.start) { + this.start = { ...newPreviousGuidableStart }; + } else { + this.start.lat = newPreviousGuidableStart.lat; + this.start.long = newPreviousGuidableStart.long; + } + } + + this.recomputeEstimatedTermination(); + } + + this.computedPath = [{ + type: PathVectorType.Line, + startPoint: this.start, + endPoint: this.getPathEndPoint(), + }]; + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.computedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.start, + annotation: 'CA START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'CA END', + }, + ); + } + + this.isComputed = true; + } + + private recomputeEstimatedTermination() { + const ESTIMATED_VS = 2000; // feet per minute + const ESTIMATED_KTS = 175; // NM per hour + + // FIXME hax! + const originAltitude = 0; + // if (this.inboundGuidable instanceof IFLeg && this.inboundGuidable.fix.icao.startsWith('A')) { + // originAltitude = (this.inboundGuidable.fix.infos as AirportInfo).oneWayRunways[0].elevation * 3.28084; + // } + + const minutesToAltitude = (this.altitude - Math.max(0, originAltitude)) / ESTIMATED_VS; // minutes + let distanceToTermination = (minutesToAltitude / 60) * ESTIMATED_KTS; // NM + + if (!this.wasMovedByPpos && this.extraLength > 0) { + distanceToTermination += this.extraLength; + } + + this.estimatedTermination = placeBearingDistance( + this.start, + this.course, + distanceToTermination, + ); + } + + get inboundCourse(): Degrees { + return this.course; + } + + get outboundCourse(): Degrees { + return this.course; + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles { + return courseToFixDistanceToGo(ppos, this.course, this.estimatedTermination); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, _tas: Knots): GuidanceParameters | undefined { + // FIXME: should be just track guidance, no xtk + // (the start of the predicted path should also float with ppos once active, along with the transition to the leg) + // return { + // law: ControlLaw.TRACK, + // course: this.course, + // }; + return courseToFixGuidance(ppos, trueTrack, this.course, this.estimatedTermination); + } + + getNominalRollAngle(_gs: Knots): Degrees { + return undefined; + } + + get distanceToTermination(): NauticalMiles { + const startPoint = this.getPathStartPoint(); + + return distanceTo(startPoint, this.estimatedTermination); + } + + isAbeam(_ppos: Coordinates): boolean { + return false; + } + + get repr(): string { + return `CA(${this.course.toFixed(1)}T) TO ${Math.round(this.altitude)} FT`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CD.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CD.ts new file mode 100644 index 00000000000..17d002cca98 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CD.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { NdbNavaid, VhfNavaid, Waypoint } from 'msfs-navdata'; +import { closestSmallCircleIntersection, Coordinates, distanceTo } from 'msfs-geo'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { procedureLegIdentAndAnnotation } from '@fmgc/flightplanning/new/legs/FlightPlanLegNaming'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { LegMetadata } from './index'; +import { courseToFixDistanceToGo, fixToFixGuidance } from '../CommonGeometry'; + +export class CDLeg extends Leg { + predictedPath: PathVector[] = []; + + inboundCourse; + + outboundCourse; + + pbdPoint: Coordinates; + + constructor( + private readonly course: DegreesTrue, + private readonly dmeDistance: NauticalMiles, + private readonly origin: Waypoint | VhfNavaid | NdbNavaid, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(); + + this.segment = segment; + + this.inboundCourse = course; + this.outboundCourse = course; + } + + get terminationWaypoint(): Waypoint | Coordinates | undefined { + return this.pbdPoint; + } + + get ident(): string { + return procedureLegIdentAndAnnotation(this.metadata.flightPlanLegDefinition, '')[0]; + } + + getPathStartPoint(): Coordinates | undefined { + if (this.inboundGuidable?.isComputed) { + return this.inboundGuidable.getPathEndPoint(); + } + + return this.origin.location; + } + + getPathEndPoint(): Coordinates | undefined { + return this.pbdPoint; + } + + recomputeWithParameters(_isActive: boolean, _tas: Knots, _gs: Knots, _ppos: Coordinates, _trueTrack: DegreesTrue) { + this.predictedPath.length = 0; + + const intersect = closestSmallCircleIntersection( + this.origin.location, + this.dmeDistance, + this.getPathStartPoint(), + this.course, + ); + + this.pbdPoint = intersect; + + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: this.getPathStartPoint(), + endPoint: this.getPathEndPoint(), + }); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push({ + type: PathVectorType.DebugPoint, + startPoint: this.getPathStartPoint(), + annotation: 'CD START', + }, { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'CD END', + }); + } + + this.isComputed = true; + } + + get distanceToTermination(): NauticalMiles { + const startPoint = this.getPathStartPoint(); + + return distanceTo(startPoint, this.pbdPoint); + } + + isAbeam(ppos: Coordinates): boolean { + const dtg = this.getDistanceToGo(ppos); + + return dtg >= 0 && dtg <= this.distance; + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles | undefined { + return courseToFixDistanceToGo(ppos, this.course, this.pbdPoint); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, _tas: Knots, _gs: Knots): GuidanceParameters | undefined { + return fixToFixGuidance(ppos, trueTrack, this.getPathStartPoint(), this.pbdPoint); + } + + getNominalRollAngle(_gs: MetresPerSecond): Degrees | undefined { + return 0; + } + + get repr(): string { + return `CD(${this.dmeDistance.toFixed(1)}NM, ${this.course.toFixed(1)})`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CF.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CF.ts new file mode 100644 index 00000000000..6422c091c29 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CF.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { courseToFixDistanceToGo, courseToFixGuidance } from '@fmgc/guidance/lnav/CommonGeometry'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { Geo } from '@fmgc/utils/Geo'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { DmeArcTransition } from '@fmgc/guidance/lnav/transitions/DmeArcTransition'; +import { placeBearingDistance } from 'msfs-geo'; +import { MathUtils } from '@shared/MathUtils'; +import { Waypoint } from 'msfs-navdata'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { IFLeg } from '@fmgc/guidance/lnav/legs/IF'; +import { PathVector, PathVectorType } from '../PathVector'; + +export class CFLeg extends XFLeg { + private computedPath: PathVector[] = []; + + constructor( + fix: Waypoint, + public readonly course: DegreesTrue, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(fix); + + this.segment = segment; + } + + getPathStartPoint(): Coordinates | undefined { + if (this.inboundGuidable instanceof IFLeg) { + return this.inboundGuidable.fix.location; + } + + if (this.inboundGuidable instanceof Transition && this.inboundGuidable.isComputed) { + return this.inboundGuidable.getPathEndPoint(); + } + + if (this.outboundGuidable instanceof DmeArcTransition && this.outboundGuidable.isComputed) { + return this.outboundGuidable.getPathStartPoint(); + } + + // Estimate where we should start the leg + return this.estimateStartWithoutInboundTransition(); + } + + /** + * Based on FBW-22-07 + * + * @private + */ + private estimateStartWithoutInboundTransition(): Coordinates { + const inverseCourse = MathUtils.clampAngle(this.course + 180); + + if (this.inboundGuidable && this.inboundGuidable.isComputed) { + const prevLegTerm = this.inboundGuidable.getPathEndPoint(); + + return Geo.doublePlaceBearingIntercept( + this.getPathEndPoint(), + prevLegTerm, + inverseCourse, + MathUtils.clampAngle(inverseCourse + 90), + ); + } + + // We start the leg at (tad + 0.1) from the fix if we have a fixed radius transition outbound. This allows showing a better looking path after sequencing. + let distance = 1; + if (this.outboundGuidable instanceof FixedRadiusTransition && this.outboundGuidable.isComputed) { + distance = this.outboundGuidable.tad + 0.1; + } + + return placeBearingDistance( + this.fix.location, + inverseCourse, + distance, + ); + } + + get predictedPath(): PathVector[] { + return this.computedPath; + } + + recomputeWithParameters( + _isActive: boolean, + _tas: Knots, + _gs: Knots, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + ) { + // Is start point after the fix ? + if (this.overshot) { + this.computedPath = [{ + type: PathVectorType.Line, + startPoint: this.getPathEndPoint(), + endPoint: this.getPathEndPoint(), + }]; + } else { + this.computedPath = [{ + type: PathVectorType.Line, + startPoint: this.getPathStartPoint(), + endPoint: this.getPathEndPoint(), + }]; + } + + this.isComputed = true; + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.computedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathStartPoint(), + annotation: 'CF START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'CF END', + }, + ); + } + } + + get inboundCourse(): Degrees { + return this.course; + } + + get outboundCourse(): Degrees { + return this.course; + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles { + return courseToFixDistanceToGo(ppos, this.course, this.getPathEndPoint()); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, _tas: Knots): GuidanceParameters | undefined { + return courseToFixGuidance(ppos, trueTrack, this.course, this.getPathEndPoint()); + } + + getNominalRollAngle(_gs: Knots): Degrees { + return 0; + } + + isAbeam(ppos: Coordinates): boolean { + const dtg = courseToFixDistanceToGo(ppos, this.course, this.getPathEndPoint()); + + return dtg >= 0 && dtg <= this.distance; + } + + get repr(): string { + return `CF(${this.course.toFixed(1)}T) TO ${this.fix.ident}`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CI.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CI.ts new file mode 100644 index 00000000000..c3199862d90 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CI.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { ControlLaw, GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { courseToFixDistanceToGo, PointSide, sideOfPointOnCourseToFix } from '@fmgc/guidance/lnav/CommonGeometry'; +import { Geo } from '@fmgc/utils/Geo'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { IFLeg } from '@fmgc/guidance/lnav/legs/IF'; +import { DmeArcTransition } from '@fmgc/guidance/lnav/transitions/DmeArcTransition'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { distanceTo } from 'msfs-geo'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { PathVector, PathVectorType } from '../PathVector'; + +export class CILeg extends Leg { + private computedPath: PathVector[] = []; + + constructor( + public readonly course: DegreesTrue, + public readonly nextLeg: Leg, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(); + + this.segment = segment; + } + + intercept: Coordinates | undefined = undefined; + + get terminationWaypoint(): Coordinates { + return this.intercept; + } + + get ident(): string { + return 'INTCPT'; + } + + getPathStartPoint(): Coordinates | undefined { + if (this.inboundGuidable instanceof IFLeg) { + return this.inboundGuidable.fix.location; + } if (this.inboundGuidable && this.inboundGuidable.isComputed) { + return this.inboundGuidable.getPathEndPoint(); + } + + throw new Error('[CILeg] No computed inbound guidable.'); + } + + getPathEndPoint(): Coordinates | undefined { + if (this.outboundGuidable instanceof FixedRadiusTransition && this.outboundGuidable.isComputed) { + return this.outboundGuidable.getPathStartPoint(); + } + + if (this.outboundGuidable instanceof DmeArcTransition && this.outboundGuidable.isComputed) { + return this.outboundGuidable.getPathStartPoint(); + } + + return this.intercept; + } + + get predictedPath(): PathVector[] { + return this.computedPath; + } + + recomputeWithParameters( + _isActive: boolean, + _tas: Knots, + _gs: Knots, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + ) { + this.intercept = Geo.legIntercept( + this.getPathStartPoint(), + this.course, + this.nextLeg, + ); + + const side = sideOfPointOnCourseToFix(this.intercept, this.outboundCourse, this.getPathStartPoint()); + const overshot = side === PointSide.After; + + if (this.intercept && !Number.isNaN(this.intercept.lat) && !overshot) { + this.isNull = false; + + this.computedPath = [{ + type: PathVectorType.Line, + startPoint: this.getPathStartPoint(), + endPoint: this.getPathEndPoint(), + }]; + + this.isComputed = true; + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.computedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathStartPoint(), + annotation: 'CI START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'CI END', + }, + ); + } + } else { + this.computedPath.length = 0; + + this.isNull = true; + this.isComputed = true; + } + } + + get inboundCourse(): Degrees { + return this.course; + } + + get outboundCourse(): Degrees { + return this.course; + } + + get distanceToTermination(): NauticalMiles { + const startPoint = this.getPathStartPoint(); + + return distanceTo(startPoint, this.intercept); + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles { + return courseToFixDistanceToGo(ppos, this.course, this.getPathEndPoint()); + } + + getGuidanceParameters(_ppos: Coordinates, _trueTrack: Degrees): GuidanceParameters | undefined { + return { + law: ControlLaw.TRACK, + course: this.course, + }; + } + + getNominalRollAngle(_gs: Knots): Degrees { + return 0; + } + + getPseudoWaypointLocation(_distanceBeforeTerminator: NauticalMiles): Coordinates | undefined { + return undefined; + } + + isAbeam(ppos: Coordinates): boolean { + const dtg = courseToFixDistanceToGo(ppos, this.course, this.getPathEndPoint()); + + return dtg >= 0 && dtg <= this.distance; + } + + get repr(): string { + return `CI(${Math.trunc(this.course)}T)`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CR.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CR.ts new file mode 100644 index 00000000000..a09c3c93c8a --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/CR.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { + courseToFixDistanceToGo, + courseToFixGuidance, + PointSide, + sideOfPointOnCourseToFix, +} from '@fmgc/guidance/lnav/CommonGeometry'; +import { Geo } from '@fmgc/utils/Geo'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { distanceTo, placeBearingIntersection } from 'msfs-geo'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { PathVector, PathVectorType } from '../PathVector'; + +export class CRLeg extends Leg { + private computedPath: PathVector[] = []; + + constructor( + public readonly course: DegreesTrue, + public readonly origin: { coordinates: Coordinates, ident: string, theta: DegreesMagnetic }, + public readonly radial: DegreesTrue, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(); + + this.segment = segment; + } + + intercept: Coordinates | undefined = undefined; + + get terminationWaypoint(): Coordinates { + return this.intercept; + } + + get ident(): string { + return this.origin.ident.substring(0, 3) + this.metadata.flightPlanLegDefinition.theta.toFixed(0); + } + + getPathStartPoint(): Coordinates | undefined { + if (this.inboundGuidable && this.inboundGuidable.isComputed) { + return this.inboundGuidable.getPathEndPoint(); + } + + throw new Error('[CRLeg] No computed inbound guidable.'); + } + + getPathEndPoint(): Coordinates | undefined { + return this.intercept; + } + + get predictedPath(): PathVector[] { + return this.computedPath; + } + + recomputeWithParameters( + _isActive: boolean, + _tas: Knots, + _gs: Knots, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + ) { + const intersections = placeBearingIntersection( + this.getPathStartPoint(), + this.course, + this.origin.coordinates, + this.radial, + ); + + const d1 = distanceTo(this.getPathStartPoint(), intersections[0]); + const d2 = distanceTo(this.getPathStartPoint(), intersections[1]); + if (d1 < d2) { + this.intercept = intersections[0]; + } else { + this.intercept = intersections[1]; + } + + const overshot = distanceTo(this.getPathStartPoint(), this.intercept) >= 5_000; + + if (this.intercept && !overshot) { + this.computedPath = [{ + type: PathVectorType.Line, + startPoint: this.getPathStartPoint(), + endPoint: this.intercept, + }]; + + this.isNull = false; + this.isComputed = true; + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.computedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathStartPoint(), + annotation: 'CR START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'CR END', + }, + ); + } + } else { + this.predictedPath.length = 0; + + this.isNull = true; + this.isComputed = true; + } + } + + /** + * Returns `true` if the inbound transition has overshot the leg + */ + get overshot(): boolean { + const side = sideOfPointOnCourseToFix(this.intercept, this.outboundCourse, this.getPathStartPoint()); + + return side === PointSide.After; + } + + get inboundCourse(): Degrees { + return this.course; + } + + get outboundCourse(): Degrees { + return this.course; + } + + get distanceToTermination(): NauticalMiles { + const startPoint = this.getPathStartPoint(); + + return distanceTo(startPoint, this.intercept); + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles { + return courseToFixDistanceToGo(ppos, this.course, this.getPathEndPoint()); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, _tas: Knots): GuidanceParameters | undefined { + return courseToFixGuidance(ppos, trueTrack, this.course, this.getPathEndPoint()); + } + + getNominalRollAngle(_gs: Knots): Degrees { + return 0; + } + + getPseudoWaypointLocation(_distanceBeforeTerminator: NauticalMiles): Coordinates | undefined { + return undefined; + } + + isAbeam(ppos: Coordinates): boolean { + const dtg = courseToFixDistanceToGo(ppos, this.course, this.getPathEndPoint()); + + return dtg >= 0 && dtg <= this.distance; + } + + get repr(): string { + return `CR ${this.course}T to ${this.origin.ident}${this.origin.theta}`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/DF.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/DF.ts new file mode 100644 index 00000000000..a6a6c298011 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/DF.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { courseToFixDistanceToGo, fixToFixGuidance } from '@fmgc/guidance/lnav/CommonGeometry'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { bearingTo, placeBearingDistance } from 'msfs-geo'; +import { Waypoint } from 'msfs-navdata'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { MathUtils } from '@shared/MathUtils'; +import { PathVector, PathVectorType } from '../PathVector'; + +export class DFLeg extends XFLeg { + private computedPath: PathVector[] = []; + + constructor( + fix: Waypoint, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(fix); + + this.segment = segment; + } + + getPathStartPoint(): Coordinates | undefined { + return this.inboundGuidable?.getPathEndPoint() ?? this.estimateStartPoint(); + } + + get predictedPath(): PathVector[] { + return this.computedPath; + } + + private start: Coordinates | undefined; + + private estimateStartPoint(): Coordinates { + let bearing = 0; + if (this.outboundGuidable instanceof Transition) { + bearing = this.outboundGuidable.nextLeg.inboundCourse + 180; + } else if (this.outboundGuidable instanceof Leg) { + bearing = this.outboundGuidable.inboundCourse + 180; + } + + bearing = MathUtils.clampAngle(bearing); + + const coordinates = this.fix.location; + + return placeBearingDistance( + coordinates, + bearing, + 2, + ); + } + + recomputeWithParameters( + _isActive: boolean, + _tas: Knots, + _gs: Knots, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + ) { + const newStart = this.inboundGuidable?.getPathEndPoint() ?? this.estimateStartPoint(); + + // Adjust the start point if we can + if (newStart) { + this.start = newStart; + } + + this.computedPath = [{ + type: PathVectorType.Line, + startPoint: this.start, + endPoint: this.getPathEndPoint(), + }]; + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.computedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.start, + annotation: 'DF START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'DF END', + }, + ); + } + + this.isComputed = true; + } + + get inboundCourse(): Degrees { + return bearingTo(this.start, this.fix.location); + } + + get outboundCourse(): Degrees { + return bearingTo(this.start, this.fix.location); + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles { + return courseToFixDistanceToGo(ppos, this.outboundCourse, this.getPathEndPoint()); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, _tas: Knots): GuidanceParameters | undefined { + return fixToFixGuidance(ppos, trueTrack, this.start, this.fix.location); + } + + getNominalRollAngle(_gs: Knots): Degrees { + return undefined; + } + + isAbeam(_ppos: Coordinates): boolean { + return false; + } + + get repr(): string { + return `DF TO '${this.fix.ident}'`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/FD.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/FD.ts new file mode 100644 index 00000000000..68afd4fbb0f --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/FD.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { NdbNavaid, VhfNavaid, Waypoint } from 'msfs-navdata'; +import { Coordinates, distanceTo, firstSmallCircleIntersection } from 'msfs-geo'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { procedureLegIdentAndAnnotation } from '@fmgc/flightplanning/new/legs/FlightPlanLegNaming'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { LegMetadata } from './index'; +import { courseToFixDistanceToGo, fixToFixGuidance } from '../CommonGeometry'; + +export class FDLeg extends Leg { + predictedPath: PathVector[] = []; + + inboundCourse; + + outboundCourse; + + intercept: Coordinates; + + constructor( + private readonly course: DegreesTrue, + private readonly dmeDistance: NauticalMiles, + private readonly fix: Waypoint | VhfNavaid | NdbNavaid, + private readonly navaid: Waypoint | VhfNavaid | NdbNavaid, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(); + + this.segment = segment; + + this.inboundCourse = course; + this.outboundCourse = course; + + // FD legs can be statically computed the first time + + this.predictedPath.length = 0; + + const intersect = firstSmallCircleIntersection( + this.navaid.location, + this.dmeDistance, + this.fix.location, + this.course, + ); + + this.intercept = intersect; + + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: this.getPathStartPoint(), + endPoint: this.getPathEndPoint(), + }); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push({ + type: PathVectorType.DebugPoint, + startPoint: this.getPathStartPoint(), + annotation: 'CD START', + }, { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'CD END', + }); + } + + this.isComputed = true; + } + + get terminationWaypoint(): Waypoint | Coordinates | undefined { + return this.intercept; + } + + get ident(): string { + return procedureLegIdentAndAnnotation(this.metadata.flightPlanLegDefinition, '')[0]; + } + + getPathStartPoint(): Coordinates | undefined { + if (this.inboundGuidable instanceof FixedRadiusTransition && this.inboundGuidable.isComputed) { + return this.inboundGuidable.getPathEndPoint(); + } + + return this.fix.location; + } + + getPathEndPoint(): Coordinates | undefined { + return this.intercept; + } + + recomputeWithParameters(_isActive: boolean, _tas: Knots, _gs: Knots, _ppos: Coordinates, _trueTrack: DegreesTrue) { + this.predictedPath.length = 0; + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: this.getPathStartPoint(), + endPoint: this.getPathEndPoint(), + }); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push({ + type: PathVectorType.DebugPoint, + startPoint: this.getPathStartPoint(), + annotation: 'FD START', + }, { + type: PathVectorType.DebugPoint, + startPoint: this.getPathEndPoint(), + annotation: 'FD END', + }); + } + + this.isComputed = true; + } + + get distanceToTermination(): NauticalMiles { + const startPoint = this.getPathStartPoint(); + + return distanceTo(startPoint, this.intercept); + } + + isAbeam(ppos: Coordinates): boolean { + const dtg = this.getDistanceToGo(ppos); + + return dtg >= 0 && dtg <= this.distance; + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles | undefined { + return courseToFixDistanceToGo(ppos, this.course, this.intercept); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, _tas: Knots, _gs: Knots): GuidanceParameters | undefined { + return fixToFixGuidance(ppos, trueTrack, this.getPathStartPoint(), this.intercept); + } + + getNominalRollAngle(_gs: MetresPerSecond): Degrees | undefined { + return 0; + } + + get repr(): string { + return `FD(${this.dmeDistance.toFixed(1)}NM, ${this.course.toFixed(1)})`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/HX.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/HX.ts new file mode 100644 index 00000000000..ec2b27c0a44 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/HX.ts @@ -0,0 +1,594 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 +/* eslint-disable max-classes-per-file */ + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { GuidanceParameters, LateralPathGuidance } from '@fmgc/guidance/ControlLaws'; +import { Geometry } from '@fmgc/guidance/Geometry'; +import { SegmentType } from '@fmgc/wtsdk'; +import { + arcDistanceToGo, + arcGuidance, + courseToFixDistanceToGo, + courseToFixGuidance, + maxBank, +} from '@fmgc/guidance/lnav/CommonGeometry'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { EntryState, HoldEntryTransition } from '@fmgc/guidance/lnav/transitions/HoldEntryTransition'; +import { AltitudeDescriptor, TurnDirection, Waypoint } from 'msfs-navdata'; +import { MathUtils } from '@shared/MathUtils'; +import { placeBearingDistance } from 'msfs-geo'; +import { PathVector, PathVectorType } from '../PathVector'; + +interface HxGeometry { + fixA: Coordinates, + fixB: Coordinates, + fixC: Coordinates, + arcCentreFix1: Coordinates, + arcCentreFix2: Coordinates, + sweepAngle: Degrees, + legLength: NauticalMiles, + radius: NauticalMiles, +} + +export enum HxLegGuidanceState { + Inbound, + Arc1, + Outbound, + Arc2, +} + +// TODO make sure IMM EXIT works during teardrop/parallel (proceed to HF via that entry then sequence the HM immediately) +// TODO move HMLeg specific logic to HMLeg +abstract class HXLeg extends XFLeg { + // TODO consider different entries for initial state... + // TODO make protected when done with DebugHXLeg + public state: HxLegGuidanceState = HxLegGuidanceState.Inbound; + + protected initialState: HxLegGuidanceState = HxLegGuidanceState.Inbound; + + protected termConditionMet: boolean = false; + + /** + * Predicted tas for next prediction update + * Not including wind + */ + protected nextPredictedTas: Knots = 180; + + /** + * Nominal TAS used for the current prediction + * Not including wind + */ + protected currentPredictedTas: Knots = 180; + + /** + * Nominal ground speed used the current prediction + * including wind + */ + protected currentPredictedGs: Knots = 180; + + /** + * Wind velocity along the inbound leg + */ + protected inboundWindSpeed: Knots; + + /** + * Current predicted hippodrome geometry + */ + public geometry: HxGeometry; + + constructor( + fix: Waypoint, + public metadata: LegMetadata, + public segment: SegmentType, + ) { + super(fix); + + this.geometry = this.computeGeometry(); + } + + get inboundLegCourse(): DegreesTrue { + return this.metadata.flightPlanLegDefinition.magneticCourse; + } + + get outboundLegCourse(): DegreesTrue { + return (this.inboundLegCourse + 180) % 360; + } + + get turnDirection(): TurnDirection { + return this.metadata.flightPlanLegDefinition.turnDirection; + } + + get ident(): string { + return this.fix.ident; + } + + /** + * Used by hold entry transition to set our initial state depending on entry type + * @param initialState + */ + setInitialState(initialState: HxLegGuidanceState): void { + // TODO check if already active and deny... + this.state = initialState; + this.initialState = initialState; + } + + get outboundStartPoint(): Coordinates { + const { fixB } = this.computeGeometry(); + return fixB; + } + + public computeLegDistance(): NauticalMiles { + // is distance in NM? + if (this.metadata.flightPlanLegDefinition.length !== undefined) { + return this.metadata.flightPlanLegDefinition.length; + } + + const alt = this.metadata.flightPlanLegDefinition.altitude1 ?? SimVar.GetSimVarValue('INDICATED ALTITUDE', 'feet'); + + // distance is in time then... + const defaultMinutes = alt < 14000 ? 1 : 1.5; + const inboundGroundSpeed = (this.currentPredictedTas + (this.inboundWindSpeed ?? 0)); + return (this.metadata.flightPlanLegDefinition.lengthTime !== undefined ? this.metadata.flightPlanLegDefinition.lengthTime : defaultMinutes) * inboundGroundSpeed / 60; + } + + protected computeGeometry(): HxGeometry { + /* + * We define some fixes at the turning points around the hippodrome like so (mirror vertically for left turn): + * A B + * *----------* + * / \ + * arc1 | * * | arc2 + * \ / + * *<---------* + * hold fix C + */ + + const legLength = this.computeLegDistance(); + const radius = this.radius; + const turnSign = this.turnDirection === TurnDirection.Left ? -1 : 1; + + const fixA = placeBearingDistance( + this.fix.location, + this.inboundLegCourse + turnSign * 90, + radius * 2, + ); + const fixB = placeBearingDistance( + fixA, + this.outboundLegCourse, + legLength, + ); + const fixC = placeBearingDistance( + this.fix.location, + this.outboundLegCourse, + legLength, + ); + + const arcCentreFix1 = placeBearingDistance( + this.fix.location, + this.inboundLegCourse + turnSign * 90, + radius, + ); + const arcCentreFix2 = placeBearingDistance( + fixC, + this.inboundLegCourse + turnSign * 90, + radius, + ); + + return { + fixA, + fixB, + fixC, + arcCentreFix1, + arcCentreFix2, + sweepAngle: turnSign * 180, + legLength, + radius, + }; + } + + get radius(): NauticalMiles { + const gsMs = this.currentPredictedGs / 1.94384; + const radius = (gsMs ** 2 / (9.81 * Math.tan(maxBank(this.currentPredictedTas, true) * Math.PI / 180)) / 1852); + + return radius; + } + + get terminationPoint(): Coordinates { + return this.fix.location; + } + + get distance(): NauticalMiles { + return 0; // 0 so no PWPs + } + + get inboundCourse(): Degrees { + return this.inboundLegCourse; + } + + get outboundCourse(): Degrees { + return this.inboundLegCourse; + } + + get startsInCircularArc(): boolean { + // this is intended to be used only for entry... + return this.state === HxLegGuidanceState.Arc1 || this.state === HxLegGuidanceState.Arc2; + } + + /** + * + * @param tas + * @returns + */ + public getNominalRollAngle(gs: Knots): Degrees { + return this.endsInCircularArc ? maxBank(gs, true) : 0; + } + + protected getDistanceToGoThisOrbit(ppos: LatLongData): NauticalMiles { + const { fixB, arcCentreFix1, arcCentreFix2, sweepAngle } = this.geometry; + + switch (this.state) { + case HxLegGuidanceState.Inbound: + return courseToFixDistanceToGo(ppos, this.inboundLegCourse, this.fix.location); + case HxLegGuidanceState.Arc1: + return arcDistanceToGo(ppos, this.fix.location, arcCentreFix1, sweepAngle) + this.computeLegDistance() * 2 + this.radius * Math.PI; + case HxLegGuidanceState.Outbound: + return courseToFixDistanceToGo(ppos, this.outboundLegCourse, fixB) + this.computeLegDistance() + this.radius * Math.PI; + case HxLegGuidanceState.Arc2: + return arcDistanceToGo(ppos, fixB, arcCentreFix2, sweepAngle) + this.computeLegDistance(); + // no default + } + + return 1; + } + + getDistanceToGo(ppos: LatLongData): NauticalMiles { + return this.getDistanceToGoThisOrbit(ppos); + } + + getHippodromePath(): PathVector[] { + const { fixA, fixB, fixC, arcCentreFix1, arcCentreFix2, sweepAngle } = this.geometry; + + return [ + { + type: PathVectorType.Arc, + startPoint: this.fix.location, + centrePoint: arcCentreFix1, + endPoint: fixA, + sweepAngle, + }, + { + type: PathVectorType.Line, + startPoint: fixA, + endPoint: fixB, + }, + { + type: PathVectorType.Arc, + startPoint: fixB, + centrePoint: arcCentreFix2, + endPoint: fixC, + sweepAngle, + }, + { + type: PathVectorType.Line, + startPoint: fixC, + endPoint: this.fix.location, + }, + ]; + } + + get predictedPath(): PathVector[] { + return this.getHippodromePath(); + } + + updateState(ppos: LatLongAlt, tas: Knots, geometry: HxGeometry): void { + let dtg = 0; + + // TODO divide up into sectors and choose based on that? + + switch (this.state) { + case HxLegGuidanceState.Inbound: { + dtg = courseToFixDistanceToGo(ppos, this.inboundLegCourse, this.fix.location); + break; + } + case HxLegGuidanceState.Arc1: { + dtg = arcDistanceToGo(ppos, this.fix.location, geometry.arcCentreFix1, geometry.sweepAngle); + break; + } + case HxLegGuidanceState.Outbound: { + dtg = courseToFixDistanceToGo(ppos, this.outboundLegCourse, geometry.fixB); + break; + } + case HxLegGuidanceState.Arc2: { + dtg = arcDistanceToGo(ppos, geometry.fixB, geometry.arcCentreFix2, geometry.sweepAngle); + break; + } + default: + throw new Error(`Bad HxLeg state ${this.state}`); + } + + if (dtg <= 0) { + if (this.state === HxLegGuidanceState.Inbound) { + if (this.termConditionMet) { + return; + } + this.updatePrediction(); + } + this.state = (this.state + 1) % (HxLegGuidanceState.Arc2 + 1); + console.log(`HX switched to state ${HxLegGuidanceState[this.state]}`); + } + } + + getGuidanceParameters(ppos: LatLongAlt, trueTrack: Degrees, tas: Knots, gs: Knots): GuidanceParameters { + const { fixB, arcCentreFix1, arcCentreFix2, sweepAngle, legLength } = this.geometry; + + this.updateState(ppos, tas, this.geometry); + + let params: LateralPathGuidance; + let dtg: NauticalMiles; + let nextPhi = 0; + let rad = 0; + + switch (this.state) { + case HxLegGuidanceState.Inbound: + params = courseToFixGuidance(ppos, trueTrack, this.inboundLegCourse, this.fix.location); + dtg = courseToFixDistanceToGo(ppos, this.inboundLegCourse, this.fix.location); + nextPhi = sweepAngle > 0 ? maxBank(tas, true) : -maxBank(tas, true); + rad = Geometry.getRollAnticipationDistance(gs, params.phiCommand, nextPhi); + break; + case HxLegGuidanceState.Arc1: + params = arcGuidance(ppos, trueTrack, this.fix.location, arcCentreFix1, sweepAngle); + dtg = arcDistanceToGo(ppos, this.fix.location, arcCentreFix1, sweepAngle); + rad = Geometry.getRollAnticipationDistance(gs, params.phiCommand, nextPhi); + if (legLength <= rad) { + nextPhi = params.phiCommand; + } + break; + case HxLegGuidanceState.Outbound: + params = courseToFixGuidance(ppos, trueTrack, this.outboundLegCourse, fixB); + dtg = courseToFixDistanceToGo(ppos, this.outboundLegCourse, fixB); + nextPhi = sweepAngle > 0 ? maxBank(tas, true) : -maxBank(tas, true); + rad = Geometry.getRollAnticipationDistance(gs, params.phiCommand, nextPhi); + break; + case HxLegGuidanceState.Arc2: + params = arcGuidance(ppos, trueTrack, fixB, arcCentreFix2, sweepAngle); + dtg = arcDistanceToGo(ppos, fixB, arcCentreFix2, sweepAngle); + rad = Geometry.getRollAnticipationDistance(gs, params.phiCommand, nextPhi); + if (legLength <= rad) { + nextPhi = params.phiCommand; + } + break; + default: + throw new Error(`Bad HxLeg state ${this.state}`); + } + + // TODO HF/HA too + if (dtg <= rad && !(this.state === HxLegGuidanceState.Inbound && this.termConditionMet)) { + params.phiCommand = nextPhi; + } + + return params; + } + + recomputeWithParameters( + isActive: boolean, + _tas: Knots, + _gs: Knots, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + _startAltitude?: Feet, + _verticalSpeed?: FeetPerMinute, + ): void { + if (!isActive) { + this.updatePrediction(); + } + } + + setPredictedTas(tas: Knots) { + this.nextPredictedTas = tas; + } + + /** + * Should be called on each crossing of the hold fix + */ + updatePrediction() { + const windDirection = SimVar.GetSimVarValue('AMBIENT WIND DIRECTION', 'Degrees'); + const windSpeed = SimVar.GetSimVarValue('AMBIENT WIND VELOCITY', 'Knots'); + const windAngleToInbound = Math.abs(MathUtils.diffAngle(windDirection, this.inboundCourse)); + this.inboundWindSpeed = Math.cos(windAngleToInbound * Math.PI / 180) * windSpeed; + + this.currentPredictedTas = this.nextPredictedTas; + this.currentPredictedGs = this.currentPredictedTas + windSpeed; + this.geometry = this.computeGeometry(); + + // TODO update entry transition too + } + + // TODO are we even using this? What exactly should it tell us? + isAbeam(_ppos: Coordinates) { + return false; + } + + getPathStartPoint(): Coordinates { + return this.fix.location; + } + + getPathEndPoint(): Coordinates { + // TODO consider early exit to CF on HF leg + return this.fix.location; + } +} + +export class HMLeg extends HXLeg { + // TODO only reset this on crossing the hold fix (so exit/resume/exit keeps the existing shortened path) + private immExitLength: NauticalMiles; + + /** + * Use for IMM EXIT set/reset function on the MCDU + * Note: if IMM EXIT is set before this leg is active it should be deleted from the f-pln instead + * @param + */ + setImmediateExit(exit: boolean, ppos: LatLongData, tas: Knots): void { + // TODO if we're still in the entry transition, HM becomes empty, but still fly the entry + + const { legLength, fixA, sweepAngle } = this.geometry; + if (exit) { + switch (this.state) { + case HxLegGuidanceState.Arc1: + // let's do a circle + this.immExitLength = 0; + break; + case HxLegGuidanceState.Outbound: + const nextPhi = sweepAngle > 0 ? maxBank(tas, true) : -maxBank(tas, true); + const rad = Geometry.getRollAnticipationDistance(tas, 0, nextPhi); + this.immExitLength = Math.min(legLength, rad + courseToFixDistanceToGo(ppos, this.inboundLegCourse, fixA)); + break; + case HxLegGuidanceState.Arc2: + case HxLegGuidanceState.Inbound: + // keep the normal leg distance as we can't shorten + this.immExitLength = legLength; + break; + // no default + } + } + + // hack to allow f-pln page to see state + // this.fix.additionalData.immExit = exit; + // TODO port over + + this.termConditionMet = exit; + + // if resuming hold, the geometry will be recomputed on the next pass of the hold fix + if (exit) { + this.geometry = this.computeGeometry(); + } + } + + public computeLegDistance(): NauticalMiles { + if (this.termConditionMet) { + return this.immExitLength; + } + + return super.computeLegDistance(); + } + + get disableAutomaticSequencing(): boolean { + return !this.termConditionMet; + } + + get repr(): string { + return `HM '${this.fix.ident}' ${TurnDirection[this.turnDirection]}`; + } +} + +// TODO +/* +If the aircraft reaches or exceeds the altitude +specified in the flight plan before the HA leg is active, the aircraft +does not enter the hold +*/ +export class HALeg extends HXLeg { + private readonly targetAltitude: Feet; + + constructor( + public to: Waypoint, + public metadata: LegMetadata, + public segment: SegmentType, + ) { + super(to, metadata, segment); + + // the term altitude is guaranteed to be at or above, and in field altitude1, by ARINC424 coding rules + const altitudeDescriptor = this.metadata.flightPlanLegDefinition.altitudeDescriptor; + + if (altitudeDescriptor !== AltitudeDescriptor.AtOrAboveAlt1) { + console.warn(`HALeg invalid altitude descriptor ${altitudeDescriptor}, must be ${AltitudeDescriptor.AtOrAboveAlt1}`); + } + + this.targetAltitude = this.metadata.flightPlanLegDefinition.altitude1; + } + + getGuidanceParameters(ppos: LatLongAlt, trueTrack: Degrees, tas: Knots, gs: Knots): GuidanceParameters { + if (SimVar.GetSimVarValue('INDICATED ALTITUDE', 'feet') >= this.targetAltitude) { + this.termConditionMet = true; + } + + return super.getGuidanceParameters(ppos, trueTrack, tas, gs); + } + + recomputeWithParameters(isActive: boolean, tas: Knots, gs: Knots, ppos: Coordinates, trueTrack: DegreesTrue): void { + if (SimVar.GetSimVarValue('INDICATED ALTITUDE', 'feet') >= this.targetAltitude) { + this.termConditionMet = true; + } + if (!isActive && this.termConditionMet) { + this.isNull = true; + } + this.setPredictedTas(tas); + super.recomputeWithParameters(isActive, tas, gs, ppos, trueTrack); + } + + getDistanceToGo(ppos: LatLongData): NauticalMiles { + if (this.isNull) { + return 0; + } + if (this.termConditionMet) { + return this.getDistanceToGoThisOrbit(ppos); + } + const { legLength, radius } = this.geometry; + return legLength * 2 + radius * Math.PI * 2; + } + + get disableAutomaticSequencing(): boolean { + return !this.termConditionMet; + } + + get predictedPath(): PathVector[] { + if (!this.isNull) { + return super.predictedPath; + } + return []; + } + + get repr(): string { + return `HA '${this.fix.ident}' ${TurnDirection[this.turnDirection]} - ${this.targetAltitude.toFixed(0)}`; + } +} + +export class HFLeg extends HXLeg { + private entryTransition: HoldEntryTransition; + + getGuidanceParameters(ppos: LatLongAlt, trueTrack: Degrees, tas: Knots, gs: Knots): GuidanceParameters { + if (this.entryTransition) { + this.termConditionMet = this.entryTransition.isNull || this.entryTransition.state === EntryState.Capture || this.entryTransition.state === EntryState.Done; + } + + return super.getGuidanceParameters(ppos, trueTrack, tas, gs); + } + + recomputeWithParameters(isActive: boolean, tas: Knots, gs: Knots, ppos: Coordinates, trueTrack: DegreesTrue): void { + if (this.inboundGuidable instanceof HoldEntryTransition) { + this.entryTransition = this.inboundGuidable; + this.termConditionMet = this.entryTransition.isNull || this.entryTransition.state === EntryState.Capture || this.entryTransition.state === EntryState.Done; + } + this.setPredictedTas(tas); + super.recomputeWithParameters(isActive, tas, gs, ppos, trueTrack); + } + + getDistanceToGo(ppos: LatLongData): NauticalMiles { + return this.entryTransition?.getDistanceToGo(ppos) ?? 0; + } + + get predictedPath(): PathVector[] { + return []; + } + + get disableAutomaticSequencing(): boolean { + return false; + } + + get repr(): string { + return `HF '${this.fix.ident}' ${TurnDirection[this.turnDirection]}`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/IF.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/IF.ts new file mode 100644 index 00000000000..af47e33d40f --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/IF.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { PathVector } from '@fmgc/guidance/lnav/PathVector'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { Guidable } from '@fmgc/guidance/Guidable'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { Waypoint, WaypointDescriptor } from 'msfs-navdata'; + +export class IFLeg extends XFLeg { + constructor( + fix: Waypoint, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(fix); + + this.segment = segment; + + // Do not display on map if this is an airport or runway leg + const { waypointDescriptor } = this.metadata.flightPlanLegDefinition; + + this.displayedOnMap = waypointDescriptor !== WaypointDescriptor.Airport && waypointDescriptor !== WaypointDescriptor.Runway; + } + + get predictedPath(): PathVector[] | undefined { + return []; + } + + getPathStartPoint(): Coordinates | undefined { + return this.fix.location; + } + + getPathEndPoint(): Coordinates | undefined { + return this.fix.location; + } + + recomputeWithParameters(_isActive: boolean, _tas: Knots, _gs: Knots, _ppos: Coordinates, _trueTrack: DegreesTrue) { + this.isComputed = true; + } + + /** @inheritdoc */ + setNeighboringGuidables(inbound: Guidable, outbound: Guidable) { + if (outbound && !(outbound instanceof Leg) && outbound !== this.outboundGuidable) { + console.error(`IF outboundGuidable must be a leg (is ${outbound?.constructor})`); + } + super.setNeighboringGuidables(inbound, outbound); + } + + get inboundCourse(): Degrees | undefined { + return undefined; + } + + get outboundCourse(): Degrees | undefined { + return undefined; + } + + get distance(): NauticalMiles { + return 0; + } + + getDistanceToGo(_ppos: Coordinates): NauticalMiles | undefined { + return undefined; + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees, tas: Knots, gs: Knots): GuidanceParameters | undefined { + return this.outboundGuidable?.getGuidanceParameters(ppos, trueTrack, tas, gs) ?? undefined; + } + + getNominalRollAngle(_gs): Degrees | undefined { + return undefined; + } + + getPseudoWaypointLocation(_distanceBeforeTerminator: NauticalMiles): Coordinates | undefined { + return undefined; + } + + isAbeam(_ppos: Coordinates): boolean { + return false; + } + + get repr(): string { + return `IF AT ${this.fix.ident}`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/Leg.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/Leg.ts new file mode 100644 index 00000000000..ecb2fc144b4 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/Leg.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { Guidable } from '@fmgc/guidance/Guidable'; +import { distanceTo } from 'msfs-geo'; +import { Waypoint } from 'msfs-navdata'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; + +export abstract class Leg extends Guidable { + segment: SegmentType; + + abstract metadata: Readonly + + get constrainedTurnDirection() { + return this.metadata.turnDirection; + } + + abstract get inboundCourse(): Degrees | undefined; + + abstract get outboundCourse(): Degrees | undefined; + + abstract get terminationWaypoint(): Waypoint | Coordinates | undefined; + + abstract get ident(): string + + isNull = false + + displayedOnMap = true + + predictedTas: Knots + + predictedGs: Knots + + get disableAutomaticSequencing(): boolean { + return false; + } + + /** @inheritDoc */ + recomputeWithParameters( + _isActive: boolean, + _tas: Knots, + _gs: Knots, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + ): void { + // Default impl. + } + + get distance(): NauticalMiles { + try { + return distanceTo(this.getPathStartPoint(), this.getPathEndPoint()); + } catch { + return 0; + } + } + + abstract get distanceToTermination(): NauticalMiles + + get overflyTermFix(): boolean { + return false; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/RF.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/RF.ts new file mode 100644 index 00000000000..6d921ea69d1 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/RF.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { SegmentType } from '@fmgc/wtsdk'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { arcDistanceToGo, arcGuidance, arcLength } from '@fmgc/guidance/lnav/CommonGeometry'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { Waypoint } from 'msfs-navdata'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { bearingTo, distanceTo } from 'msfs-geo'; +import { MathUtils } from '@shared/MathUtils'; +import { PathVector, PathVectorType } from '../PathVector'; + +export class RFLeg extends XFLeg { + // location of the centre fix of the arc + center: LatLongData; + + radius: NauticalMiles; + + angle: Degrees; + + clockwise: boolean; + + private mDistance: NauticalMiles; + + private computedPath: PathVector[] = []; + + constructor( + private from: Waypoint, + public to: Waypoint, + center: LatLongData, + public metadata: LegMetadata, + segment: SegmentType, + ) { + super(to); + + this.from = from; + this.to = to; + this.center = center; + this.radius = distanceTo(this.center, this.to.location); + this.segment = segment; + + const fromBearing = bearingTo(this.center, this.from.location); // -90? + const toBearing = bearingTo(this.center, this.to.location); // -90? + + switch (this.metadata.turnDirection) { + case TurnDirection.Left: + this.clockwise = false; + this.angle = MathUtils.clampAngle(fromBearing - toBearing); + break; + case TurnDirection.Right: + this.clockwise = true; + this.angle = MathUtils.clampAngle(toBearing - fromBearing); + break; + default: + const angle = MathUtils.diffAngle(toBearing, fromBearing); + this.clockwise = angle > 0; + this.angle = Math.abs(angle); + break; + } + + this.mDistance = 2 * Math.PI * this.radius / 360 * this.angle; + + this.computedPath = [ + { + type: PathVectorType.Arc, + startPoint: this.from.location, + centrePoint: this.center, + endPoint: this.to.location, + sweepAngle: this.clockwise ? this.angle : -this.angle, + }, + ]; + + this.isComputed = true; + } + + getPathStartPoint(): Coordinates | undefined { + return this.from.location; + } + + getPathEndPoint(): Coordinates | undefined { + return this.to.location; + } + + get predictedPath(): PathVector[] { + return this.computedPath; + } + + get startsInCircularArc(): boolean { + return true; + } + + get endsInCircularArc(): boolean { + return true; + } + + get inboundCourse(): Degrees { + return MathUtils.clampAngle(bearingTo(this.center, this.from.location) + (this.clockwise ? 90 : -90)); + } + + get outboundCourse(): Degrees { + return MathUtils.clampAngle(bearingTo(this.center, this.to.location) + (this.clockwise ? 90 : -90)); + } + + get distance(): NauticalMiles { + return this.mDistance; + } + + get distanceToTermination(): NauticalMiles { + return arcLength(this.radius, this.angle); + } + + // basically straight from type 1 transition... willl need refinement + getGuidanceParameters(ppos: LatLongAlt, trueTrack: number, _tas: Knots): GuidanceParameters | null { + // FIXME should be defined in terms of to fix + return arcGuidance(ppos, trueTrack, this.from.location, this.center, this.clockwise ? this.angle : -this.angle); + } + + getNominalRollAngle(gs: Knots): Degrees { + const gsMs = gs * (463 / 900); + return (this.clockwise ? 1 : -1) * Math.atan((gsMs ** 2) / (this.radius * 1852 * 9.81)) * (180 / Math.PI); + } + + /** + * Calculates directed DTG parameter + * + * @param ppos {LatLong} the current position of the aircraft + */ + getDistanceToGo(ppos: LatLongData): NauticalMiles { + // FIXME geometry should be defined in terms of to... + return arcDistanceToGo(ppos, this.from.location, this.center, this.clockwise ? this.angle : -this.angle); + } + + isAbeam(ppos: LatLongData): boolean { + const bearingPpos = bearingTo( + this.center, + ppos, + ); + + const bearingFrom = bearingTo( + this.center, + this.from.location, + ); + + const trackAngleError = this.clockwise ? MathUtils.diffAngle(bearingFrom, bearingPpos) : MathUtils.diffAngle(bearingPpos, bearingFrom); + + return trackAngleError >= 0; + } + + toString(): string { + return ``; + } + + get repr(): string { + return `RF(${this.radius.toFixed(1)}NM. ${this.angle.toFixed(1)}°) TO ${this.to.ident}`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/TF.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/TF.ts new file mode 100644 index 00000000000..8ef9284a335 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/TF.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { MathUtils } from '@shared/MathUtils'; +import { AltitudeConstraint, SpeedConstraint } from '@fmgc/guidance/lnav/legs'; +import { SegmentType } from '@fmgc/wtsdk'; +import { WaypointConstraintType } from '@fmgc/flightplanning/FlightPlanManager'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { courseToFixDistanceToGo, fixToFixGuidance, getIntermediatePoint } from '@fmgc/guidance/lnav/CommonGeometry'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { Waypoint, WaypointDescriptor } from 'msfs-navdata'; +import { bearingTo, distanceTo } from 'msfs-geo'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { PathVector, PathVectorType } from '../PathVector'; + +export class TFLeg extends XFLeg { + constraintType: WaypointConstraintType; + + private readonly course: Degrees; + + private computedPath: PathVector[] = []; + + altitudeConstraint: AltitudeConstraint | undefined + + speedConstraint: SpeedConstraint | undefined + + constructor( + public from: Waypoint, + public to: Waypoint, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(to); + + this.from = from; + this.to = to; + this.segment = segment; + this.constraintType = WaypointConstraintType.CLB; + this.course = bearingTo( + this.from.location, + this.to.location, + ); + + // TODO sussy + // Do not display on map if this is an airport or runway leg + const { waypointDescriptor } = this.metadata.flightPlanLegDefinition; + + this.displayedOnMap = waypointDescriptor !== WaypointDescriptor.Airport && waypointDescriptor !== WaypointDescriptor.Runway; + } + + get inboundCourse(): DegreesTrue { + return bearingTo(this.from.location, this.to.location); + } + + get outboundCourse(): DegreesTrue { + return bearingTo(this.from.location, this.to.location); + } + + get predictedPath(): PathVector[] { + return this.computedPath; + } + + getPathStartPoint(): Coordinates | undefined { + return this.inboundGuidable?.isComputed ? this.inboundGuidable.getPathEndPoint() : this.from.location; + } + + recomputeWithParameters( + _isActive: boolean, + _tas: Knots, + _gs: Knots, + _ppos: Coordinates, + _trueTrack: DegreesTrue, + ) { + const startPoint = this.getPathStartPoint(); + const endPoint = this.getPathEndPoint(); + + this.computedPath.length = 0; + + if (this.overshot) { + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: endPoint, + endPoint, + }); + } else { + this.computedPath.push({ + type: PathVectorType.Line, + startPoint, + endPoint, + }); + } + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.computedPath.push({ + type: PathVectorType.DebugPoint, + startPoint: endPoint, + annotation: 'TF END', + }); + } + + this.isComputed = true; + } + + getPseudoWaypointLocation(distanceBeforeTerminator: NauticalMiles): Coordinates | undefined { + return getIntermediatePoint( + this.getPathStartPoint(), + this.getPathEndPoint(), + (this.distance - distanceBeforeTerminator) / this.distance, + ); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees): GuidanceParameters | null { + return fixToFixGuidance(ppos, trueTrack, this.from.location, this.to.location); + } + + getNominalRollAngle(_gs: Knots): Degrees { + return 0; + } + + getDistanceToGo(ppos: LatLongData): NauticalMiles { + return courseToFixDistanceToGo(ppos, this.course, this.getPathEndPoint()); + } + + isAbeam(ppos: LatLongAlt): boolean { + const bearingAC = bearingTo(this.from.location, ppos); + const headingAC = Math.abs(MathUtils.diffAngle(this.inboundCourse, bearingAC)); + if (headingAC > 90) { + // if we're even not abeam of the starting point + return false; + } + const distanceAC = distanceTo(this.from.location, ppos); + const distanceAX = Math.cos(headingAC * MathUtils.DEGREES_TO_RADIANS) * distanceAC; + // if we're too far away from the starting point to be still abeam of the ending point + return distanceAX <= this.distance; + } + + get repr(): string { + return `TF FROM ${this.from.ident} TO ${this.to.ident}`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/VM.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/VM.ts new file mode 100644 index 00000000000..1fb2b3caa98 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/VM.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { ControlLaw, GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { SegmentType } from '@fmgc/wtsdk'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { LegMetadata } from '@fmgc/guidance/lnav/legs/index'; +import { Waypoint } from 'msfs-navdata'; +import { placeBearingDistance } from 'msfs-geo'; + +/** + * Temporary - better solution is just to have an `InfiniteLine` vector... + */ +const VM_LEG_SIZE = 32; + +// TODO needs updated with wind prediction, and maybe local magvar if following for longer distances +export class VMLeg extends Leg { + predictedPath: PathVector[] = []; + + constructor( + public heading: DegreesTrue, + public readonly metadata: Readonly, + segment: SegmentType, + ) { + super(); + this.segment = segment; + } + + get terminationWaypoint(): Waypoint { + return undefined; + } + + get ident(): string { + return 'MANUAL'; + } + + displayedOnMap = false; + + getPathStartPoint(): Coordinates | undefined { + return this.inboundGuidable?.getPathEndPoint(); + } + + getPathEndPoint(): Coordinates | undefined { + return placeBearingDistance( + this.getPathStartPoint(), + this.heading, + VM_LEG_SIZE, + ); + } + + recomputeWithParameters(_isActive: boolean, _tas: Knots, _gs: Knots, _ppos: Coordinates, _trueTrack: DegreesTrue) { + // FIXME course based on predicted wind + + this.predictedPath.length = 0; + this.predictedPath.push( + { + type: PathVectorType.Line, + startPoint: this.getPathStartPoint(), + endPoint: this.getPathEndPoint(), + }, + ); + + this.isComputed = true; + } + + get inboundCourse(): Degrees { + // FIXME this is a bit naughty... + return this.heading; + } + + get outboundCourse(): Degrees { + // FIXME this is a bit naughty... + return this.heading; + } + + get distance(): NauticalMiles { + return 0; + } + + get distanceToTermination(): NauticalMiles { + return 1; + } + + // Can't get pseudo-waypoint location without a finite terminator + getPseudoWaypointLocation(_distanceBeforeTerminator: NauticalMiles): undefined { + return undefined; + } + + getGuidanceParameters(_ppos: LatLongData, _trueTrack: Track): GuidanceParameters { + return { + law: ControlLaw.HEADING, + heading: this.heading, + }; + } + + getNominalRollAngle(_gs: Knots): Degrees { + return 0; + } + + getDistanceToGo(_ppos: LatLongData): NauticalMiles { + return undefined; + } + + isAbeam(_ppos: LatLongAlt): boolean { + return true; + } + + get disableAutomaticSequencing(): boolean { + return true; + } + + get repr(): string { + return `VM(${this.heading.toFixed(1)}T)`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/XF.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/XF.ts new file mode 100644 index 00000000000..8b4dce1aa64 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/XF.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { Waypoint } from 'msfs-navdata'; +import { distanceTo } from 'msfs-geo'; +import { PointSide, sideOfPointOnCourseToFix } from '@fmgc/guidance/lnav/CommonGeometry'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { DmeArcTransition } from '@fmgc/guidance/lnav/transitions/DmeArcTransition'; + +export abstract class XFLeg extends Leg { + protected constructor( + public fix: Waypoint, + ) { + super(); + } + + getPathEndPoint(): Coordinates | undefined { + if (this.outboundGuidable instanceof FixedRadiusTransition && this.outboundGuidable.isComputed) { + return this.outboundGuidable.getPathStartPoint(); + } + + if (this.outboundGuidable instanceof DmeArcTransition && this.outboundGuidable.isComputed) { + return this.outboundGuidable.getPathStartPoint(); + } + + return this.fix.location; + } + + get terminationWaypoint(): Waypoint { + return this.fix; + } + + get ident(): string { + return this.fix.ident; + } + + get overflyTermFix(): boolean { + return this.metadata.isOverfly; + } + + /** + * Returns `true` if the inbound transition has overshot the leg + */ + get overshot(): boolean { + const side = sideOfPointOnCourseToFix(this.fix.location, this.outboundCourse, this.getPathStartPoint()); + + return side === PointSide.After; + } + + get distanceToTermination(): NauticalMiles { + const startPoint = this.getPathStartPoint(); + + if (this.overshot) { + return 0; + } + + return distanceTo(startPoint, this.fix.location); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/index.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/index.ts new file mode 100644 index 00000000000..359f875fd5d --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/legs/index.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { HALeg, HFLeg, HMLeg } from '@fmgc/guidance/lnav/legs/HX'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { AltitudeDescriptor, SpeedDescriptor } from 'msfs-navdata'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { FlightPlanLegDefinition } from '@fmgc/flightplanning/new/legs/FlightPlanLegDefinition'; +import { MissedApproachSegment } from '@fmgc/flightplanning/new/segments/MissedApproachSegment'; + +export enum AltitudeConstraintType { + at, + atOrAbove, + atOrBelow, + range, +} + +// TODO at and atOrAbove do not exist in the airbus (former interpreted as atOrBelow, latter discarded) +export enum SpeedConstraintType { + at, + atOrAbove, + atOrBelow, +} + +export interface AltitudeConstraint { + type: AltitudeConstraintType, + altitude1: Feet, + altitude2: Feet | undefined, +} + +export interface SpeedConstraint { + type: SpeedConstraintType, + speed: Knots, +} + +export abstract class FXLeg extends Leg { + from: WayPoint; +} + +export function getAltitudeConstraintFromWaypoint(wp: WayPoint): AltitudeConstraint | undefined { + if (wp.legAltitudeDescription && wp.legAltitude1) { + const ac: Partial = {}; + ac.altitude1 = wp.legAltitude1; + ac.altitude2 = undefined; + switch (wp.legAltitudeDescription) { + case 1: + ac.type = AltitudeConstraintType.at; + break; + case 2: + ac.type = AltitudeConstraintType.atOrAbove; + break; + case 3: + ac.type = AltitudeConstraintType.atOrBelow; + break; + case 4: + ac.type = AltitudeConstraintType.range; + ac.altitude2 = wp.legAltitude2; + break; + default: + break; + } + return ac as AltitudeConstraint; + } + return undefined; +} + +export function altitudeConstraintFromFlightPlanLeg(definition: FlightPlanLegDefinition): AltitudeConstraint | undefined { + if (definition.altitudeDescriptor !== undefined && definition.altitude1 !== undefined) { + const ac: Partial = {}; + + ac.altitude1 = definition.altitude1; + ac.altitude2 = undefined; + + switch (definition.altitudeDescriptor) { + case AltitudeDescriptor.AtAlt1: + ac.type = AltitudeConstraintType.at; + break; + case AltitudeDescriptor.AtOrAboveAlt1: + ac.type = AltitudeConstraintType.atOrAbove; + break; + case AltitudeDescriptor.AtOrBelowAlt1: + ac.type = AltitudeConstraintType.atOrBelow; + break; + case AltitudeDescriptor.BetweenAlt1Alt2: + ac.type = AltitudeConstraintType.range; + ac.altitude2 = definition.altitude2; + break; + default: + break; + } + return ac as AltitudeConstraint; + } + + return undefined; +} + +export function getSpeedConstraintFromWaypoint(wp: WayPoint): SpeedConstraint | undefined { + if (wp.speedConstraint) { + const sc: Partial = {}; + sc.type = SpeedConstraintType.at; + sc.speed = wp.speedConstraint; + return sc as SpeedConstraint; + } + return undefined; +} + +export function speedConstraintFromProcedureLeg(definition: FlightPlanLegDefinition): SpeedConstraint | undefined { + if (definition.speedDescriptor !== undefined) { + let type; + if (definition.speedDescriptor === SpeedDescriptor.Minimum) { + type = SpeedConstraintType.atOrAbove; + } else if (definition.speedDescriptor === SpeedDescriptor.Mandatory) { + type = SpeedConstraintType.at; + } else if (definition.speedDescriptor === SpeedDescriptor.Maximum) { + type = SpeedConstraintType.atOrBelow; + } + + return { type, speed: definition.speed! }; + } + + return undefined; +} + +export function waypointToLocation(wp: WayPoint): LatLongData { + const loc: LatLongData = { + lat: wp.infos.coordinates.lat, + long: wp.infos.coordinates.long, + }; + return loc; +} + +export function isHold(leg: Leg): boolean { + return leg instanceof HALeg || leg instanceof HFLeg || leg instanceof HMLeg; +} + +export function isCourseReversalLeg(leg: Leg): boolean { + return isHold(leg); // TODO PILeg +} + +/** + * Geometry and vertical constraints applicable to a leg + */ +export interface LegMetadata { + /** + * Definition of the originating flight plan leg + */ + flightPlanLegDefinition: FlightPlanLegDefinition, + + /** + * Turn direction constraint applicable to this leg + */ + turnDirection: TurnDirection, + + /** + * Altitude constraint applicable to this leg + */ + altitudeConstraint?: AltitudeConstraint, + + /** + * Speed constraint applicable to this leg + */ + speedConstraint?: SpeedConstraint, + + /** + * UTC seconds required time of arrival applicable to the leg + */ + rtaUtcSeconds?: Seconds, + + /** + * Whether the termination of this leg must be overflown. The termination can be overflown even if this is `false` due to geometric constraints + */ + isOverfly?: boolean, + + /** + * Whether the leg is in the missed approach segment + */ + isInMissedApproach?: boolean, + + /** + * Lateral offset applicable to this leg. -ve if left offset, +ve if right offset. + * + * This also applies if this is the first or last leg considered "offset" in the FMS, even if the transition onto the offset path skips the leg. + */ + offset?: NauticalMiles, +} + +export function legMetadataFromFlightPlanLeg(leg: FlightPlanLeg): LegMetadata { + const altitudeConstraint = altitudeConstraintFromFlightPlanLeg(leg.definition); + const speedConstraint = speedConstraintFromProcedureLeg(leg.definition); + + let turnDirection = TurnDirection.Either; + if (leg.definition.turnDirection === 'L') { + turnDirection = TurnDirection.Left; + } else if (leg.definition.turnDirection === 'R') { + turnDirection = TurnDirection.Right; + } + + return { + flightPlanLegDefinition: leg.definition, + turnDirection, + altitudeConstraint, + speedConstraint, + isOverfly: leg.definition.overfly, + isInMissedApproach: leg.segment instanceof MissedApproachSegment, + }; +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/CourseCaptureTransition.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/CourseCaptureTransition.ts new file mode 100644 index 00000000000..8f0e4491b0d --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/CourseCaptureTransition.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { MathUtils } from '@shared/MathUtils'; +import { CALeg } from '@fmgc/guidance/lnav/legs/CA'; +import { DFLeg } from '@fmgc/guidance/lnav/legs/DF'; +import { HALeg, HFLeg, HMLeg } from '@fmgc/guidance/lnav/legs/HX'; +import { RFLeg } from '@fmgc/guidance/lnav/legs/RF'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { VMLeg } from '@fmgc/guidance/lnav/legs/VM'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { Constants } from '@shared/Constants'; +import { Geo } from '@fmgc/utils/Geo'; +import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { AFLeg } from '@fmgc/guidance/lnav/legs/AF'; +import { FDLeg } from '@fmgc/guidance/lnav/legs/FD'; +import { bearingTo } from 'msfs-geo'; +import { arcDistanceToGo, arcLength, maxBank } from '../CommonGeometry'; +import { CFLeg } from '../legs/CF'; +import { CRLeg } from '../legs/CR'; +import { CILeg } from '../legs/CI'; +import { CDLeg } from '../legs/CD'; + +type PrevLeg = AFLeg | CALeg | CDLeg | CFLeg | CRLeg | DFLeg | FDLeg | /* FALeg | FMLeg | */ HALeg | HFLeg | HMLeg | RFLeg | TFLeg | /* VALeg | VDLeg | */ VMLeg; +type NextLeg = CALeg | CDLeg | CILeg | CRLeg | /* VALeg | VDLeg | VILeg | */ VMLeg; + +const tan = (input: Degrees) => Math.tan(input * (Math.PI / 180)); + +/** + * A type I transition uses a fixed turn radius between two fix-referenced legs. + */ +export class CourseCaptureTransition extends Transition { + constructor( + public previousLeg: PrevLeg, + public nextLeg: NextLeg | TFLeg, // FIXME temporary + ) { + super(previousLeg, nextLeg); + } + + private terminator: Coordinates | undefined; + + getPathStartPoint(): Coordinates | undefined { + return this.previousLeg.getPathEndPoint(); + } + + getPathEndPoint(): Coordinates | undefined { + return this.terminator; + } + + get turnDirection(): TurnDirection { + return Math.sign(this.courseVariation) === -1 ? TurnDirection.Left : TurnDirection.Right; + } + + get deltaTrack(): Degrees { + return MathUtils.fastToFixedNum(MathUtils.diffAngle(this.previousLeg.outboundCourse, this.nextLeg.inboundCourse), 1); + } + + get courseVariation(): Degrees { + return MathUtils.adjustAngleForTurnDirection(this.deltaTrack, this.nextLeg.metadata.turnDirection); + } + + public isArc: boolean; + + public startPoint: Coordinates; + + public endPoint: Coordinates; + + public center: Coordinates; + + public sweepAngle: Degrees; + + public radius: NauticalMiles; + + public clockwise: boolean; + + public predictedPath: PathVector[] = []; + + recomputeWithParameters(_isActive: boolean, tas: Knots, gs: Knots, ppos: Coordinates, _trueTrack: DegreesTrue) { + const termFix = this.previousLeg.getPathEndPoint(); + + let courseChange; + let initialTurningPoint; + if (!this.inboundGuidable) { + if (this.courseVariation <= 90) { + courseChange = this.deltaTrack; + } else if (Math.sign(this.courseVariation) === Math.sign(this.deltaTrack)) { + courseChange = this.deltaTrack; + } else { + courseChange = Math.sign(this.courseVariation) * 360 + this.deltaTrack; + } + initialTurningPoint = ppos; + } else { + courseChange = this.courseVariation; + initialTurningPoint = termFix; + } + + // Course change and delta track? + const radius = ((gs ** 2 / (Constants.G * tan(Math.abs(maxBank(tas, false))))) / 6997.84) * LnavConfig.TURN_RADIUS_FACTOR; + const turnCenter = Geo.computeDestinationPoint(initialTurningPoint, radius, this.previousLeg.outboundCourse + 90 * Math.sign(courseChange)); + const finalTurningPoint = Geo.computeDestinationPoint(turnCenter, radius, this.previousLeg.outboundCourse - 90 * Math.sign(courseChange) + courseChange); + + this.radius = radius; + + // Turn direction + this.clockwise = courseChange >= 0; + + if (courseChange === 0) { + this.isArc = false; + this.startPoint = this.previousLeg.getPathEndPoint(); + this.endPoint = this.previousLeg.getPathEndPoint(); + + this.terminator = this.endPoint; + + this.isComputed = true; + + this.predictedPath.length = 0; + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: this.startPoint, + endPoint: this.endPoint, + }); + + this.isNull = true; + + return; + } + + this.isNull = false; + this.isArc = true; + this.startPoint = initialTurningPoint; + this.center = turnCenter; + this.endPoint = finalTurningPoint; + this.sweepAngle = courseChange; + + this.terminator = this.endPoint; + + this.predictedPath.length = 0; + this.predictedPath.push({ + type: PathVectorType.Arc, + startPoint: this.startPoint, + centrePoint: this.center, + endPoint: this.endPoint, + sweepAngle: this.sweepAngle, + }); + + this.isComputed = true; + } + + get startsInCircularArc(): boolean { + return this.isArc; + } + + get endsInCircularArc(): boolean { + return this.isArc; + } + + get angle(): Degrees { + return this.sweepAngle; + } + + isAbeam(ppos: LatLongData): boolean { + const [inbound, outbound] = this.getTurningPoints(); + + const inBearingAc = bearingTo(inbound, ppos); + const inHeadingAc = Math.abs(MathUtils.diffAngle(this.previousLeg.outboundCourse, inBearingAc)); + + const outBearingAc = bearingTo(outbound, ppos); + const outHeadingAc = Math.abs(MathUtils.diffAngle(this.nextLeg.inboundCourse, outBearingAc)); + + return inHeadingAc <= 90 && outHeadingAc >= 90; + } + + get distance(): NauticalMiles { + if (this.isNull) { + return 0; + } + + return arcLength(this.radius, this.angle); + } + + getTurningPoints(): [Coordinates, Coordinates] { + return [this.startPoint, this.endPoint]; + } + + getDistanceToGo(ppos: LatLongData): NauticalMiles { + const [itp] = this.getTurningPoints(); + + return arcDistanceToGo(ppos, itp, this.center, this.clockwise ? this.angle : -this.angle); + } + + getGuidanceParameters(ppos: LatLongAlt, trueTrack: number, tas: Knots, gs: Knots): GuidanceParameters | null { + // FIXME PPOS guidance and all... + return this.nextLeg.getGuidanceParameters(ppos, trueTrack, tas, gs); + } + + getNominalRollAngle(gs: Knots): Degrees { + const gsMs = gs * (463 / 900); + return (this.clockwise ? 1 : -1) * Math.atan((gsMs ** 2) / (this.radius * 1852 * 9.81)) * (180 / Math.PI); + } + + get repr(): string { + return `COURSE CAPTURE(${this.previousLeg.repr} TO ${this.nextLeg.repr})`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/DirectToFixTransition.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/DirectToFixTransition.ts new file mode 100644 index 00000000000..1444810fa68 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/DirectToFixTransition.ts @@ -0,0 +1,372 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { MathUtils } from '@shared/MathUtils'; +import { CALeg } from '@fmgc/guidance/lnav/legs/CA'; +import { CFLeg } from '@fmgc/guidance/lnav/legs/CF'; +import { DFLeg } from '@fmgc/guidance/lnav/legs/DF'; +import { HALeg, HFLeg, HMLeg } from '@fmgc/guidance/lnav/legs/HX'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { VMLeg } from '@fmgc/guidance/lnav/legs/VM'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { GuidanceParameters, LateralPathGuidance } from '@fmgc/guidance/ControlLaws'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { Constants } from '@shared/Constants'; +import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { bearingTo, distanceTo, placeBearingDistance } from 'msfs-geo'; +import { FDLeg } from '@fmgc/guidance/lnav/legs/FD'; +import { CILeg } from '../legs/CI'; +import { + arcDistanceToGo, + arcGuidance, + arcLength, + courseToFixDistanceToGo, + courseToFixGuidance, getRollAnticipationDistance, + maxBank, +} from '../CommonGeometry'; +import { CRLeg } from '../legs/CR'; +import { CDLeg } from '../legs/CD'; + +type PrevLeg = CALeg | CDLeg | CFLeg | CILeg | CRLeg | DFLeg | FDLeg | /* FALeg | FMLeg | */ HALeg | HFLeg | HMLeg | TFLeg | /* VALeg | VILeg | VDLeg | */ VMLeg; /* | VRLeg */ +type NextLeg = CFLeg | DFLeg /* | FALeg | FMLeg */ + +const tan = (input: Degrees) => Math.tan(input * (Math.PI / 180)); +const acos = (input: Degrees) => Math.acos(input) * (180 / Math.PI); + +export enum DirectToFixTransitionGuidanceState { + Straight, + Turn, +} + +/** + * A type I transition uses a fixed turn radius between two fix-referenced legs. + */ +export class DirectToFixTransition extends Transition { + public state = DirectToFixTransitionGuidanceState.Straight; + + private straightCourse: Degrees; + + constructor(public previousLeg: PrevLeg, public nextLeg: NextLeg) { + super(previousLeg, nextLeg); + } + + private terminator: Coordinates | undefined; + + getPathStartPoint(): Coordinates | undefined { + return this.previousLeg.getPathEndPoint(); + } + + get turnDirection(): Degrees { + return Math.sign(this.deltaTrack); + } + + get deltaTrack(): Degrees { + return MathUtils.fastToFixedNum(MathUtils.diffAngle(this.previousLeg.outboundCourse, this.nextLeg.inboundCourse), 1); + } + + get courseVariation(): Degrees { + // TODO reverse turn direction + return this.deltaTrack; + } + + public hasArc: boolean; + + public center: Coordinates; + + public radius: NauticalMiles; + + public clockwise: boolean; + + public lineStartPoint: Coordinates; + + public lineEndPoint: Coordinates; + + public arcStartPoint: Coordinates; + + public arcCentrePoint: Coordinates; + + public arcEndPoint: Coordinates; + + public arcSweepAngle: Degrees; + + private computedPath: PathVector[] = []; + + get predictedPath(): PathVector[] { + return this.computedPath; + } + + recomputeWithParameters(isActive: boolean, tas: Knots, gs: Knots, _ppos: Coordinates, _trueTrack: DegreesTrue) { + if (this.isFrozen) { + return; + } + + const termFix = this.previousLeg.getPathEndPoint(); + + // FIXME fix for FX legs + const nextFix = this.nextLeg.fix.location; + + this.radius = (gs ** 2 / (Constants.G * tan(maxBank(tas, true))) / 6997.84) * LnavConfig.TURN_RADIUS_FACTOR; + + let trackChange = MathUtils.diffAngle(this.previousLeg.outboundCourse, bearingTo(this.previousLeg.getPathEndPoint(), nextFix), this.nextLeg.metadata.turnDirection); + + if (Math.abs(trackChange) < 3 || !Number.isFinite(trackChange)) { + this.isNull = true; + this.isComputed = true; + + return; + } + + const turnDirectionSign = trackChange > 0 ? 1 : -1; + const turnDirection = turnDirectionSign > 0 ? TurnDirection.Right : TurnDirection.Left; + + const currentRollAngle = isActive ? -SimVar.GetSimVarValue('PLANE BANK DEGREES', 'degrees') : 0; + const rollAngleChange = Math.abs(turnDirectionSign * maxBank(tas, true) - currentRollAngle); + const rollAnticipationDistance = getRollAnticipationDistance(gs, 0, rollAngleChange); + + let itp = rollAnticipationDistance >= 0.05 ? placeBearingDistance(termFix, this.previousLeg.outboundCourse, rollAnticipationDistance) : termFix; + let turnCentre = placeBearingDistance(itp, this.previousLeg.outboundCourse + turnDirectionSign * 90, this.radius); + + let distanceToFix = distanceTo(turnCentre, nextFix); + + if (distanceToFix < this.radius) { + if (Math.abs(MathUtils.diffAngle(this.previousLeg.outboundCourse, bearingTo(termFix, nextFix), this.nextLeg.metadata.turnDirection)) < 60) { + this.hasArc = false; + this.lineStartPoint = termFix; + this.lineEndPoint = termFix; + this.terminator = this.lineEndPoint; + + this.predictedPath.length = 0; + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: this.lineStartPoint, + endPoint: this.lineEndPoint, + }); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push(...this.getPathDebugPoints()); + } + + this.straightCourse = bearingTo(this.lineStartPoint, this.lineEndPoint); + + this.isNull = true; + this.isComputed = true; + + return; + } + + const tcFixBearing = bearingTo(turnCentre, nextFix); + const extendDist = Math.sqrt(this.radius ** 2 - distanceToFix ** 2 * Math.sin((tcFixBearing - this.previousLeg.outboundCourse) * Math.PI / 180) ** 2) + + distanceToFix * Math.cos((tcFixBearing - this.previousLeg.outboundCourse) * Math.PI / 180) + 0.3; + + itp = placeBearingDistance(itp, this.previousLeg.outboundCourse, extendDist); + turnCentre = placeBearingDistance(turnCentre, this.previousLeg.outboundCourse, extendDist); + distanceToFix = distanceTo(turnCentre, nextFix); + } + + const bearingTcItp = bearingTo(turnCentre, itp); + const bearingTcFix = bearingTo(turnCentre, nextFix); + const angleFtpFix = acos(this.radius / distanceToFix); + + trackChange = MathUtils.diffAngle(bearingTcItp, MathUtils.diffAngle(turnDirectionSign * angleFtpFix, bearingTcFix), turnDirection); + + const ftp = placeBearingDistance(turnCentre, this.previousLeg.outboundCourse + trackChange - 90 * turnDirectionSign, this.radius); + + this.lineStartPoint = this.previousLeg.getPathEndPoint(); + this.lineEndPoint = itp; + this.hasArc = true; + this.arcStartPoint = itp; + this.arcCentrePoint = turnCentre; + this.arcEndPoint = ftp; + this.arcSweepAngle = trackChange; + this.terminator = this.arcEndPoint; + + this.predictedPath.length = 0; + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: this.lineStartPoint, + endPoint: this.lineEndPoint, + }); + + this.predictedPath.push({ + type: PathVectorType.Arc, + startPoint: this.arcStartPoint, + centrePoint: this.arcCentrePoint, + endPoint: this.arcEndPoint, + sweepAngle: this.arcSweepAngle, + }); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push(...this.getPathDebugPoints()); + } + + this.straightCourse = bearingTo(this.lineStartPoint, this.lineEndPoint); + + this.isNull = false; + this.isComputed = true; + } + + private getPathDebugPoints(): PathVector[] { + const points: PathVector[] = []; + + points.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.lineStartPoint, + annotation: 'T4 RAD START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.lineEndPoint, + annotation: 'T4 RAD END', + }, + ); + + if (this.hasArc) { + points.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.arcStartPoint, + annotation: 'T4 ARC START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.arcCentrePoint, + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.arcEndPoint, + annotation: 'T4 ARC END', + }, + ); + } + + return points; + } + + get endsInCircularArc(): boolean { + return this.hasArc; + } + + isAbeam(ppos: LatLongData): boolean { + if (this.isNull) { + return false; + } + + let dtg = 0; + + if (this.state === DirectToFixTransitionGuidanceState.Straight) { + const straightDist = distanceTo(this.lineStartPoint, this.lineEndPoint); + const straightDtg = courseToFixDistanceToGo(ppos, this.straightCourse, this.lineEndPoint); + + dtg += straightDtg; + + if (dtg >= straightDist) { + return false; + } + } + + if (this.hasArc) { + if (this.state === DirectToFixTransitionGuidanceState.Turn) { + const arcDtg = arcDistanceToGo(ppos, this.arcStartPoint, this.arcCentrePoint, this.arcSweepAngle); + + dtg += arcDtg; + } else { + dtg += arcLength(this.radius, this.arcSweepAngle); + } + } + + return dtg > 0; + } + + get distance(): NauticalMiles { + if (this.isNull) { + return 0; + } + + const straightDistance = distanceTo(this.lineStartPoint, this.lineEndPoint); + + if (this.hasArc) { + const circumference = 2 * Math.PI * this.radius; + + return straightDistance + (circumference / 360 * this.arcSweepAngle); + } + + return straightDistance; + } + + getTurningPoints(): [Coordinates, Coordinates] { + return [this.arcStartPoint, this.arcEndPoint]; + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles { + let straightDtg = 0; + if (this.state === DirectToFixTransitionGuidanceState.Straight) { + straightDtg = courseToFixDistanceToGo(ppos, this.straightCourse, this.lineEndPoint); + } + + if (!this.hasArc) { + return straightDtg; + } + + return straightDtg + arcDistanceToGo(ppos, this.arcStartPoint, this.arcCentrePoint, this.arcSweepAngle); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: number, tas: Knots): GuidanceParameters | null { + let dtg: NauticalMiles; + let params: LateralPathGuidance; + + // State machine & DTG + + switch (this.state) { + case DirectToFixTransitionGuidanceState.Straight: + dtg = courseToFixDistanceToGo(ppos, this.straightCourse, this.lineEndPoint); + if (dtg <= 0 && this.hasArc) { + this.state = DirectToFixTransitionGuidanceState.Turn; + } + break; + case DirectToFixTransitionGuidanceState.Turn: + dtg = arcDistanceToGo(ppos, this.arcStartPoint, this.arcCentrePoint, this.arcSweepAngle); + break; + default: + } + + // Guidance + + switch (this.state) { + case DirectToFixTransitionGuidanceState.Straight: + params = courseToFixGuidance(ppos, trueTrack, this.straightCourse, this.lineEndPoint); + + let bankNext: DegreesTrue = 0; + + if (this.hasArc) { + bankNext = this.arcSweepAngle > 0 ? maxBank(tas, true) : -maxBank(tas, false); + } + + const rad = getRollAnticipationDistance(tas, 0, bankNext); + + if (dtg <= rad) { + params.phiCommand = bankNext; + } + break; + case DirectToFixTransitionGuidanceState.Turn: + params = arcGuidance(ppos, trueTrack, this.arcStartPoint, this.arcCentrePoint, this.arcSweepAngle); + // TODO next leg RAD + break; + default: + } + return params; + } + + getNominalRollAngle(gs: Knots): Degrees { + const gsMs = gs * (463 / 900); + return (this.clockwise ? 1 : -1) * Math.atan((gsMs ** 2) / (this.radius * 1852 * 9.81)) * (180 / Math.PI); + } + + get repr(): string { + return `DIRECT TO FIX(${this.previousLeg.repr} TO ${this.nextLeg.repr})`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/DmeArcTransition.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/DmeArcTransition.ts new file mode 100644 index 00000000000..23922eca2a4 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/DmeArcTransition.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { AFLeg } from '@fmgc/guidance/lnav/legs/AF'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { DFLeg } from '@fmgc/guidance/lnav/legs/DF'; +import { CILeg } from '@fmgc/guidance/lnav/legs/CI'; +import { CFLeg } from '@fmgc/guidance/lnav/legs/CF'; +import { arcDistanceToGo, arcGuidance, maxBank } from '@fmgc/guidance/lnav/CommonGeometry'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { MathUtils } from '@shared/MathUtils'; +import { bearingTo, closestSmallCircleIntersection, placeBearingDistance } from 'msfs-geo'; +import { PathVector, pathVectorLength, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { CALeg } from '@fmgc/guidance/lnav/legs/CA'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { FDLeg } from '@fmgc/guidance/lnav/legs/FD'; +import { CDLeg } from '../legs/CD'; + +export type DmeArcTransitionPreviousLeg = AFLeg | CDLeg | CFLeg | CILeg | DFLeg | FDLeg | TFLeg; /* | VILeg | VDLeg */ +export type DmeArcTransitionNextLeg = AFLeg | CALeg | CFLeg /* | FALeg | FMLeg */ | TFLeg; + +const tan = (input: Degrees) => Math.tan(input * (Math.PI / 180)); + +export class DmeArcTransition extends Transition { + predictedPath: PathVector[] = []; + + constructor( + public previousLeg: DmeArcTransitionPreviousLeg, + public nextLeg: DmeArcTransitionNextLeg, + ) { + super(previousLeg, nextLeg); + } + + getPathStartPoint(): Coordinates | undefined { + return this.itp; + } + + getPathEndPoint(): Coordinates | undefined { + return this.ftp; + } + + private radius: NauticalMiles | undefined + + private itp: Coordinates | undefined + + private centre: Coordinates | undefined + + private ftp: Coordinates | undefined + + private sweepAngle: Degrees | undefined + + private clockwise: boolean | undefined + + recomputeWithParameters(_isActive: boolean, tas: Knots, gs: MetresPerSecond, _ppos: Coordinates, _trueTrack: DegreesTrue) { + if (this.isFrozen) { + return; + } + + this.radius = (gs ** 2 / (9.81 * tan(maxBank(tas, true))) / 6080.2); + + if (this.previousLeg instanceof AFLeg) { + const turnDirection = Math.sign(MathUtils.diffAngle(this.previousLeg.outboundCourse, this.nextLeg.inboundCourse)); + const nextLegReference = this.nextLeg.getPathStartPoint(); // FIXME FX legs + const reference = placeBearingDistance(nextLegReference, this.nextLeg.inboundCourse + 90 * turnDirection, this.radius); + const dme = this.previousLeg.centre; + + const turnCentre = closestSmallCircleIntersection( + dme, + this.previousLeg.radius + this.radius * turnDirection * -this.previousLeg.turnDirectionSign, + reference, + this.nextLeg.inboundCourse - 180, + ); + + if (!turnCentre) { + throw new Error('AFLeg did not intersect with previous leg offset reference'); + } + + this.centre = turnCentre; + + this.itp = placeBearingDistance( + turnCentre, + turnDirection * -this.previousLeg.turnDirectionSign === 1 ? bearingTo(turnCentre, dme) : bearingTo(dme, turnCentre), + this.radius, + ); + this.ftp = placeBearingDistance( + turnCentre, + this.nextLeg.inboundCourse - 90 * turnDirection, + this.radius, + ); + + this.sweepAngle = MathUtils.diffAngle(bearingTo(turnCentre, this.itp), bearingTo(turnCentre, this.ftp)); + this.clockwise = this.sweepAngle > 0; + + this.predictedPath.length = 0; + this.predictedPath.push({ + type: PathVectorType.Arc, + startPoint: this.itp, + centrePoint: turnCentre, + endPoint: this.ftp, + sweepAngle: this.sweepAngle, + }); + + this.isComputed = true; + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.addDebugPoints(); + } + } else if (this.nextLeg instanceof AFLeg) { + const turnDirection = Math.sign(MathUtils.diffAngle(this.previousLeg.outboundCourse, this.nextLeg.inboundCourse)); + const reference = placeBearingDistance(this.previousLeg.getPathEndPoint(), this.previousLeg.outboundCourse + 90 * turnDirection, this.radius); + const dme = this.nextLeg.centre; + + let turnCentre; + if (this.previousLeg instanceof XFLeg && !(this.previousLeg instanceof AFLeg)) { + const intersection = closestSmallCircleIntersection( + dme, + this.nextLeg.radius + this.radius * turnDirection * -this.nextLeg.turnDirectionSign, + reference, + this.previousLeg.outboundCourse, + ); + + if (intersection) { + turnCentre = intersection; + + this.itp = placeBearingDistance( + turnCentre, + this.previousLeg.outboundCourse - 90 * turnDirection, + this.radius, + ); + + this.ftp = placeBearingDistance( + turnCentre, + turnDirection * -this.nextLeg.turnDirectionSign === 1 ? bearingTo(turnCentre, dme) : bearingTo(dme, turnCentre), + this.radius, + ); + } else { + this.ftp = placeBearingDistance( + dme, + this.nextLeg.boundaryRadial, + this.nextLeg.radius, + ); + + const turnSign = turnDirection > 0 ? 1 : -1; + + turnCentre = placeBearingDistance( + this.ftp, + MathUtils.clampAngle(this.nextLeg.boundaryRadial + (turnSign > 0 ? 180 : 0)), + this.radius, + ); + + this.itp = placeBearingDistance( + turnCentre, + MathUtils.clampAngle(this.previousLeg.outboundCourse - turnSign * 90), + this.radius, + ); + } + } else { + turnCentre = closestSmallCircleIntersection( + dme, + this.nextLeg.radius + this.radius * turnDirection * -this.nextLeg.turnDirectionSign, + reference, + this.previousLeg.outboundCourse, + ); + + if (!turnCentre) { + throw new Error('AFLeg did not intersect with previous leg offset reference'); + } + + this.itp = placeBearingDistance( + turnCentre, + this.previousLeg.outboundCourse - 90 * turnDirection, + this.radius, + ); + + this.ftp = placeBearingDistance( + turnCentre, + turnDirection * -this.nextLeg.turnDirectionSign === 1 ? bearingTo(turnCentre, dme) : bearingTo(dme, turnCentre), + this.radius, + ); + } + + this.centre = turnCentre; + + this.sweepAngle = MathUtils.diffAngle(bearingTo(turnCentre, this.itp), bearingTo(turnCentre, this.ftp)); + this.clockwise = this.sweepAngle > 0; + + this.predictedPath.length = 0; + this.predictedPath.push({ + type: PathVectorType.Arc, + startPoint: this.itp, + centrePoint: turnCentre, + endPoint: this.ftp, + sweepAngle: this.sweepAngle, + }); + + this.isComputed = true; + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push({ + type: PathVectorType.DebugPoint, + startPoint: reference, + annotation: 'DME TRANS REF', + }); + this.addDebugPoints(); + } + } + } + + private addDebugPoints() { + if (this.itp && this.centre && this.ftp) { + this.predictedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.itp, + annotation: 'DME TRANS ITP', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.centre, + annotation: 'DME TRANS C', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.ftp, + annotation: 'DME TRANS FTP', + }, + ); + } + } + + getTurningPoints(): [Coordinates, Coordinates] { + return [this.itp, this.ftp]; + } + + get distance(): NauticalMiles { + return pathVectorLength(this.predictedPath[0]); // FIXME HAX + } + + get startsInCircularArc(): boolean { + return true; + } + + get endsInCircularArc(): boolean { + return true; + } + + getNominalRollAngle(gs: MetresPerSecond): Degrees | undefined { + const gsMs = gs * (463 / 900); + return (this.clockwise ? 1 : -1) * Math.atan((gsMs ** 2) / (this.radius * 1852 * 9.81)) * (180 / Math.PI); + } + + getGuidanceParameters(ppos: Coordinates, trueTrack: Degrees): GuidanceParameters | undefined { + return arcGuidance(ppos, trueTrack, this.getPathStartPoint(), this.centre, this.sweepAngle); + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles | undefined { + return arcDistanceToGo(ppos, this.getPathStartPoint(), this.centre, this.sweepAngle); + } + + isAbeam(ppos: Coordinates): boolean { + const turningPoints = this.getTurningPoints(); + if (!turningPoints) { + return false; + } + + const [inbound, outbound] = turningPoints; + + const inBearingAc = bearingTo(inbound, ppos); + const inHeadingAc = Math.abs(MathUtils.diffAngle(this.previousLeg.outboundCourse, inBearingAc)); + + const outBearingAc = bearingTo(outbound, ppos); + const outHeadingAc = Math.abs(MathUtils.diffAngle(this.nextLeg.inboundCourse, outBearingAc)); + + return inHeadingAc <= 90 && outHeadingAc >= 90; + } + + get repr(): string { + return `DME(${this.previousLeg.repr}, ${this.nextLeg.repr})`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/FixedRadiusTransition.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/FixedRadiusTransition.ts new file mode 100644 index 00000000000..5c57829799c --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/FixedRadiusTransition.ts @@ -0,0 +1,313 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { MathUtils } from '@shared/MathUtils'; +import { DFLeg } from '@fmgc/guidance/lnav/legs/DF'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { PathCaptureTransition } from '@fmgc/guidance/lnav/transitions/PathCaptureTransition'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { CILeg } from '@fmgc/guidance/lnav/legs/CI'; +import { arcDistanceToGo, arcGuidance, arcLength, maxBank, minBank } from '@fmgc/guidance/lnav/CommonGeometry'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { Constants } from '@shared/Constants'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { Geo } from '@fmgc/utils/Geo'; +import { XFLeg } from '@fmgc/guidance/lnav/legs/XF'; +import { bearingTo, distanceTo, placeBearingDistance } from 'msfs-geo'; +import { FDLeg } from '@fmgc/guidance/lnav/legs/FD'; +import { PathVector, PathVectorType } from '../PathVector'; +import { CFLeg } from '../legs/CF'; + +type PrevLeg = CILeg | CFLeg | DFLeg | FDLeg | TFLeg; +type NextLeg = CFLeg | FDLeg | /* FALeg | FMLeg | PILeg | */ TFLeg; + +const mod = (x: number, n: number) => x - Math.floor(x / n) * n; + +/** + * A type I transition uses a fixed turn radius between two fix-referenced legs. + */ +export class FixedRadiusTransition extends Transition { + public radius: NauticalMiles; + + public tad: NauticalMiles; + + public clockwise: boolean; + + public isFrozen: boolean = false; + + private computedPath: PathVector[] = []; + + private sweepAngle: Degrees; + + private centre: Coordinates | undefined = undefined; + + private revertTo: PathCaptureTransition | undefined = undefined; + + constructor( + public previousLeg: PrevLeg, // FIXME temporary + public nextLeg: NextLeg, // FIXME temporary + ) { + super(previousLeg, nextLeg); + } + + get isReverted(): boolean { + return this.revertTo !== undefined; + } + + getPathStartPoint(): Coordinates | undefined { + if (this.revertTo) { + return this.revertTo.getPathStartPoint(); + } + + if (this.isComputed) { + return this.turningPoints[0]; + } + + throw Error('?'); + } + + getPathEndPoint(): Coordinates | undefined { + if (this.revertTo) { + return this.revertTo.getPathEndPoint(); + } + + if (this.isComputed) { + return this.turningPoints[1]; + } + + throw Error('?'); + } + + recomputeWithParameters(isActive: boolean, tas: Knots, gs: Knots, ppos: Coordinates, trueTrack: DegreesTrue) { + if (this.isFrozen) { + if (DEBUG) { + console.log('[FMS/Geometry] Not recomputing Type I transition as it is frozen.'); + } + return; + } + + // Sweep angle + this.sweepAngle = MathUtils.diffAngle(this.previousLeg.outboundCourse, this.nextLeg.inboundCourse); + + // Start with half the track change + const bankAngle = Math.abs(this.sweepAngle) / 2; + + // apply limits + const finalBankAngle = Math.max(Math.min(bankAngle, maxBank(tas, true)), minBank(this.nextLeg.segment)); + + // Turn radius + this.radius = ((tas ** 2 / (9.81 * Math.tan(finalBankAngle * MathUtils.DEGREES_TO_RADIANS))) / 6997.84) * LnavConfig.TURN_RADIUS_FACTOR; + + // Turn anticipation distance + this.tad = this.radius * Math.tan(Math.abs(this.sweepAngle / 2) * MathUtils.DEGREES_TO_RADIANS); + + // Check what the distance from the fix to the next leg is (to avoid being not lined up in some XF -> CF cases) + const prevLegTermDistanceToNextLeg = Geo.distanceToLeg( + this.previousLeg instanceof XFLeg ? this.previousLeg.fix.location : this.previousLeg.intercept, + this.nextLeg, + ); + + const defaultTurnDirection = this.sweepAngle >= 0 ? TurnDirection.Right : TurnDirection.Left; + const forcedTurn = (this.nextLeg.metadata.turnDirection === TurnDirection.Left || this.nextLeg.metadata.turnDirection === TurnDirection.Right) + && defaultTurnDirection !== this.nextLeg.metadata.turnDirection; + const tooBigForPrevious = this.previousLeg.distanceToTermination < this.tad + 0.1; + const tooBigForNext = 'from' in this.nextLeg ? distanceTo(this.nextLeg.from.location, this.nextLeg.to.location) < this.tad + 0.1 : false; + const notLinedUp = Math.abs(prevLegTermDistanceToNextLeg) >= 0.25; // "reasonable" distance + + // in some circumstances we revert to a path capture transition where the fixed radius won't work well + const shouldRevert = Math.abs(this.sweepAngle) <= 3 + || Math.abs(this.sweepAngle) > 175 + || this.previousLeg.overflyTermFix || forcedTurn || tooBigForPrevious || tooBigForNext || notLinedUp; + + // We do not revert to a path capture if the previous leg was overshot anyway - draw the normal fixed radius turn + const previousLegOvershot = 'overshot' in this.previousLeg && this.previousLeg.overshot; + + if (shouldRevert && !previousLegOvershot) { + const shouldHaveTad = !this.previousLeg.overflyTermFix && !notLinedUp && (tooBigForPrevious || tooBigForNext); + + if (!this.revertTo) { + const reverted = new PathCaptureTransition(this.previousLeg, this.nextLeg); + + reverted.startWithTad = shouldHaveTad; + reverted.recomputeWithParameters(isActive, tas, gs, ppos, trueTrack); + + const reversionTad = reverted.tad; + const fixDtg = this.previousLeg.getDistanceToGo(ppos) + this.tad; + + // See if there is enough space left for the reverted transition + if (fixDtg > reversionTad) { + this.revertTo = reverted; + this.isComputed = this.revertTo.isComputed; + return; + } + } else { + this.revertTo.startWithTad = shouldHaveTad; + this.revertTo.recomputeWithParameters(isActive, tas, gs, ppos, trueTrack); + this.isComputed = this.revertTo.isComputed; + return; + } + } + + // Try to de-revert if needed + if (this.revertTo) { + // We assume we are inactive here + const fixDtg = this.previousLeg.getDistanceToGo(ppos) + this.revertTo.tad; + + // Only de-revert if there is space for the fixed radius TAD + if (fixDtg > this.tad + 0.05 || !isActive) { + this.revertTo = undefined; + } + } + + // Turn direction + this.clockwise = this.sweepAngle >= 0; + + // Turning points + this.turningPoints = this.computeTurningPoints(); + + this.computedPath.length = 0; + this.computedPath.push( + { + type: PathVectorType.Arc, + startPoint: this.getTurningPoints()[0], + centrePoint: this.centre, + endPoint: this.getTurningPoints()[1], + sweepAngle: this.sweepAngle, + }, + ); + + this.isComputed = true; + } + + get startsInCircularArc(): boolean { + return true; + } + + get endsInCircularArc(): boolean { + return true; + } + + isAbeam(ppos: LatLongData): boolean { + if (this.revertTo !== undefined) { + return this.revertTo.isAbeam(ppos); + } + + const turningPoints = this.getTurningPoints(); + if (!turningPoints) { + return false; + } + + const [inbound, outbound] = turningPoints; + + const inBearingAc = bearingTo(inbound, ppos); + const inHeadingAc = Math.abs(MathUtils.diffAngle(this.previousLeg.outboundCourse, inBearingAc)); + + const outBearingAc = bearingTo(outbound, ppos); + const outHeadingAc = Math.abs(MathUtils.diffAngle(this.nextLeg.inboundCourse, outBearingAc)); + + return inHeadingAc <= 90 && outHeadingAc >= 90; + } + + get distance(): NauticalMiles { + if (this.revertTo) { + return this.revertTo.distance; + } + + return arcLength(this.radius, this.sweepAngle); + } + + /** + * Returns the distance between the inbound turning point and the reference fix + */ + get unflownDistance() { + if (this.revertTo) { + return 0; + } + + if (!this.getTurningPoints()) { + return 0; + } + return distanceTo( + this.previousLeg.getPathEndPoint(), + this.getTurningPoints()[0], + ); + } + + private turningPoints; + + private computeTurningPoints(): [Coordinates, Coordinates] { + const coords = this.previousLeg instanceof XFLeg ? this.previousLeg.fix.location : this.previousLeg.intercept; + + const inbound = placeBearingDistance( + coords, + mod(this.previousLeg.outboundCourse + 180, 360), + this.tad, + ); + + const outbound = placeBearingDistance( + coords, + this.nextLeg.inboundCourse, + this.tad, + ); + + this.centre = placeBearingDistance( + inbound, + MathUtils.clampAngle(this.previousLeg.outboundCourse + (this.clockwise ? 90 : -90)), + this.radius, + ); + + return [inbound, outbound]; + } + + getTurningPoints(): [Coordinates, Coordinates] | undefined { + if (this.revertTo) { + return this.revertTo.getTurningPoints(); + } + + return this.turningPoints; + } + + get predictedPath(): PathVector[] { + if (this.revertTo) { + return this.revertTo.predictedPath; + } + + return this.computedPath; + } + + getDistanceToGo(ppos: Coordinates): NauticalMiles { + if (this.revertTo) { + return this.revertTo.getDistanceToGo(ppos); + } + + const [itp] = this.getTurningPoints(); + + return arcDistanceToGo(ppos, itp, this.centre, this.sweepAngle); + } + + getGuidanceParameters(ppos: LatLongAlt, trueTrack: number, tas: Knots): GuidanceParameters | null { + if (this.revertTo) { + return this.revertTo.getGuidanceParameters(ppos, trueTrack, tas); + } + + const [itp] = this.getTurningPoints(); + + return arcGuidance(ppos, trueTrack, itp, this.centre, this.sweepAngle); + } + + getNominalRollAngle(gs: Knots): Degrees { + if (this.revertTo) { + return this.revertTo.getNominalRollAngle(gs); + } + + return (this.clockwise ? 1 : -1) * Math.atan(((gs * 463 / 900) ** 2) / (this.radius * 1852 * Constants.G)) * (180 / Math.PI); + } + + get repr(): string { + return `TYPE1(${this.previousLeg.repr} TO ${this.nextLeg.repr})`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/HoldEntryTransition.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/HoldEntryTransition.ts new file mode 100644 index 00000000000..34cb98ecb81 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/HoldEntryTransition.ts @@ -0,0 +1,851 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { DFLeg } from '@fmgc/guidance/lnav/legs/DF'; +import { HALeg, HFLeg, HMLeg, HxLegGuidanceState } from '@fmgc/guidance/lnav/legs/HX'; +import { RFLeg } from '@fmgc/guidance/lnav/legs/RF'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { GuidanceParameters, LateralPathGuidance } from '@fmgc/guidance/ControlLaws'; +import { ControlLaw } from '@shared/autopilot'; +import { Geometry } from '@fmgc/guidance/Geometry'; +import { CFLeg } from '@fmgc/guidance/lnav/legs/CF'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { AFLeg } from '@fmgc/guidance/lnav/legs/AF'; +import { MathUtils } from '@shared/MathUtils'; +import { bearingTo, placeBearingDistance } from 'msfs-geo'; +import { TurnDirection } from 'msfs-navdata'; +import { + arcDistanceToGo, + arcGuidance, + courseToFixDistanceToGo, + courseToFixGuidance, + maxBank, + reciprocal, +} from '../CommonGeometry'; +import { DebugPointColour, PathVector, PathVectorType } from '../PathVector'; + +enum EntryType { + Null, + Teardrop, + Parallel, + DirectOutbound, + DirectTurn, +} + +export interface EntryTurn { + itp?: Coordinates, + arcCentre?: Coordinates, + ftp?: Coordinates, + sweepAngle?: Degrees, +} + +export enum EntryState { + Turn1, + Straight1, + Turn2, + Capture, + Done, +} + +export class HoldEntryTransition extends Transition { + private entry = EntryType.Null; + + private computedPath: PathVector[] = []; + + private turn1: EntryTurn = {}; + + private turn2: EntryTurn = {}; + + private turn3: EntryTurn = {}; + + private straightCourse: Degrees; + + public state: EntryState = EntryState.Turn1; + + // hax + private wasAbeam = false; + + private guidanceActive = false; + + private frozen = false; + + constructor( + public previousLeg: AFLeg | CFLeg | DFLeg | RFLeg | TFLeg, + public nextLeg: HALeg | HFLeg | HMLeg, + _predictWithCurrentSpeed: boolean = true, // TODO we don't need this? + ) { + super(previousLeg, nextLeg); + } + + get distance(): NauticalMiles { + return 0; // 0 so no PWPs + } + + getDistanceToGo(_ppos: LatLongData): NauticalMiles { + if (this.entry === EntryType.Null || this.state === EntryState.Done) { + return 0; + } + + // TODO + return 1; + } + + private setHxEntry(): void { + switch (this.entry) { + case EntryType.DirectTurn: + case EntryType.Parallel: + case EntryType.Teardrop: + case EntryType.Null: + this.nextLeg.setInitialState(HxLegGuidanceState.Arc1); + break; + case EntryType.DirectOutbound: + this.nextLeg.setInitialState(HxLegGuidanceState.Outbound); + break; + default: + } + } + + getParallelTeardropGuidanceParameters(ppos: LatLongAlt, trueTrack: Degrees, tas: Knots, gs: Knots): GuidanceParameters { + let dtg: NauticalMiles; + + // update state + switch (this.state) { + case EntryState.Turn1: + dtg = arcDistanceToGo(ppos, this.turn1.itp, this.turn1.arcCentre, this.turn1.sweepAngle); + if (dtg <= 0) { + this.state = EntryState.Straight1; + } + break; + case EntryState.Straight1: + dtg = courseToFixDistanceToGo(ppos, this.straightCourse, this.turn2.itp); + if (dtg <= 0) { + this.state = EntryState.Turn2; + } + break; + case EntryState.Turn2: + dtg = arcDistanceToGo(ppos, this.turn2.itp, this.turn2.arcCentre, this.turn2.sweepAngle); + const refFrameOffset = MathUtils.diffAngle(0, this.outboundCourse); + const trackAngleError = this.turn2.sweepAngle < 0 ? MathUtils.clampAngle(refFrameOffset - trueTrack) : MathUtils.clampAngle(trueTrack - refFrameOffset); + if (trackAngleError < 130) { + this.state = EntryState.Capture; + } + break; + case EntryState.Capture: + dtg = courseToFixDistanceToGo(ppos, this.outboundCourse, this.nextLeg.fix.location); + if (dtg < 0.1) { + this.nextLeg.updatePrediction(); + this.state = EntryState.Done; + } + break; + default: + } + + let bankNext: Degrees = 0; + let params: GuidanceParameters | undefined; + // compute guidance + switch (this.state) { + case EntryState.Turn1: + params = arcGuidance(ppos, trueTrack, this.turn1.itp, this.turn1.arcCentre, this.turn1.sweepAngle); + break; + case EntryState.Straight1: + params = courseToFixGuidance(ppos, trueTrack, this.straightCourse, this.turn2.itp); + bankNext = this.turn2.sweepAngle > 0 ? maxBank(tas, true) : -maxBank(tas, true); + break; + case EntryState.Turn2: + // force the initial part of the turn to ensure correct direction + const phiCommand = this.turn2.sweepAngle > 0 ? maxBank(tas, true /* FIXME false */) : -maxBank(tas, true /* FIXME false */); + bankNext = phiCommand; + params = { + law: ControlLaw.LATERAL_PATH, + trackAngleError: 0, + phiCommand, + crossTrackError: 0, + }; + break; + case EntryState.Capture: + params = courseToFixGuidance(ppos, trueTrack, this.outboundCourse, this.nextLeg.fix.location); + // TODO for HF get the following leg bank + const { sweepAngle } = this.nextLeg.geometry; + bankNext = sweepAngle > 0 ? maxBank(tas, true) : -maxBank(tas, true); + break; + case EntryState.Done: + params = this.nextLeg.getGuidanceParameters(ppos, trueTrack, tas, gs); + bankNext = params.phiCommand; + break; + default: + } + + const rad = Geometry.getRollAnticipationDistance(tas, (params as LateralPathGuidance).phiCommand, bankNext); + if (rad > 0 && dtg <= rad) { + (params as LateralPathGuidance).phiCommand = bankNext; + } + + return params; + } + + getDirectTurnGuidanceParameters(ppos: LatLongAlt, trueTrack: Degrees, tas: Knots, _gs: Knots): GuidanceParameters { + let dtg: NauticalMiles; + + switch (this.state) { + case EntryState.Turn1: + dtg = arcDistanceToGo(ppos, this.turn1.itp, this.turn1.arcCentre, this.turn1.sweepAngle); + if (dtg <= 0) { + this.state = EntryState.Straight1; + } + break; + case EntryState.Straight1: + dtg = courseToFixDistanceToGo(ppos, this.straightCourse, this.turn2.itp); + if (dtg <= 0) { + this.state = EntryState.Turn2; + } + break; + case EntryState.Turn2: + dtg = arcDistanceToGo(ppos, this.turn2.itp, this.turn2.arcCentre, this.turn2.sweepAngle); + if (dtg <= 0) { + this.state = EntryState.Capture; + } + break; + case EntryState.Capture: + dtg = courseToFixDistanceToGo(ppos, this.outboundCourse, this.nextLeg.fix.location); + if (dtg < 0.1) { + this.state = EntryState.Done; + } + break; + default: + } + + let params: LateralPathGuidance; + let bankNext: Degrees; + switch (this.state) { + case EntryState.Turn1: + params = arcGuidance(ppos, trueTrack, this.turn1.itp, this.turn1.arcCentre, this.turn1.sweepAngle); + bankNext = 0; + break; + case EntryState.Straight1: + params = courseToFixGuidance(ppos, trueTrack, this.straightCourse, this.turn2.itp); + bankNext = this.turn2.sweepAngle > 0 ? maxBank(tas, true) : -maxBank(tas, true); + break; + case EntryState.Turn2: + params = arcGuidance(ppos, trueTrack, this.turn2.itp, this.turn2.arcCentre, this.turn2.sweepAngle); + bankNext = 0; + break; + case EntryState.Capture: + params = courseToFixGuidance(ppos, trueTrack, this.outboundCourse, this.nextLeg.fix.location); + bankNext = 0; + break; + default: + } + + const rad = Geometry.getRollAnticipationDistance(tas, (params as LateralPathGuidance).phiCommand, bankNext); + if (rad > 0 && dtg <= rad) { + (params as LateralPathGuidance).phiCommand = bankNext; + } + return params; + } + + /** + * + * @todo guide inbound leg for parallel + teardrop? + */ + getGuidanceParameters(ppos: LatLongAlt, trueTrack: Degrees, tas: Knots, gs: Knots): GuidanceParameters | undefined { + if (!this.guidanceActive) { + this.nextLeg.updatePrediction(); + this.guidanceActive = true; + } + + switch (this.entry) { + case EntryType.Parallel: + case EntryType.Teardrop: + return this.getParallelTeardropGuidanceParameters(ppos, trueTrack, tas, gs); + case EntryType.DirectOutbound: + return this.nextLeg.getGuidanceParameters(ppos, trueTrack, tas, gs); + case EntryType.DirectTurn: + return this.getDirectTurnGuidanceParameters(ppos, trueTrack, tas, gs); + default: + } + + return undefined; + } + + public getNominalRollAngle(gs: Knots): Degrees { + if (this.entry === EntryType.Null) { + return this.nextLeg.getNominalRollAngle(gs); + } + + if (Math.abs(this.turn1.sweepAngle) <= 3) { + return 0; + } + + return this.turn1.sweepAngle > 0 ? maxBank(gs /* FIXME tas */, true) : -maxBank(gs /* FIXME tas */, true); + } + + getTurningPoints(): [Coordinates, Coordinates] { + switch (this.entry) { + case EntryType.Parallel: + case EntryType.Teardrop: + return [this.nextLeg.fix.location, this.turn3.ftp]; + case EntryType.DirectTurn: + case EntryType.DirectOutbound: + return [this.nextLeg.fix.location, this.turn1.ftp]; + case EntryType.Null: + default: + return [this.nextLeg.fix.location, this.nextLeg.fix.location]; + } + } + + isAbeam(ppos: Coordinates) { + // major hack + if (!this.wasAbeam && this.previousLeg.getDistanceToGo(ppos) <= 0) { + this.wasAbeam = true; + return true; + } + return this.wasAbeam && this.state !== EntryState.Done; + } + + get startsInCircularArc(): boolean { + return true; + } + + get endsInCircularArc(): boolean { + return true; + } + + get inboundCourse(): Degrees { + return this.previousLeg.outboundCourse; + } + + get outboundCourse(): Degrees { + return this.nextLeg.inboundCourse; + } + + get predictedPath(): PathVector[] { + if (this.entry === EntryType.Null) { + return []; + } + + if (this.entry === EntryType.DirectOutbound) { + if (this.nextLeg instanceof HFLeg) { + return this.nextLeg.getHippodromePath(); + } + return []; + } + + return this.computedPath; + } + + private getPathDebugPoints(): PathVector[] { + if (this.entry === EntryType.Null) { + return []; + } + + const debugPoints: PathVector[] = [ + { + type: PathVectorType.DebugPoint, + startPoint: this.turn1.arcCentre, + annotation: 'AC1', + }, + { + type: PathVectorType.DebugPoint, + startPoint: this.turn1.ftp, + annotation: 'FTP1', + }, + ]; + + if (this.entry === EntryType.Parallel || this.entry === EntryType.Teardrop) { + debugPoints.push({ + type: PathVectorType.DebugPoint, + startPoint: this.turn2.itp, + annotation: 'ITP2', + colour: DebugPointColour.Magenta, + }); + + debugPoints.push({ + type: PathVectorType.DebugPoint, + startPoint: this.turn2.arcCentre, + annotation: 'AC2', + colour: DebugPointColour.Magenta, + }); + + debugPoints.push({ + type: PathVectorType.DebugPoint, + startPoint: this.turn2.ftp, + annotation: 'FTP2', + colour: DebugPointColour.Magenta, + }); + + debugPoints.push({ + type: PathVectorType.DebugPoint, + startPoint: this.turn3.itp, + annotation: 'ITP3', + colour: DebugPointColour.Yellow, + }); + + debugPoints.push({ + type: PathVectorType.DebugPoint, + startPoint: this.turn3.arcCentre, + annotation: 'AC3', + colour: DebugPointColour.Yellow, + }); + + debugPoints.push({ + type: PathVectorType.DebugPoint, + startPoint: this.turn3.ftp, + annotation: 'FTP3', + colour: DebugPointColour.Yellow, + }); + } + + return debugPoints; + } + + computeNullEntry() { + this.entry = EntryType.Null; + this.computedPath.length = 0; + } + + computeDirectOutboundEntry() { + this.entry = EntryType.DirectOutbound; + const { radius: maxRadius } = this.nextLeg.geometry; + + const turnSign = this.nextLeg.turnDirection === TurnDirection.Right ? +1 : -1; + + const trackChange = MathUtils.diffAngle(this.inboundCourse, this.nextLeg.inboundCourse); + + const radius = 2 * maxRadius / (1 + Math.cos(trackChange * Math.PI / 180)); + + this.turn1.itp = this.nextLeg.fix.location; + this.turn1.arcCentre = placeBearingDistance( + this.turn1.itp, + this.inboundCourse + turnSign * 90, + radius, + ); + this.turn1.sweepAngle = turnSign * 180 + trackChange; + const bearing1 = MathUtils.clampAngle(this.nextLeg.inboundCourse + turnSign * 90); + this.turn1.ftp = placeBearingDistance(this.turn1.arcCentre, bearing1, radius); + + this.computedPath.length = 0; + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: this.turn1.itp, + endPoint: this.turn1.ftp, + centrePoint: this.turn1.arcCentre, + sweepAngle: this.turn1.sweepAngle, + }); + } + + computeDirectTurnEntry() { + this.entry = EntryType.DirectTurn; + const { fixB, fixC, arcCentreFix2, sweepAngle, radius: maxRadius } = this.nextLeg.geometry; + + const turnSign = this.nextLeg.turnDirection === TurnDirection.Right ? +1 : -1; + + const trackChange = MathUtils.diffAngle(this.inboundCourse, this.nextLeg.inboundCourse); + + const radius = 2 * maxRadius / (1 + Math.cos(trackChange * Math.PI / 180)); + + this.turn1.itp = this.nextLeg.fix.location; + this.turn1.arcCentre = placeBearingDistance( + this.turn1.itp, + this.inboundCourse + turnSign * 90, + radius, + ); + this.turn1.sweepAngle = turnSign * 180 + trackChange; + const bearing1 = MathUtils.clampAngle(this.nextLeg.inboundCourse + turnSign * 90); + this.turn1.ftp = placeBearingDistance(this.turn1.arcCentre, bearing1, radius); + + this.computedPath.length = 0; + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: this.turn1.itp, + endPoint: this.turn1.ftp, + centrePoint: this.turn1.arcCentre, + sweepAngle: this.turn1.sweepAngle, + }); + + this.straightCourse = reciprocal(this.outboundCourse) % 360; + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: this.turn1.ftp, + endPoint: fixB, + }); + + this.turn2.itp = fixB; + this.turn2.ftp = fixC; + this.turn2.sweepAngle = sweepAngle; + this.turn2.arcCentre = arcCentreFix2; + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: fixB, + centrePoint: arcCentreFix2, + endPoint: fixC, + sweepAngle, + }); + + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: fixC, + endPoint: this.nextLeg.fix.location, + }); + } + + /** + * @todo extend outbound path to ensure capture before hold fix + */ + computeTeardropEntry() { + this.entry = EntryType.Teardrop; + const { radius, legLength } = this.nextLeg.geometry; + + const turnSign = this.nextLeg.turnDirection === TurnDirection.Right ? +1 : -1; + + this.straightCourse = MathUtils.clampAngle(this.outboundCourse + 150 * turnSign); + this.turn1.sweepAngle = MathUtils.diffAngle(this.inboundCourse, this.straightCourse); + const turn1Clockwise = this.turn1.sweepAngle >= 0; + this.turn1.itp = this.nextLeg.fix.location; + this.turn1.arcCentre = placeBearingDistance( + this.turn1.itp, + this.inboundCourse + (turn1Clockwise ? 90 : -90), + radius, + ); + const bearing1 = MathUtils.clampAngle(this.inboundCourse + this.turn1.sweepAngle + (turn1Clockwise ? -90 : 90)); + this.turn1.ftp = placeBearingDistance(this.turn1.arcCentre, bearing1, radius); + + this.computedPath.length = 0; + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: this.turn1.itp, + endPoint: this.turn1.ftp, + centrePoint: this.turn1.arcCentre, + sweepAngle: this.turn1.sweepAngle, + }); + + const kekRads = Math.abs(MathUtils.diffAngle(this.inboundCourse, reciprocal(this.outboundCourse))) * Math.PI / 180; + let minStraightDistance = radius * 2 / Math.sqrt(3) * (0.1 + Math.SQRT2 - 1 / 2 - Math.abs(Math.sin(kekRads) - 1 / 2)); + const nominalStraightDistance = 1.15 * legLength; // - Math.sin(Math.abs(this.turn1.sweepAngle * Math.PI / 180)) * radius; + let straightDistance = Math.max(minStraightDistance, nominalStraightDistance); + let radii2Inbound = Math.abs(Math.cos(kekRads) - Math.sqrt(3) / 2) + straightDistance / radius / 2 + (1 - Math.sqrt(3) / 2); + + if ((Math.SQRT2 - radii2Inbound) > 0) { + const extraCapComponent = (Math.SQRT2 - radii2Inbound); + minStraightDistance += radius * 2 / Math.sqrt(3) * extraCapComponent; + straightDistance = Math.max(minStraightDistance, nominalStraightDistance); + radii2Inbound = Math.abs(Math.cos(kekRads) - Math.sqrt(3) / 2) + straightDistance / radius / 2 + (1 - Math.sqrt(3) / 2); + } + + this.turn2.itp = placeBearingDistance( + this.turn1.ftp, + this.straightCourse, + straightDistance, + ); + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: this.turn1.ftp, + endPoint: this.turn2.itp, + }); + + this.turn2.arcCentre = placeBearingDistance( + this.turn2.itp, + this.outboundCourse - turnSign * 120, + radius, + ); + + if (radii2Inbound >= 2) { + // we are intercepting from the inside with room for 45 deg capture + this.turn2.ftp = placeBearingDistance( + this.turn2.arcCentre, + this.straightCourse + turnSign * 75, + radius, + ); + this.turn2.sweepAngle = turnSign * 165; + + const straightDist = (radii2Inbound - 2) * Math.SQRT2 * radius; + + this.turn3.itp = placeBearingDistance( + this.turn2.ftp, + this.straightCourse + turnSign * 165, + straightDist, + ); + + this.turn3.sweepAngle = turnSign * 45; + this.turn3.arcCentre = placeBearingDistance( + this.turn3.itp, + this.straightCourse - turnSign * 105, + radius, + ); + this.turn3.ftp = placeBearingDistance( + this.turn3.arcCentre, + this.outboundCourse - turnSign * 90, + radius, + ); + + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: this.turn2.ftp, + endPoint: this.turn3.itp, + }); + } else if ((Math.SQRT2 - radii2Inbound) < 0) { + // we are intercepting from the outside without enough room for 45 deg capture + const interceptAngle = Math.acos(radii2Inbound / 2) * 180 / Math.PI; + + this.turn2.ftp = placeBearingDistance( + this.turn2.arcCentre, + this.straightCourse + turnSign * (120 + interceptAngle), + radius, + ); + this.turn2.sweepAngle = turnSign * (210 + interceptAngle); + + this.turn3.itp = this.turn2.ftp; + + this.turn3.sweepAngle = -turnSign * interceptAngle; + this.turn3.arcCentre = placeBearingDistance( + this.turn3.itp, + this.straightCourse + turnSign * (120 + interceptAngle), + radius, + ); + this.turn3.ftp = placeBearingDistance( + this.turn3.arcCentre, + this.outboundCourse + turnSign * 90, + radius, + ); + } else { + // we are intercepting from the outside with room for 45 deg capture + this.turn2.ftp = placeBearingDistance( + this.turn2.arcCentre, + this.outboundCourse - turnSign * 45, + radius, + ); + this.turn2.sweepAngle = turnSign * 255; + + const straightDist = Math.sqrt(2 * (Math.SQRT2 - radii2Inbound) ** 2) * radius; + + this.turn3.itp = placeBearingDistance( + this.turn2.ftp, + this.straightCourse + turnSign * 255, + straightDist, + ); + + this.turn3.sweepAngle = -turnSign * 45; + this.turn3.arcCentre = placeBearingDistance( + this.turn3.itp, + this.outboundCourse - turnSign * 45, + radius, + ); + this.turn3.ftp = placeBearingDistance( + this.turn3.arcCentre, + this.outboundCourse + turnSign * 90, + radius, + ); + + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: this.turn2.ftp, + endPoint: this.turn3.itp, + }); + } + + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: this.turn2.itp, + endPoint: this.turn2.ftp, + centrePoint: this.turn2.arcCentre, + sweepAngle: this.turn2.sweepAngle, + }); + + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: this.turn3.itp, + endPoint: this.turn3.ftp, + centrePoint: this.turn3.arcCentre, + sweepAngle: this.turn3.sweepAngle, + }); + + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: this.turn3.ftp, + endPoint: this.nextLeg.fix.location, + }); + } + + computeParallelEntry() { + this.entry = EntryType.Parallel; + const { radius, legLength } = this.nextLeg.geometry; + + const turnSign = this.nextLeg.turnDirection === TurnDirection.Right ? +1 : -1; + + this.turn1.itp = this.nextLeg.fix.location; + this.turn1.arcCentre = placeBearingDistance( + this.turn1.itp, + this.inboundCourse + (this.nextLeg.turnDirection === TurnDirection.Right ? -90 : 90), + radius, + ); + this.turn1.sweepAngle = MathUtils.diffAngle(this.inboundCourse, reciprocal(this.outboundCourse)); + const bearing1 = MathUtils.clampAngle(this.inboundCourse + this.turn1.sweepAngle + (this.nextLeg.turnDirection === TurnDirection.Right ? 90 : -90)); + this.turn1.ftp = placeBearingDistance(this.turn1.arcCentre, bearing1, radius); + + this.computedPath.length = 0; + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: this.turn1.itp, + endPoint: this.turn1.ftp, + centrePoint: this.turn1.arcCentre, + sweepAngle: this.turn1.sweepAngle, + }); + + const turn1Rads = Math.abs(this.turn1.sweepAngle) * Math.PI / 180; + + const minStraightDistance = 0.1 + 2 * Math.cos(1 - Math.SQRT2 / 2 - Math.sin(turn1Rads)) * radius; + const nominalStraightDistance = 1.15 * legLength - radius * Math.sin(turn1Rads); + + const straightDistance = Math.max(minStraightDistance, nominalStraightDistance); + + this.turn2.itp = placeBearingDistance( + this.turn1.ftp, + reciprocal(this.outboundCourse), + straightDistance, + ); + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: this.turn1.ftp, + endPoint: this.turn2.itp, + }); + this.straightCourse = bearingTo(this.turn1.ftp, this.turn2.itp); + + this.turn2.arcCentre = placeBearingDistance( + this.turn2.itp, + this.outboundCourse + turnSign * 90, + radius, + ); + this.turn2.ftp = placeBearingDistance( + this.turn2.arcCentre, + this.outboundCourse + turnSign * 45, + radius, + ); + this.turn2.sweepAngle = turnSign * -225; + + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: this.turn2.itp, + endPoint: this.turn2.ftp, + centrePoint: this.turn2.arcCentre, + sweepAngle: this.turn2.sweepAngle, + }); + + const ftp2ToInboundAbeamRadii = Math.cos(turn1Rads) + Math.SQRT2 / 2; + const straightDist = Math.sqrt(2 * (ftp2ToInboundAbeamRadii - (1 - Math.SQRT2 / 2)) ** 2) * radius; + + this.turn3.itp = placeBearingDistance( + this.turn2.ftp, + this.outboundCourse - turnSign * 45, + straightDist, + ); + + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: this.turn2.ftp, + endPoint: this.turn3.itp, + }); + + this.turn3.sweepAngle = turnSign * 45; + this.turn3.arcCentre = placeBearingDistance( + this.turn3.itp, + this.outboundCourse + turnSign * 45, + radius, + ); + this.turn3.ftp = placeBearingDistance( + this.turn3.arcCentre, + this.outboundCourse - turnSign * 90, + radius, + ); + + this.computedPath.push({ + type: PathVectorType.Arc, + startPoint: this.turn3.itp, + endPoint: this.turn3.ftp, + centrePoint: this.turn3.arcCentre, + sweepAngle: this.turn3.sweepAngle, + }); + + this.computedPath.push({ + type: PathVectorType.Line, + startPoint: this.turn3.ftp, + endPoint: this.nextLeg.fix.location, + }); + } + + recomputeWithParameters(isActive: boolean, _tas: Knots, _gs: Knots, _ppos: Coordinates, _trueTrack: DegreesTrue): void { + // TODO only HX leg drives this + + const hxInbound = this.outboundCourse; + const entryAngle = MathUtils.diffAngle(this.inboundCourse, hxInbound); + + if (this.frozen) { + if (this.state === EntryState.Done) { + this.computedPath.length = 0; + } + return; + } + + if (isActive && !this.frozen) { + this.frozen = true; + } + // TODO freeze once we're active? + + // TODO, should HA entry become null when the leg is no longer flown? + // might have bad implications for the next leg, and also for straying outside protected area + // related: if we still fly the entry... should we shorten the leg length to minimum? + if (!this.previousLeg || entryAngle >= -3 && entryAngle <= 3) { + this.computeNullEntry(); + this.setHxEntry(); + this.isNull = true; + return; + } + + this.isNull = false; + + // parallel entry is always used when entering from opposite of hold course... + // we give a 3 degree tolerance to allow for mag var, calculation errors etc. + if (this.nextLeg.turnDirection === TurnDirection.Left) { + if (entryAngle > 110 && entryAngle < 177) { + this.computeTeardropEntry(); + } else if ((entryAngle >= 177 && entryAngle <= 180) || (entryAngle > -180 && entryAngle < -70)) { + this.computeParallelEntry(); + } else if (entryAngle >= -70 && entryAngle < -3) { + this.computeDirectTurnEntry(); + } else { + this.computeDirectOutboundEntry(); + } + } else if (this.nextLeg.turnDirection === TurnDirection.Right) { + if (entryAngle > -177 && entryAngle < -110) { + this.computeTeardropEntry(); + } else if ((entryAngle > 70 && entryAngle <= 180) || (entryAngle > -180 && entryAngle <= -177)) { + this.computeParallelEntry(); + } else if (entryAngle > 3 && entryAngle <= 70) { + this.computeDirectTurnEntry(); + } else { + this.computeDirectOutboundEntry(); + } + } + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.computedPath.push(...this.getPathDebugPoints()); + } + + // prepare the HX leg for our entry type + this.setHxEntry(); + } + + getPathStartPoint(): Coordinates { + return this.getTurningPoints()[0]; + } + + getPathEndPoint(): Coordinates { + return this.getTurningPoints()[1]; + } + + get repr(): string { + return `HOLD ENTRY(${this.nextLeg.repr})`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/PathCaptureTransition.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/PathCaptureTransition.ts new file mode 100644 index 00000000000..e3ba1d97b6d --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/PathCaptureTransition.ts @@ -0,0 +1,479 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { MathUtils } from '@shared/MathUtils'; +import { CALeg } from '@fmgc/guidance/lnav/legs/CA'; +import { CILeg } from '@fmgc/guidance/lnav/legs/CI'; +import { DFLeg } from '@fmgc/guidance/lnav/legs/DF'; +import { HALeg, HFLeg, HMLeg } from '@fmgc/guidance/lnav/legs/HX'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { Transition } from '@fmgc/guidance/lnav/Transition'; +import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { Geo } from '@fmgc/utils/Geo'; +import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { CourseChange } from '@fmgc/guidance/lnav/transitions/utilss/CourseChange'; +import { Constants } from '@shared/Constants'; +import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { TurnDirection } from '@fmgc/types/fstypes/FSEnums'; +import { + arcLength, + getIntermediatePoint, + maxBank, + maxTad, + PointSide, + reciprocal, + sideOfPointOnCourseToFix, +} from '@fmgc/guidance/lnav/CommonGeometry'; +import { ControlLaw } from '@shared/autopilot'; +import { AFLeg } from '@fmgc/guidance/lnav/legs/AF'; +import { + bearingTo, + distanceTo, + firstSmallCircleIntersection, + placeBearingDistance, + placeBearingIntersection, + smallCircleGreatCircleIntersection, +} from 'msfs-geo'; +import { CDLeg } from '@fmgc/guidance/lnav/legs/CD'; +import { FDLeg } from '@fmgc/guidance/lnav/legs/FD'; +import { RFLeg } from '@fmgc/guidance/lnav/legs/RF'; +import { Leg } from '../legs/Leg'; +import { CFLeg } from '../legs/CF'; +import { CRLeg } from '../legs/CR'; + +export type PrevLeg = AFLeg | CALeg | CDLeg | CRLeg | /* FALeg | */ FDLeg | HALeg | HFLeg | HMLeg | RFLeg; +export type ReversionLeg = CFLeg | CILeg | DFLeg | TFLeg; +export type NextLeg = AFLeg | CFLeg | FDLeg | /* FALeg | */ TFLeg; + +const cos = (input: Degrees) => Math.cos(input * (Math.PI / 180)); +const tan = (input: Degrees) => Math.tan(input * MathUtils.DEGREES_TO_RADIANS); + +const compareTurnDirections = (sign: number, data: TurnDirection) => { + if ((data === TurnDirection.Left || data === TurnDirection.Right) && (sign === -1 || sign === 1)) { + return (data === TurnDirection.Left && sign === -1) || (data === TurnDirection.Right && sign === 1); + } + return true; +}; + +/** + * A type II transition + */ +export class PathCaptureTransition extends Transition { + constructor( + public previousLeg: PrevLeg | ReversionLeg, + public nextLeg: NextLeg, + ) { + super(previousLeg, nextLeg); + } + + startWithTad = false + + getPathStartPoint(): Coordinates | undefined { + return this.itp; + } + + get turnDirection(): TurnDirection { + return this.nextLeg.metadata.turnDirection; + } + + get deltaTrack(): Degrees { + return MathUtils.fastToFixedNum(MathUtils.diffAngle(this.previousLeg.outboundCourse, this.nextLeg.inboundCourse), 1); + } + + public predictedPath: PathVector[] = []; + + private itp: Coordinates; + + private ftp: Coordinates; + + tad: NauticalMiles | undefined; + + private forcedTurnRequired = false; + + private forcedTurnComplete = false; + + recomputeWithParameters(_isActive: boolean, tas: Knots, gs: Knots, _ppos: Coordinates, _trueTrack: DegreesTrue) { + if (this.isFrozen) { + return; + } + + if (!(this.inboundGuidable instanceof Leg)) { + throw new Error('[FMS/Geometry/PathCapture] previousGuidable must be a leg'); + } + + const targetTrack = this.inboundGuidable.outboundCourse; + + const naturalTurnDirectionSign = Math.sign(MathUtils.diffAngle(targetTrack, this.nextLeg.inboundCourse)); + + let prevLegTermFix: Coordinates; + if (this.previousLeg instanceof AFLeg) { + prevLegTermFix = this.previousLeg.arcEndPoint; + } else if ('lat' in this.previousLeg.terminationWaypoint) { + prevLegTermFix = this.previousLeg.terminationWaypoint; + } else { + prevLegTermFix = this.previousLeg.terminationWaypoint.location; + } + + // Start the transition before the termination fix if we are reverted because of an overshoot + let initialTurningPoint: Coordinates; + if (this.startWithTad) { + const prevLegDistanceToTerm = this.previousLeg.distanceToTermination; + + this.tad = Math.min(maxTad(tas), prevLegDistanceToTerm - 0.05); + + // If we are inbound of a TF leg, we use getIntermediatePoint in order to get more accurate results + if ('from' in this.previousLeg) { + const start = this.previousLeg.from.location; + const end = this.previousLeg.to.location; + const length = distanceTo(start, end); + + const ratio = (length - this.tad) / length; + + initialTurningPoint = getIntermediatePoint(start, end, ratio); + } else { + initialTurningPoint = placeBearingDistance( + prevLegTermFix, + reciprocal(this.previousLeg.outboundCourse), + this.tad, + ); + } + } else { + this.tad = 0; + initialTurningPoint = prevLegTermFix; + } + + const distanceFromItp: NauticalMiles = Geo.distanceToLeg(initialTurningPoint, this.nextLeg); + const deltaTrack: Degrees = MathUtils.diffAngle(targetTrack, this.nextLeg.inboundCourse, this.nextLeg.metadata.turnDirection); + + this.predictedPath.length = 0; + + this.forcedTurnRequired = Math.abs(deltaTrack) > 130; + + if (Math.abs(deltaTrack) < 3 && distanceFromItp < 0.1) { + this.itp = this.previousLeg.getPathEndPoint(); + this.ftp = this.previousLeg.getPathEndPoint(); + + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: this.previousLeg.getPathEndPoint(), + endPoint: this.previousLeg.getPathEndPoint(), + }); + + this.isNull = true; + + this.distance = 0; + + this.isComputed = true; + + return; + } + + this.isNull = false; + + // If track change is very similar to a 45 degree intercept, we do a direct intercept + if (Math.abs(deltaTrack) > 42 && Math.abs(deltaTrack) < 48 && distanceFromItp > 0.01) { + this.computeDirectIntercept(); + + this.isComputed = true; + + return; + } + + let turnDirection = Math.sign(deltaTrack); + + // Theta variable should be stored based on turn direction and max roll, but it is only used once in an absolute sense, so it is useless + const radius = (gs ** 2 / (Constants.G * tan(maxBank(tas, true)) * 6997.84)) * LnavConfig.TURN_RADIUS_FACTOR; + const distanceLimit = radius * cos(48); + + // TODO: Turn center is slightly off for some reason, fix + let turnCenter = placeBearingDistance(initialTurningPoint, targetTrack + turnDirection * 90, radius); + let turnCenterDistance = Math.sign(MathUtils.diffAngle(bearingTo(turnCenter, this.nextLeg.getPathEndPoint()), this.nextLeg.outboundCourse)) + * Geo.distanceToLeg(turnCenter, this.nextLeg); + + let courseChange; + if (Math.abs(deltaTrack) < 45) { + if ((deltaTrack > 0 && turnCenterDistance >= radius) || (deltaTrack < 0 && turnCenterDistance <= -radius)) { + turnCenter = placeBearingDistance(initialTurningPoint, targetTrack - turnDirection * 90, radius); + turnDirection = -turnDirection; + // Turn direction is to be flipped, FBW-22-05 + turnCenterDistance = Math.sign(MathUtils.diffAngle(bearingTo(turnCenter, this.nextLeg.getPathEndPoint()), this.nextLeg.outboundCourse)) + * Geo.distanceToLeg(turnCenter, this.nextLeg); + courseChange = CourseChange.acuteFar(turnDirection, turnCenterDistance, deltaTrack); + } else { + courseChange = CourseChange.acuteNear(turnDirection, turnCenterDistance, deltaTrack); + } + } else if (Math.abs(deltaTrack) >= 45 && !compareTurnDirections(turnDirection, this.nextLeg.metadata.turnDirection)) { + turnCenter = placeBearingDistance(initialTurningPoint, targetTrack - turnDirection * 90, radius); + turnDirection = -turnDirection; + turnCenterDistance = Math.sign(MathUtils.diffAngle(bearingTo(turnCenter, this.nextLeg.getPathEndPoint()), this.nextLeg.outboundCourse)) + * Geo.distanceToLeg(turnCenter, this.nextLeg); + } + + // Omit 45 degree intercept segment if possible + if (distanceLimit <= Math.abs(turnCenterDistance) && Math.abs(turnCenterDistance) < radius) { + const radiusToLeg = radius - Math.abs(turnCenterDistance); + + let intercept: Coordinates; + + // If we are inbound of a TF leg, we use the TF leg ref fix for our small circle intersect in order to get + // more accurate results + if ('from' in this.nextLeg) { + const intersects = smallCircleGreatCircleIntersection(turnCenter, radius, this.nextLeg.from.location, this.nextLeg.outboundCourse); + + if (intersects) { + const [one, two] = intersects; + + if (distanceTo(initialTurningPoint, one) > distanceTo(initialTurningPoint, two)) { + intercept = one; + } else { + intercept = two; + } + } + } else { + intercept = firstSmallCircleIntersection(turnCenter, radius, this.nextLeg.getPathEndPoint(), reciprocal(this.nextLeg.outboundCourse)); + } + + // If the difference between the radius and turnCenterDistance is very small, we might not find an intercept using the circle. + // Do a direct intercept instead. + if (!intercept && radiusToLeg < 0.1) { + this.computeDirectIntercept(); + this.isComputed = true; + return; + } + + if (intercept && !Number.isNaN(intercept.lat)) { + const bearingTcFtp = bearingTo(turnCenter, intercept); + + const angleToLeg = MathUtils.diffAngle( + MathUtils.clampAngle(bearingTcFtp - (turnDirection > 0 ? -90 : 90)), + this.nextLeg.outboundCourse, + ); + + if (Math.abs(angleToLeg) <= 48) { + this.itp = initialTurningPoint; + this.ftp = intercept; + + this.predictedPath.push({ + type: PathVectorType.Arc, + startPoint: initialTurningPoint, + endPoint: intercept, + centrePoint: turnCenter, + sweepAngle: Math.abs(deltaTrack) * turnDirection, + }); + + this.distance = arcLength(radius, Math.abs(deltaTrack) * turnDirection); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: initialTurningPoint, + annotation: 'PATH CAPTURE ARC START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: turnCenter, + annotation: 'PATH CAPTURE CENTRE', + }, + { + type: PathVectorType.DebugPoint, + startPoint: intercept, + annotation: 'PATH CAPTURE INTCPT', + }, + ); + } + + this.isComputed = true; + + return; + } + } + } + + if (Math.abs(deltaTrack) < 45) { + if ((deltaTrack > 0 && turnCenterDistance >= radius) || (deltaTrack < 0 && turnCenterDistance <= -radius)) { + courseChange = CourseChange.acuteFar(turnDirection, turnCenterDistance, deltaTrack); + } else { + courseChange = CourseChange.acuteNear(turnDirection, turnCenterDistance, deltaTrack); + } + } else { + const isReverse = !compareTurnDirections(naturalTurnDirectionSign, this.nextLeg.metadata.turnDirection); + + if (isReverse) { + courseChange = CourseChange.reverse(turnDirection, turnCenterDistance, deltaTrack, radius); + } else { + courseChange = CourseChange.normal(turnDirection, turnCenterDistance, deltaTrack, radius); + } + } + + const finalTurningPoint = placeBearingDistance(turnCenter, targetTrack + courseChange - 90 * turnDirection, radius); + + let intercept; + + // If we are inbound of a TF leg, we use the TF leg FROM ref fix for our great circle intersect in order to get + // more accurate results + if ('from' in this.nextLeg) { + const intersections = placeBearingIntersection( + finalTurningPoint, + MathUtils.clampAngle(targetTrack + courseChange), + this.nextLeg.from.location, + this.nextLeg.outboundCourse, + ); + + if (intersections) { + const [one, two] = intersections; + + if (distanceTo(finalTurningPoint, one) < distanceTo(finalTurningPoint, two)) { + intercept = one; + } else { + intercept = two; + } + } + } else { + intercept = Geo.legIntercept(finalTurningPoint, targetTrack + courseChange, this.nextLeg); + } + + const overshot = sideOfPointOnCourseToFix(finalTurningPoint, targetTrack + courseChange, intercept) === PointSide.Before; + + this.itp = initialTurningPoint; + this.ftp = finalTurningPoint; + + this.isComputed = true; + + this.predictedPath.push( + { + type: PathVectorType.Arc, + startPoint: initialTurningPoint, + endPoint: finalTurningPoint, + centrePoint: turnCenter, + sweepAngle: courseChange, + }, + ); + + if (!overshot) { + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: finalTurningPoint, + endPoint: intercept, + }); + } + + this.distance = arcLength(radius, courseChange) + (overshot ? 0 : distanceTo(finalTurningPoint, intercept)); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: initialTurningPoint, + annotation: 'PATH CAPTURE ARC START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: turnCenter, + annotation: 'PATH CAPTURE CENTRE', + }, + { + type: PathVectorType.DebugPoint, + startPoint: finalTurningPoint, + annotation: 'PATH CAPTURE ARC EMD', + }, + { + type: PathVectorType.DebugPoint, + startPoint: intercept, + annotation: 'PATH CAPTURE INTCPT', + }, + ); + } + } + + /** + * Computes the path capture as a direct leg intercept from the previous leg path end point to the next leg, + * with previous leg outbound course + * + * @private + */ + private computeDirectIntercept() { + const intercept = Geo.legIntercept(this.previousLeg.getPathEndPoint(), this.previousLeg.outboundCourse, this.nextLeg); + + this.itp = this.previousLeg.getPathEndPoint(); + this.ftp = intercept; + + this.predictedPath.push({ + type: PathVectorType.Line, + startPoint: this.previousLeg.getPathEndPoint(), + endPoint: intercept, + }); + + if (LnavConfig.DEBUG_PREDICTED_PATH) { + this.predictedPath.push( + { + type: PathVectorType.DebugPoint, + startPoint: this.previousLeg.getPathEndPoint(), + annotation: 'PATH CAPTURE START', + }, + { + type: PathVectorType.DebugPoint, + startPoint: intercept, + annotation: 'PATH CAPTURE INTCPT', + }, + ); + } + + this.distance = distanceTo(this.previousLeg.getPathEndPoint(), intercept); + } + + get startsInCircularArc(): boolean { + return false; // We don't want to do RAD for path captures + } + + get endsInCircularArc(): boolean { + return false; // We don't want to do RAD for path captures + } + + isAbeam(ppos: LatLongData): boolean { + return !this.isNull && this.forcedTurnRequired && !this.forcedTurnComplete && this.previousLeg.getDistanceToGo(ppos) <= 0; + } + + distance = 0; + + getTurningPoints(): [Coordinates, Coordinates] { + return [this.itp, this.ftp]; + } + + getDistanceToGo(_ppos: LatLongData): NauticalMiles { + return 1; + } + + getGuidanceParameters(ppos: LatLongAlt, trueTrack: number, tas: Knots): GuidanceParameters | null { + if (this.forcedTurnRequired) { + const turnSign = this.nextLeg.metadata.turnDirection === TurnDirection.Left ? -1 : 1; + let trackAngleError = this.nextLeg.inboundCourse - trueTrack; + if (turnSign !== Math.sign(trackAngleError)) { + trackAngleError += turnSign * 360; + } + if (Math.abs(trackAngleError) > 130) { + const phiCommand = turnSign * maxBank(tas, true); + return { + law: ControlLaw.LATERAL_PATH, + trackAngleError: 0, + phiCommand, + crossTrackError: 0, + }; + } + this.forcedTurnComplete = true; + } + + return this.nextLeg.getGuidanceParameters(ppos, trueTrack, tas); + } + + getNominalRollAngle(_gs: Knots): Degrees { + return 0; + } + + get repr(): string { + return `PATH CAPTURE(${this.previousLeg.repr} TO ${this.nextLeg.repr})`; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/utilss/CourseChange.ts b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/utilss/CourseChange.ts new file mode 100644 index 00000000000..b5d8a83d1b0 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/lnav/transitions/utilss/CourseChange.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +/** + * Functions for figuring out an appropriate course change for leg captures + */ +export class CourseChange { + static normal( + turnDirection: number, + turnCenterDistance: NauticalMiles, + trackChange: Degrees, + radius: NauticalMiles, + ): Degrees { + if (turnDirection > 0) { + if (turnCenterDistance >= radius) { + return trackChange - 45; + } + return trackChange + 45; + } + + if (-turnCenterDistance >= radius) { + return trackChange + 45; + } + + return trackChange - 45; + } + + static reverse( + turnDirection: number, + turnCenterDistance: NauticalMiles, + trackChange: Degrees, + radius: NauticalMiles, + ): Degrees { + if (trackChange > 0) { + if (turnCenterDistance > 0) { + if (turnCenterDistance > radius) { + return trackChange - 45; + } + + return trackChange + 45; + } + + return trackChange + 45; + } + + if (turnCenterDistance > 0) { + return trackChange - 45; + } + + if (-turnCenterDistance > radius) { + return trackChange + 45; + } + return trackChange - 45; + } + + static acuteFar( + turnDirection: number, + turnCenterDistance: NauticalMiles, + trackChange: Degrees, + ): Degrees { + return turnDirection * (45 - Math.abs(trackChange)); + } + + static acuteNear( + turnDirection: number, + turnCenterDistance: NauticalMiles, + trackChange: Degrees, + ): Degrees { + return trackChange + (turnDirection > 0 ? 45 : -45); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/AtmosphericConditions.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/AtmosphericConditions.ts new file mode 100644 index 00000000000..0cf73254333 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/AtmosphericConditions.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { MathUtils } from '@shared/MathUtils'; +import { Common } from './common'; + +export class AtmosphericConditions { + private ambientTemperatureFromSim: Celcius; + + private altitudeFromSim: Feet; + + // TODO use tropo from mcdu + private tropo = 36090; + + private casFromSim: Knots; + + private tasFromSim: Knots; + + private windSpeedFromSim: Knots; + + private windDirectionFromSim: DegreesTrue; + + private computedIsaDeviation: Celcius; + + constructor() { + this.update(); + } + + update() { + this.ambientTemperatureFromSim = SimVar.GetSimVarValue('AMBIENT TEMPERATURE', 'celsius'); + this.altitudeFromSim = SimVar.GetSimVarValue('INDICATED ALTITUDE', 'feet'); + this.tasFromSim = SimVar.GetSimVarValue('AIRSPEED TRUE', 'knots'); + this.casFromSim = this.computeCasFromTas(this.altitudeFromSim, this.tasFromSim); + // TODO filter? + this.windSpeedFromSim = SimVar.GetSimVarValue('AMBIENT WIND VELOCITY', 'Knots'); + this.windDirectionFromSim = SimVar.GetSimVarValue('AMBIENT WIND DIRECTION', 'Degrees'); + + this.computedIsaDeviation = this.ambientTemperatureFromSim - Common.getIsaTemp(this.altitudeFromSim); + } + + get currentStaticAirTemperature(): Celcius { + return this.ambientTemperatureFromSim; + } + + get currentAltitude(): Feet { + return this.altitudeFromSim; + } + + get currentAirspeed(): Knots { + return this.casFromSim; + } + + get currentTrueAirspeed(): Knots { + return this.tasFromSim; + } + + get currentWindSpeed(): Knots { + return this.windSpeedFromSim; + } + + get currentWindDirection(): DegreesTrue { + return this.windDirectionFromSim; + } + + getCurrentWindVelocityComponent(direction: DegreesTrue): Knots { + return Math.cos(MathUtils.diffAngle(direction, this.currentWindDirection)) * this.currentWindSpeed; + } + + get isaDeviation(): Celcius { + return this.computedIsaDeviation; + } + + predictStaticAirTemperatureAtAltitude(altitude: Feet): number { + return Common.getIsaTemp(altitude, altitude > this.tropo) + this.isaDeviation; + } + + totalAirTemperatureFromMach(altitude: Feet, mach: number) { + // From https://en.wikipedia.org/wiki/Total_air_temperature, using gamma = 1.4 + return (this.predictStaticAirTemperatureAtAltitude(altitude) + 273.15) * (1 + 0.2 * mach ** 2) - 273.15; + } + + computeMachFromCas(altitude: Feet, speed: Knots): number { + const deltaSrs = Common.getDelta(altitude, altitude > this.tropo); + + return Common.CAStoMach(speed, deltaSrs); + } + + computeCasFromMach(altitude: Feet, mach: Mach): number { + const deltaSrs = Common.getDelta(altitude, altitude > this.tropo); + + return Common.machToCas(mach, deltaSrs); + } + + computeCasFromTas(altitude: Feet, speed: Knots): Knots { + const thetaSrs = Common.getTheta(altitude, this.isaDeviation, altitude > this.tropo); + const deltaSrs = Common.getDelta(altitude, altitude > this.tropo); + + return Common.TAStoCAS(speed, thetaSrs, deltaSrs); + } + + computeTasFromCas(altitude: Feet, speed: Knots): Knots { + const thetaSrs = Common.getTheta(altitude, this.isaDeviation, altitude > this.tropo); + const deltaSrs = Common.getDelta(altitude, altitude > this.tropo); + + return Common.CAStoTAS(speed, thetaSrs, deltaSrs); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/CoarsePredictions.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/CoarsePredictions.ts new file mode 100644 index 00000000000..41a60c7476e --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/CoarsePredictions.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; + +/** + * This class exists to provide very coarse predictions for + * LNAV turn prediction while we await the full VNAV experience + */ +export class CoarsePredictions { + static updatePredictions(guidanceController: GuidanceController, atmosphere: AtmosphericConditions) { + const plan = FlightPlanService.active; + + for (let i = 0; i < plan.legCount; i++) { + const planLeg = plan.elementAt(i); + const geomLeg = guidanceController.activeGeometry.legs.get(i); + + if (!planLeg || !geomLeg) { + continue; + } + + // TODO port over + + // if (LnavConfig.DEBUG_USE_SPEED_LVARS) { + // geomLeg.predictedTas = SimVar.GetSimVarValue('L:A32NX_DEBUG_FM_TAS', 'knots'); + // geomLeg.predictedGs = SimVar.GetSimVarValue('L:A32NX_DEBUG_FM_GS', 'knots'); + // continue; + + // const alt = planLeg.additionalData.predictedAltitude; + // const cas = planLeg.additionalData.predictedSpeed; + // let tas = atmosphere.computeTasFromCas(alt, cas); + // let gs = tas; + + // predicted with live data for active and next two legs + // if (i >= guidanceController.activeLegIndex && i < (guidanceController.activeLegIndex + 3)) { + // tas = atmosphere.currentTrueAirspeed; + // gs = tas + atmosphere.currentWindSpeed; + // } + + // geomLeg.predictedTas = Number.isFinite(tas) ? tas : undefined; + // geomLeg.predictedGs = Number.isFinite(gs) ? gs : tas; + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/CostIndex.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/CostIndex.ts new file mode 100644 index 00000000000..ce440508170 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/CostIndex.ts @@ -0,0 +1,108 @@ +import { Common, FlapConf } from './common'; +import { EngineModel } from './EngineModel'; +import { FlightModel } from './FlightModel'; + +export class CostIndex { + /** + * Calculate specific air range (KTAS / uncorrected fuel flow) + * @param mach + * @param altitude in feet + * @param weight in pounds + * @param isaDev ISA deviation (in celsius) + * @returns SR in nautical miles per pound of fuel + */ + static calculateSpecificRange(mach: number, altitude: number, weight: number, isaDev: number): number { + const theta = Common.getTheta(altitude, isaDev); + const theta2 = Common.getTheta2(theta, mach); + const delta = Common.getDelta(theta); + const delta2 = Common.getDelta2(delta, mach); + + const Vt = Common.machToTAS(mach, theta); + const thrust = FlightModel.getDrag(weight, mach, delta, false, false, FlapConf.CLEAN); + + // Divide by 2 to get thrust per engine + const correctedThrust = (thrust / delta2) / 2; + // Since table 1506 describes corrected thrust as a fraction of max thrust, divide it + const correctedN1 = EngineModel.reverseTableInterpolation(EngineModel.table1506, mach, (correctedThrust / EngineModel.maxThrust)); + + // Fuel flow units are lbs/hr + const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, altitude); + const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); + + return Vt / fuelFlow; + } + + /** + * Generates an initial estimate for Mmrc + * @param weight in pounds + * @param delta pressure ratio + * @returns mach + */ + static initialMachEstimate(weight: number, delta: number): number { + return 1.565 * Math.sqrt(weight / (1481 * FlightModel.wingArea * delta)); + } + + /** + * Placeholder + * @param altitude in feet + * @param weight in pounds + * @param isaDev ISA deviation (in celsius) + * @returns Mmrc + */ + static naiveFindMmrc(altitude: number, weight: number, isaDev: number): number { + const delta = Common.getDelta(altitude); + const m1 = Math.min(CostIndex.initialMachEstimate(weight, delta), 0.78); + const mRound = Math.round((m1 + Number.EPSILON) * 100) / 100; + + const lowerBound = mRound - 0.1; + const upperBound = Math.min(mRound + 0.1, 0.79); + const results = []; + for (let i = lowerBound; i < upperBound; i += 0.01) { + results.push(CostIndex.calculateSpecificRange(i, altitude, weight, isaDev)); + } + + const indexofMax = results.reduce((iMax, x, i, arr) => (x > arr[iMax] ? i : iMax), 0); + return lowerBound + indexofMax * 0.01; + } + + /** + * Placeholder + * @param ci in kg/min + * @param flightLevel altitude in feet / 100 + * @param weight in pounds + * @param isaDev ISA deviation (in celsius) + * @param headwind in knots (negative for tailwind) + * @returns econ mach + */ + static costIndexToMach(ci: number, flightLevel: number, weight: number, isaDev = 0, headwind = 0): number { + // Add 0.01 mach for every 100 kts of headwind (subtract for tailwind) + const Mmrc = CostIndex.naiveFindMmrc(flightLevel * 100, weight, isaDev) + (headwind / 10000); + return ((-1 * (0.8 - Mmrc)) * Math.exp(-0.05 * ci)) + 0.8; + } + + /** + * Placeholder + * @param ci in kg/min + * @param flightLevel altitude in feet / 100 + * @param weight in pounds - should be total weight at T/C + * @returns econ climb cas + */ + static costIndexToClimbCas(ci: number, flightLevel: number, weight: number): number { + const weightInTons = Math.min(Math.max(Common.poundsToMetricTons(weight), 50), 77); + const airspeed = 240 + (0.1 * flightLevel) + ci + (0.5 * (weightInTons - 50)); + return Math.min(Math.max(airspeed, 270), 340); + } + + /** + * Placeholder + * @param ci in kg/min + * @param flightLevel altitude in feet / 100 + * @param weight in pounds - should be total weight at T/D + * @returns econ descent cas + */ + static costIndexToDescentCas(ci: number, flightLevel: number, weight: number): number { + const weightInTons = Math.min(Math.max(Common.poundsToMetricTons(weight), 50), 77); + const airspeed = 205 + (0.13 * flightLevel) + (1.5 * ci) - (0.05 * (weightInTons - 50)); + return Math.min(Math.max(airspeed, 270), 340); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/EngineModel.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/EngineModel.ts new file mode 100644 index 00000000000..cb26c61f8a7 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/EngineModel.ts @@ -0,0 +1,226 @@ +import { Common } from './common'; + +export class EngineModel { + // In pounds of force. Used as a multiplier for results of table 1506 + static maxThrust = 27120; + + /** + * Table 1502 - CN2 vs CN1 @ Mach 0, 0.2, 0.9 + * n2_to_n1_table + * @param i row index (n2) + * @param j 1 = Mach 0, 2 = Mach 0.2, 3 = Mach 0.9 + * @returns Corrected N1 (CN1) + */ + static table1502 = [ + [0, 0, 0.2, 0.9], + [18.200000, 0.000000, 0.000000, 17.000000], + [22.000000, 1.900000, 1.900000, 17.400000], + [26.000000, 2.500000, 2.500000, 18.200000], + [57.000000, 12.800000, 12.800000, 27.000000], + [68.200000, 19.600000, 19.600000, 34.827774], + [77.000000, 26.000000, 26.000000, 40.839552], + [83.000000, 31.420240, 31.420240, 44.768766], + [89.000000, 40.972041, 40.972041, 50.092140], + [92.800000, 51.000000, 51.000000, 55.042000], + [97.000000, 65.000000, 65.000000, 65.000000], + [100.000000, 77.000000, 77.000000, 77.000000], + [104.000000, 85.000000, 85.000000, 85.500000], + [116.500000, 101.000000, 101.000000, 101.000000], + ]; + + /** + * Table 1503 - Turbine LoMach (0) CN2 vs. Throttle @ IAP Ratio 1.00000000, 1.20172257, 1.453783983, 2.175007333, 3.364755652, 4.47246108, 5.415178313 + * mach_0_corrected_commanded_ne_table + * @param i row index (thrust lever position) + * @param j IAP ratio + * @returns Corrected N2 (CN2) + */ + static table1503 = [ + [0, 1.00000000, 1.20172257, 1.453783983, 2.175007333, 3.364755652, 4.47246108, 5.415178313], + [0.000000, 68.200000, 69.402657, 70.671269, 73.432244, 76.544349, 78.644882, 78.644882], + [0.100000, 76.000000, 77.340205, 78.753906, 81.830654, 85.298688, 87.639458, 87.639458], + [0.200000, 83.000000, 84.463645, 86.007556, 89.367688, 93.155146, 95.711513, 95.711513], + [0.400000, 92.800000, 94.436461, 96.162664, 99.919535, 104.154188, 107.012390, 107.012390], + [0.600000, 98.000000, 99.728159, 101.551090, 105.518475, 109.990414, 113.008774, 113.008774], + [0.750000, 101.500000, 103.289879, 105.177914, 109.286991, 113.918643, 117.044802, 117.044802], + [0.900000, 103.000000, 104.816330, 106.000000, 110.902070, 115.602170, 118.774528, 118.774528], + [1.000000, 104.200000, 106.037491, 107.975750, 112.194133, 116.948991, 120.158309, 120.158309], + ]; + + /** + * Table 1504 - Turbine HiMach (0.9) CN2 vs. Throttle @ IAP Ratio 1.00000000, 1.20172257, 1.453783983, 2.175007333, 3.364755652, 4.47246108, 5.415178313 + * mach_hi_corrected_commanded_ne_table + * @param i row index (thrust lever position) + * @param j IAP ratio + * @returns Corrected N2 (CN2) + */ + static table1504 = [ + [0, 1.00000000, 1.20172257, 1.453783983, 2.175007333, 3.364755652, 4.47246108, 5.415178313], + [0.000000, 63.267593, 64.383271, 65.560133, 68.121427, 71.008456, 72.957073, 72.957073], + [0.100000, 70.503476, 71.746753, 73.058212, 75.912441, 79.129658, 81.301137, 81.301137], + [0.200000, 76.997217, 78.355007, 79.787258, 82.904376, 86.417916, 88.789399, 88.789399], + [0.400000, 86.088455, 87.606562, 89.207922, 92.693086, 96.621477, 99.272967, 99.272967], + [0.600000, 90.912377, 92.515550, 94.206642, 97.887095, 102.035612, 104.835676, 104.835676], + [0.750000, 94.159247, 95.819677, 97.571165, 101.383063, 105.679741, 108.579808, 108.579808], + [0.900000, 95.550763, 97.235732, 98.333795, 102.881334, 107.241510, 110.184435, 110.184435], + [1.000000, 104.200000, 106.037491, 107.975750, 112.194133, 116.948991, 120.158309, 120.158309], + ]; + + /** + * Table 1506 - Corrected net Thrust vs CN1 @ Mach 0 to 0.9 in 0.1 steps + * n1_and_mach_on_thrust_table + * @param i row index (CN1) + * @param j mach + * @returns Corrected net thrust (pounds of force) + */ + static table1506 = [ + [0, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9], + [0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000], + [20.000000, 0.091741, 0.057020, 0.031529, 0.014096, -0.017284, -0.037284, -0.057077, -0.205841, -0.315399, -0.488717], + [25.000000, 0.142810, 0.072215, 0.038026, 0.020404, -0.009593, -0.026571, -0.024556, -0.151328, -0.266204, -0.439028], + [30.000000, 0.189837, 0.082322, 0.04205, 0.026748, 0.017389, 0.003990, -0.026921, -0.056814, -0.081946, -0.369391], + [35.000000, 0.262207, 0.126047, 0.077206, 0.045921, 0.024719, 0.006062, -0.0028121, -0.022800, -0.06972, -0.293631], + [40.000000, 0.330230, 0.162757, 0.124088, 0.069579, 0.057905, 0.049621, 0.029790, 0.054284, 0.054218, -0.220630], + [45.000000, 0.393293, 0.250096, 0.156707, 0.112419, 0.091418, 0.076757, 0.056090, 0.018509, -0.057520, -0.155120], + [50.000000, 0.452337, 0.311066, 0.211353, 0.158174, 0.127429, 0.104915, 0.081171, 0.047419, -0.007399, -0.098474], + [55.000000, 0.509468, 0.373568, 0.269961, 0.209106, 0.168650, 0.137223, 0.108383, 0.075660, 0.028704, -0.049469], + [60.000000, 0.594614, 0.439955, 0.334629, 0.267477, 0.217773, 0.176899, 0.141404, 0.107148, 0.064556, -0.005036], + [65.000000, 0.660035, 0.512604, 0.407151, 0.335055, 0.276928, 0.226669, 0.183627, 0.145850, 0.104441, 0.039012], + [70.000000, 0.733601, 0.593506, 0.488571, 0.412623, 0.347163, 0.288210, 0.237559, 0.195142, 0.152485, 0.087269], + [75.000000, 0.818693, 0.683880, 0.578756, 0.499514, 0.427939, 0.361604, 0.304241, 0.257197, 0.212005, 0.144042], + [80.000000, 0.910344, 0.783795, 0.675982, 0.593166, 0.516644, 0.444822, 0.382689, 0.332384, 0.284867, 0.212679], + [85.000000, 1.025165, 0.891823, 0.776548, 0.688692, 0.608128, 0.533210, 0.469351, 0.418690, 0.370870, 0.294907], + [90.000000, 1.157049, 1.004695, 0.874400, 0.778466, 0.694251, 0.619011, 0.557581, 0.511153, 0.467149, 0.390203], + [95.000000, 1.281333, 1.116993, 0.960774, 0.851733, 0.763455, 0.690890, 0.637136, 0.601322, 0.567588, 0.495167], + [100.000000, 1.357935, 1.220844, 1.023864, 0.894234, 0.800352, 0.733488, 0.693684, 0.654691, 0.617963, 0.539115], + [105.000000, 1.378826, 1.239626, 1.048498, 0.915750, 0.819609, 0.751137, 0.710375, 0.670444, 0.632832, 0.552086], + [110.000000, 1.392754, 1.252148, 1.069322, 0.933937, 0.835886, 0.766054, 0.724483, 0.683759, 0.645400, 0.563051], + ]; + + /** + * Placeholder + * @param table + * @param i + * @param j + * @returns + */ + static tableInterpolation(table: number[][], i: number, j: number): number { + const numRows = table.length; + const numCols = table[0].length; + // Iterate through rows to find the upper bound to i + let r: number; + for (r = 1; r < numRows; r++) { + if (table[r][0] > i) { + break; + } + } + // Get lower bound to i + const r1 = Math.max(1, r - 1); + const r2 = Math.min(numRows - 1, r); + // Iterate through rows to find the upper bound to j + let c: number; + for (c = 1; c < numCols; c++) { + if (table[0][c] > j) { + break; + } + } + // Get the lower bound to j + const c1 = Math.max(1, c - 1); + const c2 = Math.min(numCols - 1, c); + + const interpolatedRowAtC1 = r1 === r2 ? table[r1][c1] : Common.interpolate(i, table[r1][0], table[r2][0], table[r1][c1], table[r2][c1]); + const interpolatedRowAtC2 = r1 === r2 ? table[r1][c2] : Common.interpolate(i, table[r1][0], table[r2][0], table[r1][c2], table[r2][c2]); + + return Common.interpolate(j, table[0][c1], table[0][c2], interpolatedRowAtC1, interpolatedRowAtC2); + } + + /** + * Retrieve a bilinear interpolated row value from a table + * @param table + * @param j Value on column axis + * @param result Value normally returned as result + */ + static reverseTableInterpolation(table: number[][], j: number, result: number): number { + const numRows = table.length; + const numCols = table[0].length; + + let c: number; + for (c = 1; c < numCols; c++) { + if (table[0][c] > j) { + break; + } + } + const c1 = Math.max(1, c - 1); + const c2 = Math.min(numCols - 1, c); + + let r: number; + for (r = 1; r < numRows; r++) { + if (table[r][c1] > result) { + break; + } + } + const r1 = Math.max(1, r - 1); + const r2 = Math.min(numRows - 1, r); + for (r = 1; r < numRows; r++) { + if (table[r][c2] > result) { + break; + } + } + const r3 = Math.max(1, r - 1); + const r4 = Math.min(numRows - 1, r); + + const interpolatedRowAtC1 = r1 === r2 ? table[r1][0] : Common.interpolate(result, table[r1][c1], table[r2][c1], table[r1][0], table[r2][0]); + const interpolatedRowAtC2 = r3 === r4 ? table[r3][0] : Common.interpolate(result, table[r3][c2], table[r4][c2], table[r3][0], table[r4][0]); + + return Common.interpolate(j, table[0][c1], table[0][c2], interpolatedRowAtC1, interpolatedRowAtC2); + } + + /** + * Placeholder + * @param cn1 corrected N1 % + * @param mach mach value + * @param alt altitude in feet + * @returns fuel flow, in pounds per hour (per engine) + */ + static getCorrectedFuelFlow(cn1: number, mach: number, alt: number): number { + const coefficients = [-639.6602981, 0.00000e+00, 1.03705e+02, -2.23264e+03, 5.70316e-03, -2.29404e+00, 1.08230e+02, + 2.77667e-04, -6.17180e+02, -7.20713e-02, 2.19013e-07, 2.49418e-02, -7.31662e-01, -1.00003e-05, + -3.79466e+01, 1.34552e-03, 5.72612e-09, -2.71950e+02, 8.58469e-02, -2.72912e-06, 2.02928e-11]; + + const flow = coefficients[0] + coefficients[1] + (coefficients[2] * cn1) + (coefficients[3] * mach) + (coefficients[4] * alt) + + (coefficients[5] * cn1 ** 2) + (coefficients[6] * cn1 * mach) + (coefficients[7] * cn1 * alt) + + (coefficients[8] * mach ** 2) + (coefficients[9] * mach * alt) + (coefficients[10] * alt ** 2) + + (coefficients[11] * cn1 ** 3) + (coefficients[12] * cn1 ** 2 * mach) + (coefficients[13] * cn1 ** 2 * alt) + + (coefficients[14] * cn1 * mach ** 2) + (coefficients[15] * cn1 * mach * alt) + (coefficients[16] * cn1 * alt ** 2) + + (coefficients[17] * mach ** 3) + (coefficients[18] * mach ** 2 * alt) + (coefficients[19] * mach * alt ** 2) + + (coefficients[20] * alt ** 3); + + return flow; + } + + // static getCN1fromUncorrectedThrust(thrust: number) + + static getCorrectedN1(n1: number, theta2: number): number { + return n1 / Math.sqrt(theta2); + } + + static getUncorrectedN1(cn1: number, theta2: number): number { + return cn1 * Math.sqrt(theta2); + } + + static getUncorrectedN2(cn2: number, theta2: number): number { + return cn2 * Math.sqrt(theta2); + } + + static getUncorrectedThrust(correctedThrust: number, delta2: number): number { + return correctedThrust * delta2; + } + + static getUncorrectedFuelFlow(correctedFuelFlow: number, delta2: number, theta2: number): number { + return correctedFuelFlow * delta2 * Math.sqrt(theta2); + } + + static getCorrectedThrust(uncorrectedThrust: number, delta2: number): number { + return uncorrectedThrust / delta2; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/FlightModel.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/FlightModel.ts new file mode 100644 index 00000000000..3ba3f1b13d7 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/FlightModel.ts @@ -0,0 +1,287 @@ +import { MathUtils } from '@shared/MathUtils'; +import { Common, FlapConf } from './common'; + +export class FlightModel { + static Cd0 = 0.0187; + + static wingSpan = 117.454; + + static wingArea = 1319.7; + + static wingEffcyFactor = 0.70; + + static requiredAccelRateKNS = 1.33; // in knots/second + + static requiredAccelRateMS2 = 0.684; // in m/s^2 + + static gravityConstKNS = 19.0626 // in knots/second + + static gravityConstMS2 = 9.806665; // in m/s^2 + + // From https://github.com/flybywiresim/a32nx/pull/6903#issuecomment-1073168320 + static machValues: Mach[] = [0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85] + + static dragCoefficientCorrections: number[] = [0, 0.0002, 0.0003, 0.0004, 0.0008, 0.0015, 0.01] + + /** + * Get lift coefficient at given conditions + * @param weight in pounds + * @param mach self-explanatory + * @param delta pressure at the altitude divided by the pressure at sea level + * @param loadFactor g-Force + * @returns lift coefficient (Cl) + */ + static getLiftCoefficient(weight: number, mach: number, delta: number, loadFactor = 1): number { + return (weight * loadFactor) / (1481.4 * (mach ** 2) * delta * this.wingArea); + } + + static getLiftCoefficientFromEAS(lift: number, eas: number): number { + return (295.369 * lift) / ((eas ** 2) * this.wingArea); + } + + /** + * Get drag coefficient at given conditions + * @param Cl coefficient of lift + * @param spdBrkDeflected whether speedbrake is deflected at half or not + * @param gearExtended whether gear is extended or not + * @param flapConf flap configuration + * @returns drag coefficient (Cd) + */ + static getDragCoefficient(Cl: number, spdBrkDeflected = false, gearExtended = false, flapConf = FlapConf.CLEAN) : number { + // Values taken at mach 0 + let baseDrag; + switch (flapConf) { + case FlapConf.CLEAN: + baseDrag = (-0.1043 * Cl ** 5) + (0.2635 * Cl ** 4) - (0.2319 * Cl ** 3) + (0.1537 * Cl ** 2) - (0.0379 * Cl) + 0.0233; + break; + case FlapConf.CONF_1: + baseDrag = (-0.0207 * Cl ** 5) + (0.0764 * Cl ** 4) - (0.0813 * Cl ** 3) + (0.0912 * Cl ** 2) - (0.0285 * Cl) + 0.0337; + break; + case FlapConf.CONF_2: + baseDrag = (0.0066 * Cl ** 5) - (0.0271 * Cl ** 4) + (0.0615 * Cl ** 3) - (0.0187 * Cl ** 2) + (0.0035 * Cl) + 0.0538; + break; + case FlapConf.CONF_3: + baseDrag = (0.0768 * Cl ** 5) - (0.3979 * Cl ** 4) + (0.8252 * Cl ** 3) - (0.7951 * Cl ** 2) + (0.3851 * Cl) - 0.0107; + break; + case FlapConf.CONF_FULL: + baseDrag = (0.017 * Cl ** 5) - (0.0978 * Cl ** 4) + (0.2308 * Cl ** 3) - (0.2278 * Cl ** 2) + (0.1157 * Cl) + 0.0682; + break; + default: + break; + } + + const spdBrkIncrement = spdBrkDeflected ? 0.01 : 0; + const gearIncrement = gearExtended ? 0.03 : 0; + return baseDrag + spdBrkIncrement + gearIncrement; + } + + /** + * Get drag at given conditions + * @param weight in pounds + * @param mach self-explanatory + * @param delta pressure at the altitude divided by the pressure at sea level + * @param spdBrkDeflected Whether speedbrake is deflected at half or not + * @param gearExtended whether gear is extended or not + * @param flapConf flap configuration + * @returns drag + */ + static getDrag(weight: number, mach: number, delta: number, spdBrkDeflected: boolean, gearExtended: boolean, flapConf: FlapConf): number { + const Cl = this.getLiftCoefficient(weight, mach, delta); + const Cd = this.getDragCoefficient(Cl, spdBrkDeflected, gearExtended, flapConf); + const deltaCd = this.getMachCorrection(mach, flapConf); + + return 1481.4 * (mach ** 2) * delta * this.wingArea * (Cd + deltaCd); + } + + static getMachCorrection(mach: Mach, flapConf: FlapConf): number { + if (flapConf !== FlapConf.CLEAN) { + return 0; + } + + return this.interpolate(mach, this.machValues, this.dragCoefficientCorrections); + } + + /** + * Interpolates in a list + * @param x The value to look up in in `xs`. + * @param xs The table of x values with known y values + * @param ys The y values corresponding to the x values in `xs` + */ + static interpolate(x: number, xs: number[], ys: number[]) { + if (x <= xs[0]) { + return ys[0]; + } + + for (let i = 0; i < xs.length - 1; i++) { + if (x > xs[i] && x <= xs[i + 1]) { + return Common.interpolate(x, xs[i], xs[i + 1], ys[i], ys[i + 1]); + } + } + + return ys[ys.length - 1]; + } + + // NEW + + /** + * Returns the available climb or descent gradient. + * + * @param thrust the thrust in lbf + * @param drag + * @param weight in lbs + * + * @returns the available gradient in radians + */ + static getAvailableGradient( + thrust: number, + drag: number, + weight: number, + ): number { + return Math.asin((thrust - drag) / weight); + } + + /** + * Returns an acceleration for a given available gradient, fpa and acceleration factor. + * + * @param availableGradient in radians + * @param fpa in radians + * @param accelFactor + * + * @returns the acceleration + */ + static accelerationForGradient( + availableGradient: Radians, + fpa: number, + accelFactor: number, + ): number { + return (Math.sin(availableGradient) - Math.sin(fpa)) * accelFactor; + } + + /** + * Returns an fpa for a given available gradient, acceleration and acceleration factor. + * + * @param availableGradient in radians + * @param acceleration + * @param accelFactor + * + * @returns the fpa in radians + */ + static fpaForGradient( + availableGradient: Radians, + acceleration: number, + accelFactor: number, + ): number { + return Math.asin(Math.sin(availableGradient) - (acceleration / accelFactor)); + } + + // END NEW + + static getConstantThrustPathAngle( + thrust: number, + weight: number, + drag: number, + accelFactor: number, + ): number { + return Math.asin(((thrust - drag) / weight) / accelFactor); + } + + static getConstantThrustPathAngleFromCoefficients( + thrust: number, + weight: number, + Cl: number, + Cd: number, + accelFactor: number, + ): number { + return Math.asin(((thrust / weight) - (Cd / Cl)) / accelFactor); + } + + static getThrustFromConstantPathAngle( + fpa: number, + weight: number, + drag: number, + accelFactor: number, + ): number { + // fpa is in degrees + return weight * (accelFactor * Math.sin(fpa * MathUtils.DEGREES_TO_RADIANS)) + drag; + } + + static getThrustFromConstantPathAngleCoefficients( + fpa: number, + weight: number, + Cl: number, + Cd: number, + accelFactor: number, + ): number { + // fpa is in degrees + return weight * (accelFactor * Math.sin(fpa * MathUtils.DEGREES_TO_RADIANS) + (Cd / Cl)); + } + + static getSpeedChangePathAngle( + thrust: number, + weight: number, + drag: number, + ): number { + return Math.asin(((thrust - drag) / weight) - (1 / FlightModel.gravityConstMS2) * FlightModel.requiredAccelRateMS2); + } + + static getSpeedChangePathAngleFromCoefficients( + thrust: number, + weight: number, + Cl: number, + Cd: number, + ): number { + return Math.asin(((thrust / weight) - (Cd / Cl)) - (1 / FlightModel.gravityConstMS2) * FlightModel.requiredAccelRateMS2); + } + + static getAccelRateFromIdleGeoPath( + thrust: number, + weight: number, + drag: number, + fpaDeg: number, + ): number { + // fpa is in degrees + const fpaRad = fpaDeg * MathUtils.DEGREES_TO_RADIANS; + return FlightModel.gravityConstKNS * ((thrust - drag) / weight - Math.sin(fpaRad)); + } + + static getAccelRateFromIdleGeoPathCoefficients( + thrust: number, + weight: number, + Cl: number, + Cd: number, + fpaDeg: number, + ): number { + // fpa is in degrees + const fpaRad = fpaDeg * MathUtils.DEGREES_TO_RADIANS; + return FlightModel.gravityConstKNS * (((thrust / weight) - (Cd / Cl)) - Math.sin(fpaRad)); + } + + /** + * Gets distance required to accelerate/decelerate + * @param thrust + * @param drag + * @param weight in pounds + * @param initialSpeed + * @param targetSpeed + * @param fpa flight path angle, default value 0 for level segments + * @param accelFactor acceleration factor, default value 0 for level segments + * @returns distance to accel/decel + */ + static getAccelerationDistance( + thrust: number, + drag: number, + weight: number, + initialSpeed: number, + targetSpeed: number, + fpa = 0, + accelFactor = 0, + ): number { + const sign = Math.sign(fpa); + const force = thrust - drag + (sign * weight * Math.sin(fpa * (Math.PI / 180))) * accelFactor; + + const accel = force / weight; // TODO: Check units + const timeToAccel = (targetSpeed - initialSpeed) / accel; + const distanceToAccel = (initialSpeed * timeToAccel) + (0.5 * accel * (timeToAccel ** 2)); // TODO: Check units + return distanceToAccel; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/Predictions.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/Predictions.ts new file mode 100644 index 00000000000..42029c39b42 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/Predictions.ts @@ -0,0 +1,580 @@ +import { MathUtils } from '@shared/MathUtils'; +import { AccelFactorMode, Common, FlapConf } from './common'; +import { EngineModel } from './EngineModel'; +import { FlightModel } from './FlightModel'; + +export enum VnavStepError { + + /** + * The desired path angle is not achievable + */ + AVAILABLE_GRADIENT_INSUFFICIENT, + + /** + * While the desired path angle is achievable in theory, the resulting deceleration is lower than the given minimum deceleration + */ + TOO_LOW_DECELERATION + +} + +export interface StepResults { + pathAngle: number, + verticalSpeed: number, + distanceTraveled: number, + fuelBurned: number, + timeElapsed: number, + initialAltitude?: number, + finalAltitude: number, + error?: VnavStepError, +} + +export class Predictions { + /** + * THIS IS DONE. + * @param initialAltitude altitude at beginning of step, in feet + * @param stepSize the size of the altitude step, in feet + * @param econCAS airspeed during climb (taking SPD LIM & restrictions into account) + * @param econMach mach during climb, after passing crossover altitude + * @param commandedN1 N1% at CLB (or idle) setting, depending on flight phase + * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B) + * @param initialFuelWeight weight of fuel at the end of last step + * @param headwindAtMidStepAlt headwind component (in knots) at initialAltitude + (stepSize / 2); tailwind is negative + * @param isaDev ISA deviation (in celsius) + * @param tropoAltitude tropopause altitude (feet) + * @param speedbrakesExtended whether or not speedbrakes are extended at half (for geometric segment path test only) + */ + static altitudeStep( + initialAltitude: number, + stepSize: number, + econCAS: number, + econMach: number, + commandedN1: number, + zeroFuelWeight: number, + initialFuelWeight: number, + headwindAtMidStepAlt: number, + isaDev: number, + tropoAltitude: number, + speedbrakesExtended = false, + flapsConfig: FlapConf = FlapConf.CLEAN, + ): StepResults { + const midStepAltitude = initialAltitude + (stepSize / 2); + const theta = Common.getTheta(midStepAltitude, isaDev); + const delta = Common.getDelta(midStepAltitude); + let mach = Common.CAStoMach(econCAS, delta); + + let eas; + let tas; + let usingMach = false; + // If above crossover altitude, use econMach + if (mach > econMach) { + mach = econMach; + eas = Common.machToEAS(mach, delta); + tas = Common.machToTAS(mach, theta); + usingMach = true; + } else { + eas = Common.CAStoEAS(econCAS, delta); + tas = Common.CAStoTAS(econCAS, theta, delta); + } + + // Engine model calculations + const theta2 = Common.getTheta2(theta, mach); + const delta2 = Common.getDelta2(delta, mach); + const correctedN1 = EngineModel.getCorrectedN1(commandedN1, theta2); + const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, mach) * 2 * EngineModel.maxThrust; + const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, midStepAltitude) * 2; + const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf + const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); // in lbs/hour + + const weightEstimate = zeroFuelWeight + initialFuelWeight; + + let pathAngle; + let verticalSpeed; + let stepTime; + let distanceTraveled; + let fuelBurned; + let lift = weightEstimate; + let midStepWeight = weightEstimate; + let previousMidStepWeight = midStepWeight; + let iterations = 0; + do { + // Assume lift force is equal to weight as an initial approximation + const liftCoefficient = FlightModel.getLiftCoefficientFromEAS(lift, eas); + const dragCoefficient = FlightModel.getDragCoefficient(liftCoefficient, speedbrakesExtended, false, flapsConfig); + const accelFactorMode = usingMach ? AccelFactorMode.CONSTANT_MACH : AccelFactorMode.CONSTANT_CAS; + const accelFactor = Common.getAccelerationFactor(mach, midStepAltitude, isaDev, midStepAltitude > tropoAltitude, accelFactorMode); + pathAngle = FlightModel.getConstantThrustPathAngleFromCoefficients( + thrust, + midStepWeight, + liftCoefficient, + dragCoefficient, + accelFactor, + ); + + verticalSpeed = 101.268 * tas * Math.sin(pathAngle); // in feet per minute + stepTime = stepSize / verticalSpeed; // in minutes + distanceTraveled = (tas - headwindAtMidStepAlt) * (stepTime / 60); // in nautical miles + fuelBurned = (fuelFlow / 60) * stepTime; + // const endStepWeight = zeroFuelWeight + (initialFuelWeight - fuelBurned); <- not really needed + + // Adjust variables for better accuracy next iteration + previousMidStepWeight = midStepWeight; + midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2)); + lift = midStepWeight * Math.cos(pathAngle); + iterations++; + } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) < 100); + + return { + pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES, + verticalSpeed, + timeElapsed: stepTime, + distanceTraveled, + fuelBurned, + finalAltitude: initialAltitude + stepSize, + }; + } + + /** + * THIS IS DONE. + * @param altitude altitude of this level segment + * @param stepSize the distance of the step, in NM + * @param econCAS airspeed during level segment + * @param econMach mach during level segment (when over crossover altitude) + * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B) + * @param initialFuelWeight weight of fuel at the end of last step + * @param headwind headwind component (in knots) at altitude; tailwind is negative + * @param isaDev ISA deviation (in celsius) + */ + static levelFlightStep( + altitude: number, + stepSize: number, + econCAS: number, + econMach: number, + zeroFuelWeight: number, + initialFuelWeight: number, + headwind: number, + isaDev: number, + ): StepResults { + const theta = Common.getTheta(altitude, isaDev); + const delta = Common.getDelta(altitude); + let mach = Common.CAStoMach(econCAS, delta); + + let tas; + // If above crossover altitude, use econMach + if (mach > econMach) { + mach = econMach; + tas = Common.machToTAS(mach, theta); + } else { + tas = Common.CAStoTAS(econCAS, theta, delta); + } + + const initialWeight = zeroFuelWeight + initialFuelWeight; + const thrust = FlightModel.getDrag(initialWeight, mach, delta, false, false, FlapConf.CLEAN); + + // Engine model calculations + const theta2 = Common.getTheta2(theta, mach); + const delta2 = Common.getDelta2(delta, mach); + // Divide by 2 to get thrust per engine + const correctedThrust = (thrust / delta2) / 2; + // Since table 1506 describes corrected thrust as a fraction of max thrust, divide it + const correctedN1 = EngineModel.reverseTableInterpolation(EngineModel.table1506, mach, (correctedThrust / EngineModel.maxThrust)); + const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, altitude) * 2; + const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); // in lbs/hour + + const stepTime = ((tas - headwind) / stepSize) / 60; // in minutes + const fuelBurned = (fuelFlow / 60) * stepTime; + + let result: StepResults; + result.pathAngle = 0; + result.verticalSpeed = 0; + result.timeElapsed = stepTime; + result.distanceTraveled = stepSize; + result.fuelBurned = fuelBurned; + result.finalAltitude = altitude; + return result; + } + + /** + * THIS IS DONE. + * @param initialAltitude altitude at beginning of step, in feet + * @param initialCAS airspeed at beginning of step + * @param finalCAS airspeed at end of step + * @param initialMach initial mach, above crossover altitude + * @param finalMach final mach, above crossover altitude + * @param commandedN1 N1% at CLB (or idle) setting, depending on flight phase + * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B) + * @param initialFuelWeight weight of fuel at the end of last step + * @param headwindAtInitialAltitude headwind component (in knots) at initialAltitude + * @param isaDev ISA deviation (in celsius) + * @param tropoAltitude tropopause altitude (feet) + * @param gearExtended whether the gear is extended + * @param flapConfig the flaps configuration + * @param minimumAbsoluteAcceleration the minimum absolute acceleration before emitting TOO_LOW_DECELERATION (kts/s) + */ + static speedChangeStep( + flightPahAngle: number, + initialAltitude: number, + initialCAS: number, + finalCAS: number, + initialMach: number, + finalMach: number, + commandedN1: number, + zeroFuelWeight: number, + initialFuelWeight: number, + headwindAtInitialAltitude: number, + isaDev: number, + tropoAltitude: number, + gearExtended = false, + flapConfig = FlapConf.CLEAN, + minimumAbsoluteAcceleration?: number, + ): StepResults { + const theta = Common.getTheta(initialAltitude, isaDev); + const delta = Common.getDelta(initialAltitude); + + let actualInitialMach = Common.CAStoMach(initialCAS, delta); + let actualFinalMach = Common.CAStoMach(finalCAS, delta); + let initialTas; + let finalTas; + // let initialEas; + // let finalEas; + + // let usingMachAtStart; + // If above crossover altitude, use mach + if (actualInitialMach > initialMach) { + actualInitialMach = initialMach; + initialTas = Common.machToTAS(actualInitialMach, theta); + // initialEas = Common.machToEAS(actualInitialMach, delta); + // usingMachAtStart = true; + } else { + initialTas = Common.CAStoTAS(initialCAS, theta, delta); + // initialEas = Common.CAStoEAS(initialCAS, delta); + // usingMachAtStart = false; + } + + // let usingMachAtEnd; + if (actualFinalMach > finalMach) { + actualFinalMach = finalMach; + finalTas = Common.machToTAS(actualFinalMach, theta); + // finalEas = Common.machToEAS(actualFinalMach, delta); + // usingMachAtEnd = true; + } else { + finalTas = Common.CAStoTAS(finalCAS, theta, delta); + // finalEas = Common.CAStoEAS(finalCAS, delta); + // usingMachAtEnd = false; + } + + const averageMach = (actualInitialMach + actualFinalMach) / 2; + const averageTas = (initialTas + finalTas) / 2; + + // Engine model calculations + const theta2 = Common.getTheta2(theta, averageMach); + const delta2 = Common.getDelta2(delta, averageMach); + const correctedN1 = EngineModel.getCorrectedN1(commandedN1, theta2); + const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, averageMach) * 2 * EngineModel.maxThrust; + const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, averageMach, initialAltitude) * 2; + const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf + const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); // in lbs/hour + + const weightEstimate = zeroFuelWeight + initialFuelWeight; + + const pathAngleRadians = flightPahAngle * MathUtils.DEGREES_TO_RADIANS; + + let error; + let verticalSpeed; + let stepTime; + let distanceTraveled; + let fuelBurned; + let finalAltitude; + let lift = weightEstimate; + let midStepWeight = weightEstimate; + let previousMidStepWeight = midStepWeight; + let iterations = 0; + do { + // Calculate the available gradient + const drag = FlightModel.getDrag(lift, averageMach, delta, false, gearExtended, flapConfig); + const availableGradient = FlightModel.getAvailableGradient(thrust, drag, weightEstimate); + + if (Math.abs(availableGradient) < Math.abs(pathAngleRadians)) { + if (DEBUG) { + console.warn('[FMS/VNAV/ConstantSlopeSegment] Desired path angle is greater than available gradient.'); + } + error = VnavStepError.AVAILABLE_GRADIENT_INSUFFICIENT; + } + + const acceleration = FlightModel.accelerationForGradient( + availableGradient, + pathAngleRadians, + 9.81, + ); + + // TODO what do we do with this + // const accelFactorMode = usingMachAtStart ? AccelFactorMode.CONSTANT_MACH : AccelFactorMode.CONSTANT_CAS; + // const accelFactor = Common.getAccelerationFactor(averageMach, + // initialAltitude, + // isaDev, + // initialAltitude > tropoAltitude, + // accelFactorMode); + + // pathAngle = FlightModel.fpaForGradient( + // availableGradient, + // FlightModel.requiredAccelRateMS2, + // accelFactor, + // ); + + const accelerationKNS = (FlightModel.requiredAccelRateKNS * acceleration) / FlightModel.requiredAccelRateMS2; + + if (Math.abs(accelerationKNS) < minimumAbsoluteAcceleration) { + if (DEBUG) { + console.warn('[FMS/VNAV/ConstantSlopeSegment] Minimum absolute acceleration not achieved with given desired path angle.'); + } + error = VnavStepError.TOO_LOW_DECELERATION; + } + + stepTime = Math.abs(finalTas - initialTas) / Math.abs(accelerationKNS); + + distanceTraveled = (stepTime / 3600) * averageTas; + + verticalSpeed = 101.268 * averageTas * Math.sin(pathAngleRadians); // in feet per minute + // // TODO: double-check if accel rate operates on TAS or CAS + // stepTime = Math.abs(finalTas - initialTas) / accelerationKNS; // in seconds + finalAltitude = initialAltitude + (verticalSpeed * (stepTime / 60)); // in feet + // TODO: now that we have final altitude, we could get accurate mid-step headwind instead of using initial headwind... + // distanceTraveled = (averageTas - headwindAtInitialAltitude) * (stepTime / 3_600); // in NM + fuelBurned = (fuelFlow / 60) * stepTime; + // const endStepWeight = zeroFuelWeight + (initialFuelWeight - fuelBurned); <- not really needed + + // Adjust variables for better accuracy next iteration + previousMidStepWeight = midStepWeight; + midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2)); + lift = midStepWeight * Math.cos(pathAngleRadians); + iterations++; + } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) < 100); + + return { + pathAngle: pathAngleRadians * MathUtils.RADIANS_TO_DEGREES, + verticalSpeed, + timeElapsed: stepTime, + distanceTraveled, + fuelBurned, + finalAltitude, + error, + }; + } + + /** + * THIS IS DONE. + * @param initialAltitude altitude at beginning of step, in feet + * @param finalAltitude altitude at end of step, in feet + * @param distance distance of step, in NM + * @param econCAS airspeed during step + * @param econMach mach during step + * @param idleN1 N1% at idle setting + * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B) + * @param initialFuelWeight weight of fuel at the end of last step + * @param headwindAtMidStepAlt headwind component (in knots) at initialAltitude + (stepSize / 2); tailwind is negative + * @param isaDev ISA deviation (in celsius) + * @param tropoAltitude tropopause altitude (feet) + */ + static geometricStepAchievable( + initialAltitude: number, + finalAltitude: number, + distance: number, + econCAS: number, + econMach: number, + idleN1: number, + zeroFuelWeight: number, + initialFuelWeight: number, + headwindAtMidStepAlt: number, + isaDev: number, + tropoAltitude: number, + ): boolean { + const idleStepResults = Predictions.altitudeStep( + initialAltitude, + (finalAltitude - initialAltitude), + econCAS, + econMach, + idleN1, + zeroFuelWeight, + initialFuelWeight, + headwindAtMidStepAlt, + isaDev, + tropoAltitude, + true, + ); + + // If converted FPA is less than the FPA from altitudeStep, then this path is too steep :( + const distanceInFeet = distance * 6076.12; + const stepFPA = Math.atan((finalAltitude - initialAltitude) / distanceInFeet) * MathUtils.RADIANS_TO_DEGREES; + return idleStepResults.pathAngle <= stepFPA; + } + + /** + * THIS IS DONE. + * @param initialAltitude altitude at beginning of step, in feet + * @param finalAltitude altitude at end of step, in feet + * @param distance distance of step, in NM + * @param econCAS airspeed during step + * @param econMach mach during step + * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B) + * @param initialFuelWeight weight of fuel at the end of last step + * @param isaDev ISA deviation (in celsius) + * @param tropoAltitude tropopause altitude (feet) + */ + static geometricStep( + initialAltitude: number, + finalAltitude: number, + distance: number, + econCAS: number, + econMach: number, + zeroFuelWeight: number, + initialFuelWeight: number, + isaDev: number, + tropoAltitude: number, + ): StepResults { + const distanceInFeet = distance * 6076.12; + const fpaRadians = Math.atan((finalAltitude - initialAltitude) / distanceInFeet); + const fpaDegrees = fpaRadians * MathUtils.RADIANS_TO_DEGREES; + const midStepAltitude = (initialAltitude + finalAltitude) / 2; + + const theta = Common.getTheta(midStepAltitude, isaDev); + const delta = Common.getDelta(midStepAltitude); + let mach = Common.CAStoMach(econCAS, delta); + + let eas; + let tas; + let usingMach = false; + // If above crossover altitude, use econMach + if (mach > econMach) { + mach = econMach; + eas = Common.machToEAS(mach, delta); + tas = Common.machToTAS(mach, theta); + usingMach = true; + } else { + eas = Common.CAStoEAS(econCAS, delta); + tas = Common.CAStoTAS(econCAS, theta, delta); + } + + const weightEstimate = zeroFuelWeight + initialFuelWeight; + const theta2 = Common.getTheta2(theta, mach); + const delta2 = Common.getDelta2(delta, mach); + + let thrust; + let verticalSpeed; + let stepTime; + let fuelBurned; + let lift = weightEstimate * Math.cos(fpaRadians); + let midStepWeight = weightEstimate; + let previousMidStepWeight = midStepWeight; + let iterations = 0; + do { + const liftCoefficient = FlightModel.getLiftCoefficientFromEAS(lift, eas); + const dragCoefficient = FlightModel.getDragCoefficient(liftCoefficient); + const accelFactorMode = usingMach ? AccelFactorMode.CONSTANT_MACH : AccelFactorMode.CONSTANT_CAS; + const accelFactor = Common.getAccelerationFactor(mach, midStepAltitude, isaDev, midStepAltitude > tropoAltitude, accelFactorMode); + + thrust = FlightModel.getThrustFromConstantPathAngleCoefficients( + fpaDegrees, + midStepWeight, + liftCoefficient, + dragCoefficient, + accelFactor, + ); + + verticalSpeed = 101.268 * tas * Math.sin(fpaRadians); // in feet per minute + stepTime = (finalAltitude - initialAltitude) / verticalSpeed; // in minutes + + // Divide by 2 to get thrust per engine + const correctedThrust = (thrust / delta2) / 2; + // Since table 1506 describes corrected thrust as a fraction of max thrust, divide it + const correctedN1 = EngineModel.reverseTableInterpolation(EngineModel.table1506, mach, (correctedThrust / EngineModel.maxThrust)); + const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, midStepAltitude) * 2; + const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); // in lbs/hour + + fuelBurned = (fuelFlow / 60) * stepTime; + + // Adjust variables for better accuracy next iteration + previousMidStepWeight = midStepWeight; + midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2)); + lift = midStepWeight * Math.cos(fpaRadians); + iterations++; + } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) < 100); + + return { + pathAngle: fpaDegrees, + verticalSpeed, + timeElapsed: stepTime, + distanceTraveled: distance, + fuelBurned, + finalAltitude, + }; + } + + // static constantSlopeSegment( + // + // ): StepResults { + // // e = ((T - D / W) + // // a = g * (sin(available climb angle) - sin (desired fpa)) + // // d = ((final velocity squared) - (initial velocity squared)) / (2 * a) + // } + + /** + * THIS IS DONE. + * @param initialAltitude altitude at beginning of step, in feet + * @param finalAltitude altitude at end of step, in feet + * @param distance distance of step, in NM + * @param econCAS airspeed during step + * @param econMach mach during step + * @param idleN1 N1% at idle setting + * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B) + * @param initialFuelWeight weight of fuel at the end of last step + * @param isaDev ISA deviation (in celsius) + */ + static decelerationFromGeometricStep( + initialAltitude: number, + finalAltitude: number, + econCAS: number, + econMach: number, + idleN1: number, + zeroFuelWeight: number, + initialFuelWeight: number, + isaDev: number, + ): number { + const distanceInFeet = distance * 6076.12; + const fpaRadians = Math.atan((finalAltitude - initialAltitude) / distanceInFeet); + const fpaDegrees = fpaRadians * MathUtils.RADIANS_TO_DEGREES; + const midStepAltitude = (initialAltitude + finalAltitude) / 2; + + const theta = Common.getTheta(midStepAltitude, isaDev); + const delta = Common.getDelta(midStepAltitude); + let mach = Common.CAStoMach(econCAS, delta); + + let eas; + // If above crossover altitude, use econMach + if (mach > econMach) { + mach = econMach; + eas = Common.machToEAS(mach, delta); + } else { + eas = Common.CAStoEAS(econCAS, delta); + } + + const theta2 = Common.getTheta2(theta, mach); + const delta2 = Common.getDelta2(delta, mach); + const correctedN1 = EngineModel.getCorrectedN1(idleN1, theta2); + const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, mach) * 2 * EngineModel.maxThrust; + const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf + + const weightEstimate = zeroFuelWeight + initialFuelWeight; + const lift = weightEstimate * Math.cos(fpaRadians); + const liftCoefficient = FlightModel.getLiftCoefficientFromEAS(lift, eas); + const dragCoefficient = FlightModel.getDragCoefficient(liftCoefficient); + + const accelRate = FlightModel.getAccelRateFromIdleGeoPathCoefficients( + thrust, + weightEstimate, + liftCoefficient, + dragCoefficient, + fpaDegrees, + ); + + return accelRate; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/SpeedLimit.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/SpeedLimit.ts new file mode 100644 index 00000000000..ad7285f94f9 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/SpeedLimit.ts @@ -0,0 +1,4 @@ +export interface SpeedLimit { + speed: Knots, + underAltitude: Feet, +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/VnavConfig.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/VnavConfig.ts new file mode 100644 index 00000000000..6332c3ea7ff --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/VnavConfig.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +export enum VnavDescentMode { + NORMAL, + CDA, + DPO, +} + +export const VnavConfig = { + + /** + * Whether to calculate climb profile + */ + VNAV_CALCULATE_CLIMB_PROFILE: false, + + /** + * Whether to emit ToD pseudo waypoint + */ + VNAV_EMIT_TOD: false, + + /** + * Whether to emit (DECEL) pseudo waypoint + */ + VNAV_EMIT_DECEL: true, + + /** + * VNAV descent calculation mode (NORMAL, CDA or DPO) + */ + VNAV_DESCENT_MODE: VnavDescentMode.CDA, + + /** + * Whether to emit CDA flap1/2 pseudo-waypoints (only if VNAV_DESCENT_MODE is CDA) + */ + VNAV_EMIT_CDA_FLAP_PWP: false, + +}; diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/VnavDriver.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/VnavDriver.ts new file mode 100644 index 00000000000..d51b651e6e3 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/VnavDriver.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { TheoreticalDescentPathCharacteristics } from '@fmgc/guidance/vnav/descent/TheoreticalDescentPath'; +import { DecelPathBuilder, DecelPathCharacteristics } from '@fmgc/guidance/vnav/descent/DecelPathBuilder'; +import { DescentBuilder } from '@fmgc/guidance/vnav/descent/DescentBuilder'; +import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig'; +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { RequestedVerticalMode, TargetAltitude, TargetVerticalSpeed } from '@fmgc/guidance/ControlLaws'; +import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; +import { VerticalMode } from '@shared/autopilot'; +import { CoarsePredictions } from '@fmgc/guidance/vnav/CoarsePredictions'; +import { UpdateThrottler } from '@shared/UpdateThrottler'; +import { Geometry } from '../Geometry'; +import { GuidanceComponent } from '../GuidanceComponent'; +import { ClimbPathBuilder } from './climb/ClimbPathBuilder'; +import { ClimbProfileBuilderResult } from './climb/ClimbProfileBuilderResult'; + +export class VnavDriver implements GuidanceComponent { + atmosphericConditions: AtmosphericConditions = new AtmosphericConditions(); + + currentClimbProfile: ClimbProfileBuilderResult; + + currentDescentProfile: TheoreticalDescentPathCharacteristics + + currentApproachProfile: DecelPathCharacteristics; + + private guidanceMode: RequestedVerticalMode; + + private targetVerticalSpeed: TargetVerticalSpeed; + + private targetAltitude: TargetAltitude; + + // eslint-disable-next-line camelcase + private coarsePredictionsUpdate = new UpdateThrottler(5000); + + constructor( + private readonly guidanceController: GuidanceController, + ) { + } + + init(): void { + console.log('[FMGC/Guidance] VnavDriver initialized!'); + } + + acceptMultipleLegGeometry(geometry: Geometry) { + this.computeVerticalProfile(geometry); + } + + lastCruiseAltitude: Feet = 0; + + update(deltaTime: number): void { + this.atmosphericConditions.update(); + + if (false && this.coarsePredictionsUpdate.canUpdate(deltaTime) !== -1) { + CoarsePredictions.updatePredictions(this.guidanceController, this.atmosphericConditions); + } + + const newCruiseAltitude = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number'); + if (newCruiseAltitude !== this.lastCruiseAltitude) { + this.lastCruiseAltitude = newCruiseAltitude; + + if (DEBUG) { + console.log('[FMS/VNAV] Computed new vertical profile because of new cruise altitude.'); + } + + this.computeVerticalProfile(this.guidanceController.activeGeometry); + } + + this.updateGuidance(); + } + + private computeVerticalProfile(geometry: Geometry) { + if (geometry.legs.size > 0) { + if (VnavConfig.VNAV_CALCULATE_CLIMB_PROFILE) { + this.currentClimbProfile = ClimbPathBuilder.computeClimbPath(geometry); + } + this.currentApproachProfile = DecelPathBuilder.computeDecelPath(geometry); + this.currentDescentProfile = DescentBuilder.computeDescentPath(geometry, this.currentApproachProfile); + + this.guidanceController.pseudoWaypoints.acceptVerticalProfile(); + } else if (DEBUG) { + console.warn('[FMS/VNAV] Did not compute vertical profile. Reason: no legs in flight plan.'); + } + } + + private updateGuidance(): void { + let newGuidanceMode = RequestedVerticalMode.None; + let newVerticalSpeed = 0; + let newAltitude = 0; + + if (this.guidanceController.isManualHoldActive()) { + const fcuVerticalMode = SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Enum'); + if (fcuVerticalMode === VerticalMode.DES) { + const holdSpeed = SimVar.GetSimVarValue('L:A32NX_FM_HOLD_SPEED', 'number'); + const atHoldSpeed = this.atmosphericConditions.currentAirspeed <= (holdSpeed + 5); + if (atHoldSpeed) { + newGuidanceMode = RequestedVerticalMode.VsSpeed; + newVerticalSpeed = -1000; + newAltitude = 0; + } + } + } + + if (this.guidanceController.isManualHoldActive() || this.guidanceController.isManualHoldNext()) { + let holdSpeedCas = SimVar.GetSimVarValue('L:A32NX_FM_HOLD_SPEED', 'number'); + const holdDecelReached = SimVar.GetSimVarValue('L:A32NX_FM_HOLD_DECEL', 'bool'); + + const speedControlManual = Simplane.getAutoPilotAirspeedSelected(); + const isMach = Simplane.getAutoPilotMachModeActive(); + if (speedControlManual && holdDecelReached) { + if (isMach) { + const holdValue = Simplane.getAutoPilotMachHoldValue(); + holdSpeedCas = this.atmosphericConditions.computeCasFromMach(this.atmosphericConditions.currentAltitude, holdValue); + } else { + holdSpeedCas = Simplane.getAutoPilotAirspeedHoldValue(); + } + } + + const holdSpeedTas = this.atmosphericConditions.computeTasFromCas(this.atmosphericConditions.currentAltitude, holdSpeedCas); + + this.guidanceController.setHoldSpeed(holdSpeedTas); + } + + if (newGuidanceMode !== this.guidanceMode) { + this.guidanceMode = newGuidanceMode; + SimVar.SetSimVarValue('L:A32NX_FG_REQUESTED_VERTICAL_MODE', 'number', this.guidanceMode); + } + if (newVerticalSpeed !== this.targetVerticalSpeed) { + this.targetVerticalSpeed = newVerticalSpeed; + SimVar.SetSimVarValue('L:A32NX_FG_TARGET_VERTICAL_SPEED', 'number', this.targetVerticalSpeed); + } + if (newAltitude !== this.targetAltitude) { + this.targetAltitude = newAltitude; + SimVar.SetSimVarValue('L:A32NX_FG_TARGET_ALTITUDE', 'number', this.targetAltitude); + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts new file mode 100644 index 00000000000..f98081e8419 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts @@ -0,0 +1,85 @@ +import { Geometry } from '@fmgc/guidance/Geometry'; +import { Predictions } from '../Predictions'; +import { ClimbProfileBuilderResult } from './ClimbProfileBuilderResult'; +import { Common } from '../common'; + +export class ClimbPathBuilder { + static computeClimbPath( + _geometry: Geometry, + ): ClimbProfileBuilderResult { + const airfieldElevation = SimVar.GetSimVarValue('L:A32NX_DEPARTURE_ELEVATION', 'feet') ?? 0; + const accelerationAltitude = airfieldElevation + 1500; + + const midwayAltitudeSrs = (accelerationAltitude + airfieldElevation) / 2; + const isaDev = 8; + const v2 = SimVar.GetSimVarValue('L:AIRLINER_V2_SPEED', 'knots') ?? 130; + console.log(`v2 + 10: ${JSON.stringify(v2 + 10)}`); + + const commandedN1Toga = SimVar.GetSimVarValue('L:A32NX_AUTOTHRUST_THRUST_LIMIT', 'Percent') ?? 0; + console.log(`commandedN1: ${JSON.stringify(commandedN1Toga)}`); + + const thetaSrs = Common.getTheta(midwayAltitudeSrs, isaDev); + const deltaSrs = Common.getDelta(thetaSrs); + const machSrs = Common.CAStoMach(v2 + 10, deltaSrs); + + console.log(`mach: ${JSON.stringify(machSrs)}`); + + const zeroFuelWeight = 101853.57; + const fuelWeight = SimVar.GetSimVarValue('FUEL TOTAL QUANTITY WEIGHT', 'lbs'); + console.log(`fuelWeight: ${JSON.stringify(fuelWeight)}`); + + const takeoffRollDistance = this.computeTakeOffRollDistance(); + console.log(`takeoffRollDistance: ${JSON.stringify(takeoffRollDistance)}`); + + const { pathAngle: pathAngleSrs, distanceTraveled: distanceTraveledSrs } = Predictions.altitudeStep( + airfieldElevation, + accelerationAltitude - airfieldElevation, + v2 + 10, + machSrs, + commandedN1Toga, + zeroFuelWeight, + fuelWeight, + 0, + isaDev, + 36000, + false, + ); + console.log(`pathAngleSrs: ${pathAngleSrs}`); + console.log(`distanceToAccelerationAltitude: ${JSON.stringify(distanceTraveledSrs)}`); + + const cruiseAltitude = 20000; + const climbSpeed = v2 + 10; + + const commandedN1Climb = SimVar.GetSimVarValue('L:A32NX_AUTOTHRUST_THRUST_LIMIT', 'Percent') ?? 0; + const midwayAltitudeClimb = (cruiseAltitude + accelerationAltitude) / 2; + + const thetaClimb = Common.getTheta(midwayAltitudeClimb, isaDev); + const deltaClimb = Common.getDelta(thetaClimb); + const machClimb = Common.CAStoMach(climbSpeed, deltaClimb); + + const { pathAngle: pathAngleClimb, distanceTraveled: distanceTraveledClb } = Predictions.altitudeStep( + accelerationAltitude, + cruiseAltitude - accelerationAltitude, + climbSpeed, + machClimb, + commandedN1Climb, + zeroFuelWeight, + fuelWeight, + 0, + isaDev, + 36000, + false, + ); + console.log(`pathAngleClimb: ${pathAngleClimb}`); + console.log(`distanceToCruiseAltitude: ${JSON.stringify(distanceTraveledClb)}`); + + console.log(`[FMS/VNAV] T/C: ${JSON.stringify(takeoffRollDistance + distanceTraveledSrs + distanceTraveledClb)}`); + + return { distanceToAccelerationAltitude: distanceTraveledSrs }; + } + + static computeTakeOffRollDistance(): number { + // TODO + return 1; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/climb/ClimbProfileBuilderResult.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/climb/ClimbProfileBuilderResult.ts new file mode 100644 index 00000000000..60d12722720 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/climb/ClimbProfileBuilderResult.ts @@ -0,0 +1,3 @@ +export interface ClimbProfileBuilderResult { + distanceToAccelerationAltitude: number +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/common.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/common.ts new file mode 100644 index 00000000000..44c24b6e187 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/common.ts @@ -0,0 +1,202 @@ +import { AltitudeConstraint, SpeedConstraint } from '../lnav/legs'; + +export enum FlapConf { + CLEAN, + CONF_1, + CONF_2, + CONF_3, + CONF_FULL +} + +export enum AccelFactorMode { + CONSTANT_CAS, + CONSTANT_MACH, +} + +export enum VerticalWaypointType { + CRZ, + CLB, + DES, +} + +export interface VerticalLeg { + constraintType: VerticalWaypointType; + length: NauticalMiles; + distanceFromRef: NauticalMiles; + altConstraint: AltitudeConstraint | undefined; + speedConstraint: SpeedConstraint | undefined; + altIgnored: boolean; + speedIgnored: boolean; + altConstraintDirectlyApplied: boolean; + speedConstraintDirectlyApplied: boolean; +} + +export interface VerticalLegPrediction { + +} + +export class Common { + /** + * Calculates ISA temperature as a function of altitude + * @param alt in feet + * @param aboveTropo boolean + * @returns ISA temperature in celsius + */ + static getIsaTemp(alt: number, aboveTropo = false): number { + if (aboveTropo) { + return -56.5; + } + return 15 - (0.0019812 * alt); + } + + static getTemp(alt: number, isaDev = 0, aboveTropo = false): number { + if (aboveTropo) { + return (-56.5 + isaDev); + } + return 15 - (0.0019812 * alt) + isaDev; + } + + /** + * Get temperature ratio for a particular altitude (below tropopause) + * @param alt pressure altitude + * @param isaDev ISA deviation in celsius + * @param aboveTropo whether the aircraft is above the tropopause + * @returns temperature ratio + */ + static getTheta(alt: number, isaDev = 0, aboveTropo = false): number { + if (aboveTropo) { + return (216.65 + isaDev) / 288.15; + } + return (288.15 - (0.0019812 * alt) + isaDev) / 288.15; + } + + /** + * Get temperature ratio for a particular altitude and mach. + * @param theta temperature ratio (from only altitude) + * @param mach mach + * @returns temperature ratio + */ + static getTheta2(theta: number, mach: number): number { + return theta * (1 + 0.2 * (mach ** 2)); + } + + /** + * Get pressure ratio for a particular theta + * @param alt pressure altitude + * @param aboveTropo whether the aircraft is above the tropopause + * @returns pressure ratio + */ + static getDelta(alt: number, aboveTropo = false): number { + if (aboveTropo && alt !== undefined) { + return 0.22336 * Math.exp((36089.24 - alt) / 20805.7); + } + return this.getTheta(alt, 0, aboveTropo) ** 5.25588; + } + + /** + * Get pressure ratio for a particular theta and mach + * @param delta pressure ratio (from only theta) + * @param mach mach + * @returns pressure ratio + */ + static getDelta2(delta: number, mach: number): number { + return delta * (1 + 0.2 * (mach ** 2)) ** 3.5; + } + + /** + * Get KTAS value from mach + * @param mach + * @param theta + * @returns speed in KTAS (knots true airspeed) + */ + static machToTAS(mach: number, theta: number): number { + return 661.4786 * mach * Math.sqrt(theta); + } + + static machToEAS(mach: number, delta: number): number { + return 661.4786 * mach * Math.sqrt(delta); + } + + static CAStoMach(cas: number, delta: number): number { + const term1 = 1 + 0.2 * (cas / 661.4786) ** 2; + const term2 = (1 / delta) * ((term1 ** 3.5) - 1); + const term3 = 5 * (((term2 + 1) ** (1 / 3.5)) - 1); + return Math.sqrt(term3); + } + + static machToCas(mach: number, delta: number): number { + const term1 = (0.2 * mach ** 2 + 1) ** 3.5; + const term2 = (delta * term1 + 1) ** (1 / 3.5) - 1; + return 1479.1 * Math.sqrt(term2); + } + + static TAStoCAS(tas: number, theta: number, delta: number): number { + const term1 = 1 + (1 / theta) * (tas / 1479.1) ** 2; + const term2 = delta * ((term1 ** 3.5) - 1) + 1; + const term3 = ((term2) ** (1 / 3.5)) - 1; + return 1479.1 * Math.sqrt(term3); + } + + static CAStoTAS(cas: number, theta: number, delta: number): number { + const term1 = 1 + 0.2 * (cas / 661.4786) ** 2; + const term2 = (1 / delta) * ((term1 ** 3.5) - 1); + const term3 = theta * (((term2 + 1) ** (1 / 3.5)) - 1); + return 1479.1 * Math.sqrt(term3); + } + + static CAStoEAS(cas: number, delta: number): number { + const term1 = 1 + 0.2 * (cas / 661.4786) ** 2; + const term2 = (1 / delta) * ((term1 ** 3.5) - 1); + const term3 = delta * (((term2 + 1) ** (1 / 3.5)) - 1); + return 1479.1 * Math.sqrt(term3); + } + + static getAccelFactorCAS(mach: number, aboveTropo: boolean, tempRatio?: number): number { + const phi = (((1 + 0.2 * mach ** 2) ** 3.5) - 1) / ((0.7 * mach ** 2) * (1 + 0.2 * mach ** 2) ** 2.5); + if (aboveTropo) { + return 1 + 0.7 * (mach ** 2) * phi; + } + return 1 + 0.7 * (mach ** 2) * (phi - 0.190263 * tempRatio); + } + + static getAccelFactorMach(mach: number, aboveTropo: boolean, tempRatio?: number): number { + if (aboveTropo) { + return 0; + } + return -0.13318 * (mach ** 2) * tempRatio; + } + + /** + * Placeholder + * @param mach + * @param temp + * @param stdTemp + * @param aboveTropo + * @param accelFactorMode + * @returns + */ + static getAccelerationFactor( + mach: number, + altitude: number, + isaDev: number, + aboveTropo: boolean, + accelFactorMode: AccelFactorMode, + ): number { + const stdTemp = Common.getIsaTemp(altitude, aboveTropo); + const temp = Common.getTemp(altitude, isaDev, aboveTropo); + const tempRatio = stdTemp / temp; + if (accelFactorMode === AccelFactorMode.CONSTANT_CAS) { + return Common.getAccelFactorCAS(mach, aboveTropo, tempRatio); + } + + return Common.getAccelFactorMach(mach, aboveTropo, tempRatio); + } + + static interpolate(x: number, x0: number, x1: number, y0: number, y1: number): number { + return ((y0 * (x1 - x)) + (y1 * (x - x0))) / (x1 - x0); + } + + static poundsToMetricTons(pounds: number): number { + return pounds / 2204.6; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts new file mode 100644 index 00000000000..74002d0bdc6 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { Geometry } from '@fmgc/guidance/Geometry'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; +import { Predictions, StepResults, VnavStepError } from '@fmgc/guidance/vnav/Predictions'; +import { FlapConf } from '@fmgc/guidance/vnav/common'; + +const ALTITUDE_ADJUSTMENT_FACTOR = 1.4; + +/** + * The minimum deceleration rate, in knots per second, to target on the approach path. + * + * This will be used as the target rate in case it cannot be achieved using the desired fpa. + */ +const MINIMUM_APPROACH_DECELERATION = 0.5; + +export enum ApproachPathSegmentType { + CONSTANT_SLOPE, + CONSTANT_SPEED, + LEVEL_DECELERATION, +} + +export interface DecelPathCharacteristics { + flap1: NauticalMiles, + flap2: NauticalMiles, + decel: NauticalMiles, + top: Feet, +} + +export class DecelPathBuilder { + static computeDecelPath( + geometry: Geometry, + ): DecelPathCharacteristics { + // TO GET FPA: + // If approach exists, use approach alt constraints to get FPA and glidepath + // If no approach but arrival, use arrival alt constraints, if any + // If no other alt constraints, use 3 degree descent from cruise altitude + + // Given FPA above, calculate distance required (backwards from Vapp @ runway threshold alt + 50ft + 1000ft), + // to decelerate from green dot speed to Vapp using `decelerationFromGeometricStep` + // Then, add a speedChangeStep (1.33 knots/second decel) backwards from this point (green dot spd) to previous speed, aka min(last spd constraint, spd lim) + // - TODO: make sure alt constraints are obeyed during this speed change DECEL segment? + // The point at the beginning of the speedChangeStep is DECEL + + const TEMP_TROPO = 36_000; + const TEMP_FUEL_WEIGHT = 2_300; + const DES = 250; + const O = 203; + const S = 184; + const F = 143; + + const vappSegment = DecelPathBuilder.computeVappSegment(geometry); + + let fuelWeight = TEMP_FUEL_WEIGHT; + + const cFullTo3Segment = DecelPathBuilder.computeConfigurationChangeSegment( + ApproachPathSegmentType.CONSTANT_SLOPE, + -3, + 1_000, + F, + 135, + fuelWeight, + FlapConf.CONF_FULL, + true, + TEMP_TROPO, + ); + fuelWeight += cFullTo3Segment.fuelBurned; + + const c3to2Segment = DecelPathBuilder.computeConfigurationChangeSegment( + ApproachPathSegmentType.CONSTANT_SLOPE, + -3, + cFullTo3Segment.initialAltitude, + F + (S - F) / 2, + F, + fuelWeight, + FlapConf.CONF_3, + true, + TEMP_TROPO, + ); + fuelWeight += c3to2Segment.fuelBurned; + + const c2to1Segment = DecelPathBuilder.computeConfigurationChangeSegment( + ApproachPathSegmentType.CONSTANT_SLOPE, + -3, + c3to2Segment.initialAltitude, + S, + F + (S - F) / 2, + fuelWeight, + FlapConf.CONF_2, + false, + TEMP_TROPO, + ); + fuelWeight += c2to1Segment.fuelBurned; + + const c1toCleanSegment = DecelPathBuilder.computeConfigurationChangeSegment( + ApproachPathSegmentType.CONSTANT_SLOPE, + -2.5, + c2to1Segment.initialAltitude, + O, + S, + fuelWeight, + FlapConf.CONF_1, + false, + TEMP_TROPO, + ); + fuelWeight += c1toCleanSegment.fuelBurned; + + let cleanToDesSpeedSegment = DecelPathBuilder.computeConfigurationChangeSegment( + ApproachPathSegmentType.CONSTANT_SLOPE, + -2.5, + c1toCleanSegment.initialAltitude, + DES, + O, + fuelWeight, + FlapConf.CLEAN, + false, + TEMP_TROPO, + ); + + // TODO for TOO_LOW_DECELERATION do CONSTANT_DECELERATION, not LEVEL_DECELERATION + if (cleanToDesSpeedSegment.error === VnavStepError.AVAILABLE_GRADIENT_INSUFFICIENT + || cleanToDesSpeedSegment.error === VnavStepError.TOO_LOW_DECELERATION) { + if (DEBUG) { + console.warn('[VNAV/computeDecelPath] AVAILABLE_GRADIENT_INSUFFICIENT/TOO_LOW_DECELERATION on cleanToDesSpeedSegment -> reverting to LEVEL_DECELERATION segment.'); + } + + // if (VnavConfig.VNAV_DESCENT_MODE !== VnavDescentMode.CDA) { + cleanToDesSpeedSegment = DecelPathBuilder.computeConfigurationChangeSegment( + ApproachPathSegmentType.LEVEL_DECELERATION, + undefined, + c1toCleanSegment.initialAltitude, + DES, + O, + fuelWeight, + FlapConf.CLEAN, + false, + TEMP_TROPO, + ); + // } else { + // throw new Error('[VNAV/computeDecelPath] Computation of cleanToDesSpeedSegment for CDA is not yet implemented'); + // } + } + + return { + flap1: vappSegment.distanceTraveled + + cFullTo3Segment.distanceTraveled + + c3to2Segment.distanceTraveled + + c2to1Segment.distanceTraveled + + c1toCleanSegment.distanceTraveled, + flap2: vappSegment.distanceTraveled + + cFullTo3Segment.distanceTraveled + + c3to2Segment.distanceTraveled + + c2to1Segment.distanceTraveled, + decel: vappSegment.distanceTraveled + + cFullTo3Segment.distanceTraveled + + c3to2Segment.distanceTraveled + + c2to1Segment.distanceTraveled + + c1toCleanSegment.distanceTraveled + + cleanToDesSpeedSegment.distanceTraveled, + top: cleanToDesSpeedSegment.finalAltitude, + }; + } + + /** + * Calculates the Vapp segment of the DECEL path. + * + * @return the Vapp segment step results + */ + private static computeVappSegment( + geometry: Geometry, + ): StepResults { + const TEMP_VAPP = 135; // TODO actual Vapp + + const finalAltitude = DecelPathBuilder.findLastApproachPoint(geometry); + + // TODO For now we use some "reasonable" values for the segment. When we have the ability to predict idle N1 and such at approach conditions, + // we can change this. + return { + ...Predictions.altitudeStep( + 1_000, + -(1_000 - finalAltitude), + TEMP_VAPP, // TODO placeholder value + 999, // TODO placeholder value + 26, // TODO placeholder value + 107_000, // TODO placeholder value + 5_000, // TODO placeholder value + 2, // TODO placeholder value + 0, // TODO placeholder value + 36_000, // TODO placeholder value + false, // TODO placeholder value + ), + distanceTraveled: 3.14, // FIXME hard-coded correct value for -3deg fpa + }; + } + + /** + * Calculates a config change segment of the DECEL path. + * + * @return the config change segment step results + */ + private static computeConfigurationChangeSegment( + type: ApproachPathSegmentType, + fpa: number, + finalAltitude: Feet, + fromSpeed: Knots, + toSpeed: Knots, + initialFuelWeight: number, // TODO take finalFuelWeight and make an iterative prediction + newConfiguration: FlapConf, + gearExtended: boolean, + tropoAltitude: number, + ): StepResults { + // TODO For now we use some "reasonable" values for the segment. When we have the ability to predict idle N1 and such at approach conditions, + // we can change this. + + switch (type) { + case ApproachPathSegmentType.CONSTANT_SLOPE: // FIXME hard-coded to -3deg in speedChangeStep + + let currentIterationAltitude = finalAltitude * ALTITUDE_ADJUSTMENT_FACTOR; + let stepResults: StepResults; + let altitudeError = 0; + let iterationCount = 0; + + if (DEBUG) { + console.log('starting iterative step compute'); + console.time(`step to altitude ${finalAltitude}`); + } + + do { + if (DEBUG) { + console.log(`iteration #${iterationCount}, with initialAltitude = ${currentIterationAltitude}, targetFinalAltitude = ${finalAltitude}`); + + console.time(`step to altitude ${finalAltitude} iteration ${iterationCount}`); + } + + const newStepResults = Predictions.speedChangeStep( + fpa ?? -3, + currentIterationAltitude, + fromSpeed, + toSpeed, + 999, + 999, + 26, + 107_000, + initialFuelWeight, + 2, + 0, + tropoAltitude, + gearExtended, + newConfiguration, + MINIMUM_APPROACH_DECELERATION, + ); + + // Stop if we encounter a NaN + if (Number.isNaN(newStepResults.finalAltitude)) { + if (DEBUG) { + console.timeEnd(`step to altitude ${finalAltitude} iteration ${iterationCount}`); + } + break; + } + + stepResults = newStepResults; + + altitudeError = finalAltitude - stepResults.finalAltitude; + currentIterationAltitude += altitudeError; + + if (DEBUG) { + console.timeEnd('stuff after'); + + console.log(`iteration #${iterationCount} done finalAltitude = ${stepResults.finalAltitude}, error = ${altitudeError}`); + + console.timeEnd(`step to altitude ${finalAltitude} iteration ${iterationCount}`); + } + + iterationCount++; + } while (Math.abs(altitudeError) >= 25 && iterationCount < 4); + + if (DEBUG) { + console.timeEnd(`step to altitude ${finalAltitude}`); + console.log('done with iterative step compute'); + } + + return { + ...stepResults, + initialAltitude: currentIterationAltitude, + }; + case ApproachPathSegmentType.CONSTANT_SPEED: + throw new Error('[FMS/VNAV/computeConfigurationChangeSegment] CONSTANT_SPEED is not supported for configuration changes.'); + case ApproachPathSegmentType.LEVEL_DECELERATION: + return Predictions.speedChangeStep( + 0, + finalAltitude * ALTITUDE_ADJUSTMENT_FACTOR, + fromSpeed, + toSpeed, + 999, + 999, + 26, + 107_000, + initialFuelWeight, + 2, + 0, + tropoAltitude, + gearExtended, + newConfiguration, + ); + default: + throw new Error('[FMS/VNAV/computeConfigurationChangeSegment] Unknown segment type.'); + } + } + + /** + * Returns altitude of either, in order of priority: + * - runway threshold; + * - missed approach point; + * - airport. + */ + private static findLastApproachPoint( + geometry: Geometry, + ): Feet { + const lastLeg = geometry.legs.get(geometry.legs.size - 1); + + // Last leg is TF AND is runway or airport + if (lastLeg instanceof TFLeg && (lastLeg.to.isRunway || lastLeg.to.type === 'A')) { + return lastLeg.to.legAltitude1; + } + return 150; // TODO temporary value + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/DescentBuilder.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/DescentBuilder.ts new file mode 100644 index 00000000000..7f3f0a348c3 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/DescentBuilder.ts @@ -0,0 +1,49 @@ +import { TheoreticalDescentPathCharacteristics } from '@fmgc/guidance/vnav/descent/TheoreticalDescentPath'; +import { Geometry } from '@fmgc/guidance/Geometry'; +import { DecelPathCharacteristics } from '@fmgc/guidance/vnav/descent/DecelPathBuilder'; + +export class DescentBuilder { + static computeDescentPath( + geometry: Geometry, + decelPath: DecelPathCharacteristics, + ): TheoreticalDescentPathCharacteristics { + const cruiseAlt = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number'); + const verticalDistance = cruiseAlt - decelPath.top; + const fpa = 3; + + if (DEBUG) { + console.log(cruiseAlt); + console.log(verticalDistance); + } + + const tod = decelPath.decel + (verticalDistance / Math.tan((fpa * Math.PI) / 180)) * 0.000164579; + + if (DEBUG) { + console.log(`[FMS/VNAV] T/D: ${tod.toFixed(1)}nm`); + } + + return { tod }; + + // const decelPointDistance = DecelPathBuilder.computeDecelPath(geometry); + // + // const lastLegIndex = geometry.legs.size - 1; + // + // // Find descent legs before decel point + // let accumulatedDistance = 0; + // let currentLegIdx; + // let currentLeg; + // for (currentLegIdx = lastLegIndex; accumulatedDistance < decelPointDistance; currentLegIdx--) { + // currentLeg = geometry.legs.get(currentLegIdx); + // + // accumulatedDistance += currentLeg.distance; + // } + // currentLegIdx--; + // + // const geometricPath = GeomtricPathBuilder.buildGeometricPath(geometry, currentLegIdx); + // + // console.log(geometricPath); + // + // return { geometricPath }; + // } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/GeomtricPathBuilder.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/GeomtricPathBuilder.ts new file mode 100644 index 00000000000..77c1e427f3c --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/GeomtricPathBuilder.ts @@ -0,0 +1,71 @@ +import { Geometry } from '@fmgc/guidance/Geometry'; +import { GeometricPath } from '@fmgc/guidance/vnav/descent/TheoreticalDescentPath'; +import { Predictions, StepResults } from '@fmgc/guidance/vnav/Predictions'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; + +export class GeomtricPathBuilder { + static buildGeometricPath( + geometry: Geometry, + endLegIdx: number, + ): GeometricPath { + const fpaTable = {}; + + let currentLegIdx = endLegIdx; + let currentLeg = geometry.legs.get(currentLegIdx); + let [previousLeg] = GeomtricPathBuilder.findSlopeStart(geometry, currentLegIdx); + + while (previousLeg) { + const step = GeomtricPathBuilder.buildGeometricStep(previousLeg, currentLeg); + + console.log(step); + + currentLegIdx--; + currentLeg = previousLeg; + [previousLeg, currentLegIdx] = GeomtricPathBuilder.findSlopeStart(geometry, currentLegIdx); + } + + return fpaTable as GeometricPath; + } + + /** + * Finds the next valid slope segment for the geometrical path. + * + * @param geometry lateral geometry to use + * @param startLegIdx the leg index to serve as the end of the slope + * + * @return the leg that starts the slope, and the index of that leg + */ + private static findSlopeStart(geometry: Geometry, startLegIdx: number): [Leg, number] | undefined { + let searchIdx = startLegIdx - 1; + let searchLeg = geometry.legs.get(searchIdx); + while (searchLeg) { + if (searchLeg.altitudeConstraint) { + break; + } + + searchIdx--; + searchLeg = geometry.legs.get(searchIdx); + } + + return [searchLeg, searchIdx]; + } + + private static buildGeometricStep( + leg1: Leg, + leg2: Leg, + ): StepResults { + const geometricalStepResult = Predictions.geometricStep( + leg1.altitudeConstraint.altitude2 || leg1.altitudeConstraint.altitude1, + leg2.altitudeConstraint.altitude2 || leg2.altitudeConstraint.altitude1, + leg2.distance, // this should include transition from leg1 to leg2 + 200, + 0.6, + 70_000, + 72_750, + 0, + 36_000, + ); + + return geometricalStepResult; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/TheoreticalDescentPath.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/TheoreticalDescentPath.ts new file mode 100644 index 00000000000..01a35990fa8 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/descent/TheoreticalDescentPath.ts @@ -0,0 +1,20 @@ +/** + * Theoretical descent path model + */ +export interface TheoreticalDescentPathCharacteristics { + tod: number, +} + +export interface IdlePath { + speedLimitStartDistanceFromEnd: NauticalMiles, + speedLimitValue: Knots, +} + +export interface GeometricPath { + /** + * Table of flight path angles indexed by the leg whose termination they end up at + */ + flightPathAngles: { + [k: number]: Degrees, + }, +} diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/verticalFlightPlan/VerticalFlightPlan.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/verticalFlightPlan/VerticalFlightPlan.ts new file mode 100644 index 00000000000..47f565e8d52 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/verticalFlightPlan/VerticalFlightPlan.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { AltitudeConstraint, SpeedConstraint } from '@fmgc/guidance/lnav/legs'; +import { SpeedLimit } from '@fmgc/guidance/vnav/SpeedLimit'; + +export interface VerticalFlightPlan { + climb: VerticalClimb, + cruise: VerticalCruise, + descent: VerticalDescent, +} + +/** + * Leg indices mapped to altitude constraints + */ +export type ConstraintTable = { + [k: number]: { + speed?: SpeedConstraint, + altitude?: AltitudeConstraint, + }, +} + +export interface VerticalClimb { + thrustReductionAltitude: number, + accelerationAltitude: number, + + climConstraints: ConstraintTable, + + climbSpeedLimit?: SpeedLimit, +} + +export type FlightLevel = number + +export interface VerticalCruise { + cruiseAltitude: FlightLevel, + + // stepClimb?: StepClimb, +} + +export interface VerticalDescent { + descentConstraints: ConstraintTable, + + descentSpeedLimit?: SpeedLimit, + + approachConstraints: ConstraintTable, +} + +// export interface VerticalMissedApproach diff --git a/fbw-a380x/src/systems/fmgc/src/guidance/vnav/verticalFlightPlan/VerticalFlightPlanBuilder.ts b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/verticalFlightPlan/VerticalFlightPlanBuilder.ts new file mode 100644 index 00000000000..f5c88cdf340 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/guidance/vnav/verticalFlightPlan/VerticalFlightPlanBuilder.ts @@ -0,0 +1,50 @@ +import { + ConstraintTable, + VerticalDescent, + VerticalFlightPlan, +} from '@fmgc/guidance/vnav/verticalFlightPlan/VerticalFlightPlan'; +import { Geometry } from '@fmgc/guidance/Geometry'; +import { SegmentType } from '@fmgc/flightplanning/FlightPlanSegment'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; + +export class VerticalFlightPlanBuilder { + static buildVerticalFlightPlan(geometry: Geometry): VerticalFlightPlan { + const arrivalLegs = geometry.legsInSegment(SegmentType.Arrival); + const approachLegs = geometry.legsInSegment(SegmentType.Approach); + + const descent = VerticalFlightPlanBuilder.buildVerticalDescent(arrivalLegs, approachLegs); + + return { + climb: { + thrustReductionAltitude: 1500, + accelerationAltitude: 2100, + climConstraints: {}, + }, + cruise: { cruiseAltitude: 36_000 }, + descent, + }; + } + + private static buildVerticalDescent(descentLegs: Map, approachLegs: Map): VerticalDescent { + const descentConstraints: ConstraintTable = {}; + + for (const leg of descentLegs.entries()) { + descentConstraints[leg[0]] = {}; + descentConstraints[leg[0]].altitude = leg[1].altitudeConstraint; + descentConstraints[leg[0]].speed = leg[1].speedConstraint; + } + + const approachConstraints: ConstraintTable = {}; + + for (const leg of approachLegs.entries()) { + approachConstraints[leg[0]] = {}; + approachConstraints[leg[0]].altitude = leg[1].altitudeConstraint; + approachConstraints[leg[0]].speed = leg[1].speedConstraint; + } + + return { + descentConstraints, + approachConstraints, + }; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/index.ts b/fbw-a380x/src/systems/fmgc/src/index.ts new file mode 100644 index 00000000000..5d1b60766ab --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/index.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { normaliseApproachName } from '@shared/flightplan'; +import { FlightPlanService } from './flightplanning/new/FlightPlanService'; +import { NavigationDatabase, NavigationDatabaseBackend } from './NavigationDatabase'; +import { FlightPlanManager } from './flightplanning/FlightPlanManager'; +import { FlightPhaseManager, getFlightPhaseManager } from './flightphase'; +import { FlightPlanAsoboSync } from './flightplanning/FlightPlanAsoboSync'; +import { GuidanceController } from './guidance/GuidanceController'; +import { NavRadioManager } from './radionav/NavRadioManager'; +import { EfisSymbols } from './efis/EfisSymbols'; +import { DescentBuilder } from './guidance/vnav/descent/DescentBuilder'; +import { DecelPathBuilder } from './guidance/vnav/descent/DecelPathBuilder'; +import { VerticalFlightPlanBuilder } from './guidance/vnav/verticalFlightPlan/VerticalFlightPlanBuilder'; +import { initComponents, updateComponents, recallMessageById } from './components'; +import { WaypointBuilder } from './flightplanning/WaypointBuilder'; +import { Navigation } from './navigation/Navigation'; +import { FlightPlanIndex } from './flightplanning/new/FlightPlanManager'; +import { NavigationDatabaseService } from './flightplanning/new/NavigationDatabaseService'; + +function initFmgcLoop(baseInstrument: BaseInstrument, flightPlanManager: FlightPlanManager): void { + initComponents(baseInstrument, flightPlanManager); +} + +function updateFmgcLoop(deltaTime: number): void { + updateComponents(deltaTime); +} + +export { + FlightPlanService, + NavigationDatabase, + NavigationDatabaseBackend, + NavigationDatabaseService, + FlightPlanIndex, + FlightPhaseManager, + getFlightPhaseManager, + FlightPlanManager, + FlightPlanAsoboSync, + GuidanceController, + NavRadioManager, + initFmgcLoop, + updateFmgcLoop, + recallMessageById, + EfisSymbols, + DescentBuilder, + DecelPathBuilder, + VerticalFlightPlanBuilder, + WaypointBuilder, + normaliseApproachName, + Navigation, +}; diff --git a/fbw-a380x/src/systems/fmgc/src/navigation/FlightArea.ts b/fbw-a380x/src/systems/fmgc/src/navigation/FlightArea.ts new file mode 100644 index 00000000000..b49da137a5f --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/navigation/FlightArea.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +/** + * Navigation flight areas + */ + export enum FlightArea { + Terminal, + Takeoff, + Enroute, + Oceanic, + VorApproach, + GpsApproach, + PrecisionApproach, + NonPrecisionApproach, +} diff --git a/fbw-a380x/src/systems/fmgc/src/navigation/Navigation.ts b/fbw-a380x/src/systems/fmgc/src/navigation/Navigation.ts new file mode 100644 index 00000000000..86b5e54fe41 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/navigation/Navigation.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { FlightArea } from '@fmgc/flightplanning/FlightPlanManager'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { ApproachSegment } from '@fmgc/flightplanning/new/segments/ApproachSegment'; +import { ApproachViaSegment } from '@fmgc/flightplanning/new/segments/ApproachViaSegment'; +import { ArrivalEnrouteTransitionSegment } from '@fmgc/flightplanning/new/segments/ArrivalEnrouteTransitionSegment'; +import { ArrivalSegment } from '@fmgc/flightplanning/new/segments/ArrivalSegment'; +import { DepartureEnrouteTransitionSegment } from '@fmgc/flightplanning/new/segments/DepartureEnrouteTransitionSegment'; +import { DepartureRunwayTransitionSegment } from '@fmgc/flightplanning/new/segments/DepartureRunwayTransitionSegment'; +import { DepartureSegment } from '@fmgc/flightplanning/new/segments/DepartureSegment'; +import { OriginSegment } from '@fmgc/flightplanning/new/segments/OriginSegment'; +import { FlightPlanManager } from '@fmgc/index'; +import { RequiredPerformance } from '@fmgc/navigation/RequiredPerformance'; +import { ApproachType } from 'msfs-navdata'; + +export class Navigation { + activeArea: FlightArea = FlightArea.Enroute; + + requiredPerformance: RequiredPerformance; + + currentPerformance: number | undefined; + + accuracyHigh: boolean = false; + + constructor(private flightPlanManager: FlightPlanManager) { + this.requiredPerformance = new RequiredPerformance(this.flightPlanManager); + } + + // eslint-disable-next-line no-empty-function + init(): void {} + + update(deltaTime: number): void { + this.updateFlightArea(); + + this.requiredPerformance.update(this.activeArea, deltaTime); + + this.updateCurrentPerformance(); + } + + private updateCurrentPerformance(): void { + const gs = SimVar.GetSimVarValue('GPS GROUND SPEED', 'knots'); + + // FIXME fake it until we make it :D + const estimate = 0.03 + Math.random() * 0.02 + gs * 0.00015; + // basic IIR filter + this.currentPerformance = this.currentPerformance === undefined ? estimate : this.currentPerformance * 0.9 + estimate * 0.1; + + const accuracyHigh = this.currentPerformance <= this.requiredPerformance.activeRnp; + if (accuracyHigh !== this.accuracyHigh) { + this.accuracyHigh = accuracyHigh; + SimVar.SetSimVarValue('L:A32NX_FMGC_L_NAV_ACCURACY_HIGH', 'bool', this.accuracyHigh); + SimVar.SetSimVarValue('L:A32NX_FMGC_R_NAV_ACCURACY_HIGH', 'bool', this.accuracyHigh); + } + } + + private updateFlightArea(): void { + this.activeArea = this.getActiveFlightArea(); + } + + private getActiveFlightArea(): FlightArea { + const activeLeg = FlightPlanService.active.activeLeg; + if (!activeLeg || !(activeLeg instanceof FlightPlanLeg)) { + return FlightArea.Enroute; + } + + if ( + activeLeg.segment instanceof OriginSegment + || activeLeg.segment instanceof DepartureRunwayTransitionSegment + ) { + return FlightArea.Takeoff; + } + + if ( + activeLeg.segment instanceof DepartureSegment + || activeLeg.segment instanceof DepartureEnrouteTransitionSegment + || activeLeg.segment instanceof ArrivalEnrouteTransitionSegment + || activeLeg.segment instanceof ArrivalSegment + ) { + return FlightArea.Terminal; + } + + if ( + activeLeg.segment instanceof ApproachViaSegment + || activeLeg.segment instanceof ApproachSegment + ) { + switch (FlightPlanService.active.approach?.type) { + case ApproachType.Gls: + case ApproachType.Ils: + case ApproachType.Mls: + case ApproachType.MlsTypeA: + case ApproachType.MlsTypeBC: + return FlightArea.PrecisionApproach; + case ApproachType.Fms: + case ApproachType.Gps: + case ApproachType.Rnav: + return FlightArea.GpsApproach; + case ApproachType.VorDme: + case ApproachType.Vor: + case ApproachType.Vortac: + case ApproachType.Tacan: + return FlightArea.VorApproach; + default: + return FlightArea.NonPrecisionApproach; + } + } + + return FlightArea.Enroute; + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/navigation/RequiredPerformance.ts b/fbw-a380x/src/systems/fmgc/src/navigation/RequiredPerformance.ts new file mode 100644 index 00000000000..bd513f51d74 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/navigation/RequiredPerformance.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { getFlightPhaseManager } from '@fmgc/flightphase'; +import { FlightArea } from '@fmgc/flightplanning/FlightPlanManager'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { FlightPlanManager } from '@fmgc/index'; +import { FmgcFlightPhase } from '@shared/flightphase'; + +const rnpDefaults: Record = { + [FlightArea.Takeoff]: 1, + [FlightArea.Terminal]: 1, + [FlightArea.Enroute]: 2, + [FlightArea.Oceanic]: 2, + [FlightArea.VorApproach]: 0.5, + [FlightArea.GpsApproach]: 0.3, + [FlightArea.PrecisionApproach]: 0.5, + [FlightArea.NonPrecisionApproach]: 0.5, +}; + +export class RequiredPerformance { + activeArea: FlightArea = FlightArea.Enroute; + + activeRnp: number | undefined; + + requestLDev = false; + + manualRnp = false; + + constructor(private flightPlanManager: FlightPlanManager) {} + + update(activeArea: FlightArea, _deltaTime: number): void { + this.activeArea = activeArea; + + this.updateAutoRnp(); + + this.updateLDev(); + } + + setPilotRnp(rnp): void { + this.manualRnp = true; + this.setActiveRnp(rnp); + } + + clearPilotRnp(): void { + this.manualRnp = false; + this.updateAutoRnp(); + } + + private updateAutoRnp(): void { + if (this.manualRnp) { + return; + } + + let rnp; + + const activeLeg = FlightPlanService.active.activeLeg; + if (activeLeg instanceof FlightPlanLeg) { + if (activeLeg.rnp) { + rnp = activeLeg.definition.rnp; + } + } + + if (!rnp) { + rnp = rnpDefaults[this.activeArea] ?? 2; + } + + if (rnp !== this.activeRnp) { + this.setActiveRnp(rnp); + } + } + + private setActiveRnp(rnp: number): void { + this.activeRnp = rnp; + SimVar.SetSimVarValue('L:A32NX_FMGC_L_RNP', 'number', rnp ?? 0); + SimVar.SetSimVarValue('L:A32NX_FMGC_R_RNP', 'number', rnp ?? 0); + } + + private updateLDev(): void { + const ldev = this.activeArea !== FlightArea.Enroute + && this.activeArea !== FlightArea.Oceanic + && this.activeRnp <= (0.3 + Number.EPSILON) + && getFlightPhaseManager().phase >= FmgcFlightPhase.Takeoff; + + if (ldev !== this.requestLDev) { + this.requestLDev = ldev; + SimVar.SetSimVarValue('L:A32NX_FMGC_L_LDEV_REQUEST', 'bool', this.requestLDev); + SimVar.SetSimVarValue('L:A32NX_FMGC_R_LDEV_REQUEST', 'bool', this.requestLDev); + } + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/radionav/NavRadioManager.ts b/fbw-a380x/src/systems/fmgc/src/radionav/NavRadioManager.ts new file mode 100644 index 00000000000..f3cc3cc22f0 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/radionav/NavRadioManager.ts @@ -0,0 +1,26 @@ +export enum TuningMode { + Auto = 0, + Manual, + Remote +} + +/** + * This is a placeholder for the new radio nav tuning logic... coming soon to an A32NX near you + */ +export class NavRadioManager { + tuningMode1: TuningMode = TuningMode.Auto; + + tuningMode2: TuningMode = TuningMode.Auto; + + tuningMode3: TuningMode = TuningMode.Auto; + + constructor(public _parentInstrument: BaseInstrument) { + SimVar.SetSimVarValue('L:A32NX_FMGC_RADIONAV_1_TUNING_MODE', 'Enum', TuningMode.Manual); + SimVar.SetSimVarValue('L:A32NX_FMGC_RADIONAV_2_TUNING_MODE', 'Enum', TuningMode.Manual); + SimVar.SetSimVarValue('L:A32NX_FMGC_RADIONAV_3_TUNING_MODE', 'Enum', TuningMode.Manual); + } + + update(_: number): void { + // Do nothing + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/radionav/index.ts b/fbw-a380x/src/systems/fmgc/src/radionav/index.ts new file mode 100644 index 00000000000..63fcd0cf68a --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/radionav/index.ts @@ -0,0 +1,3 @@ +import { TuningMode } from './NavRadioManager'; + +export { TuningMode }; diff --git a/fbw-a380x/src/systems/fmgc/src/types/A32NX_Util.d.ts b/fbw-a380x/src/systems/fmgc/src/types/A32NX_Util.d.ts new file mode 100644 index 00000000000..d057b5501f6 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/types/A32NX_Util.d.ts @@ -0,0 +1,65 @@ +export type Spherical = [number, number, number] + +declare global { + interface StateMachineStateTransition { + target: StateMachineState, + } + + interface StateMachineState { + transitions: { [event: number]: StateMachineStateTransition } + } + + interface StateMachineDefinition { + init: StateMachineState, + } + + interface StateMachine { + value: StateMachineState, + + action(event: number): void, + + setState(newState: StateMachineState): void, + } + + // eslint-disable-next-line camelcase + namespace A32NX_Util { + function createDeltaTimeCalculator(startTime: number): () => number + + function createFrameCounter(interval: number): number + + function createMachine(machineDef: StateMachineDefinition): StateMachine + + function trueToMagnetic(heading: Degrees, magVar?: Degrees): Degrees + + function magneticToTrue(heading: Degrees, magVar?: Degrees): Degrees + + function latLonToSpherical(ll: LatLongData): Spherical + + function sphericalToLatLon(s: Spherical): LatLongData + + function greatCircleIntersection(latlon1: LatLongData, brg1: Degrees, latlon2: LatLongData, brg2: Degrees): LatLongData + + function bothGreatCircleIntersections(latlon1: LatLongData, brg1: Degrees, latlon2: LatLongData, brg2: Degrees): [LatLongData, LatLongData] + + function getIsaTemp(alt?: Feet): number; + + function getIsaTempDeviation(alt?: Feet, sat?: Celcius): Celcius + + class UpdateThrottler { + constructor(intervalMs: number); + + /** + * Checks whether the instrument should be updated in the current frame according to the + * configured update interval. + * + * @param deltaTime + * @param forceUpdate - True if you want to force an update during this frame. + * @returns -1 if the instrument should not update, or the time elapsed since the last + * update in milliseconds + */ + canUpdate(deltaTime: number, forceUpdate?: boolean); + } + } +} + +export {}; diff --git a/fbw-a380x/src/systems/fmgc/src/types/fstypes/FSEnums.ts b/fbw-a380x/src/systems/fmgc/src/types/fstypes/FSEnums.ts new file mode 100644 index 00000000000..5f766b05a2d --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/types/fstypes/FSEnums.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2020-2021 Working Title, FlyByWire Simulations +// SPDX-License-Identifier: MIT + +export enum AirportClass { + Unknown = 0, + Normal = 1, + SoftUnknown = 2, // TODO no idea but is "soft" according to waypoint.js + Seaplane = 3, + Heliport = 4, + Private = 5, +} + +export enum AirportPrivateType { + Unknown = 0, + Public = 1, + Military = 2, + Private = 3, +} + +export enum AirspaceType { + None = 0, + Center = 1, + ClassA = 2, + ClassB = 3, + ClassC = 4, + ClassD = 5, + ClassE = 6, + ClassF = 7, + ClassG = 8, + Tower = 9, + Clearance = 10, + Ground = 11, + Departure = 12, + Approach = 13, + MOA = 14, + Restricted = 15, + Prohibited = 16, + Warning = 17, + Alert = 18, + Danger = 19, + NationalPark = 20, + ModeC = 21, + Radar = 22, + Training = 23, +} + +export enum AltitudeDescriptor { + Empty = 0, + At = 1, // @, At in Alt1 + AtOrAbove = 2, // +, at or above in Alt1 + AtOrBelow = 3, // -, at or below in Alt1 + Between = 4, // B, range between Alt1 and Alt2 + C = 5, // C, at or above in Alt2 + G = 6, // G, Alt1 At for FAF, Alt2 is glideslope MSL + H = 7, // H, Alt1 is At or above for FAF, Alt2 is glideslope MSL + I = 8, // I, Alt1 is at for FACF, Alt2 is glidelope intercept + J = 9, // J, Alt1 is at or above for FACF, Alt2 is glideslope intercept + V = 10, // V, Alt1 is procedure alt for step-down, Alt2 is at alt for vertical path angle + // X, not supported + // Y, not supported +} + +export enum FixTypeFlags { + None = 0, + IAF = 1, + IF = 2, + MAP = 4, + FAF = 8, +} + +export enum FrequencyType { + None = 0, + ATIS = 1, + Multicom = 2, + Unicom = 3, + CTAF = 4, + Ground = 5, + Tower = 6, + Clearance = 7, + Approach = 8, + Departure = 9, + Center = 10, + FSS = 11, + AWOS = 12, + ASOS = 13, + ClearancePreTaxi = 14, + RemoteDeliveryClearance = 15, +} + +// ARINC424 names +export enum LegType { + Unknown = 0, + AF = 1, // Arc to a fix (i.e. DME ARC) + CA = 2, // Course to an Altitude + CD = 3, // Course to a DME distance + CF = 4, // Course to a Fix + CI = 5, // Course to an intercept (next leg) + CR = 6, // Course to a VOR radial + DF = 7, // Direct to Fix from PPOS + FA = 8, // Track from Fix to Altitude + FC = 9, // Track from Fix to a Distance + FD = 10, // Track from Fix to a DME distance (not the same fix) + FM = 11, // Track from Fix to a Manual termination + HA = 12, // Holding with Altitude termination + HF = 13, // Holding, single circuit terminating at the fix + HM = 14, // Holding with manual termination + IF = 15, // Initial Fix + PI = 16, // Procedure turn + RF = 17, // Constant radius arc between two fixes, lines tangent to arc and a centre fix + TF = 18, // Track to a Fix + VA = 19, // Heading to an altitude + VD = 20, // Heading to a DME distance + VI = 21, // Heading to an intercept + VM = 22, // Heading to a manual termination + VR = 23, // Heading to a VOR radial +} + +export enum NdbType { + CompassLocator = 0, // < 25 W? + MH = 1, // 25 - <50 W ? + H = 2, // 50 - 199 W ? + HH = 3, // > 200 W ? +} + +export enum NearestSearchType { + None = 0, + Airport = 1, + Intersection = 2, + Vor = 3, + Ndb = 4, + Boundary = 5, +} + +export enum RnavTypeFlags { + None = 0, + Lnav = 1 << 0, + LnavVnav = 1 << 1, + Lp = 1 << 2, + Lpv = 1 << 3 +} + +export enum RouteType { + None = 0, + LowLevel = 1, // L, victor + HighLevel = 2, // H, jet + All = 3, // B, both +} + +export enum RunwayDesignatorChar { + L = 1, + R = 2, + C = 3, + W = 4, // water + A = 5, + B = 6, +} + +export enum RunwayLighting { + Unknown = 0, + None = 1, + PartTime = 2, + FullTime = 3, + Frequency = 4, +} + +export enum RunwaySurface { + Concrete = 0, + Grass = 1, + WaterFsx = 2, + GrassBumpy = 3, + Asphalt = 4, + ShortGrass = 5, + LongGrass = 6, + HardTurf = 7, + Snow = 8, + Ice = 9, + Urban = 10, + Forest = 11, + Dirt = 12, + Coral = 13, + Gravel = 14, + OilTreated = 15, + SteelMats = 16, + Bituminous = 17, + Brick = 18, + Macadam = 19, + Planks = 20, + Sand = 21, + Shale = 22, + Tarmac = 23, + WrightFlyerTrack = 24, + Ocean = 26, + Water = 27, + Pond = 28, + Lake = 29, + River = 30, + WasterWater = 31, + Paint = 32, +} + +export enum TurnDirection { + Unknown = 0, + Left = 1, + Right = 2, + Either = 3, +} + +export enum VorClass { + Unknown = 0, + Terminal = 1, // T + LowAltitude = 2, // L + HighAlttitude = 3, // H + ILS = 4, // C TODO Tacan as well according to ARINC? + VOT = 5, +} + +export enum VorType { + Unknown = 0, + VOR = 1, + VORDME = 2, + DME = 3, + TACAN = 4, + VORTAC = 5, + ILS = 6, + VOT = 7, +} diff --git a/fbw-a380x/src/systems/fmgc/src/types/fstypes/FSTypes.d.ts b/fbw-a380x/src/systems/fmgc/src/types/fstypes/FSTypes.d.ts new file mode 100644 index 00000000000..73ae165aea6 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/types/fstypes/FSTypes.d.ts @@ -0,0 +1,375 @@ +// Copyright (c) 2020-2021 Working Title, FlyByWire Simulations +// SPDX-License-Identifier: MIT + +import { WaypointConstraintType } from '@fmgc/flightplanning/FlightPlanManager'; +import { + AirportClass, + AirportPrivateType, + AirspaceType, + AltitudeDescriptor, + FixTypeFlags, + FrequencyType, + LegType, + NdbType, + RnavTypeFlags, + RouteType, + RunwayDesignatorChar, + RunwayLighting, + RunwaySurface, + TurnDirection, + VorClass, + VorType, +} from './FSEnums'; + +declare global { + class WayPoint { + constructor(_baseInstrument: BaseInstrument); + + icao: string; + + ident: string; + + endsInDiscontinuity?: boolean; + + discontinuityCanBeCleared?: boolean; + + isVectors?: boolean; + + isRunway?: boolean; + + isTurningPoint?: boolean; + + infos: WayPointInfo; + + type: string; + + bearingInFP: number; + + distanceInFP: number; + + distanceInMinutes: number; + + cumulativeDistanceInFP: number; + + waypointReachedAt: number; + + instrument: BaseInstrument; + + /** + * These are the default MS types but for some reason we changed + altDesc: number; + + altitude1: number; + + altitude2: number; + */ + + legAltitudeDescription: AltitudeDescriptor; + + legAltitude1: number; + + legAltitude2: number; + + speedConstraint: number; + + constraintType: WaypointConstraintType; + + additionalData: { [key: string]: any } + + turnDirection: TurnDirection; + + verticalAngle?: number; + + _svgElements: any; + + static formatIdentFromIcao(icao: string): string; + } + + class WayPointInfo { + constructor(_instrument: BaseInstrument); + + coordinates: LatLongAlt; + + icao: string; + + ident: string; + + airwayIn: string; + + airwayOut: string; + + routes: any[]; + + instrument: BaseInstrument; + + magneticVariation?: number; + + _svgElements: any; + + UpdateInfos(_CallBack?, loadFacilitiesTransitively?); + + CopyBaseInfosFrom(_WP: WayPoint); + } + + class AirportInfo extends WayPointInfo { + constructor(_instrument: BaseInstrument); + + frequencies: any[]; + + namedFrequencies: any[]; + + departures: any[]; + + approaches: RawApproach[]; + + arrivals: any[]; + + runways: any[]; + + oneWayRunways: OneWayRunway[]; + + UpdateNamedFrequencies(icao?: string): Promise + } + + class IntersectionInfo extends WayPointInfo { + constructor(_instrument: BaseInstrument); + } + + class VORInfo extends WayPointInfo { + constructor(_instrument: BaseInstrument); + type: VorType; + } + + class NDBInfo extends WayPointInfo { + constructor(_instrument: BaseInstrument); + } + + interface OneWayRunway { + designation: string; + designator: RunwayDesignatorChar; + direction: number; + beginningCoordinates: LatLongAlt; + endCoordinates: LatLongAlt; + elevation: number; + length: number; + number: number; + slope: number; + thresholdCoordinates: LatLongAlt; + thresholdLength: number; + thresholdElevation: number; + } + + interface RawProcedureLeg { + altDesc: AltitudeDescriptor; + // constraint 1 metres + altitude1: number; + // constraint 2 metres + altitude2: number; + // icao of arc centre... + arcCenterFixIcao: string; + // course if needed, otherwise 0 + course: number; + // length of leg in metres or minutes... + distance: number; + // distance in minutes? + distanceMinutes: boolean; + // fix this leg goes TO + fixIcao: string; + // type for approach fixes + fixTypeFlags: FixTypeFlags; + flyOver: boolean; + // reference navaid... is the ILS for final leg of ILS approaches + originIcao: string; + // distance to originIcao in metres + rho: number; + // knots + speedRestriction: number; + // heading to originIcao, megnetic unless trueDegrees is true? + theta: number; + trueDegrees: boolean; + turnDirection: TurnDirection; + type: LegType; + __Type: 'JS_Leg'; + } + + interface RawApproachTransition { + // denotes the subclass of this instance + legs: RawProcedureLeg[]; + name: string; + __Type: 'JS_ApproachTransition'; + } + + interface RawApproach { + approachSuffix: string; + approachType: ApproachType; + finalLegs: RawProcedureLeg[]; + // unknown/empty + icaos: Array; + missedLegs: RawProcedureLeg[]; + // "(VOR|ILS|LOC|RNAV|...) [0-9]{2} ([A-Z])?" + name: string; + rnavTypeFlags: RnavTypeFlags; + // 3 digits [0-9]{2}[LCR] + runway: string; + runwayDesignator: RunwayDesignatorChar; + runwayNumber: number; + transitions: RawApproachTransition[]; + __Type: 'JS_Approach'; + } + + interface RawDeparture { + // seems to be always empty... + commonLegs: RawProcedureLeg[]; + enRouteTransitions: RawEnRouteTransition[]; + name: string; + runwayTransitions: RawRunwayTransition[]; + __Type: 'JS_Departure'; + } + + interface RawArrival { + // seems to be always empty... + commonLegs: RawProcedureLeg[]; + enRouteTransitions: RawEnRouteTransition[]; + name: string; + runwayTransitions: RawRunwayTransition[]; + __Type: 'JS_Arrival'; + } + + interface RawApproachTransition { + legs: RawProcedureLeg[]; + name: string; + __Type: 'JS_ApproachTransition'; + } + + interface RawEnRouteTransition { + legs: RawProcedureLeg[]; + name: string; + __Type: 'JS_EnRouteTransition'; + } + + interface RawRunwayTransition { + legs: RawProcedureLeg[]; + runwayDesignation: RunwayDesignatorChar; + runwayNumber: number; + __Type: 'JS_RunwayTransition'; + } + + interface RawFacility { + // sometimes empty, sometimes two digit ICAO area code + region: string; + // translation string identifier + city: string; + icao: string; + // often seems to be empty + name: string; + lat: number; + lon: number; + __Type: string; + } + + interface RawVor extends RawFacility { + freqBCD16: number; + freqMHz: number; + type: VorType; + vorClass: VorClass; + weatherBroadcast: number; + // [0, 360) + magneticVariation?: number; + __Type: 'JS_FacilityVOR'; + } + + interface RawNdb extends RawFacility { + freqMHz: number; + type: NdbType; + weatherBroadcast: number; + // [0, 360) + magneticVariation?: number; + __Type: 'JS_FacilityNDB'; + } + + interface RawIntersection extends RawFacility { + nearestVorDistance: number; + nearestVorFrequencyBCD16: number; + nearestVorFrequencyMHz: number; + nearestVorICAO: string; + nearestVorMagneticRadial: number; + nearestVorTrueRadial: number; + nearestVorType: VorType; + routes: RawRoute[]; // airways + __Type: 'JS_FacilityIntersection'; + } + + interface RawRoute { + // airway name + name: string; + // end of airway + nextIcao: string; + // start of airway + prevIcao: string; + type: RouteType; + __Type: 'JS_Route'; + } + + interface RawAirport extends RawFacility { + airportClass: AirportClass; + airportPrivateType: AirportPrivateType; + airspaceType: AirspaceType; + approaches: RawApproach[]; + arrivals: RawArrival[]; + // often (always?) "Unknown" + bestApproach: string; + departures: RawDeparture[]; + // com frequencies, and sometimes navaids too... + frequencies: RawFrequency[]; + // these two seem to be unused? + fuel1: string; + fuel2: string; + // TODO enum? + radarCoverage: number; + runways: RawRunway[]; + towered: boolean; + __Type: 'JS_FacilityAirport'; + } + + interface RawFrequency { + freqBCD16: number; + freqMHz: number; + name: string; + type: FrequencyType; + __Type: 'JS_Frequency'; + } + + interface RawRunway { + // denotes the subclass of this instance + __Type: string; + // [0-9]{2}[LCR](\-[0-9]{2}[LCR])? + designation: string; + designatorCharPrimary: RunwayDesignatorChar; + designatorCharSecondary: RunwayDesignatorChar; + // degrees + direction: number; + // metres + elevation: number; + latitude: number; + // metres + length: number; + lighting: RunwayLighting; + longitude: number; + // these don't seem to be filled on almost all runways, but a curious few are... + primaryILSFrequency: RawFrequency; + secondaryILSFrequency: RawFrequency; + surface: RunwaySurface; + // metres + width: number; + } + + interface NearestSearch { + __Type: 'JS_NearestSearch', + sessionId: number, + searchId: number, + added: string[], + removed: string[], + } + + function RegisterViewListener(handler: string): ViewListener.ViewListener +} diff --git a/fbw-a380x/src/systems/fmgc/src/types/fstypes/main.d.ts b/fbw-a380x/src/systems/fmgc/src/types/fstypes/main.d.ts new file mode 100644 index 00000000000..ced182d0d69 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/types/fstypes/main.d.ts @@ -0,0 +1,25 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +declare const LZUTF8; diff --git a/fbw-a380x/src/systems/fmgc/src/utils/Geo.ts b/fbw-a380x/src/systems/fmgc/src/utils/Geo.ts new file mode 100644 index 00000000000..340c4e79fc4 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/utils/Geo.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2021-2022 FlyByWire Simulations +// Copyright (c) 2021-2022 Synaptic Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { computeDestinationPoint as geolibDestPoint } from 'geolib'; +import { Coordinates } from '@fmgc/flightplanning/data/geo'; +import { MathUtils } from '@shared/MathUtils'; +import { Leg } from '@fmgc/guidance/lnav/legs/Leg'; +import { + bearingTo, + distanceTo, + placeBearingDistance, + smallCircleGreatCircleIntersection, + placeBearingIntersection, +} from 'msfs-geo'; +import { AFLeg } from '@fmgc/guidance/lnav/legs/AF'; +import { TFLeg } from '@fmgc/guidance/lnav/legs/TF'; + +const sin = (input: Degrees) => Math.sin(input * (Math.PI / 180)); + +const asin = (input: Degrees) => Math.asin(input) * (180 / Math.PI); + +export class Geo { + static computeDestinationPoint(start: Coordinates, distance: NauticalMiles, bearing: DegreesTrue, radius: Metres = 6371000): Coordinates { + // FIXME rm -f geolib ? + const a = geolibDestPoint({ ...start, lon: start.long }, distance * 1852, bearing, radius); + return { + lat: a.latitude, + long: a.longitude, + }; + } + + static distanceToLeg(from: Coordinates, leg: Leg): NauticalMiles { + const intersections1 = placeBearingIntersection( + from, + MathUtils.clampAngle(leg.outboundCourse - 90), + leg.getPathEndPoint(), + MathUtils.clampAngle(leg.outboundCourse - 180), + ); + + const d1 = distanceTo(from, intersections1[0]); + const d2 = distanceTo(from, intersections1[1]); + + let legStartReference; + + if (leg instanceof TFLeg) { + legStartReference = leg.from.location; + } else { + legStartReference = leg.getPathStartPoint(); + } + + // We might call this on legs that do not have a defined start point yet, as it depends on their inbound transition, which is what is passing + // them in to this function. + // In that case, do not consider the second intersection set. + if (!legStartReference) { + return Math.min(d1, d2); + } + + const intersections2 = placeBearingIntersection( + from, + MathUtils.clampAngle(leg.outboundCourse - 90), + legStartReference, + MathUtils.clampAngle(leg.outboundCourse - 180), + ); + + const d3 = distanceTo(from, intersections2[0]); + const d4 = distanceTo(from, intersections2[1]); + + return Math.min(d1, d2, d3, d4); + } + + static legIntercept(from: Coordinates, bearing: DegreesTrue, leg: Leg): Coordinates { + if (leg instanceof AFLeg) { + const intersections = smallCircleGreatCircleIntersection( + leg.centre, + leg.radius, + from, + bearing, + ); + + const d1 = distanceTo(from, intersections[0]); + const d2 = distanceTo(from, intersections[1]); + + return d1 > d2 ? intersections[1] : intersections[0]; + } + + if (leg.getPathEndPoint() === undefined || leg.outboundCourse === undefined) { + throw new Error('[FMS/LNAV] Cannot compute leg intercept if leg end point or outbound course are undefined'); + } + + const intersections1 = placeBearingIntersection( + from, + MathUtils.clampAngle(bearing), + 'fix' in leg ? leg.fix.location : leg.getPathEndPoint(), + MathUtils.clampAngle(leg.outboundCourse - 180), + ); + + const d1 = distanceTo(from, intersections1[0]); + const d2 = distanceTo(from, intersections1[1]); + + // We might call this on legs that do not have a defined start point yet, as it depends on their inbound transition, which is what is passing + // them in to this function. + // In that case, do not consider the second intersection set. + if (!leg.getPathStartPoint()) { + return d1 > d2 ? intersections1[1] : intersections1[0]; + } + + const intersections2 = placeBearingIntersection( + from, + MathUtils.clampAngle(bearing), + leg.getPathStartPoint(), + MathUtils.clampAngle(leg.outboundCourse - 180), + ); + + const d3 = distanceTo(from, intersections2[0]); + const d4 = distanceTo(from, intersections2[1]); + + const smallest = Math.min(d1, d2, d3, d4); + + if (smallest === d1) { + return intersections1[0]; + } + + if (smallest === d2) { + return intersections1[1]; + } + + if (smallest === d3) { + return intersections2[0]; + } + + return intersections2[1]; + } + + static placeBearingPlaceDistanceIntercept(bearingPoint: Coordinates, distancePoint: Coordinates, bearing: DegreesTrue, distance: NauticalMiles): Coordinates { + const relativeBearing = bearingTo(bearingPoint, distancePoint); + const distanceBetween = distanceTo(bearingPoint, distancePoint); + const angleA = Math.abs(MathUtils.diffAngle(relativeBearing, bearing)); + const angleC = angleA > 90 ? asin(distanceBetween * (sin(angleA) / distance)) : 180 - asin(distanceBetween * (sin(angleA) / distance)); + const angleB = 180 - angleA - angleC; + return placeBearingDistance(bearingPoint, bearing, Math.abs(sin(angleB) * (distance / sin(angleA)))); + } + + static doublePlaceBearingIntercept(pointA: Coordinates, pointB: Coordinates, bearingA: DegreesTrue, bearingB: DegreesTrue): Coordinates { + return A32NX_Util.greatCircleIntersection(pointA, bearingA, pointB, bearingB); + } +} diff --git a/fbw-a380x/src/systems/fmgc/src/utils/LzUtf8.js b/fbw-a380x/src/systems/fmgc/src/utils/LzUtf8.js new file mode 100644 index 00000000000..b5130ac2f63 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/utils/LzUtf8.js @@ -0,0 +1,579 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +let IE10SubarrayBugPatcher; let LZUTF8; !(function (r) { + r.runningInNodeJS = function () { + return typeof process === 'object' && typeof process.versions === 'object' && typeof process.versions.node === 'string'; + }, r.runningInMainNodeJSModule = function () { + return r.runningInNodeJS() && require.main === module; + }, r.commonJSAvailable = function () { + return typeof module === 'object' && typeof module.exports === 'object'; + }, r.runningInWebWorker = function () { + return typeof window === 'undefined' && typeof self === 'object' && typeof self.addEventListener === 'function' && typeof self.close === 'function'; + }, r.runningInNodeChildProcess = function () { + return r.runningInNodeJS() && typeof process.send === 'function'; + }, r.runningInNullOrigin = function () { + return typeof window === 'object' && typeof window.location === 'object' && (document.location.protocol !== 'http:' && document.location.protocol !== 'https:'); + }, r.webWorkersAvailable = function () { + return typeof Worker === 'function' && !r.runningInNullOrigin() && (!r.runningInNodeJS() && !(navigator && navigator.userAgent && navigator.userAgent.indexOf('Android 4.3') >= 0)); + }, r.log = function (e, t) { + void 0 === t && (t = !1), typeof console === 'object' && (console.log(e), t && typeof document === 'object' && (document.body.innerHTML += `${e}
`)); + }, r.createErrorMessage = function (e, t) { + if (void 0 === t && (t = 'Unhandled exception'), e == null) { + return t; + } if (t += ': ', typeof e.content !== 'object') { + return typeof e.content === 'string' ? t + e.content : t + e; + } if (r.runningInNodeJS()) { + return t + e.content.stack; + } const n = JSON.stringify(e.content); return n !== '{}' ? t + n : t + e.content; + }, r.printExceptionAndStackTraceToConsole = function (e, t) { + void 0 === t && (t = 'Unhandled exception'), r.log(r.createErrorMessage(e, t)); + }, r.getGlobalObject = function () { + return typeof global === 'object' ? global : typeof window === 'object' ? window : typeof self === 'object' ? self : {}; + }, r.toString = Object.prototype.toString, r.commonJSAvailable() && (module.exports = r); +}(LZUTF8 = LZUTF8 || {})), (function () { + if (typeof Uint8Array === 'function' && new Uint8Array(1).subarray(1).byteLength !== 0) { + function e(e, t) { + function n(e, t, n) { + return e < t ? t : n < e ? n : e; + }e |= 0, t |= 0, arguments.length < 1 && (e = 0), arguments.length < 2 && (t = this.length), e < 0 && (e = this.length + e), t < 0 && (t = this.length + t), e = n(e, 0, this.length); let r = (t = n(t, 0, this.length)) - e; return r < 0 && (r = 0), new this.constructor(this.buffer, this.byteOffset + e * this.BYTES_PER_ELEMENT, r); + } const t = ['Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array']; let n = void 0; if (typeof window === 'object' ? n = window : typeof self === 'object' && (n = self), void 0 !== n) { + for (let r = 0; r < t.length; r++) { + n[t[r]] && (n[t[r]].prototype.subarray = e); + } + } + } +}(IE10SubarrayBugPatcher = IE10SubarrayBugPatcher || {})), (function (i) { + let e; let u; (u = e = i.WebWorker || (i.WebWorker = {})).compressAsync = function (e, t, n) { + let r; let o; t.inputEncoding != 'ByteArray' || e instanceof Uint8Array ? (r = { + token: Math.random().toString(), type: 'compress', data: e, inputEncoding: t.inputEncoding, outputEncoding: t.outputEncoding, + }, o = function (e) { + const t = e.data; t && t.token == r.token && (u.globalWorker.removeEventListener('message', o), t.type == 'error' ? n(void 0, new Error(t.error)) : n(t.data)); + }, u.globalWorker.addEventListener('message', o), u.globalWorker.postMessage(r, [])) : n(void 0, new TypeError('compressAsync: input is not a Uint8Array')); + }, u.decompressAsync = function (e, t, n) { + const r = { + token: Math.random().toString(), type: 'decompress', data: e, inputEncoding: t.inputEncoding, outputEncoding: t.outputEncoding, + }; var o = function (e) { + const t = e.data; t && t.token == r.token && (u.globalWorker.removeEventListener('message', o), t.type == 'error' ? n(void 0, new Error(t.error)) : n(t.data)); + }; u.globalWorker.addEventListener('message', o), u.globalWorker.postMessage(r, []); + }, u.installWebWorkerIfNeeded = function () { + typeof self === 'object' && void 0 === self.document && self.addEventListener != null && (self.addEventListener('message', (e) => { + const t = e.data; if (t.type == 'compress') { + let n = void 0; try { + n = i.compress(t.data, { outputEncoding: t.outputEncoding }); + } catch (e) { + return void self.postMessage({ token: t.token, type: 'error', error: i.createErrorMessage(e) }, []); + }(r = { + token: t.token, type: 'compressionResult', data: n, encoding: t.outputEncoding, + }).data instanceof Uint8Array && navigator.appVersion.indexOf('MSIE 10') === -1 ? self.postMessage(r, [r.data.buffer]) : self.postMessage(r, []); + } else if (t.type == 'decompress') { + var r; let o = void 0; try { + o = i.decompress(t.data, { inputEncoding: t.inputEncoding, outputEncoding: t.outputEncoding }); + } catch (e) { + return void self.postMessage({ token: t.token, type: 'error', error: i.createErrorMessage(e) }, []); + }(r = { + token: t.token, type: 'decompressionResult', data: o, encoding: t.outputEncoding, + }).data instanceof Uint8Array && navigator.appVersion.indexOf('MSIE 10') === -1 ? self.postMessage(r, [r.data.buffer]) : self.postMessage(r, []); + } + }), self.addEventListener('error', (e) => { + i.log(i.createErrorMessage(e.error, 'Unexpected LZUTF8 WebWorker exception')); + })); + }, u.createGlobalWorkerIfNeeded = function () { + return !!u.globalWorker || !!i.webWorkersAvailable() && (u.scriptURI || typeof document !== 'object' || (e = document.getElementById('lzutf8')) != null && (u.scriptURI = e.getAttribute('src') || void 0), !!u.scriptURI && (u.globalWorker = new Worker(u.scriptURI), !0)); let e; + }, u.terminate = function () { + u.globalWorker && (u.globalWorker.terminate(), u.globalWorker = void 0); + }, e.installWebWorkerIfNeeded(); +}(LZUTF8 = LZUTF8 || {})), (function (e) { + const t = (n.prototype.get = function (e) { + return this.container[this.startPosition + e]; + }, n.prototype.getInReversedOrder = function (e) { + return this.container[this.startPosition + this.length - 1 - e]; + }, n.prototype.set = function (e, t) { + this.container[this.startPosition + e] = t; + }, n); function n(e, t, n) { + this.container = e, this.startPosition = t, this.length = n; + }e.ArraySegment = t; +}(LZUTF8 = LZUTF8 || {})), (function (e) { + let t; (t = e.ArrayTools || (e.ArrayTools = {})).copyElements = function (e, t, n, r, o) { + for (;o--;) { + n[r++] = e[t++]; + } + }, t.zeroElements = function (e, t, n) { + for (;n--;) { + e[t++] = 0; + } + }, t.countNonzeroValuesInArray = function (e) { + for (var t = 0, n = 0; n < e.length; n++) { + e[n] && t++; + } return t; + }, t.truncateStartingElements = function (e, t) { + if (e.length <= t) { + throw new RangeError('truncateStartingElements: Requested length should be smaller than array length'); + } for (let n = e.length - t, r = 0; r < t; r++) { + e[r] = e[n + r]; + }e.length = t; + }, t.doubleByteArrayCapacity = function (e) { + const t = new Uint8Array(2 * e.length); return t.set(e), t; + }, t.concatUint8Arrays = function (e) { + for (var t = 0, n = 0, r = e; n < r.length; n++) { + t += (s = r[n]).length; + } for (var o = new Uint8Array(t), i = 0, u = 0, a = e; u < a.length; u++) { + var s = a[u]; o.set(s, i), i += s.length; + } return o; + }, t.splitByteArray = function (e, t) { + for (var n = [], r = 0; r < e.length;) { + const o = Math.min(t, e.length - r); n.push(e.subarray(r, r + o)), r += o; + } return n; + }; +}(LZUTF8 = LZUTF8 || {})), (function (e) { + let t; (t = e.BufferTools || (e.BufferTools = {})).convertToUint8ArrayIfNeeded = function (e) { + return typeof Buffer === 'function' && Buffer.isBuffer(e) ? t.bufferToUint8Array(e) : e; + }, t.uint8ArrayToBuffer = function (e) { + if (Buffer.prototype instanceof Uint8Array) { + const t = new Uint8Array(e.buffer, e.byteOffset, e.byteLength); return Object.setPrototypeOf(t, Buffer.prototype), t; + } for (var n = e.length, r = new Buffer(n), o = 0; o < n; o++) { + r[o] = e[o]; + } return r; + }, t.bufferToUint8Array = function (e) { + if (Buffer.prototype instanceof Uint8Array) { + return new Uint8Array(e.buffer, e.byteOffset, e.byteLength); + } for (var t = e.length, n = new Uint8Array(t), r = 0; r < t; r++) { + n[r] = e[r]; + } return n; + }; +}(LZUTF8 = LZUTF8 || {})), (function (o) { + let e; (e = o.CompressionCommon || (o.CompressionCommon = {})).getCroppedBuffer = function (e, t, n, r) { + void 0 === r && (r = 0); const o = new Uint8Array(n + r); return o.set(e.subarray(t, t + n)), o; + }, e.getCroppedAndAppendedByteArray = function (e, t, n, r) { + return o.ArrayTools.concatUint8Arrays([e.subarray(t, t + n), r]); + }, e.detectCompressionSourceEncoding = function (e) { + if (e == null) { + throw new TypeError('detectCompressionSourceEncoding: input is null or undefined'); + } if (typeof e === 'string') { + return 'String'; + } if (e instanceof Uint8Array || typeof Buffer === 'function' && Buffer.isBuffer(e)) { + return 'ByteArray'; + } throw new TypeError("detectCompressionSourceEncoding: input must be of type 'string', 'Uint8Array' or 'Buffer'"); + }, e.encodeCompressedBytes = function (e, t) { + switch (t) { + case 'ByteArray': return e; case 'Buffer': return o.BufferTools.uint8ArrayToBuffer(e); case 'Base64': return o.encodeBase64(e); case 'BinaryString': return o.encodeBinaryString(e); case 'StorageBinaryString': return o.encodeStorageBinaryString(e); default: throw new TypeError('encodeCompressedBytes: invalid output encoding requested'); + } + }, e.decodeCompressedBytes = function (e, t) { + if (t == null) { + throw new TypeError('decodeCompressedData: Input is null or undefined'); + } switch (t) { + case 'ByteArray': case 'Buffer': var n = o.BufferTools.convertToUint8ArrayIfNeeded(e); if (!(n instanceof Uint8Array)) { + throw new TypeError("decodeCompressedData: 'ByteArray' or 'Buffer' input type was specified but input is not a Uint8Array or Buffer"); + } return n; case 'Base64': if (typeof e !== 'string') { + throw new TypeError("decodeCompressedData: 'Base64' input type was specified but input is not a string"); + } return o.decodeBase64(e); case 'BinaryString': if (typeof e !== 'string') { + throw new TypeError("decodeCompressedData: 'BinaryString' input type was specified but input is not a string"); + } return o.decodeBinaryString(e); case 'StorageBinaryString': if (typeof e !== 'string') { + throw new TypeError("decodeCompressedData: 'StorageBinaryString' input type was specified but input is not a string"); + } return o.decodeStorageBinaryString(e); default: throw new TypeError(`decodeCompressedData: invalid input encoding requested: '${t}'`); + } + }, e.encodeDecompressedBytes = function (e, t) { + switch (t) { + case 'String': return o.decodeUTF8(e); case 'ByteArray': return e; case 'Buffer': if (typeof Buffer !== 'function') { + throw new TypeError("encodeDecompressedBytes: a 'Buffer' type was specified but is not supported at the current envirnment"); + } return o.BufferTools.uint8ArrayToBuffer(e); default: throw new TypeError('encodeDecompressedBytes: invalid output encoding requested'); + } + }; +}(LZUTF8 = LZUTF8 || {})), (function (o) { + let t; let e; let i; let u; e = t = o.EventLoop || (o.EventLoop = {}), u = [], e.enqueueImmediate = function (e) { + u.push(e), u.length === 1 && i(); + }, e.initializeScheduler = function () { + function t() { + for (let e = 0, t = u; e < t.length; e++) { + const n = t[e]; try { + n.call(void 0); + } catch (e) { + o.printExceptionAndStackTraceToConsole(e, 'enqueueImmediate exception'); + } + }u.length = 0; + } let n; let e; let r; o.runningInNodeJS() && (i = function () { + return setImmediate(t); + }), i = typeof window === 'object' && typeof window.addEventListener === 'function' && typeof window.postMessage === 'function' ? (n = `enqueueImmediate-${Math.random().toString()}`, window.addEventListener('message', (e) => { + e.data === n && t(); + }), e = o.runningInNullOrigin() ? '*' : window.location.href, function () { + return window.postMessage(n, e); + }) : typeof MessageChannel === 'function' && typeof MessagePort === 'function' ? ((r = new MessageChannel()).port1.onmessage = t, function () { + return r.port2.postMessage(0); + }) : function () { + return setTimeout(t, 0); + }; + }, e.initializeScheduler(), o.enqueueImmediate = function (e) { + return t.enqueueImmediate(e); + }; +}(LZUTF8 = LZUTF8 || {})), (function (e) { + let n; (n = e.ObjectTools || (e.ObjectTools = {})).override = function (e, t) { + return n.extend(e, t); + }, n.extend = function (e, t) { + if (e == null) { + throw new TypeError('obj is null or undefined'); + } if (typeof e !== 'object') { + throw new TypeError('obj is not an object'); + } if (t == null && (t = {}), typeof t !== 'object') { + throw new TypeError('newProperties is not an object'); + } if (t != null) { + for (const n in t) { + e[n] = t[n]; + } + } return e; + }; +}(LZUTF8 = LZUTF8 || {})), (function (o) { + o.getRandomIntegerInRange = function (e, t) { + return e + Math.floor(Math.random() * (t - e)); + }, o.getRandomUTF16StringOfLength = function (e) { + for (var t = '', n = 0; n < e; n++) { + for (var r = void 0; (r = o.getRandomIntegerInRange(0, 1114112)) >= 55296 && r <= 57343;) { + + }t += o.Encoding.CodePoint.decodeToString(r); + } return t; + }; +}(LZUTF8 = LZUTF8 || {})), (function (e) { + const t = (n.prototype.appendCharCode = function (e) { + this.outputBuffer[this.outputPosition++] = e, this.outputPosition === this.outputBufferCapacity && this.flushBufferToOutputString(); + }, n.prototype.appendCharCodes = function (e) { + for (let t = 0, n = e.length; t < n; t++) { + this.appendCharCode(e[t]); + } + }, n.prototype.appendString = function (e) { + for (let t = 0, n = e.length; t < n; t++) { + this.appendCharCode(e.charCodeAt(t)); + } + }, n.prototype.appendCodePoint = function (e) { + if (e <= 65535) { + this.appendCharCode(e); + } else { + if (!(e <= 1114111)) { + throw new Error(`appendCodePoint: A code point of ${e} cannot be encoded in UTF-16`); + } this.appendCharCode(55296 + (e - 65536 >>> 10)), this.appendCharCode(56320 + (e - 65536 & 1023)); + } + }, n.prototype.getOutputString = function () { + return this.flushBufferToOutputString(), this.outputString; + }, n.prototype.flushBufferToOutputString = function () { + this.outputPosition === this.outputBufferCapacity ? this.outputString += String.fromCharCode.apply(null, this.outputBuffer) : this.outputString += String.fromCharCode.apply(null, this.outputBuffer.subarray(0, this.outputPosition)), this.outputPosition = 0; + }, n); function n(e) { + void 0 === e && (e = 1024), this.outputBufferCapacity = e, this.outputPosition = 0, this.outputString = '', this.outputBuffer = new Uint16Array(this.outputBufferCapacity); + }e.StringBuilder = t; +}(LZUTF8 = LZUTF8 || {})), (function (o) { + const e = (t.prototype.restart = function () { + this.startTime = t.getTimestamp(); + }, t.prototype.getElapsedTime = function () { + return t.getTimestamp() - this.startTime; + }, t.prototype.getElapsedTimeAndRestart = function () { + const e = this.getElapsedTime(); return this.restart(), e; + }, t.prototype.logAndRestart = function (e, t) { + void 0 === t && (t = !0); const n = this.getElapsedTime(); const r = `${e}: ${n.toFixed(3)}ms`; return o.log(r, t), this.restart(), n; + }, t.getTimestamp = function () { + return this.timestampFunc || this.createGlobalTimestampFunction(), this.timestampFunc(); + }, t.getMicrosecondTimestamp = function () { + return Math.floor(1e3 * t.getTimestamp()); + }, t.createGlobalTimestampFunction = function () { + let n; let e; let t; let r; typeof process === 'object' && typeof process.hrtime === 'function' ? (n = 0, this.timestampFunc = function () { + const e = process.hrtime(); const t = 1e3 * e[0] + e[1] / 1e6; return n + t; + }, n = Date.now() - this.timestampFunc()) : typeof chrome === 'object' && chrome.Interval ? (e = Date.now(), (t = new chrome.Interval()).start(), this.timestampFunc = function () { + return e + t.microseconds() / 1e3; + }) : typeof performance === 'object' && performance.now ? (r = Date.now() - performance.now(), this.timestampFunc = function () { + return r + performance.now(); + }) : Date.now ? this.timestampFunc = function () { + return Date.now(); + } : this.timestampFunc = function () { + return (new Date()).getTime(); + }; + }, t); function t() { + this.restart(); + }o.Timer = e; +}(LZUTF8 = LZUTF8 || {})), (function (r) { + const e = (t.prototype.compressBlock = function (e) { + if (e == null) { + throw new TypeError('compressBlock: undefined or null input received'); + } return typeof e === 'string' && (e = r.encodeUTF8(e)), e = r.BufferTools.convertToUint8ArrayIfNeeded(e), this.compressUtf8Block(e); + }, t.prototype.compressUtf8Block = function (e) { + if (!e || e.length == 0) { + return new Uint8Array(0); + } const t = this.cropAndAddNewBytesToInputBuffer(e); const n = this.inputBuffer; const r = this.inputBuffer.length; this.outputBuffer = new Uint8Array(e.length); for (let o = this.outputBufferPosition = 0, i = t; i < r; i++) { + var u; var a; var s; const c = n[i]; let f = i < o; i > r - this.MinimumSequenceLength ? f || this.outputRawByte(c) : (u = this.getBucketIndexForPrefix(i), f || (a = this.findLongestMatch(i, u)) != null && (this.outputPointerBytes(a.length, a.distance), o = i + a.length, f = !0), f || this.outputRawByte(c), s = this.inputBufferStreamOffset + i, this.prefixHashTable.addValueToBucket(u, s)); + } return this.outputBuffer.subarray(0, this.outputBufferPosition); + }, t.prototype.findLongestMatch = function (e, t) { + const n = this.prefixHashTable.getArraySegmentForBucketIndex(t, this.reusableArraySegmentObject); if (n == null) { + return null; + } for (var r, o = this.inputBuffer, i = 0, u = 0; u < n.length; u++) { + const a = n.getInReversedOrder(u) - this.inputBufferStreamOffset; const s = e - a; var c = void 0; var c = void 0 === r ? this.MinimumSequenceLength - 1 : r < 128 && s >= 128 ? i + (i >>> 1) : i; if (s > this.MaximumMatchDistance || c >= this.MaximumSequenceLength || e + c >= o.length) { + break; + } if (o[a + c] === o[e + c]) { + for (let f = 0; ;f++) { + if (e + f === o.length || o[a + f] !== o[e + f]) { + c < f && (r = s, i = f); break; + } if (f === this.MaximumSequenceLength) { + return { distance: s, length: this.MaximumSequenceLength }; + } + } + } + } return void 0 !== r ? { distance: r, length: i } : null; + }, t.prototype.getBucketIndexForPrefix = function (e) { + return (7880599 * this.inputBuffer[e] + 39601 * this.inputBuffer[e + 1] + 199 * this.inputBuffer[e + 2] + this.inputBuffer[e + 3]) % this.PrefixHashTableSize; + }, t.prototype.outputPointerBytes = function (e, t) { + t < 128 ? (this.outputRawByte(192 | e), this.outputRawByte(t)) : (this.outputRawByte(224 | e), this.outputRawByte(t >>> 8), this.outputRawByte(255 & t)); + }, t.prototype.outputRawByte = function (e) { + this.outputBuffer[this.outputBufferPosition++] = e; + }, t.prototype.cropAndAddNewBytesToInputBuffer = function (e) { + if (void 0 === this.inputBuffer) { + return this.inputBuffer = e, 0; + } const t = Math.min(this.inputBuffer.length, this.MaximumMatchDistance); const n = this.inputBuffer.length - t; return this.inputBuffer = r.CompressionCommon.getCroppedAndAppendedByteArray(this.inputBuffer, n, t, e), this.inputBufferStreamOffset += n, t; + }, t); function t(e) { + void 0 === e && (e = !0), this.MinimumSequenceLength = 4, this.MaximumSequenceLength = 31, this.MaximumMatchDistance = 32767, this.PrefixHashTableSize = 65537, this.inputBufferStreamOffset = 1, e && typeof Uint32Array === 'function' ? this.prefixHashTable = new r.CompressorCustomHashTable(this.PrefixHashTableSize) : this.prefixHashTable = new r.CompressorSimpleHashTable(this.PrefixHashTableSize); + }r.Compressor = e; +}(LZUTF8 = LZUTF8 || {})), (function (a) { + const e = (t.prototype.addValueToBucket = function (e, t) { + e <<= 1, this.storageIndex >= this.storage.length >>> 1 && this.compact(); let n; let r; let o = this.bucketLocators[e]; o === 0 ? (o = this.storageIndex, n = 1, this.storage[this.storageIndex] = t, this.storageIndex += this.minimumBucketCapacity) : ((n = this.bucketLocators[e + 1]) === this.maximumBucketCapacity - 1 && (n = this.truncateBucketToNewerElements(o, n, this.maximumBucketCapacity / 2)), r = o + n, this.storage[r] === 0 ? (this.storage[r] = t, r === this.storageIndex && (this.storageIndex += n)) : (a.ArrayTools.copyElements(this.storage, o, this.storage, this.storageIndex, n), o = this.storageIndex, this.storageIndex += n, this.storage[this.storageIndex++] = t, this.storageIndex += n), n++), this.bucketLocators[e] = o, this.bucketLocators[e + 1] = n; + }, t.prototype.truncateBucketToNewerElements = function (e, t, n) { + const r = e + t - n; return a.ArrayTools.copyElements(this.storage, r, this.storage, e, n), a.ArrayTools.zeroElements(this.storage, e + n, t - n), n; + }, t.prototype.compact = function () { + const e = this.bucketLocators; const t = this.storage; this.bucketLocators = new Uint32Array(this.bucketLocators.length), this.storageIndex = 1; for (var n = 0; n < e.length; n += 2) { + const r = e[n + 1]; r !== 0 && (this.bucketLocators[n] = this.storageIndex, this.bucketLocators[n + 1] = r, this.storageIndex += Math.max(Math.min(2 * r, this.maximumBucketCapacity), this.minimumBucketCapacity)); + } for (this.storage = new Uint32Array(8 * this.storageIndex), n = 0; n < e.length; n += 2) { + var o; var i; const u = e[n]; u !== 0 && (o = this.bucketLocators[n], i = this.bucketLocators[n + 1], a.ArrayTools.copyElements(t, u, this.storage, o, i)); + } + }, t.prototype.getArraySegmentForBucketIndex = function (e, t) { + e <<= 1; const n = this.bucketLocators[e]; return n === 0 ? null : (void 0 === t && (t = new a.ArraySegment(this.storage, n, this.bucketLocators[e + 1])), t); + }, t.prototype.getUsedBucketCount = function () { + return Math.floor(a.ArrayTools.countNonzeroValuesInArray(this.bucketLocators) / 2); + }, t.prototype.getTotalElementCount = function () { + for (var e = 0, t = 0; t < this.bucketLocators.length; t += 2) { + e += this.bucketLocators[t + 1]; + } return e; + }, t); function t(e) { + this.minimumBucketCapacity = 4, this.maximumBucketCapacity = 64, this.bucketLocators = new Uint32Array(2 * e), this.storage = new Uint32Array(2 * e), this.storageIndex = 1; + }a.CompressorCustomHashTable = e; +}(LZUTF8 = LZUTF8 || {})), (function (r) { + const e = (t.prototype.addValueToBucket = function (e, t) { + const n = this.buckets[e]; void 0 === n ? this.buckets[e] = [t] : (n.length === this.maximumBucketCapacity - 1 && r.ArrayTools.truncateStartingElements(n, this.maximumBucketCapacity / 2), n.push(t)); + }, t.prototype.getArraySegmentForBucketIndex = function (e, t) { + const n = this.buckets[e]; return void 0 === n ? null : (void 0 === t && (t = new r.ArraySegment(n, 0, n.length)), t); + }, t.prototype.getUsedBucketCount = function () { + return r.ArrayTools.countNonzeroValuesInArray(this.buckets); + }, t.prototype.getTotalElementCount = function () { + for (var e = 0, t = 0; t < this.buckets.length; t++) { + void 0 !== this.buckets[t] && (e += this.buckets[t].length); + } return e; + }, t); function t(e) { + this.maximumBucketCapacity = 64, this.buckets = new Array(e); + }r.CompressorSimpleHashTable = e; +}(LZUTF8 = LZUTF8 || {})), (function (f) { + const e = (t.prototype.decompressBlockToString = function (e) { + return e = f.BufferTools.convertToUint8ArrayIfNeeded(e), f.decodeUTF8(this.decompressBlock(e)); + }, t.prototype.decompressBlock = function (e) { + this.inputBufferRemainder && (e = f.ArrayTools.concatUint8Arrays([this.inputBufferRemainder, e]), this.inputBufferRemainder = void 0); for (var t = this.cropOutputBufferToWindowAndInitialize(Math.max(4 * e.length, 1024)), n = 0, r = e.length; n < r; n++) { + const o = e[n]; if (o >>> 6 == 3) { + const i = o >>> 5; if (n == r - 1 || n == r - 2 && i == 7) { + this.inputBufferRemainder = e.subarray(n); break; + } if (e[n + 1] >>> 7 == 1) { + this.outputByte(o); + } else { + const u = 31 & o; let a = void 0; i == 6 ? (a = e[n + 1], n += 1) : (a = e[n + 1] << 8 | e[n + 2], n += 2); for (let s = this.outputPosition - a, c = 0; c < u; c++) { + this.outputByte(this.outputBuffer[s + c]); + } + } + } else { + this.outputByte(o); + } + } return this.rollBackIfOutputBufferEndsWithATruncatedMultibyteSequence(), f.CompressionCommon.getCroppedBuffer(this.outputBuffer, t, this.outputPosition - t); + }, t.prototype.outputByte = function (e) { + this.outputPosition === this.outputBuffer.length && (this.outputBuffer = f.ArrayTools.doubleByteArrayCapacity(this.outputBuffer)), this.outputBuffer[this.outputPosition++] = e; + }, t.prototype.cropOutputBufferToWindowAndInitialize = function (e) { + if (!this.outputBuffer) { + return this.outputBuffer = new Uint8Array(e), 0; + } const t = Math.min(this.outputPosition, this.MaximumMatchDistance); if (this.outputBuffer = f.CompressionCommon.getCroppedBuffer(this.outputBuffer, this.outputPosition - t, t, e), this.outputPosition = t, this.outputBufferRemainder) { + for (let n = 0; n < this.outputBufferRemainder.length; n++) { + this.outputByte(this.outputBufferRemainder[n]); + } this.outputBufferRemainder = void 0; + } return t; + }, t.prototype.rollBackIfOutputBufferEndsWithATruncatedMultibyteSequence = function () { + for (let e = 1; e <= 4 && this.outputPosition - e >= 0; e++) { + const t = this.outputBuffer[this.outputPosition - e]; if (e < 4 && t >>> 3 == 30 || e < 3 && t >>> 4 == 14 || e < 2 && t >>> 5 == 6) { + return this.outputBufferRemainder = this.outputBuffer.subarray(this.outputPosition - e, this.outputPosition), void (this.outputPosition -= e); + } + } + }, t); function t() { + this.MaximumMatchDistance = 32767, this.outputPosition = 0; + }f.Decompressor = e; +}(LZUTF8 = LZUTF8 || {})), (function (a) { + let e; let t; let s; let c; e = a.Encoding || (a.Encoding = {}), t = e.Base64 || (e.Base64 = {}), s = new Uint8Array([65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 43, 47]), c = new Uint8Array([255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 62, 255, 255, 255, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 255, 255, 255, 0, 255, 255, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 255, 255, 255, 255]), t.encode = function (e) { + return e && e.length != 0 ? a.runningInNodeJS() ? a.BufferTools.uint8ArrayToBuffer(e).toString('base64') : t.encodeWithJS(e) : ''; + }, t.decode = function (e) { + return e ? a.runningInNodeJS() ? a.BufferTools.bufferToUint8Array(new Buffer(e, 'base64')) : t.decodeWithJS(e) : new Uint8Array(0); + }, t.encodeWithJS = function (e, t) { + if (void 0 === t && (t = !0), !e || e.length == 0) { + return ''; + } for (var n, r = s, o = new a.StringBuilder(), i = 0, u = e.length; i < u; i += 3) { + i <= u - 3 ? (n = e[i] << 16 | e[i + 1] << 8 | e[i + 2], o.appendCharCode(r[n >>> 18 & 63]), o.appendCharCode(r[n >>> 12 & 63]), o.appendCharCode(r[n >>> 6 & 63]), o.appendCharCode(r[63 & n]), n = 0) : i === u - 2 ? (n = e[i] << 16 | e[i + 1] << 8, o.appendCharCode(r[n >>> 18 & 63]), o.appendCharCode(r[n >>> 12 & 63]), o.appendCharCode(r[n >>> 6 & 63]), t && o.appendCharCode(61)) : i === u - 1 && (n = e[i] << 16, o.appendCharCode(r[n >>> 18 & 63]), o.appendCharCode(r[n >>> 12 & 63]), t && (o.appendCharCode(61), o.appendCharCode(61))); + } return o.getOutputString(); + }, t.decodeWithJS = function (e, t) { + if (!e || e.length == 0) { + return new Uint8Array(0); + } const n = e.length % 4; if (n == 1) { + throw new Error('Invalid Base64 string: length % 4 == 1'); + } n == 2 ? e += '==' : n == 3 && (e += '='), t = t || new Uint8Array(e.length); for (var r = 0, o = e.length, i = 0; i < o; i += 4) { + const u = c[e.charCodeAt(i)] << 18 | c[e.charCodeAt(i + 1)] << 12 | c[e.charCodeAt(i + 2)] << 6 | c[e.charCodeAt(i + 3)]; t[r++] = u >>> 16 & 255, t[r++] = u >>> 8 & 255, t[r++] = 255 & u; + } return e.charCodeAt(o - 1) == 61 && r--, e.charCodeAt(o - 2) == 61 && r--, t.subarray(0, r); + }; +}(LZUTF8 = LZUTF8 || {})), (function (a) { + let e; let t; e = a.Encoding || (a.Encoding = {}), (t = e.BinaryString || (e.BinaryString = {})).encode = function (e) { + if (e == null) { + throw new TypeError('BinaryString.encode: undefined or null input received'); + } if (e.length === 0) { + return ''; + } for (var t = e.length, n = new a.StringBuilder(), r = 0, o = 1, i = 0; i < t; i += 2) { + var u = void 0; var u = i == t - 1 ? e[i] << 8 : e[i] << 8 | e[i + 1]; n.appendCharCode(r << 16 - o | u >>> o), r = u & (1 << o) - 1, o === 15 ? (n.appendCharCode(r), r = 0, o = 1) : o += 1, t - 2 <= i && n.appendCharCode(r << 16 - o); + } return n.appendCharCode(32768 | t % 2), n.getOutputString(); + }, t.decode = function (e) { + if (typeof e !== 'string') { + throw new TypeError('BinaryString.decode: invalid input type'); + } if (e == '') { + return new Uint8Array(0); + } for (var t, n = new Uint8Array(3 * e.length), r = 0, o = 0, i = 0, u = 0; u < e.length; u++) { + const a = e.charCodeAt(u); a >= 32768 ? (a == 32769 && r--, i = 0) : (o = i == 0 ? a : (t = o << i | a >>> 15 - i, n[r++] = t >>> 8, n[r++] = 255 & t, a & (1 << 15 - i) - 1), i == 15 ? i = 0 : i += 1); + } return n.subarray(0, r); + }; +}(LZUTF8 = LZUTF8 || {})), (function (e) { + let t; let n; t = e.Encoding || (e.Encoding = {}), (n = t.CodePoint || (t.CodePoint = {})).encodeFromString = function (e, t) { + const n = e.charCodeAt(t); if (n < 55296 || n > 56319) { + return n; + } const r = e.charCodeAt(t + 1); if (r >= 56320 && r <= 57343) { + return r - 56320 + (n - 55296 << 10) + 65536; + } throw new Error(`getUnicodeCodePoint: Received a lead surrogate character, char code ${n}, followed by ${r}, which is not a trailing surrogate character code.`); + }, n.decodeToString = function (e) { + if (e <= 65535) { + return String.fromCharCode(e); + } if (e <= 1114111) { + return String.fromCharCode(55296 + (e - 65536 >>> 10), 56320 + (e - 65536 & 1023)); + } throw new Error(`getStringFromUnicodeCodePoint: A code point of ${e} cannot be encoded in UTF-16`); + }; +}(LZUTF8 = LZUTF8 || {})), (function (e) { + let t; let n; let r; t = e.Encoding || (e.Encoding = {}), n = t.DecimalString || (t.DecimalString = {}), r = ['000', '001', '002', '003', '004', '005', '006', '007', '008', '009', '010', '011', '012', '013', '014', '015', '016', '017', '018', '019', '020', '021', '022', '023', '024', '025', '026', '027', '028', '029', '030', '031', '032', '033', '034', '035', '036', '037', '038', '039', '040', '041', '042', '043', '044', '045', '046', '047', '048', '049', '050', '051', '052', '053', '054', '055', '056', '057', '058', '059', '060', '061', '062', '063', '064', '065', '066', '067', '068', '069', '070', '071', '072', '073', '074', '075', '076', '077', '078', '079', '080', '081', '082', '083', '084', '085', '086', '087', '088', '089', '090', '091', '092', '093', '094', '095', '096', '097', '098', '099', '100', '101', '102', '103', '104', '105', '106', '107', '108', '109', '110', '111', '112', '113', '114', '115', '116', '117', '118', '119', '120', '121', '122', '123', '124', '125', '126', '127', '128', '129', '130', '131', '132', '133', '134', '135', '136', '137', '138', '139', '140', '141', '142', '143', '144', '145', '146', '147', '148', '149', '150', '151', '152', '153', '154', '155', '156', '157', '158', '159', '160', '161', '162', '163', '164', '165', '166', '167', '168', '169', '170', '171', '172', '173', '174', '175', '176', '177', '178', '179', '180', '181', '182', '183', '184', '185', '186', '187', '188', '189', '190', '191', '192', '193', '194', '195', '196', '197', '198', '199', '200', '201', '202', '203', '204', '205', '206', '207', '208', '209', '210', '211', '212', '213', '214', '215', '216', '217', '218', '219', '220', '221', '222', '223', '224', '225', '226', '227', '228', '229', '230', '231', '232', '233', '234', '235', '236', '237', '238', '239', '240', '241', '242', '243', '244', '245', '246', '247', '248', '249', '250', '251', '252', '253', '254', '255'], n.encode = function (e) { + for (var t = [], n = 0; n < e.length; n++) { + t.push(r[e[n]]); + } return t.join(' '); + }; +}(LZUTF8 = LZUTF8 || {})), (function (e) { + let t; let n; t = e.Encoding || (e.Encoding = {}), (n = t.StorageBinaryString || (t.StorageBinaryString = {})).encode = function (e) { + return t.BinaryString.encode(e).replace(/\0/g, '耂'); + }, n.decode = function (e) { + return t.BinaryString.decode(e.replace(/\u8002/g, '\0')); + }; +}(LZUTF8 = LZUTF8 || {})), (function (s) { + let i; let t; let n; let r; i = s.Encoding || (s.Encoding = {}), (t = i.UTF8 || (i.UTF8 = {})).encode = function (e) { + return e && e.length != 0 ? s.runningInNodeJS() ? s.BufferTools.bufferToUint8Array(new Buffer(e, 'utf8')) : t.createNativeTextEncoderAndDecoderIfAvailable() ? n.encode(e) : t.encodeWithJS(e) : new Uint8Array(0); + }, t.decode = function (e) { + return e && e.length != 0 ? s.runningInNodeJS() ? s.BufferTools.uint8ArrayToBuffer(e).toString('utf8') : t.createNativeTextEncoderAndDecoderIfAvailable() ? r.decode(e) : t.decodeWithJS(e) : ''; + }, t.encodeWithJS = function (e, t) { + if (!e || e.length == 0) { + return new Uint8Array(0); + } t = t || new Uint8Array(4 * e.length); for (var n = 0, r = 0; r < e.length; r++) { + const o = i.CodePoint.encodeFromString(e, r); if (o <= 127) { + t[n++] = o; + } else if (o <= 2047) { + t[n++] = 192 | o >>> 6, t[n++] = 128 | 63 & o; + } else if (o <= 65535) { + t[n++] = 224 | o >>> 12, t[n++] = 128 | o >>> 6 & 63, t[n++] = 128 | 63 & o; + } else { + if (!(o <= 1114111)) { + throw new Error('Invalid UTF-16 string: Encountered a character unsupported by UTF-8/16 (RFC 3629)'); + } t[n++] = 240 | o >>> 18, t[n++] = 128 | o >>> 12 & 63, t[n++] = 128 | o >>> 6 & 63, t[n++] = 128 | 63 & o, r++; + } + } return t.subarray(0, n); + }, t.decodeWithJS = function (e, t, n) { + if (void 0 === t && (t = 0), !e || e.length == 0) { + return ''; + } void 0 === n && (n = e.length); for (var r, o, i = new s.StringBuilder(), u = t, a = n; u < a;) { + if ((o = e[u]) >>> 7 == 0) { + r = o, u += 1; + } else if (o >>> 5 == 6) { + if (n <= u + 1) { + throw new Error(`Invalid UTF-8 stream: Truncated codepoint sequence encountered at position ${u}`); + } r = (31 & o) << 6 | 63 & e[u + 1], u += 2; + } else if (o >>> 4 == 14) { + if (n <= u + 2) { + throw new Error(`Invalid UTF-8 stream: Truncated codepoint sequence encountered at position ${u}`); + } r = (15 & o) << 12 | (63 & e[u + 1]) << 6 | 63 & e[u + 2], u += 3; + } else { + if (o >>> 3 != 30) { + throw new Error(`Invalid UTF-8 stream: An invalid lead byte value encountered at position ${u}`); + } if (n <= u + 3) { + throw new Error(`Invalid UTF-8 stream: Truncated codepoint sequence encountered at position ${u}`); + } r = (7 & o) << 18 | (63 & e[u + 1]) << 12 | (63 & e[u + 2]) << 6 | 63 & e[u + 3], u += 4; + }i.appendCodePoint(r); + } return i.getOutputString(); + }, t.createNativeTextEncoderAndDecoderIfAvailable = function () { + return !!n || typeof TextEncoder === 'function' && (n = new TextEncoder('utf-8'), r = new TextDecoder('utf-8'), !0); + }; +}(LZUTF8 = LZUTF8 || {})), (function (o) { + o.compress = function (e, t) { + if (void 0 === t && (t = {}), e == null) { + throw new TypeError('compress: undefined or null input received'); + } const n = o.CompressionCommon.detectCompressionSourceEncoding(e); t = o.ObjectTools.override({ inputEncoding: n, outputEncoding: 'ByteArray' }, t); const r = (new o.Compressor()).compressBlock(e); return o.CompressionCommon.encodeCompressedBytes(r, t.outputEncoding); + }, o.decompress = function (e, t) { + if (void 0 === t && (t = {}), e == null) { + throw new TypeError('decompress: undefined or null input received'); + } t = o.ObjectTools.override({ inputEncoding: 'ByteArray', outputEncoding: 'String' }, t); const n = o.CompressionCommon.decodeCompressedBytes(e, t.inputEncoding); const r = (new o.Decompressor()).decompressBlock(n); return o.CompressionCommon.encodeDecompressedBytes(r, t.outputEncoding); + }, o.compressAsync = function (e, t, n) { + let r; n == null && (n = function () {}); try { + r = o.CompressionCommon.detectCompressionSourceEncoding(e); + } catch (e) { + return void n(void 0, e); + }t = o.ObjectTools.override({ + inputEncoding: r, outputEncoding: 'ByteArray', useWebWorker: !0, blockSize: 65536, + }, t), o.enqueueImmediate(() => { + t.useWebWorker && o.WebWorker.createGlobalWorkerIfNeeded() ? o.WebWorker.compressAsync(e, t, n) : o.AsyncCompressor.compressAsync(e, t, n); + }); + }, o.decompressAsync = function (e, t, n) { + let r; n == null && (n = function () {}), e != null ? (t = o.ObjectTools.override({ + inputEncoding: 'ByteArray', outputEncoding: 'String', useWebWorker: !0, blockSize: 65536, + }, t), r = o.BufferTools.convertToUint8ArrayIfNeeded(e), o.EventLoop.enqueueImmediate(() => { + t.useWebWorker && o.WebWorker.createGlobalWorkerIfNeeded() ? o.WebWorker.decompressAsync(r, t, n) : o.AsyncDecompressor.decompressAsync(e, t, n); + })) : n(void 0, new TypeError('decompressAsync: undefined or null input received')); + }, o.createCompressionStream = function () { + return o.AsyncCompressor.createCompressionStream(); + }, o.createDecompressionStream = function () { + return o.AsyncDecompressor.createDecompressionStream(); + }, o.encodeUTF8 = function (e) { + return o.Encoding.UTF8.encode(e); + }, o.decodeUTF8 = function (e) { + return o.Encoding.UTF8.decode(e); + }, o.encodeBase64 = function (e) { + return o.Encoding.Base64.encode(e); + }, o.decodeBase64 = function (e) { + return o.Encoding.Base64.decode(e); + }, o.encodeBinaryString = function (e) { + return o.Encoding.BinaryString.encode(e); + }, o.decodeBinaryString = function (e) { + return o.Encoding.BinaryString.decode(e); + }, o.encodeStorageBinaryString = function (e) { + return o.Encoding.StorageBinaryString.encode(e); + }, o.decodeStorageBinaryString = function (e) { + return o.Encoding.StorageBinaryString.decode(e); + }; +}(LZUTF8 = LZUTF8 || {})); diff --git a/fbw-a380x/src/systems/fmgc/src/wtsdk.ts b/fbw-a380x/src/systems/fmgc/src/wtsdk.ts new file mode 100644 index 00000000000..76b8f55b300 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/src/wtsdk.ts @@ -0,0 +1,36 @@ +/* + * MIT License + * + * Copyright (c) 2020-2021 Working Title, FlyByWire Simulations + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DirectTo } from './flightplanning/DirectTo'; +import { FlightPlanManager } from './flightplanning/FlightPlanManager'; +import { FlightPlanSegment, SegmentType } from './flightplanning/FlightPlanSegment'; +import { getSegmentedFlightPlan } from './flightplanning/SegmentedFlightPlan'; +import { GPS } from './flightplanning/GPS'; +import { LegsProcedure } from './flightplanning/LegsProcedure'; +import { ManagedFlightPlan } from './flightplanning/ManagedFlightPlan'; +import { ProcedureDetails } from './flightplanning/ProcedureDetails'; +import { RawDataMapper } from './flightplanning/RawDataMapper'; +import { FlightPlanAsoboSync } from './flightplanning/FlightPlanAsoboSync'; + +export { DirectTo, FlightPlanAsoboSync, FlightPlanManager, FlightPlanSegment, SegmentType, getSegmentedFlightPlan, GPS, LegsProcedure, ManagedFlightPlan, ProcedureDetails, RawDataMapper }; diff --git a/fbw-a380x/src/systems/fmgc/tsconfig.json b/fbw-a380x/src/systems/fmgc/tsconfig.json new file mode 100644 index 00000000000..55303170d54 --- /dev/null +++ b/fbw-a380x/src/systems/fmgc/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "moduleResolution": "node", + "target": "ESNext", + "noEmit": true, + "typeRoots": ["../../typings"] + }, + "include": [ + "src/**/*", + "../../typings/**/*.d.ts", + "../../node_modules/@types/jest/index.d.ts" + ] +} \ No newline at end of file diff --git a/fbw-a380x/src/systems/instruments/README.md b/fbw-a380x/src/systems/instruments/README.md new file mode 100644 index 00000000000..bab906e522a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/README.md @@ -0,0 +1,27 @@ +# Instruments + +To create a new instrument, create a folder in `/src/instruments/src`, with a `config.json` file, for example: + +```json +{ + "index": "./index.jsx", + "isInteractive": false, +} +``` + +- `index` - Name of the main file in the instrument, in this example `index.jsx`. +- `isInteractive` - `true` if this instrument should intercept click events and such. + +Once you have your index file, you can import the render element target and start doing stuff with it: + +```jsx +import { renderTarget } from '../util.mjs'; + +// modify it manually... +renderTarget.style.backgroundColor = 'red'; + +// or use a rendering library... +ReactDOM.render(, renderTarget); + +// or something else! +``` diff --git a/fbw-a380x/src/systems/instruments/buildSrc/aceBuild.mjs b/fbw-a380x/src/systems/instruments/buildSrc/aceBuild.mjs new file mode 100644 index 00000000000..2c14dca9159 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/buildSrc/aceBuild.mjs @@ -0,0 +1,35 @@ +import fs from 'fs'; +import { join } from 'path'; +import { baseCompile } from './plugins.mjs'; +import { Directories } from './directories.mjs'; + +process.chdir(Directories.src); + +function getInputs() { + const baseInstruments = fs.readdirSync(join(Directories.instruments, 'src'), { withFileTypes: true }) + .filter((d) => d.isDirectory() && fs.existsSync(join(Directories.instruments, 'src', d.name, 'config.json'))); + + return [ + ...baseInstruments.map(({ name }) => ({ + path: name, + name, + })), + ]; +} + +export default getInputs() + .map(({ path }) => { + const config = JSON.parse(fs.readFileSync(join(Directories.instruments, 'src', path, 'config.json')).toString()); + + return { + watch: true, + input: join(Directories.instruments, 'src', path, config.index), + output: { + file: join(Directories.instrumentsAceOutput, path, 'bundle.js'), + format: 'iife', + }, + plugins: [ + ...baseCompile(path, path), + ], + }; + }); diff --git a/fbw-a380x/src/systems/instruments/buildSrc/directories.mjs b/fbw-a380x/src/systems/instruments/buildSrc/directories.mjs new file mode 100644 index 00000000000..13f89432902 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/buildSrc/directories.mjs @@ -0,0 +1,15 @@ +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const directoryName = path.dirname(fileURLToPath(import.meta.url)); + +export const Directories = { + temp: path.join(os.tmpdir(), 'instruments-build'), + instruments: path.join(directoryName, '/..'), + instrumentsAceOutput: path.join(directoryName, '/../aceBundles'), + src: path.join(directoryName, '../..'), + root: path.join(directoryName, '../../../../..'), +}; + +console.log('Using Directories:', Directories); diff --git a/fbw-a380x/src/systems/instruments/buildSrc/igniter/tasks.mjs b/fbw-a380x/src/systems/instruments/buildSrc/igniter/tasks.mjs new file mode 100644 index 00000000000..edf671ff0d8 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/buildSrc/igniter/tasks.mjs @@ -0,0 +1,19 @@ +import fs from 'fs'; +import { join } from 'path'; +import { ExecTask } from '@flybywiresim/igniter'; +import { Directories } from '../directories.mjs'; + +export function getInstrumentsIgniterTasks() { + const baseInstruments = fs.readdirSync(join(Directories.instruments, 'src'), { withFileTypes: true }) + .filter((d) => d.isDirectory() && fs.existsSync(join(Directories.instruments, 'src', d.name, 'config.json'))); + + return baseInstruments.map(({ name }) => new ExecTask( + name, + `cd fbw-a380x && mach build -f ${name}`, + [ + join('fbw-a380x/src/systems/instruments/src', name), + 'fbw-a380x/src/systems/instruments/src/Common', + join('fbw-a380x/out/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/A380X', name), + ], + )); +} diff --git a/fbw-a380x/src/systems/instruments/buildSrc/plugins.mjs b/fbw-a380x/src/systems/instruments/buildSrc/plugins.mjs new file mode 100644 index 00000000000..3d57c9680cb --- /dev/null +++ b/fbw-a380x/src/systems/instruments/buildSrc/plugins.mjs @@ -0,0 +1,85 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import fs from 'fs'; +import { join } from 'path'; +import image from '@rollup/plugin-image'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import { babel as babelPlugin } from '@rollup/plugin-babel'; +import { typescriptPaths } from 'rollup-plugin-typescript-paths'; +import replace from '@rollup/plugin-replace'; +import postcss from 'rollup-plugin-postcss'; +import tailwindcss from 'tailwindcss'; +import dotenv from 'dotenv'; +import json from '@rollup/plugin-json'; +import postcssColorFunctionalNotation from 'postcss-color-functional-notation'; +import { Directories } from './directories.mjs'; + +const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; + +dotenv.config(); + +function babel() { + return babelPlugin({ + presets: [ + ['@babel/preset-env', { targets: { safari: '11' } }], + ['@babel/preset-react', { runtime: 'automatic' }], + ['@babel/preset-typescript'], + ], + plugins: [ + '@babel/plugin-proposal-class-properties', + ['@babel/plugin-transform-runtime', { regenerator: true }], + ], + babelHelpers: 'runtime', + compact: false, + extensions, + }); +} + +function postCss(_, instrumentFolder) { + let plugins; + + const tailwindConfigPath = join(Directories.instruments, 'src', instrumentFolder, 'tailwind.config.js'); + + if (fs.existsSync(tailwindConfigPath)) { + plugins = [ + tailwindcss(tailwindConfigPath), + ]; + } else { + plugins = []; + } + + plugins.push(postcssColorFunctionalNotation()); + + return postcss({ + use: { sass: {} }, + plugins, + extract: 'bundle.css', + }); +} + +export function baseCompile(instrumentName, instrumentFolder) { + return [ + image(), + nodeResolve({ extensions, browser: true }), + json(), + commonjs({ include: /node_modules/ }), + babel(), + typescriptPaths({ + tsConfigPath: join(Directories.src, 'tsconfig.json'), + preserveExtensions: true, + }), + replace({ + 'DEBUG': 'false', + 'preventAssignment': true, + 'process.env.VITE_BUILD': 'false', + 'process.env.NODE_ENV': JSON.stringify('production'), + 'process.env.CLIENT_ID': JSON.stringify(process.env.CLIENT_ID), + 'process.env.CLIENT_SECRET': JSON.stringify(process.env.CLIENT_SECRET), + 'process.env.CHARTFOX_SECRET': JSON.stringify(process.env.CHARTFOX_SECRET), + 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), + }), + postCss(instrumentName, instrumentFolder), + ]; +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/Assets/ECAMFont.ttf b/fbw-a380x/src/systems/instruments/src/Common/Assets/ECAMFont.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a2a1b5030dee19b4f1d741e36cf260b45999647a GIT binary patch literal 23800 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 TT>K~RuRQ4Z1q&O0_xt|?hwDVR literal 0 HcmV?d00001 diff --git a/fbw-a380x/src/systems/instruments/src/Common/CdsDisplayUnit.scss b/fbw-a380x/src/systems/instruments/src/Common/CdsDisplayUnit.scss new file mode 100644 index 00000000000..8eacb0d463d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/CdsDisplayUnit.scss @@ -0,0 +1,3 @@ +vcockpit-panel { + font-family: Ecam, sans-serif; +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/CdsDisplayUnit.tsx b/fbw-a380x/src/systems/instruments/src/Common/CdsDisplayUnit.tsx new file mode 100644 index 00000000000..0deec937fe5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/CdsDisplayUnit.tsx @@ -0,0 +1,196 @@ +import React, { forwardRef, PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'; +import { NXDataStore } from '@shared/persistence'; +import { DcElectricalBus } from '@shared/electrical'; +import { useSimVar } from './simVars'; +import { useUpdate } from './hooks'; + +import './common.scss'; +import './pixels.scss'; + +import './CdsDisplayUnit.scss'; + +export enum DisplayUnitID { + CaptPfd, + CaptNd, + CaptMfd, + FoPfd, + FoNd, + FoMfd, + Ewd, + Sd, +} + +const DisplayUnitToDCBus: { [k in DisplayUnitID]: DcElectricalBus[] } = { + [DisplayUnitID.CaptPfd]: [DcElectricalBus.DcEss], + [DisplayUnitID.CaptNd]: [DcElectricalBus.DcEss, DcElectricalBus.Dc1], + [DisplayUnitID.CaptMfd]: [DcElectricalBus.DcEss, DcElectricalBus.Dc1], + [DisplayUnitID.FoPfd]: [DcElectricalBus.Dc2], + [DisplayUnitID.FoNd]: [DcElectricalBus.Dc1, DcElectricalBus.Dc2], + [DisplayUnitID.FoMfd]: [DcElectricalBus.Dc1, DcElectricalBus.Dc2], + [DisplayUnitID.Ewd]: [DcElectricalBus.DcEss], + [DisplayUnitID.Sd]: [DcElectricalBus.Dc2], +}; + +const DisplayUnitToPotentiometer: { [k in DisplayUnitID]: number } = { + [DisplayUnitID.CaptPfd]: 88, + [DisplayUnitID.CaptNd]: 89, + [DisplayUnitID.CaptMfd]: 98, + [DisplayUnitID.FoPfd]: 90, + [DisplayUnitID.FoNd]: 91, + [DisplayUnitID.FoMfd]: 99, + [DisplayUnitID.Ewd]: 92, + [DisplayUnitID.Sd]: 93, +}; + +interface DisplayUnitProps { + displayUnitId: DisplayUnitID, + failed?: boolean +} + +enum DisplayUnitState { + On, + Off, + ThalesBootup, + Selftest, + Standby +} + +function BacklightBleed(props) { + if (props.homeCockpit) { + return null; + } + return

; +} + +export const CdsDisplayUnit = forwardRef>(({ displayUnitId, failed, children }, ref) => { + const [coldDark] = useSimVar('L:A32NX_COLD_AND_DARK_SPAWN' /* TODO 380 simvar */, 'Bool', 200); + const [state, setState] = useState((coldDark) ? DisplayUnitState.Off : DisplayUnitState.Standby); + const [timer, setTimer] = useState(null); + const thalesBootupEndTime = useRef(null); + + const [potentiometer] = useSimVar(`LIGHT POTENTIOMETER:${DisplayUnitToPotentiometer[displayUnitId]}`, 'percent over 100', 200); + const [electricityState0] = useSimVar(`L:A32NX_ELEC_${DisplayUnitToDCBus[displayUnitId][0]}_BUS_IS_POWERED` /* TODO 380 simvar */, 'bool', 200); + const [electricityState1] = useSimVar(`L:A32NX_ELEC_${DisplayUnitToDCBus[displayUnitId][1]}_BUS_IS_POWERED` /* TODO 380 simvar */, 'bool', 200); + const [homeCockpit] = useSimVar('L:A32NX_HOME_COCKPIT_ENABLED', 'bool', 200); + + useUpdate(useCallback((deltaTime) => { + if (timer !== null && thalesBootupEndTime.current !== null) { + if (state === DisplayUnitState.ThalesBootup || DisplayUnitState.Selftest) { + setTimer((current) => { + if (current !== null) { + return Math.max(0, current - (deltaTime / 1000)); + } + + return current; + }); + } + + if (timer > 0 && timer < thalesBootupEndTime.current) { + setState(DisplayUnitState.Selftest); + } else if (timer <= 0) { + setState(DisplayUnitState.On); + } else if (state === DisplayUnitState.Standby) { + setState(DisplayUnitState.Off); + setTimer(null); + } else if (state === DisplayUnitState.Selftest) { + setState(DisplayUnitState.On); + setTimer(null); + } + } + + // override MSFS menu animations setting for this instrument + if (!document.documentElement.classList.contains('animationsEnabled')) { + document.documentElement.classList.add('animationsEnabled'); + } + }, [timer, state])); + + useEffect(() => { + if (state !== DisplayUnitState.Off && failed) { + setState(DisplayUnitState.Off); + } else if (state === DisplayUnitState.On && (potentiometer === 0 || (electricityState0 === 0 && electricityState1 === 0))) { + setState(DisplayUnitState.Standby); + setTimer(10); + } else if (state === DisplayUnitState.Standby && (potentiometer !== 0 && (electricityState0 !== 0 || electricityState1 !== 0))) { + setState(DisplayUnitState.On); + setTimer(null); + } else if (state === DisplayUnitState.Off && (potentiometer !== 0 && (electricityState0 !== 0 || electricityState1 !== 0) && !failed)) { + setState(DisplayUnitState.ThalesBootup); + const delay = parseInt(NXDataStore.get('CONFIG_SELF_TEST_TIME', '15')) - 0.5 + Math.random(); + setTimer(delay); + thalesBootupEndTime.current = delay - (0.25 + (Math.random() * 0.2)); + } else if ((state === DisplayUnitState.Selftest || state === DisplayUnitState.ThalesBootup) && (potentiometer === 0 || (electricityState0 === 0 && electricityState1 === 0))) { + setState(DisplayUnitState.Off); + setTimer(null); + } + }, [timer, state, potentiometer, electricityState0, electricityState1]); + + if (window.ACE_ENGINE_HANDLE) { + return ( + <> + {children} + + ); + } + + if (state === DisplayUnitState.ThalesBootup) { + return ( + <> + + + + + + + + + ); + } + + if (state === DisplayUnitState.Selftest) { + return ( + <> + + + + + + SAFETY TEST IN PROGRESS + + + (MAX 30 SECONDS) + + + + ); + } + + if (state === DisplayUnitState.Off) { + return ( + <> + ); + } + + return ( + <> + + + {children} + + + ); +}); diff --git a/fbw-a380x/src/systems/instruments/src/Common/ComponentPosition.ts b/fbw-a380x/src/systems/instruments/src/Common/ComponentPosition.ts new file mode 100644 index 00000000000..9baabeaae4c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/ComponentPosition.ts @@ -0,0 +1,4 @@ +export interface ComponentPositionProps { + x: number, + y: number, +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/EWDMessageParser.tsx b/fbw-a380x/src/systems/instruments/src/Common/EWDMessageParser.tsx new file mode 100644 index 00000000000..df102efafa0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/EWDMessageParser.tsx @@ -0,0 +1,169 @@ +import React from 'react'; + +const LINE_SPACING = 32; +const LETTER_WIDTH = 16; + +type FormattedFwcTextProps = { + x: number, + y: number, + message: string +} + +const FormattedFwcText: React.FC = ({ x, y, message }) => { + const lines: any[] = []; + let spans: any[] = []; + + let color = 'White'; + let underlined = false; + // const flashing = false; TODO + let framed = false; + + const decorations: any[] = []; + + let buffer = ''; + let startCol = 0; + let col = 0; + for (let i = 0; i < message.length; i++) { + const char = message[i]; + if (char === '\x1b' || char === '\r') { + if (buffer !== '') { + // close current part + spans.push( + + {buffer} + , + ); + buffer = ''; + + if (underlined) { + decorations.push( + , + ); + } + + if (framed) { + decorations.push( + , + ); + } + + startCol = col; + } + + if (char === '\x1B') { + let ctrlBuffer = ''; + i++; + for (; i < message.length; i++) { + ctrlBuffer += message[i]; + + let match = true; + switch (ctrlBuffer) { + case 'm': + // Reset attribute + underlined = false; + // flashing = false; + framed = false; + break; + case '4m': + // Underlined attribute + underlined = true; + break; + case ')m': + // Flashing attribute + // flashing = true; + break; + case "'m": + // Characters which follow must be framed + framed = true; + break; + case '<1m': + // Select YELLOW + color = 'Yellow'; + break; + case '<2m': + // Select RED + color = 'Red'; + break; + case '<3m': + // Select GREEN + color = 'Green'; + break; + case '<4m': + // Select AMBER + color = 'Amber'; + break; + case '<5m': + // Select CYAN (blue-green) + color = 'Cyan'; + break; + case '<6m': + // Select MAGENTA + color = 'Magenta'; + break; + case '<7m': + // Select WHITE + color = 'White'; + break; + default: + match = false; + break; + } + + if (match) { + break; + } + } + + continue; + } + + if (char === '\r') { + lines.push({spans}); + + spans = []; + col = 0; + startCol = 0; + continue; + } + } + + buffer += char; + col++; + } + + if (buffer !== '') { + spans.push( + + {buffer} + , + ); + } + + if (spans.length) { + lines.push({spans}); + } + + return ( + + {lines} + {decorations} + + ); +}; + +export default FormattedFwcText; diff --git a/fbw-a380x/src/systems/instruments/src/Common/EWDMessages.tsx b/fbw-a380x/src/systems/instruments/src/Common/EWDMessages.tsx new file mode 100644 index 00000000000..92468b74e43 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/EWDMessages.tsx @@ -0,0 +1,227 @@ +const EWDMessages = { + '000001001': '\x1b<3m\x1b4mT.O\x1bm AUTO BRK\x1b<5m.....MAX', + '000001002': '\x1b<3m\x1b4mT.O\x1bm AUTO BRK MAX', + '000001003': ' \x1b<3mSIGNS\x1b<5m.........ON', + '000001004': ' \x1b<3mSIGNS ON', + '000001005': ' \x1b<3mCABIN\x1b<5m......CHECK', + '000001006': ' \x1b<3mCABIN READY', + '000001007': ' \x1b<3mSPLRS\x1b<5m........ARM', + '000001008': ' \x1b<3mSPLRS ARM', + '000001009': ' \x1b<3mFLAPS\x1b<5m........T.O', + '000001010': ' \x1b<3mFLAPS T.O', + '000001011': ' \x1b<3mT.O CONFIG\x1b<5m..TEST', + '000001012': ' \x1b<3mT.O CONFIG NORMAL', + '000002001': '\x1b<3m\x1b4mLDG\x1bm LDG GEAR\x1b<5m......DN', + '000002002': '\x1b<3m\x1b4mLDG\x1bm LDG GEAR DN', + '000002003': ' \x1b<3mSIGNS\x1b<5m.........ON', + '000002004': ' \x1b<3mSIGNS ON', + '000002005': ' \x1b<3mCABIN\x1b<5m......CHECK', + '000002006': ' \x1b<3mCABIN READY', + '000002007': ' \x1b<3mSPLRS\x1b<5m........ARM', + '000002008': ' \x1b<3mSPLRS ARM', + '000002009': ' \x1b<3mFLAPS\x1b<5m.......FULL', + '000002010': ' \x1b<3mFLAPS FULL', + '000002011': ' \x1b<3mFLAPS\x1b<5m.....CONF 3', + '000002012': ' \x1b<3mFLAPS CONF 3', + '000002201': '\x1b<3mAUTO BRK LO', + '000002202': '\x1b<3mAUTO BRK MED', + '000002203': '\x1b<3mAUTO BRK MAX', + '000002204': '\x1b<3mAUTO BRK OFF', + '000002701': '\x1b<3mIR 1 IN ATT ALIGN', + '000002702': '\x1b<3mIR 2 IN ATT ALIGN', + '000002703': '\x1b<3mIR 3 IN ATT ALIGN', + '000002704': '\x1b<3mIR 1+2 IN ATT ALIGN', + '000002705': '\x1b<3mIR 1+3 IN ATT ALIGN', + '000002706': '\x1b<3mIR 2+3 IN ATT ALIGN', + '000002707': '\x1b<3mIR 1+2+3 IN ATT ALIGN', + '000003001': '\x1b<3mIR IN ALIGN > 7 MN', + '000003002': '\x1b<4mIR IN ALIGN > 7 MN', + '000003003': '\x1b<3mIR IN ALIGN 6 MN', + '000003004': '\x1b<4mIR IN ALIGN 6 MN', + '000003005': '\x1b<3mIR IN ALIGN 5 MN', + '000003006': '\x1b<4mIR IN ALIGN 5 MN', + '000003007': '\x1b<3mIR IN ALIGN 4 MN', + '000003008': '\x1b<4mIR IN ALIGN 4 MN', + '000003101': '\x1b<3mIR IN ALIGN 3 MN', + '000003102': '\x1b<4mIR IN ALIGN 3 MN', + '000003103': '\x1b<3mIR IN ALIGN 2 MN', + '000003104': '\x1b<4mIR IN ALIGN 2 MN', + '000003105': '\x1b<3mIR IN ALIGN 1 MN', + '000003106': '\x1b<4mIR IN ALIGN 1 MN', + '000003107': '\x1b<3mIR IN ALIGN', + '000003108': '\x1b<4mIR IN ALIGN', + '000003109': '\x1b<3mIR ALIGNED', + '000004001': '\x1b<3mNW STRG DISC', + '000004002': '\x1b<4mNW STRG DISC', + '000005001': '\x1b<3mREFUELG', + '000005501': '\x1b<3mGND SPLRS ARMED', + '000056101': '\x1b<3mCOMPANY ALERT', + '000056102': '\x1b<3m\x1b)mCOMPANY ALERT', + '000006001': '\x1b<3mSPEED BRK', + '000006002': '\x1b<4mSPEED BRK', + '000007001': '\x1b<3mIGNITION', + '000008001': '\x1b<3mSEAT BELTS', + '000008501': '\x1b<3mNO MOBILE', + '000009001': '\x1b<3mNO SMOKING', + '000009501': '\x1b<3mNO PORTABLE DEVICES', + '000010001': '\x1b<3mSTROBE LT OFF', + '000010501': '\x1b<3mOUTR TK FUEL XFRD', + '000011001': '\x1b<3mFOB BELOW 3 T', + '000011002': '\x1b<3mFOB BELOW 6600 LBS', + '000013501': '\x1b<3mACARS STBY', + '000014001': '\x1b<6mT.O INHIBIT', + '000015001': '\x1b<6mLDG INHIBIT', + '000030501': '\x1b<3mGPWS FLAP MODE OFF', + '000066001': '\x1b<3mGSM DISC < 4MN', + '000056501': '\x1b<3mATC DATALINK STBY', + '000016001': '\x1b<3mHYD PTU', + '000017001': '\x1b<3mAPU AVAIL', + '000018001': '\x1b<3mAPU BLEED', + '000019001': '\x1b<3mLDG LT', + '000020001': '\x1b<3mPARK BRK', + '000021001': '\x1b<3mRAT OUT', + '000021002': '\x1b<3mRAT OUT', + '000022001': '\x1b<3mBRK FAN', + '000023001': '\x1b<3mMAN LDG ELEV', + '000025001': '\x1b<3mFUEL X FEED', + '000025002': '\x1b<4mFUEL X FEED', + '000026001': '\x1b<3mENG A. ICE', + '000027001': '\x1b<3mWING A. ICE', + '000027501': '\x1b<3mICE NOT DET', + '000029001': '\x1b<3mSWITCHG PNL', + '000030001': '\x1b<3mGPWS FLAP 3', + '000032001': '\x1b<3mTCAS STBY', + '000032501': '\x1b<4mTCAS STBY', + '000035001': '\x1b<2mLAND ASAP', + '000036001': '\x1b<4mLAND ASAP', + '000054001': '\x1b<3mPRED W/S OFF', + '000054002': '\x1b<4mPRED W/S OFF', + '000054501': '\x1b<3mTERR OFF', + '000054502': '\x1b<4mTERR OFF', + '000055201': '\x1b<3mCOMPANY MSG', + '000056001': '\x1b<3mHI ALT SET', + '000068001': '\x1b<3mADIRS SWTG', + '213122101': '\x1b<2m\x1b4mCAB PR\x1bm EXCESS CAB ALT', + '213122102': '\x1b<5m -CREW OXY MASKS.....USE', + '213122103': '\x1b<5m -SIGNS...............ON', + '213122104': '\x1b<7m .\x1b4mEMER DESCENT\x1bm:', + '213122105': '\x1b<5m -DESCENT.......INITIATE', + '213122106': '\x1b<5m -THR LEVERS........IDLE', + '213122107': '\x1b<5m -SPD BRK...........FULL', + '213122108': '\x1b<5m SPD.....MAX/APPROPRIATE', + '213122109': '\x1b<5m -ENG MODE SEL.......IGN', + '213122110': '\x1b<5m -ATC.............NOTIFY', + '213122111': '\x1b<5m -CABIN CREW......ADVISE', + '213122112': '\x1b<5m -EMER DES (PA).ANNOUNCE', + '213122113': '\x1b<5m -XPDR 7700.....CONSIDER', + '213122114': '\x1b<5m MAX FL.....100/MEA-MORA', + '213122115': '\x1b<7m .IF CAB ALT>14000FT:', + '213122116': '\x1b<5m -PAX OXY MASKS...MAN ON', + '216120701': '\x1b<4m\x1b4mAIR\x1bm PACK 1 OFF', + '216120801': '\x1b<4m\x1b4mAIR\x1bm PACK 2 OFF', + '260001001': '\x1b<2m\x1b4mENG 1 FIRE\x1bm', + '260001002': '\x1b<5m -THR LEVER 1.......IDLE', + '260001003': '\x1b<5m -THR LEVERS........IDLE', + '260001004': '\x1b<7m .WHEN A/C IS STOPPED:', + '260001005': '\x1b<5m -PARKING BRK.........ON', + '260001006': '\x1b<5m -ATC.............NOTIFY', + '260001007': '\x1b<5m -CABIN CREW.......ALERT', + '260001008': '\x1b<5m -ENG MASTER 1.......OFF', + '260001009': '\x1b<5m -ENG 1 FIRE P/B....PUSH', + '260001010': '\x1b<7m -AGENT1 AFTER 10S.DISCH', + '260001011': '\x1b<5m -AGENT 1..........DISCH', + '260001012': '\x1b<5m -AGENT 1..........DISCH', + '260001013': '\x1b<5m -AGENT 2..........DISCH', + '260001014': '\x1b<5m -EMER EVAC PROC...APPLY', + '260001015': '\x1b<5m -ATC.............NOTIFY', + '260001016': '\x1b<7m .IF FIRE AFTER 30S:', + '260001017': '\x1b<5m -AGENT 2..........DISCH', + '260002001': '\x1b<2m\x1b4mENG 2 FIRE\x1bm', + '260002002': '\x1b<5m -THR LEVER 2.......IDLE', + '260002003': '\x1b<5m -THR LEVERS........IDLE', + '260002004': '\x1b<7m .WHEN A/C IS STOPPED:', + '260002005': '\x1b<5m -PARKING BRK.........ON', + '260002006': '\x1b<5m -ATC.............NOTIFY', + '260002007': '\x1b<5m -CABIN CREW.......ALERT', + '260002008': '\x1b<5m -ENG MASTER 2.......OFF', + '260002009': '\x1b<5m -ENG 2 FIRE P/B....PUSH', + '260002010': '\x1b<5m -AGENT1 AFTER 10S.DISCH', + '260002011': '\x1b<5m -AGENT 1..........DISCH', + '260002012': '\x1b<5m -AGENT 1..........DISCH', + '260002013': '\x1b<5m -AGENT 2..........DISCH', + '260002014': '\x1b<5m -EMER EVAC PROC...APPLY', + '260002015': '\x1b<5m -ATC.............NOTIFY', + '260002016': '\x1b<7m .IF FIRE AFTER 30S:', + '260002017': '\x1b<5m -AGENT 2..........DISCH', + '260003001': '\x1b<2m\x1b4mAPU FIRE\x1bm', + '260003002': '\x1b<5m -APU FIRE P/B......PUSH', + '260003003': '\x1b<5m -AGENT AFTER 10S..DISCH', + '260003004': '\x1b<5m -AGENT............DISCH', + '260003005': '\x1b<5m -MASTER SW..........OFF', + '260015001': '\x1b<2m\x1b4mSMOKE\x1bm FWD CARGO SMOKE', + '260015002': '\x1b<5m -FWD ISOL VALVE.....OFF', + '260015003': '\x1b<5m -CAB FANS...........OFF', + '260015004': '\x1b<7m .IF FWD CARG CLOSED:', + '260015005': '\x1b<5m -AGENT............DISCH', + '260015006': '\x1b<7m .WHEN ON GROUND:', + '260015007': '\x1b<7m BEFORE OPEN CRG DOORS:', + '260015008': '\x1b<7m .BEFORE OPEN CRG DOORS:', + '260015009': '\x1b<5m -PAX..........DISEMBARK', + '270008501': '\x1b<2m\x1b4mCONFIG\x1bm', + '270008502': '\x1b<2mSLATS NOT IN T.O CONFIG', + '270009001': '\x1b<2m\x1b4mCONFIG\x1bm', + '270009002': '\x1b<2mFLAPS NOT IN T.O CONFIG', + '290031001': '\x1b<4m*HYD', + '290031201': '\x1b<4m*HYD', + '308118601': '\x1b<4m\x1b4mSEVERE ICE\x1bm DETECTED', + '308118602': '\x1b5m -WING ANTI ICE.......ON', + '308118603': '\x1b5m -ENG MOD SEL........IGN', + '308128001': '\x1b<4m\x1b4mANTI ICE\x1bm ICE DETECTED', + '308128002': '\x1b5m -ENG 1 ANTI ICE......ON', + '308128003': '\x1b5m -ENG 2 ANTI ICE......ON', + '320001001': '\x1b<4m\x1b4mBRAKES\x1bm HOT', + '320001002': '\x1b<7m .IF PERF PERMITS :', + '320001003': '\x1b<5m -PARK BRK:PREFER CHOCKS', + '320001004': '\x1b<5m MAX SPEED.......250/.60', + '320001005': '\x1b<5m -BRK FAN.............ON', + '320001006': '\x1b<5m -DELAY T.O FOR COOL', + '320001007': '\x1b<5m -L/G........DN FOR COOL', + '320001008': '\x1b<7m .FOR L/G RETRACTION:', + '320001009': '\x1b<5m MAX SPEED.......220/.54', + '320005001': '\x1b<2m\x1b4mCONFIG\x1bm PARK BRK ON', + '320006001': '\x1b<4m\x1b4mBRAKES\x1bm A/SKID N/WS FAULT', + '320006002': '\x1b<5m MAX BRK PR......1000 PSI', + '340014001': '\x1b<4m\x1b4mNAV\x1bm RA 1 FAULT', + '340015001': '\x1b<4m\x1b4mNAV\x1bm RA 2 FAULT', + '340050001': '\x1b<4m\x1b4mNAV\x1bm TCAS FAULT', + '340050701': '\x1b<4m\x1b4mNAV\x1bm TCAS STBY', + '340021001': '\x1b<2m\x1b4mOVERSPEED\x1bm', + '340021002': '\x1b<2m -VFE...............177', + '340022001': '\x1b<2m\x1b4mOVERSPEED\x1bm', + '340022002': '\x1b<2m -VFE...............185', + '340023001': '\x1b<2m\x1b4mOVERSPEED\x1bm', + '340023002': '\x1b<2m -VFE...............200', + '340023501': '\x1b<2m\x1b4mOVERSPEED\x1bm', + '340023502': '\x1b<2m -VFE...............215', + '340024001': '\x1b<2m\x1b4mOVERSPEED\x1bm', + '340024002': '\x1b<2m -VFE...............230', + '770002701': '\x1b<2m\x1b4mENG\x1bm ALL ENGINES FAILURE', + '770002702': '\x1b<5m -EMER ELEC PWR...MAN ON', + '770002703': '\x1b<5m OPT RELIGHT SPD.280/.77', + '770002704': '\x1b<5m OPT RELIGHT SPD.300/.77', + '770002705': '\x1b<5m OPT RELIGHT SPD.260/.77', + '770002706': '\x1b<5m OPT RELIGHT SPD.270/.77', + '770002707': '\x1b<5m -APU..............START', + '770002708': '\x1b<5m -THR LEVERS........IDLE', + '770002709': '\x1b<5m -FAC 1......OFF THEN ON', + '770002710': '\x1b<5m GLDG DIST: 2NM/1000FT', + '770002711': '\x1b<5m -DIVERSION.....INITIATE', + '770002712': '\x1b<5m-ALL ENG FAIL PROC.APPLY', + '770064201': '\x1b<4m\x1b4mENG\x1bm THR LEVERS NOT SET', + '770064202': '\x1b<5m -THR LEVERS.....TO/GA', + '770064701': '\x1b<4m\x1b4mENG\x1bm \x1b<4mTHR LEVERS NOT SET', + '770064702': '\x1b<5m -THR LEVERS.....MCT/FLX', + '770064703': '\x1b<5m -THR LEVERS.....TO/GA', +}; + +export default EWDMessages; diff --git a/fbw-a380x/src/systems/instruments/src/Common/FuelFunctions.tsx b/fbw-a380x/src/systems/instruments/src/Common/FuelFunctions.tsx new file mode 100644 index 00000000000..1ae96cf9060 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/FuelFunctions.tsx @@ -0,0 +1,12 @@ +export function fuelForDisplay(fuelValue, unitsC, timeUnit = 1, fobMultiplier = 1) { + const fuelWeight = unitsC === '1' ? fuelValue / timeUnit : fuelValue / timeUnit / 0.4535934; + const roundValue = unitsC === '1' ? 10 * fobMultiplier : 20 * fobMultiplier; + return Math.round(fuelWeight / roundValue) * roundValue; +} + +export function fuelInTanksForDisplay(fuelValue, unitsC, gallon2Kg) { + const weightInKg = fuelValue * gallon2Kg; + const fuelWeight = unitsC === '1' ? weightInKg : weightInKg / 0.4535934; + const roundValue = unitsC === '1' ? 10 : 20; + return Math.round(fuelWeight / roundValue) * roundValue; +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/NXLogic.ts b/fbw-a380x/src/systems/instruments/src/Common/NXLogic.ts new file mode 100644 index 00000000000..92af1b9b580 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/NXLogic.ts @@ -0,0 +1,224 @@ +/* + * This file contains various nodes that can be used for logical processing. Systems like the FWC may use them to + * accurately implement their functionality. + */ + +/** + * The following class represents a monostable circuit. It is inspired by the MTRIG nodes as described in the ESLD and + * used by the FWC. + * When it detects either a rising or a falling edge (depending on it's type) it will emit a signal for a certain time t + * after the detection. It is not retriggerable, so a rising/falling edge within t will not reset the timer. + */ +export class NXLogicTriggeredMonostableNode { + t: number; + + risingEdge: boolean; + + timer: number; + + previousValue: any; + + constructor(t, risingEdge = true) { + this.t = t; + this.risingEdge = risingEdge; + this.timer = 0; + this.previousValue = null; + } + + write(value, _deltaTime) { + if (this.previousValue === null && SimVar.GetSimVarValue('L:A32NX_FWC_SKIP_STARTUP', 'Bool')) { + this.previousValue = value; + } + if (this.risingEdge) { + if (this.timer > 0) { + this.timer = Math.max(this.timer - _deltaTime / 1000, 0); + this.previousValue = value; + return true; + } if (!this.previousValue && value) { + this.timer = this.t; + this.previousValue = value; + return true; + } + } else { + if (this.timer > 0) { + this.timer = Math.max(this.timer - _deltaTime / 1000, 0); + this.previousValue = value; + return true; + } if (this.previousValue && !value) { + this.timer = this.t; + this.previousValue = value; + return true; + } + } + this.previousValue = value; + return false; + } +} + +/** + * The following class represents a "confirmation" circuit, which only passes a signal once it has been stable for a + * certain amount of time. It is inspired by the CONF nodes as described in the ESLD and used by the FWC. + * When it detects either a rising or falling edge (depending on it's type) it will wait for up to time t and emit the + * incoming signal if it was stable throughout t. If at any point the signal reverts during t the state is fully reset, + * and the original signal will be emitted again. + */ +export class NXLogicConfirmNode { + t: number; + + risingEdge: boolean; + + timer: number; + + previousInput: any; + + previousOutput: any; + + constructor(t, risingEdge = true) { + this.t = t; + this.risingEdge = risingEdge; + this.timer = 0; + this.previousInput = null; + this.previousOutput = null; + } + + write(value, deltaTime) { + if (this.previousInput === null && SimVar.GetSimVarValue('L:A32NX_FWC_SKIP_STARTUP', 'Bool')) { + this.previousInput = value; + this.previousOutput = value; + } + if (this.risingEdge) { + if (!value) { + this.timer = 0; + } else if (this.timer > 0) { + this.timer = Math.max(this.timer - deltaTime / 1000, 0); + this.previousInput = value; + this.previousOutput = !value; + return !value; + } else if (!this.previousInput && value) { + this.timer = this.t; + this.previousInput = value; + this.previousOutput = !value; + return !value; + } + } else if (value) { + this.timer = 0; + } else if (this.timer > 0) { + this.timer = Math.max(this.timer - deltaTime / 1000, 0); + this.previousInput = value; + this.previousOutput = !value; + return !value; + } else if (this.previousInput && !value) { + this.timer = this.t; + this.previousInput = value; + this.previousOutput = !value; + return !value; + } + this.previousInput = value; + this.previousOutput = value; + return value; + } + + read() { + return this.previousOutput; + } +} + +/** + * The following class represents a flip-flop or memory circuit that can be used to store a single bit. It is inspired + * by the S+R nodes as described in the ESLD. + * It has two inputs: Set and Reset. At first it will always emit a falsy value, until it receives a signal on the set + * input, at which point it will start emitting a truthy value. This will continue until a signal is received on the + * reset input, at which point it reverts to the original falsy output. It a signal is sent on both set and reset at the + * same time, the input with a star will have precedence. + * The NVM flag is not implemented right now but can be used to indicate non-volatile memory storage, which means the + * value will persist even when power is lost and subsequently restored. + */ +export class NXLogicMemoryNode { + /** + * @param setStar Whether set has precedence over reset if both are applied simultaneously. + * @param nvm Whether the is non-volatile and will be kept even when power is lost. + */ + + setStar: boolean; + + nvm: boolean; + + value:boolean; + + constructor(setStar = true, nvm = false) { + this.setStar = setStar; + this.nvm = nvm; // TODO in future, reset non-nvm on power cycle + this.value = false; + } + + write(set, reset) { + if (set && reset) { + this.value = this.setStar; + } else if (set && !this.value) { + this.value = true; + } else if (reset && this.value) { + this.value = false; + } + return this.value; + } + + read() { + return this.value; + } +} + +/** + * The following class outputs state S1 until the clock has reached the 'TO' time + * at which point it wil output state S2. + * + */ +export class NXLogicClockNode { + /** + * @param from Starting time (in seconds) + * @param to End time (in seconds) + * @param inc Increment time (in seconds) + * @param dir Direction of increment (UP/DOWN) + */ + + from: number; + + to: number; + + inc:number; + + dir: string; + + timer: number; + + flag: boolean; + + output: number; + + constructor(from, to, inc = 1, dir = 'DN') { + this.from = from; + this.to = to; + this.inc = inc; + this.dir = dir; + this.output = 0; + this.flag = false; + } + + write(value, deltaTime) { + if (!value) { + this.timer = 0; + this.flag = false; + this.output = 0; + } if (!this.flag) { + this.flag = true; + this.timer = this.from; + } else if (this.flag) { + this.timer = this.dir === 'DN' ? Math.max(this.timer - deltaTime / 1000, 0) : Math.max(this.timer + deltaTime / 1000, 0); + this.output = this.timer === this.to ? 2 : 1; + } + return this.output; + } + + read() { + return this.output; + } +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/Shapes.tsx b/fbw-a380x/src/systems/instruments/src/Common/Shapes.tsx new file mode 100644 index 00000000000..a0971cd57c9 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/Shapes.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +type TriangleProps = { + x: number, + y: number, + colour: string, + fill: number, + orientation: number, + scale?: number +} + +export const Triangle = ({ x, y, colour, fill, orientation, scale = 1 } : TriangleProps) => { + // x,y marks the top of the triangle + // You can rotate this 0, 90, -90 degrees + const polyPoints = `${x + (scale * 9)},${y + (2 * scale * 7)} ${x},${y} ${x - (scale * 9)},${y + (2 * scale * 7)}`; + const transformation = `rotate(${orientation} ${x} ${y})`; + let classSelector = `${colour} Line`; + if (fill === 1) { + classSelector += ` Fill ${colour}`; + } + + return ( + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/Valve.tsx b/fbw-a380x/src/systems/instruments/src/Common/Valve.tsx new file mode 100644 index 00000000000..8432a4a4fc1 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/Valve.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; + +interface ValveProps { + x: number, + y: number, + radius: number, + position: 'V' |'H', + css: string, + sdacDatum: boolean +} + +const Valve: FC = ({ x, y, radius, position, css, sdacDatum }) => ( + + + {sdacDatum && position === 'V' ? : null} + {sdacDatum && position === 'H' ? : null} + +); + +export default Valve; diff --git a/fbw-a380x/src/systems/instruments/src/Common/arinc429.tsx b/fbw-a380x/src/systems/instruments/src/Common/arinc429.tsx new file mode 100644 index 00000000000..10ec4cf2636 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/arinc429.tsx @@ -0,0 +1,10 @@ +import { Arinc429Word } from '@shared/arinc429'; +import { useSimVar } from './simVars'; + +export const useArinc429Var = ( + name: string, + maxStaleness = 0, +): Arinc429Word => { + const [value] = useSimVar(name, 'number', maxStaleness); + return new Arinc429Word(value); +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/common.scss b/fbw-a380x/src/systems/instruments/src/Common/common.scss new file mode 100644 index 00000000000..5dd4d0e17bf --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/common.scss @@ -0,0 +1,21 @@ +@import "definitions"; + +.SelfTest { + position: absolute; + left: 0%; + top: 0%; + width: 100%; + height: 100%; + border: none; +} + +.SelfTestBackground { + fill: $display-background; +} + +.SelfTestText { + font-size: 26px; + fill: $display-green; + text-anchor: middle; + font-family: "Ecam", monospace; +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/defaults.ts b/fbw-a380x/src/systems/instruments/src/Common/defaults.ts new file mode 100644 index 00000000000..4d9f3629023 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/defaults.ts @@ -0,0 +1,28 @@ +// We currently assume that these two elements will be found. +// Might be worth implementing checking in the future. + +let reactMount = document.getElementById('MSFS_REACT_MOUNT') as HTMLElement; + +const getEcamPageRenderTarget = (pageName: string): HTMLElement => (document.getElementById(`A32NX_${pageName}_PAGE_REACT_MOUNT`) as HTMLElement); + +/** + * Configures the framework to render inside the ECAM. Temporary solution for moving individual SD pages to React. + */ +export const setIsEcamPage = (pageName: string) => { + reactMount = getEcamPageRenderTarget(pageName); +}; + +/** + * Returns the render target which React mounts onto + */ +export const getRenderTarget = () => reactMount; + +/** + * Returns the root element which receives `update` events + */ +export const getRootElement: () => HTMLElement = () => { + if (reactMount?.parentElement) { + return reactMount.parentElement; + } + throw new Error('Could not find rootElement'); +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/definitions.scss b/fbw-a380x/src/systems/instruments/src/Common/definitions.scss new file mode 100644 index 00000000000..df7799e67d8 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/definitions.scss @@ -0,0 +1,25 @@ +@font-face { + font-family: 'Ecam'; + src: url(/Fonts/ECAMFontRegular.ttf); +} + +$font-size-small: 14px; +$font-size-medium: 16px; +$font-size-large: 17px; +$font-size-larger: 18px; +$font-size-xlarge: 20px; +$font-size-huge: 22px; +$font-size-title: 24px; + +$display-white: #ffffff; +$display-grey: #787878; +$display-dark-grey: #b3b3b3; +$display-light-grey: lightgray; +$display-amber: #e68000; +$display-cyan: #00ffff; +$display-green: #00ff00; +$display-magenta: #ff94ff; +$display-red: #ff0000; +$display-yellow: #ffff00; + +$display-background: #040405; diff --git a/fbw-a380x/src/systems/instruments/src/Common/flightplan.tsx b/fbw-a380x/src/systems/instruments/src/Common/flightplan.tsx new file mode 100644 index 00000000000..7ea7dfbbef0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/flightplan.tsx @@ -0,0 +1,49 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { ExternalBackend, Database } from 'msfs-navdata'; +import { useUpdate } from '@instruments/common/hooks'; +import { FlightPlan } from '@fmgc/flightplanning/new/plans/FlightPlan'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { NavigationDatabaseService } from '@fmgc/flightplanning/new/NavigationDatabaseService'; +import { NavigationDatabase } from '@fmgc/NavigationDatabase'; + +const FlightPlanContext = React.createContext<{ database: Database }>(undefined as any); + +export const FlightPlanProvider: React.FC = ({ children }) => { + const [database] = useState(() => new Database(new ExternalBackend('http://localhost:5000'))); + + return ( + + {children} + + ); +}; + +export const useNavDatabase = (): Database => useContext(FlightPlanContext).database; + +/** + * Returns the current flight plan, whether it is temporary, and the current flight plans version + */ +export const useActiveOrTemporaryFlightPlan = (): [FlightPlan, boolean, number] => { + const [version, setVersion] = useState(() => FlightPlanService.version); + + useUpdate(() => { + setVersion(FlightPlanService.activeOrTemporary.version); + }); + + return [FlightPlanService.activeOrTemporary, FlightPlanService.hasTemporary, version]; +}; + +export const useActiveNavDatabase = (): [NavigationDatabase, number] => { + const [version, setVersion] = useState(() => NavigationDatabaseService.version); + const [database, setDatabase] = useState(() => NavigationDatabaseService.activeDatabase); + + useUpdate(() => { + setVersion(NavigationDatabaseService.version); + }); + + useEffect(() => { + setDatabase(NavigationDatabaseService.activeDatabase); + }, [version]); + + return [database, version]; +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/gauges.scss b/fbw-a380x/src/systems/instruments/src/Common/gauges.scss new file mode 100644 index 00000000000..9eaddb44f6e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/gauges.scss @@ -0,0 +1,65 @@ +@import "./definitions.scss"; + +.GaugeComponent { + .Show { + display:block; + } + + .Hide { + display: none; + } + + .Gauge { + stroke: $display-white; + stroke-width: 2; + fill: none; + } + + .GaugeInactive { + stroke: $display-amber; + stroke-width: 2; + fill: none; + } + + .GaugeText { + stroke: $display-white; + fill: $display-white; + font-size: 20px; + } + + .GaugeIndicator { + stroke: $display-green; + stroke-width: 3; + fill: none; + stroke-linecap: round; + } + + .RedGaugeIndicator { + stroke: $display-red; + stroke-width: 3; + fill: none; + stroke-linecap: round; + } + + .GaugeThrustLimitIndicator { + stroke: $display-amber; + stroke-width: 3; + fill: none; + } + + .GaugeThrustLimitIndicatorFill { + stroke: $display-amber; + stroke-width: 3; + fill: $display-amber; + } + + .GaugeThrustFill { + fill: $display-grey; + } +} + +.DonutThrottleIndicator { + fill: none; + stroke-width: 2px; + stroke: $display-cyan; +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/gauges.tsx b/fbw-a380x/src/systems/instruments/src/Common/gauges.tsx new file mode 100644 index 00000000000..720d23b536a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/gauges.tsx @@ -0,0 +1,286 @@ +import React, { FC, memo } from 'react'; + +import './gauges.scss'; + +function polarToCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) { + const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; + + return { + x: centerX + (radius * Math.cos(angleInRadians)), + y: centerY + (radius * Math.sin(angleInRadians)), + }; +} + +/** + * Draws an arc between startAngle and endAngle. This can start and finish anywhere on a circle + * Note all arcs are drawn in a clockwise fashion + * + * @param x x coordinate of arc centre + * @param y y coordinate of arc centre + * @param radius radius of arc + * @param startAngle value between 0 and 360 degrees where arc starts + * @param endAngle value between 0 and 360 degrees where arc finishes + * @param sweep value between 0 and 1 for sweep flag + * @param largeArc value between 0 and 1 for large arc flag + */ +export function describeArc(x: number, y: number, radius: number, startAngle: number, endAngle: number, sweep: number = 0, largeArc: number = 2) { + const start = polarToCartesian(x, y, radius, endAngle); + const end = polarToCartesian(x, y, radius, startAngle); + + const arcSize = startAngle > endAngle ? 360 - endAngle + startAngle : endAngle - startAngle; + if (largeArc === 2) { + largeArc = arcSize <= 180 ? 0 : 1; + } + + return [ + 'M', start.x, start.y, + 'A', radius, radius, 0, largeArc, sweep, end.x, end.y, + ].join(' '); +} + +export const splitDecimals = (value: number) => (value.toFixed(1).split('.', 2)); + +type valueRadianAngleConverterType = { + value: number, + min: number, + max: number, + endAngle: number, + startAngle: number, + perpendicular?: boolean +} + +export const valueRadianAngleConverter = ({ value, min, max, endAngle, startAngle, perpendicular = false }: valueRadianAngleConverterType) => { + const valuePercentage = (value - min) / (max - min); + const angle = perpendicular ? 0 : 90; + const angleInDegress = startAngle > endAngle + ? startAngle + (valuePercentage * (360 - startAngle + endAngle)) - angle + : startAngle + (valuePercentage * (endAngle - startAngle)) - angle; + const angleInRadians = angleInDegress * (Math.PI / 180.0); + return ({ + x: Math.cos(angleInRadians), + y: Math.sin(angleInRadians), + angle: angleInDegress, + }); +}; + +export type GaugeMarkerComponentType = { + value: number, + x: number, + y: number, + min: number, + max: number, + radius: number, + startAngle: number, + endAngle: number, + className: string, + showValue?: boolean, + indicator?: boolean, + outer?: boolean, + multiplierOuter?: number, + multiplierInner?: number, + textNudgeX?: number, + textNudgeY?: number, + halfIndicator?: boolean + bold?: boolean, + reverse?: boolean +}; + +export const GaugeMarkerComponent: React.FC = ({ + value, x, y, min, max, radius, startAngle, endAngle, className, showValue, + indicator, outer, multiplierOuter = 1.15, multiplierInner = 0.85, textNudgeX = 0, textNudgeY = 0, bold, halfIndicator = false, reverse = false, +}) => { + const dir = valueRadianAngleConverter({ value, min, max, endAngle, startAngle, perpendicular: reverse }); + + let start = { + x: x + (dir.x * radius * multiplierInner), + y: y + (dir.y * radius * multiplierInner), + }; + let end = { + x: x + (dir.x * radius), + y: y + (dir.y * radius), + }; + + if (outer) { + start = { + x: x + (dir.x * radius), + y: y + (dir.y * radius), + }; + end = { + x: x + (dir.x * radius * multiplierOuter), + y: y + (dir.y * radius * multiplierOuter), + }; + } + + if (indicator) { + // Need case for EGT and other gauges which do not originate from the centre of the arc + // In this case use original start definition + if (!halfIndicator) { + start = { x, y }; + } + + end = { + x: x + (dir.x * radius * multiplierOuter), + y: y + (dir.y * radius * multiplierOuter), + }; + } + + // Text + const pos = { + x: x + (dir.x * radius * multiplierInner) + textNudgeX, + y: y + (dir.y * radius * multiplierInner) + textNudgeY, + }; + + const textValue = !showValue ? '' : Math.abs(value).toString(); + + return ( + <> + + {textValue} + + ); +}; + +export type GaugeComponentProps = { + x: number, + y: number, + radius: number, + startAngle: number, + endAngle: number, + className: string, + visible?: boolean, + sweep?: number, + largeArc?: number +} + +export const GaugeComponent: FC = memo(({ x, y, radius, startAngle, endAngle, className, children, visible, sweep, largeArc }) => { + const d = describeArc(x, y, radius, startAngle, endAngle, sweep, largeArc); + + return ( + <> + + + + <>{children} + + + + ); +}); + +type ThrottlePositionDonutComponentType = { + value: number, + x: number, + y: number, + min: number, + max: number, + radius: number, + startAngle: number, + endAngle: number, + className: string, + reverse?: boolean, + outerMultiplier?: number, + donutRadius?: number, +}; + +export const ThrottlePositionDonutComponent: FC = memo(({ + value, x, y, min, max, radius, startAngle, endAngle, className, + reverse = false, outerMultiplier = 1.12, donutRadius = 4, +}) => { + const dir = valueRadianAngleConverter({ value, min, max, endAngle, startAngle, perpendicular: reverse }); + + x += (dir.x * radius * outerMultiplier); + y += (dir.y * radius * outerMultiplier); + + return ( + <> + + + ); +}); + +type GaugeMaxComponentType = { + value: number, + x: number, + y: number, + min: number, + max: number, + radius: number, + startAngle: number, + endAngle: number, + className: string, +}; + +export const GaugeMaxComponent: FC = memo(({ value, x, y, min, max, radius, startAngle, endAngle, className }) => { + const dir = valueRadianAngleConverter({ value, min, max, endAngle, startAngle }); + + const xy = { + x: x + (dir.x * radius), + y: y + (dir.y * radius), + }; + + return ( + <> + + + ); +}); + +export const GaugeMaxEGTComponent: FC = memo(({ value, x, y, min, max, radius, startAngle, endAngle, className }) => { + const dir = valueRadianAngleConverter({ value, min, max, endAngle, startAngle }); + + const xy = { + x: x + (dir.x * radius), + y: y + (dir.y * radius), + }; + + return ( + <> + + + + ); +}); + +export type GaugeThrustComponentProps = { + x: number, + y: number, + min: number, + max: number, + radius: number, + startAngle: number, + endAngle: number, + className: string, + visible?: boolean, + valueIdle: number, + valueMax: number, + reverse?: boolean, +} + +export const GaugeThrustComponent: FC = memo(({ x, y, radius, min, max, startAngle, endAngle, className, visible, valueIdle, valueMax, reverse = false }) => { + const valueIdleDir = valueRadianAngleConverter({ value: valueIdle, min, max, endAngle, startAngle }); + const valueIdleEnd = { + x: x + (valueIdleDir.x * radius), + y: y + (valueIdleDir.y * radius), + }; + const valueMaxDir = valueRadianAngleConverter({ value: valueMax, min, max, endAngle, startAngle }); + const valueMaxEnd = { + x: x + (valueMaxDir.x * radius), + y: y + (valueMaxDir.y * radius), + }; + + const ThrustPath = [ + `M ${x},${y} L ${valueIdleEnd.x},${valueIdleEnd.y}`, + `A ${radius} ${radius} 0 ${reverse ? '0' : '1'} 1 ${valueMaxEnd.x} ${valueMaxEnd.y}`, + `M ${valueMaxEnd.x} ${valueMaxEnd.y} L ${x},${y}`, + ].join(' '); + + return ( + <> + + + + + + + ); +}); diff --git a/fbw-a380x/src/systems/instruments/src/Common/hooks.tsx b/fbw-a380x/src/systems/instruments/src/Common/hooks.tsx new file mode 100644 index 00000000000..1838f62f055 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/hooks.tsx @@ -0,0 +1,112 @@ +/* + * A32NX + * Copyright (C) 2020-2021 FlyByWire Simulations and its contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { FlowEventSync } from '@shared/FlowEventSync'; +import { getRootElement } from './defaults'; + +export const useUpdate = (handler: (deltaTime: number) => void) => { + // Logic based on https://usehooks.com/useEventListener/ + const savedHandler = React.useRef(handler); + React.useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + React.useEffect(() => { + const wrappedHandler = (event: CustomEvent) => { + savedHandler.current(event.detail); + }; + getRootElement().addEventListener('update', wrappedHandler); + return () => { + getRootElement().removeEventListener('update', wrappedHandler); + }; + }); +}; + +export const useInteractionEvent = (event: string, handler: (any?) => void): void => { + // Logic based on https://usehooks.com/useEventListener/ + const savedHandler = React.useRef(handler); + React.useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + React.useEffect(() => { + const wrappedHandler = (e) => { + if (event === '*') { + savedHandler.current(e.detail); + } else { + savedHandler.current(); + } + }; + getRootElement().addEventListener(event, wrappedHandler); + return () => { + getRootElement().removeEventListener(event, wrappedHandler); + }; + }, [event]); +}; + +export const useInteractionEvents = (events: string[], handler: (any?) => void): void => { + // Logic based on https://usehooks.com/useEventListener/ + const savedHandler = React.useRef(handler); + React.useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + React.useEffect(() => { + const wrappedHandler = () => { + savedHandler.current(); + }; + events.forEach((event) => getRootElement().addEventListener(event, wrappedHandler)); + return () => { + events.forEach((event) => getRootElement().removeEventListener(event, wrappedHandler)); + }; + }, [ + ...events, + ]); +}; + +declare const Coherent: any; +export const useCoherentEvent = (event: string, handler: (any?) => void): void => { + const savedHandler = React.useRef(handler); + React.useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + React.useEffect(() => { + console.log('hooking coherent event', event); + const coherentHandler = Coherent.on(event, savedHandler.current); + console.log(coherentHandler); + return () => { + coherentHandler.clear(); + }; + }, [event]); +}; + +export const useFlowSyncEvent = (event: string, handler: (topic: string, data: any) => void): void => { + const savedHandler = React.useRef(handler); + React.useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + React.useEffect(() => { + const flowEventHandler = new FlowEventSync(savedHandler.current, event); + return () => { + flowEventHandler.stop(); + }; + }, [event]); +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/hooks/index.tsx b/fbw-a380x/src/systems/instruments/src/Common/hooks/index.tsx new file mode 100644 index 00000000000..f7412c2d595 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/hooks/index.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from 'react'; + +export const useHover = (): [React.MutableRefObject, boolean, (value: boolean) => void] => { + const [value, setValue] = useState(false); + + const ref = useRef(null); + + const handleMouseOver = () => setValue(true); + const handleMouseOut = () => setValue(false); + + useEffect(() => { + const node = ref.current; + + if (node) { + node.addEventListener('mouseover', handleMouseOver); + node.addEventListener('mouseout', handleMouseOut); + + return () => { + node.removeEventListener('mouseover', handleMouseOver); + node.removeEventListener('mouseout', handleMouseOut); + }; + } + }, [ref.current]); + + return [ref, value, setValue]; +}; + +export const useMouseMove = (effect: () => void): [React.MutableRefObject] => { + const ref = useRef(null); + + const handleMouseMove = () => effect(); + + useEffect(() => { + const node = ref.current; + + if (node) { + node.addEventListener('mousemove', handleMouseMove); + + return () => { + node.removeEventListener('mousemove', handleMouseMove); + }; + } + }, [ref.current]); + + return [ref]; +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/index.tsx b/fbw-a380x/src/systems/instruments/src/Common/index.tsx new file mode 100644 index 00000000000..95433c85898 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/index.tsx @@ -0,0 +1,41 @@ +/* + * A32NX + * Copyright (C) 2020-2021 FlyByWire Simulations and its contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import * as Defaults from './defaults'; + +/** + * Use the given React element to render the instrument using React. + */ +export const render = (Slot: React.ReactElement) => { + ReactDOM.render(Slot, Defaults.getRenderTarget()); +}; + +/** + * Computes time delta out of absolute env time and previous + * time debounced on time shift. + */ +export const debouncedTimeDelta = ( + absTimeSeconds: number, + prevTimeSeconds: number, +): number => { + const diff = Math.max(absTimeSeconds - prevTimeSeconds, 0); + // 60s detects forward time-shift + return diff < 60 ? diff : 0; +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/input.tsx b/fbw-a380x/src/systems/instruments/src/Common/input.tsx new file mode 100644 index 00000000000..9d359e15ea4 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/input.tsx @@ -0,0 +1,131 @@ +import React, { createContext, FC, useContext, useEffect, useRef } from 'react'; + +declare const Coherent: any; + +type InputManagerType = { + setKeyboardHandler: (handler: (e: KeyboardEvent) => void) => void, + setScrollWheelHandler: (handler: (e: WheelEvent) => void) => void, + setMouseClickHandler: (handler: (e: MouseEvent) => void) => void, + setMouseUpHandler: (handler: (e: MouseEvent) => void) => void, + setMouseMoveHandler: (handler: (e: MouseEvent) => void) => void, + setUiResetHandler: (handler: () => void) => void, + triggerUiReset: () => void, + clearUiResetHandler: () => void, + clearHandlers: () => void, +} +export const InputManagerContext = createContext({ + setKeyboardHandler: () => null, + setScrollWheelHandler: () => null, + setMouseClickHandler: () => null, + setMouseUpHandler: () => null, + setMouseMoveHandler: () => null, + setUiResetHandler: () => null, + triggerUiReset: () => null, + clearUiResetHandler: () => null, + clearHandlers: () => null, +}); +export const useInputManager = (): InputManagerType => useContext(InputManagerContext); +export const InputManagerProvider: FC<{ onInputChange?: (state: boolean) => void }> = ({ children, onInputChange }) => { + const keyRef = useRef<(handler: KeyboardEvent) => void>(); + const wheelRef = useRef<(handler: WheelEvent) => void>(); + const mouseDownRef = useRef<(handler: MouseEvent) => void>(); + const mouseUpRef = useRef<(handler: MouseEvent) => void>(); + const mouseMoveRef = useRef<(handler: MouseEvent) => void>(); + const uiResetRef = useRef<() => void>(); + + const keyCallback = useRef((event: KeyboardEvent) => { + if (keyRef.current) { + keyRef.current(event); + event.preventDefault(); + } + }); + const wheelCallback = useRef((event: WheelEvent) => { + if (wheelRef.current) { + wheelRef.current(event); + event.preventDefault(); + } + }); + const mouseDownCallback = useRef((event: MouseEvent) => { + if (mouseDownRef.current) { + mouseDownRef.current(event); + event.preventDefault(); + } + }); + const mouseUpCallback = useRef((event: MouseEvent) => { + if (mouseUpRef.current) { + mouseUpRef.current(event); + event.preventDefault(); + } + }); + const mouseMoveCallback = useRef((event: MouseEvent) => { + if (mouseMoveRef.current) { + mouseMoveRef.current(event); + event.preventDefault(); + } + }); + useEffect(() => { + Coherent.trigger('UNFOCUS_INPUT_FIELD'); + document.body.addEventListener('keydown', keyCallback.current); + document.body.addEventListener('wheel', wheelCallback.current); + document.body.addEventListener('mousedown', mouseDownCallback.current); + document.body.addEventListener('mouseup', mouseUpCallback.current); + document.body.addEventListener('mousemove', mouseMoveCallback.current); + + return () => { + document.body.removeEventListener('keydown', keyCallback.current); + document.body.removeEventListener('wheel', wheelCallback.current); + document.body.removeEventListener('mousedown', mouseDownCallback.current); + document.body.removeEventListener('mouseup', mouseUpCallback.current); + document.body.removeEventListener('mousemove', mouseMoveCallback.current); + }; + }, []); + + return ( + void) => { + keyRef.current = handler; + onInputChange?.(true); + }, + setScrollWheelHandler: (handler: (event: WheelEvent) => void) => { + wheelRef.current = handler; + onInputChange?.(true); + }, + setMouseClickHandler: (handler: (event: MouseEvent) => void) => { + mouseDownRef.current = handler; + onInputChange?.(true); + }, + setMouseUpHandler: (handler: (event: MouseEvent) => void) => { + mouseUpRef.current = handler; + onInputChange?.(true); + }, + setMouseMoveHandler: (handler: (event: MouseEvent) => void) => { + mouseMoveRef.current = handler; + onInputChange?.(true); + }, + setUiResetHandler: (handler: () => void) => { + uiResetRef.current = handler; + }, + triggerUiReset: () => { + if (uiResetRef.current) { + uiResetRef.current(); + uiResetRef.current = undefined; + } + }, + clearUiResetHandler: () => { + uiResetRef.current = undefined; + }, + clearHandlers: () => { + keyRef.current = undefined; + wheelRef.current = undefined; + mouseDownRef.current = undefined; + mouseUpRef.current = undefined; + mouseMoveRef.current = undefined; + uiResetRef.current = undefined; + onInputChange?.(false); + }, + }} + > + {children} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/persistence.tsx b/fbw-a380x/src/systems/instruments/src/Common/persistence.tsx new file mode 100644 index 00000000000..bf1e92b59f5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/persistence.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; +import { NXDataStore } from '@shared/persistence'; + +/** + * This hook allows to read and set a persistent storage property. + * Overloads are provided to absolve callers with defaults from dealing with possibly undefined + */ +export function usePersistentProperty(propertyName: string, defaultValue: string): [string, (value: string) => void]; +export function usePersistentProperty(propertyName: string, defaultValue?: string): [string | undefined, (value: string) => void]; +export function usePersistentProperty(propertyName: string, defaultValue?: string): any { + const [propertyValue, rawPropertySetter] = useState(() => NXDataStore.get(propertyName, defaultValue)); + + useEffect(() => { + const unsubscribe = NXDataStore.subscribe(propertyName, (key, value) => rawPropertySetter(value)); + return () => { + unsubscribe(); + }; + }, []); + + const propertySetter = (value: string) => { + NXDataStore.set(propertyName, value); + rawPropertySetter(value); + }; + + return [propertyValue, propertySetter]; +} + +/** + * This hook allows to read and set a persistent storage property as a number. + * Overloads are provided to absolve callers with defaults from dealing with possibly undefined + */ +export function usePersistentNumberProperty(propertyName: string, defaultValue: number): [number, (value: number) => void]; +export function usePersistentNumberProperty(propertyName: string, defaultValue?: number): [number | undefined, (value: number) => void]; +export function usePersistentNumberProperty(propertyName: string, defaultValue?: number): any { + const [strPropertyValue, strPropertySetter] = usePersistentProperty(propertyName, defaultValue !== undefined ? `${defaultValue}` : undefined); + + const propertyValue = strPropertyValue !== undefined ? parseInt(strPropertyValue) : undefined; + const propertySetter = (value: number) => strPropertySetter(`${value}`); + + return [propertyValue, propertySetter]; +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/pixels.scss b/fbw-a380x/src/systems/instruments/src/Common/pixels.scss new file mode 100644 index 00000000000..37d0f381ce0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/pixels.scss @@ -0,0 +1,11 @@ +.BacklightBleed { + pointer-events: none; + content: ''; + width: 100%; + height: 100%; + position: absolute; + z-index: 998; + opacity: 1; + background-color: rgba(0, 0, 255, 0.035); + box-shadow: inset 0px 0px 30px 10px rgba(0, 76, 255, 0.08); +} diff --git a/fbw-a380x/src/systems/instruments/src/Common/simVars.tsx b/fbw-a380x/src/systems/instruments/src/Common/simVars.tsx new file mode 100644 index 00000000000..bf6948793b5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/simVars.tsx @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useInteractionEvents, useUpdate } from './hooks'; + +type SimVarSetter = (oldValue: T) => T; + +type UnitName = string | any; // once typings is next to tsconfig.json, use those units +type SimVarValue = number | any; + +/** + * The useSimVar hook provides an easy way to read and write SimVars from React. + * + * It's signature is similar to useState and it regularly refreshes the SimVar + * to ensure your React component stays in sync with the SimVar being modified + * from outside your component (like from other components, XML or SimConnect). + * + * You may optionally specify the refresh interval. If the same SimVar + * is used in multiple places, this hook will automatically deduplicate those + * for maximum performance, rather than fetching the SimVar multiple times. + * Setting the SimVar will instantly cause it to be updated in all other places + * within the same React tree. + * + * @param name The name of the SimVar. + * @param unit The unit of the SimVar. + * @param refreshInterval The time in milliseconds that needs to elapse before + * the next render will cause a SimVar refresh from the simulator. + * + * @example + * // the return value is the value itself and a setter, similar to useState + * const [v1, setV1] = useSimVar('L:AIRLINER_V1_SPEED', 'Knots'); + * + * @example + * // only refresh the SimVar every 500ms + * const [lightsTest] = useSimVar('L:A32NX_OVHD_INTLT_ANN', 'Bool', 500); + * + * @returns {[*, (function(*): void)]} + * + * @see {@link useSplitSimVar} if your SimVar is set through a K event + * @see {@link useInteractionSimVar} if you emit an H event whenever your SimVar changes + * @see {@link useGlobalVar} if you have a Global Var instead + */ +export const useSimVar = ( + name: string, + unit: UnitName, + refreshInterval = 0, +): [SimVarValue, (newValueOrSetter: SimVarValue | SimVarSetter +) => void] => { + const lastUpdate = useRef(Date.now() - refreshInterval - 1); + + const [stateValue, setStateValue] = useState(() => SimVar.GetSimVarValue(name, unit)); + + const updateCallback = useCallback(() => { + const delta = Date.now() - lastUpdate.current; + + if (delta >= refreshInterval) { + lastUpdate.current = Date.now(); + + const newValue = SimVar.GetSimVarValue(name, unit); + + setStateValue(newValue); + } + }, [name, unit, refreshInterval]); + + useUpdate(updateCallback); + + const setter = useCallback((valueOrSetter: any | SimVarSetter) => { + const executedValue = typeof valueOrSetter === 'function' ? valueOrSetter(stateValue) : valueOrSetter; + + SimVar.SetSimVarValue(name, unit, executedValue); + + setStateValue(executedValue); + + return stateValue; + }, [name, unit, stateValue]); + + return [stateValue, setter]; +}; + +/** + * The useGlobalVar hook provides an easy way to read and write GlobalVars from + * React. The signature is similar to useSimVar, except for the return being a + * single value as it is non-writeable. + * + * @param name The name of the GlobalVar. + * @param unit The unit of the GlobalVar. + * @param refreshInterval The time in milliseconds that needs to elapse before + * the next render will cause a SimVar refresh from the simulator. + * + * @example + * // only refresh the GlobalVar every 100ms (unless this GlobalVar is lower elsewhere) + * const time = useGlobalVar('ZULU TIME', 'seconds', 100); + * + * @returns {[*, (function(*): void)]} + * + * @see {@link useSimVar} if you're trying to access a SimVar instead + */ +export const useGlobalVar = ( + name: string, + unit: UnitName, + refreshInterval = 0, +): SimVarValue => { + const lastUpdate = useRef(Date.now() - refreshInterval - 1); + + const [stateValue, setStateValue] = useState(() => SimVar.GetGlobalVarValue(name, unit)); + + const updateCallback = useCallback(() => { + const delta = Date.now() - lastUpdate.current; + + if (delta >= refreshInterval) { + lastUpdate.current = Date.now(); + + const newValue = SimVar.GetGlobalVarValue(name, unit); + + setStateValue(newValue); + } + }, [name, unit, refreshInterval]); + + useUpdate(updateCallback); + + return stateValue; +}; + +/** + * The useGameVar hook provides an easy way to read and write GameVars from + * React. The signature is similar to useSimVar, except for the return being a + * single value as it is non-writeable. + * + * @param name The name of the useGameVar. + * @param unit The unit of the useGameVar. + * @param refreshInterval The time in milliseconds that needs to elapse before + * the next render will cause a SimVar refresh from the simulator. + * + * @example + * // only refresh the useGameVar every 200ms (unless this useGameVar is lower elsewhere) + * const time = useGameVar('CAMERA POS IN PLANE', 'xyz', 200); + * + * @returns {[*, (function(*): void)]} + * + * @see {@link useSimVar} if you're trying to access a SimVar instead + */ +export const useGameVar = ( + name: string, + unit: UnitName, + refreshInterval = 0, +): SimVarValue => { + const lastUpdate = useRef(Date.now() - refreshInterval - 1); + + const [stateValue, setStateValue] = useState(() => SimVar.GetGameVarValue(name, unit)); + + const updateCallback = useCallback(() => { + const delta = Date.now() - lastUpdate.current; + + if (delta >= refreshInterval) { + lastUpdate.current = Date.now(); + + const newValue = SimVar.GetGameVarValue(name, unit); + + setStateValue(newValue); + } + }, [name, unit, refreshInterval]); + + useUpdate(updateCallback); + + return stateValue; +}; + +/** + * The useInteractionSimVar hook is an optimized version of the useSimVar hook + * when we can guarantee that an interaction event (H event) is emitted whenever + * the SimVar has changed. This can be helpful when the SimVar is set by + * physical button and not a system. + * + * By relying on an H event we need to poll the variable much less frequently, + * as we're guaranteed to be notified of any changes. To handle the SimVar + * change itself through some external means, like third-party plugin or cockpit + * hardware, the SimVar is still refreshed occasionally, but much less + * frequently than with useSimVar. + * + * @param name The name of the SimVar. + * @param unit The unit of the SimVar. + * @param interactionEvents The name of the interaction events that signals a + * change to the SimVar. + * @param refreshInterval The time in milliseconds that needs to elapse before + * the next render will cause a SimVar refresh from the simulator. + * + * @example + * // the XML updates the SimVar and emits an H event, so we can use the optimized version + * const [toggleSwitch, setToggleSwitch] = useInteractionSimVar( + * 'L:A32NX_RMP_LEFT_TOGGLE_SWITCH', + * 'bool', + * 'H:A32NX_RMP_LEFT_TOGGLE_SWITCH' + * ); + * + * @returns {[*, (function(*): void)]} + * + * @see useSimVar if you do not have an H event indicating this SimVar has changed + */ +export const useInteractionSimVar = ( + name: string, + unit: UnitName, + interactionEvents: string | string[], + refreshInterval = 500, +): [SimVarValue, (newValueOrSetter: SimVarValue | SimVarSetter +) => void] => { + const lastUpdate = useRef(Date.now() - refreshInterval - 1); + + const [stateValue, setStateValue] = useState(() => SimVar.GetSimVarValue(name, unit)); + + const updateCallback = useCallback(() => { + const delta = Date.now() - lastUpdate.current; + + if (delta >= refreshInterval) { + lastUpdate.current = Date.now(); + + const newValue = SimVar.GetSimVarValue(name, unit); + + setStateValue(newValue); + } + }, [name, unit, refreshInterval]); + + useUpdate(updateCallback); + + useInteractionEvents( + Array.isArray(interactionEvents) ? interactionEvents : [interactionEvents], + () => setStateValue(SimVar.GetSimVarValue(name, unit)), // force an update + ); + + const setter = useCallback((valueOrSetter: any | SimVarSetter) => { + const executedValue = typeof valueOrSetter === 'function' ? valueOrSetter(stateValue) : valueOrSetter; + + SimVar.SetSimVarValue(name, unit, executedValue); + + setStateValue(executedValue); + + return stateValue; + }, [name, unit]); + + return [stateValue, setter]; +}; + +/** + * The useSplitSimVar hook is a special version of the userSimVar hook that is + * appropriate for some special SimVars where sets need to happen using a + * K event. + * + * @param readName The name of the SimVar to read from. + * @param readUnit The unit of the SimVar to read from. + * @param writeName The name of the SimVar to write to. + * @param writeUnit The unit of the SimVar to write to. + * @param refreshInterval The time in milliseconds that needs to elapse before + * the next render will cause a SimVar refresh from the simulator. + * + * @example + * // read the SimVar 'COM STANDBY FREQUENCY:2', and set it through 'K:COM_2_RADIO_SET_HZ' + * const [frequencyTwo, setFrequencyTwo] = useSplitSimVar( + * 'COM STANDBY FREQUENCY:2', 'Hz', + * 'K:COM_2_RADIO_SET_HZ', 'Hz' + * ); + * + * @returns {[*, (function(*): void)]} + * + * @see useSimVar if you're reading and writing from the same SimVar + */ +export const useSplitSimVar = ( + readName: string, + readUnit: UnitName, + writeName: string, + writeUnit?: UnitName, + refreshInterval = 0, +): [SimVarValue, (newValueOrSetter: SimVarValue | SimVarSetter +) => void] => { + const [readValue] = useSimVar(readName, readUnit, refreshInterval); + const [, writeSetter] = useSimVar(writeName, writeUnit || readUnit); + + const [stateValue, setStateValue] = useState(readValue); + + useEffect(() => { + setStateValue(readValue); + }, [readValue]); + + const setter = useCallback((valueOrSetter: any | SimVarSetter) => { + const executedValue = typeof valueOrSetter === 'function' ? valueOrSetter(stateValue) : valueOrSetter; + + writeSetter(executedValue); + setStateValue(executedValue); + }, [stateValue, writeName]); + + return [stateValue, setter]; +}; diff --git a/fbw-a380x/src/systems/instruments/src/Common/types.tsx b/fbw-a380x/src/systems/instruments/src/Common/types.tsx new file mode 100644 index 00000000000..f5d8884f732 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/types.tsx @@ -0,0 +1,48 @@ +import { KeyboardEvent } from 'react'; + +export type Position = { x: number, y: number } + +export type OneDimensionalSize = { size: number } + +export type TwoDimensionalSize = { width: number, height: number } + +export type OnClick = { onClick: () => void } + +export type OnKeyDown = { onKeyDown: (event: KeyboardEvent) => void } + +export type EngineNumber = { engine: 1 | 2 | 3 | 4 } + +export type FadecActive = { active: boolean; } + +export type SdacActive = { active: boolean; } + +export type IgnitionActive = { ignition: boolean; } + +export type n1Degraded = { n1Degraded: boolean; } + +export type PackNumber = { pack: 1 | 2} + +export type EGTProps = { + engine: 1 | 2 | 3 | 4, + x: number, + y: number, + active: boolean, +}; + +export type CabinDoorProps = { + doorNumber: number, + side: 'L' | 'R', + engineRunning: boolean, + mainOrUpper: 'MAIN' | 'UPPER', +} + +export type CargoDoorProps = { + label: 'AFT' | 'FWD' | 'BULK' | 'AVNCS', + width: number, + height: number, + engineRunning: boolean, +} + +export type OnGround = { + onGround: boolean +} \ No newline at end of file diff --git a/fbw-a380x/src/systems/instruments/src/Common/utils.tsx b/fbw-a380x/src/systems/instruments/src/Common/utils.tsx new file mode 100644 index 00000000000..6de019ce57c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/Common/utils.tsx @@ -0,0 +1,38 @@ +import React, { forwardRef, PropsWithChildren } from 'react'; +import { Position, TwoDimensionalSize } from './types'; + +export type LayerProps = PropsWithChildren & React.SVGProps> + +export const Layer = forwardRef((props, ref) => ( + + {props.children} + +)); + +/** + * Gets the smallest angle between two angles + * @param angle1 First angle in degrees + * @param angle2 Second angle in degrees + * @returns {number} Smallest angle between angle1 and angle2 in degrees + */ +export const getSmallestAngle = (angle1: number, angle2: number) : number => { + let smallestAngle = angle1 - angle2; + if (smallestAngle > 180) { + smallestAngle -= 360; + } else if (smallestAngle < -180) { + smallestAngle += 360; + } + return smallestAngle; +}; + +export const isCaptainSide = (displayIndex: number | undefined) => displayIndex === 1; + +export const getSupplier = (displayIndex: number | undefined, knobValue: number) => { + const adirs3ToCaptain = 0; + const adirs3ToFO = 2; + + if (isCaptainSide(displayIndex)) { + return knobValue === adirs3ToCaptain ? 3 : 1; + } + return knobValue === adirs3ToFO ? 3 : 2; +}; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/EngineWarningDisplay.tsx b/fbw-a380x/src/systems/instruments/src/EWD/EngineWarningDisplay.tsx new file mode 100644 index 00000000000..5315f6ea468 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/EngineWarningDisplay.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { CdsDisplayUnit, DisplayUnitID } from '@instruments/common/CdsDisplayUnit'; +import { useSimVar } from '@instruments/common/simVars'; +import { EngineGauge } from './elements/EngineGauge'; +import ThrustRatingMode from './elements/ThrustRatingMode'; +import PseudoFWC from './elements/PseudoFWC'; +import EWDMemo from './elements/EWDMemo'; +// import { Checklist } from './elements/Checklist'; + +import '../index.scss'; + +export const EngineWarningDisplay: React.FC = () => { + const [engSelectorPosition] = useSimVar('L:XMLVAR_ENG_MODE_SEL', 'Enum', 1000); + const [engine1State] = useSimVar('L:A32NX_ENGINE_STATE:1', 'enum', 500); // TODO: Update with correct SimVars + const [engine2State] = useSimVar('L:A32NX_ENGINE_STATE:2', 'enum', 500); // TODO: Update with correct SimVars + const [engine3State] = useSimVar('L:A32NX_ENGINE_STATE:3', 'enum', 500); // TODO: Update with correct SimVars + const [engine4State] = useSimVar('L:A32NX_ENGINE_STATE:4', 'enum', 500); // TODO: Update with correct SimVars + const engineState = [engine1State, engine2State, engine3State, engine3State, engine4State]; + const engineRunning = engineState.some((value) => value > 0); // TODO Implement FADEC SimVars once available + + const engineRunningOrIgnitionOn = !!(engSelectorPosition === 2 || engineRunning); + + const [n1Degraded] = useState([false, false, false, false]); + const displayMemo = true; + + return ( + + + + + + + + + THR + % + + {/* N1 */} + + N1 + % + + + + + + + {/* EGT */} + + EGT + °C + + + + {/* BEFORE START + */} + + {/* */} + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/config.json b/fbw-a380x/src/systems/instruments/src/EWD/config.json new file mode 100644 index 00000000000..bdcdc31e049 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/config.json @@ -0,0 +1,9 @@ +{ + "index": "./index.tsx", + "isInteractive": true, + "name": "EWD", + "dimensions": { + "width": 768, + "height": 1024 + } +} diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/Checklist.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/Checklist.tsx new file mode 100644 index 00000000000..f528c60c333 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/Checklist.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useState } from 'react'; +import { Position } from '@instruments/common/types'; + +const checklistData = { + completed: [ + 'CKPT PREP : COMPLETE', + 'PARK BRK : ON', + 'GEAR PINS & COVERS : REMOVE', + 'FUEL QTY : CHECK', + 'T.O DATA : SET', + 'BARO REF VALUE : SET', + 'BIG CHUNGUS : ON', + 'SIGNS ON/AUTO', + 'ADIRS NAV', + ], + next: [ + 'WINDOWS/DOORS : CLOSE (BOTH)', + 'BEACON : ON', + 'C/L COMPLETE', + 'RESET', + ], +}; + +const Item = ({ x, y, checked, handleClick, children }: Position & any) => { + const [color, setColor] = useState('#00ff00'); + + useEffect(() => setColor(checked ? '#00ff00' : '#00ffff'), [checked]); + + return ( + + {/** Tick box * */} + + {checked && } + + {/* Hyphen */} + + + {children} + + ); +}; + +const Ticker = ({ x, y, nextItemsHeight, checkedItemsHeight, position }: Position & { nextItemsHeight: number, checkedItemsHeight: number, position: number }) => ( + + {/* Upper frame */} + + + + + {/* Item highlight */} + + + + + {/* Lower frame */} + + + +); + +export const Checklist = ({ x, y }: Position) => { + const [checkedItems, setCheckedItems] = useState(checklistData.completed); + const [nextItems, setNextItems] = useState(checklistData.next); + + const [height, setNextItemsHeight] = useState(0); + const [checkedItemsHeight, setCheckedItemsHeight] = useState(0); + + useEffect(() => { + setCheckedItemsHeight((checkedItems.length - 1) * 32); + }, [checkedItems]); + + useEffect(() => { + setNextItemsHeight(nextItems.length * 32); + }, [nextItems, checkedItemsHeight]); + + const handleItemClick = (itemIndex: number, checked: boolean) => { + if (checked) { + setCheckedItems((items) => items.filter((_, index) => index !== itemIndex)); + } else { + setNextItems((items) => items.filter((_, index) => index !== itemIndex)); + } + }; + + return ( + <> + + + + {/** Completed items * */} + {checkedItems.map((item, index) => handleItemClick(index, true)} checked x={x + 22} y={y + (32 * index)}>{item})} + + + + {/** Completed items * */} + {nextItems.map((item, index) => handleItemClick(index, false)} x={x + 22} y={y + checkedItemsHeight + 62 + (32 * index)}>{item})} + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/EGT.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/EGT.tsx new file mode 100644 index 00000000000..9314705cf31 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/EGT.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { GaugeComponent, GaugeMarkerComponent, GaugeMaxEGTComponent } from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; +import { EGTProps } from '@instruments/common/types'; + +const getModeEGTMax = () => { + const [throttleMode] = useSimVar('L:A32NX_AUTOTHRUST_THRUST_LIMIT_TYPE', 'number', 500); + const [togaWarning] = useSimVar('L:A32NX_AUTOTHRUST_THRUST_LEVER_WARNING_TOGA', 'boolean', 500); + + switch (throttleMode) { + case 4: + return togaWarning ? 1060 : 1025; + + case 1: + case 2: + case 3: + case 5: + return 1025; + + default: + return 750; + } +}; + +const warningEGTColor = (EGTemperature: number) => { + if (EGTemperature > 1060) { + return 'Red'; + } + if (EGTemperature > getModeEGTMax()) { + return 'Amber'; + } + return 'Green'; +}; + +const EGT: React.FC = ({ x, y, engine, active }) => { + const [EGTemperature] = useSimVar(`L:A32NX_ENGINE_EGT:${engine}`, 'celsius'); + const radius = 68; + const startAngle = 270; + const endAngle = 90; + const min = 0; + const max = 1200; + + const modeEGTMax = getModeEGTMax(); + const EGTColour = warningEGTColor(EGTemperature); + + return ( + <> + + {!active + && ( + <> + + XX + + )} + {active && ( + <> + {Math.round(EGTemperature)} + + + + + + + + + + + )} + + + ); +}; + +export default EGT; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/EWDMemo.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/EWDMemo.tsx new file mode 100644 index 00000000000..f3b93bce3dd --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/EWDMemo.tsx @@ -0,0 +1,46 @@ +import FormattedFwcText from '@instruments/common/EWDMessageParser'; +import EWDMessages from '@instruments/common/EWDMessages'; +import { useSimVar } from '@instruments/common/simVars'; +import React from 'react'; + +const padEWDCode = (code: number) => code.toString().padStart(9, '0'); + +interface EWDMemoProps { + x: number, + y: number, + active: boolean, +} + +export const EWDMemo: React.FC = ({ x, y, active }) => { + const [line1] = useSimVar('L:A380X_EWD_RIGHT_LINE_1', 'number', 500); + const [line2] = useSimVar('L:A380X_EWD_RIGHT_LINE_2', 'number', 500); + const [line3] = useSimVar('L:A380X_EWD_RIGHT_LINE_3', 'number', 500); + const [line4] = useSimVar('L:A380X_EWD_RIGHT_LINE_4', 'number', 500); + const [line5] = useSimVar('L:A380X_EWD_RIGHT_LINE_5', 'number', 500); + const [line6] = useSimVar('L:A380X_EWD_RIGHT_LINE_6', 'number', 500); + const [line7] = useSimVar('L:A380X_EWD_RIGHT_LINE_7', 'number', 500); + const [line8] = useSimVar('L:A380X_EWD_RIGHT_LINE_8', 'number', 500); + const message = [ + EWDMessages[padEWDCode(line1)], + EWDMessages[padEWDCode(line2)], + EWDMessages[padEWDCode(line3)], + EWDMessages[padEWDCode(line4)], + EWDMessages[padEWDCode(line5)], + EWDMessages[padEWDCode(line6)], + EWDMessages[padEWDCode(line7)], + EWDMessages[padEWDCode(line8)], + ].join('\r'); + + const numMemos = [line1, line2, line3, line4, line5, line6, line7, line8].filter(Boolean).length; + + return ( + + + 0 ? 'WhiteLine' : 'Hide'} d={`M ${x - 10},${y - 23} l 0,${6 + (numMemos * 30)}`} /> + + + + ); +}; + +export default EWDMemo; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/EngineGauge.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/EngineGauge.tsx new file mode 100644 index 00000000000..d8436dd7f57 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/EngineGauge.tsx @@ -0,0 +1,15 @@ +import { EngineNumber, FadecActive, n1Degraded, Position } from '@instruments/common/types'; +import React from 'react'; +import ThrustGauge from './ThrustGauge'; +import EGT from './EGT'; +import N1 from './N1'; +import IgnitionBorder from './IgnitionBorder'; + +export const EngineGauge: React.FC = ({ x, y, engine, active, n1Degraded }) => ( + + + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/IgnitionBorder.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/IgnitionBorder.tsx new file mode 100644 index 00000000000..a0c146971e9 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/IgnitionBorder.tsx @@ -0,0 +1,27 @@ +import { useSimVar } from '@instruments/common/simVars'; +import { Position, EngineNumber, FadecActive } from '@instruments/common/types'; +import React from 'react'; + +const IgnitionBorder: React.FC = ({ x, y, engine, active }) => { + const [engineState] = useSimVar(`L:A32NX_ENGINE_STATE:${engine}`, 'bool', 500); + const [N1Percent] = useSimVar(`L:A32NX_ENGINE_N1:${engine}`, 'percent', 100); + const [N1Idle] = useSimVar('L:A32NX_ENGINE_IDLE_N1', 'percent', 1000); + const showBorder = !!((N1Percent < Math.floor(N1Idle) - 1) && (engineState === 2)); + // const showBorder = true; + + return ( + <> + + {active && showBorder + && ( + <> + + + + )} + + + ); +}; + +export default IgnitionBorder; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/N1.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/N1.tsx new file mode 100644 index 00000000000..0972d7726bb --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/N1.tsx @@ -0,0 +1,257 @@ +import { GaugeComponent, GaugeMarkerComponent, splitDecimals, ThrottlePositionDonutComponent, valueRadianAngleConverter } from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; +import { Position, EngineNumber, FadecActive, n1Degraded } from '@instruments/common/types'; +import React from 'react'; + +const N1: React.FC = ({ x, y, engine, active, n1Degraded }) => { + const [N1Percent] = useSimVar(`L:A32NX_ENGINE_N1:${engine}`, 'percent', 100); + const N1PercentSplit = splitDecimals(N1Percent); + const [N1Idle] = useSimVar('L:A32NX_ENGINE_IDLE_N1', 'percent', 1000); + const [throttlePosition] = useSimVar(`L:A32NX_AUTOTHRUST_TLA_N1:${engine}`, 'number', 100); + + const radius = 64; + const startAngle = 230; + const endAngle = 90; + const min = 2; + const max = 11.1; + + const xDegraded = x + 2; + + return ( + <> + + {!active + && ( + <> + + XX + + )} + {active && !n1Degraded + && ( + <> + {N1PercentSplit[0]} + . + {N1PercentSplit[1]} + + )} + {active && n1Degraded + && ( + <> + {N1PercentSplit[0]} + . + {N1PercentSplit[1]} + + + + + + + + + + + + + + + )} + + + ); +}; + +export default N1; + +interface N1CommandAndTrendProps { + x: number, + y: number, + radius: number, + N1Actual: number, + startAngle, + endAngle, + min: number, + max: number, + engine: 1 | 2 | 3 | 4 +} + +const N1CommandAndTrend: React.FC = ({ x, y, radius, startAngle, endAngle, min, max, N1Actual, engine }) => { + const [N1Commanded] = useSimVar(`L:A32NX_AUTOTHRUST_N1_COMMANDED:${engine}`, 'number', 100); + const [autothrustStatus] = useSimVar('L:A32NX_AUTOTHRUST_STATUS', 'enum', 100); + + const n1ActualXY = valueRadianAngleConverter({ value: N1Actual, min, max, endAngle, startAngle, perpendicular: true }); + const n1CommandXY = valueRadianAngleConverter({ value: N1Commanded / 10, min, max, endAngle, startAngle, perpendicular: true }); + + const n1CommandPlusArrow = valueRadianAngleConverter({ + value: N1Commanded / 10, + min, + max, + endAngle: (N1Actual > (N1Commanded / 10) ? n1CommandXY.angle : n1CommandXY.angle + 20), + startAngle: (N1Actual > (N1Commanded / 10) ? n1CommandXY.angle - 24 : n1CommandXY.angle), + perpendicular: false, + }); + + const n1CommandArrow = valueRadianAngleConverter({ value: N1Commanded / 10, min, max, endAngle, startAngle, perpendicular: false }); + const n1ActualArrowXY = { + x: x + (n1CommandPlusArrow.x * radius * 0.50), + y: y + (n1CommandPlusArrow.y * radius * 0.50), + }; + const n1CommandArrowXY = { + x: x + (n1CommandArrow.x * radius * 0.50), // Based on 20 degree angle and hypotenuse of 0.5 + y: y + (n1CommandArrow.y * radius * 0.50), + }; + + // console.log(Math.abs(N1Actual - (N1Commanded / 10))); + + const radiusDivide = radius / 5; + const commandAndTrendRadius = [radius - radiusDivide, radius - (2 * radiusDivide), radius - (3 * radiusDivide), radius - (4 * radiusDivide)]; + const N1CommandArray : any[] = []; + commandAndTrendRadius.forEach((commandradius) => N1CommandArray.push( (N1Commanded / 10) ? n1CommandXY.angle : n1ActualXY.angle} + endAngle={N1Actual > (N1Commanded / 10) ? n1ActualXY.angle : n1CommandXY.angle} + visible={autothrustStatus === 2 && Math.abs(N1Actual - (N1Commanded / 10)) > 0.3} + className='GreenLine' + />)); + + return ( + <> + + 0.3 ? 'Show' : 'Hide'}`} + indicator + /> + (N1Commanded / 10) ? n1CommandXY.angle - 20 : n1CommandXY.angle} + endAngle={N1Actual > (N1Commanded / 10) ? n1CommandXY.angle : n1CommandXY.angle + 20} + multiplierOuter={0.51} + className={`GreenLine ${autothrustStatus === 2 && Math.abs(N1Actual - (N1Commanded / 10)) > 0.3 ? 'Show' : 'Hide'}`} + indicator + /> + 0.3 ? 'Show' : 'Hide'}`} + /> + {N1CommandArray} + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/PseudoFWC.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/PseudoFWC.tsx new file mode 100644 index 00000000000..a481b0e721b --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/PseudoFWC.tsx @@ -0,0 +1,838 @@ +import React, { useEffect, useState } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { NXDataStore } from '@shared/persistence'; +import { usePersistentProperty } from '@instruments/common/persistence'; +import { useUpdate } from '@instruments/common/hooks'; +import { NXLogicConfirmNode, NXLogicClockNode, NXLogicMemoryNode } from '@instruments/common/NXLogic'; +import { useArinc429Var } from '@instruments/common/arinc429'; +import { Arinc429Word } from '@shared/arinc429'; + +const mapOrder = (array, order) => { + array.sort((a, b) => { + if (order.indexOf(a) > order.indexOf(b)) { + return 1; + } + return -1; + }); + return array; +}; + +const adirsMessage1 = (adirs, engineRunning) => { + let rowChoice = 0; + switch (true) { + case (Math.ceil(adirs / 60) >= 7 && !engineRunning): + rowChoice = 0; + break; + case (Math.ceil(adirs / 60) >= 7 && engineRunning): + rowChoice = 1; + break; + case (Math.ceil(adirs / 60) === 6 && !engineRunning): + rowChoice = 2; + break; + case (Math.ceil(adirs / 60) === 6 && engineRunning): + rowChoice = 3; + break; + case (Math.ceil(adirs / 60) === 5 && !engineRunning): + rowChoice = 4; + break; + case (Math.ceil(adirs / 60) === 5 && engineRunning): + rowChoice = 5; + break; + case (Math.ceil(adirs / 60) === 4 && !engineRunning): + rowChoice = 6; + break; + case (Math.ceil(adirs / 60) === 4 && engineRunning): + rowChoice = 7; + break; + default: + break; + } + return rowChoice; +}; + +const adirsMessage2 = (adirs, engineRunning) => { + let rowChoice = 0; + switch (true) { + case (Math.ceil(adirs / 60) === 3 && !engineRunning): + rowChoice = 0; + break; + case (Math.ceil(adirs / 60) === 3 && engineRunning): + rowChoice = 1; + break; + case (Math.ceil(adirs / 60) === 2 && !engineRunning): + rowChoice = 2; + break; + case (Math.ceil(adirs / 60) === 2 && engineRunning): + rowChoice = 3; + break; + case (Math.ceil(adirs / 60) === 1 && !engineRunning): + rowChoice = 4; + break; + case (Math.ceil(adirs / 60) === 1 && engineRunning): + rowChoice = 5; + break; + default: + break; + } + return rowChoice; +}; + +const PseudoFWC: React.FC = () => { + const [toInhibitTimer] = useState(() => new NXLogicConfirmNode(3)); + const [ldgInhibitTimer] = useState(() => new NXLogicConfirmNode(3)); + const [agent1Eng1DischargeTimer] = useState(() => new NXLogicClockNode(10, 0)); + const [agent2Eng1DischargeTimer] = useState(() => new NXLogicClockNode(30, 0)); + const [agent1Eng2DischargeTimer] = useState(() => new NXLogicClockNode(10, 0)); + const [agent2Eng2DischargeTimer] = useState(() => new NXLogicClockNode(30, 0)); + const [agentAPUDischargeTimer] = useState(() => new NXLogicClockNode(10, 0)); + const [iceSevereDetectedTimer] = useState(() => new NXLogicConfirmNode(40, false)); + const [iceDetectedTimer1] = useState(() => new NXLogicConfirmNode(40, false)); + const [iceDetectedTimer2] = useState(() => new NXLogicConfirmNode(5)); + const [iceNotDetTimer1] = useState(() => new NXLogicConfirmNode(60)); + const [iceNotDetTimer2] = useState(() => new NXLogicConfirmNode(130)); + const [packOffNotFailed1] = useState(() => new NXLogicConfirmNode(60)); + const [packOffNotFailed2] = useState(() => new NXLogicConfirmNode(60)); + const [packOffBleedAvailable1] = useState(() => new NXLogicConfirmNode(5, false)); + const [packOffBleedAvailable2] = useState(() => new NXLogicConfirmNode(5, false)); + const [cabAltSetReset1] = useState(() => new NXLogicMemoryNode()); + const [cabAltSetReset2] = useState(() => new NXLogicMemoryNode()); + + const [memoMessageRight, setMemoMessageRight] = useState([]); + const [flightPhase] = useSimVar('L:A32NX_FWC_FLIGHT_PHASE', 'enum', 1000); + + /* SETTINGS */ + const [unit] = usePersistentProperty('CONFIG_USING_METRIC_UNIT', '1'); + const configPortableDevices = parseInt(NXDataStore.get('CONFIG_USING_PORTABLE_DEVICES', '0')); + + /* ANTI-ICE */ + const [eng1AntiIce] = useSimVar('ENG ANTI ICE:1', 'bool', 500); + const [eng2AntiIce] = useSimVar('ENG ANTI ICE:2', 'bool', 500); + const [wingAntiIce] = useSimVar('STRUCTURAL DEICE SWITCH', 'bool', 500); + const [icePercentage] = useSimVar('STRUCTURAL ICE PCT', 'percent over 100', 500); + const [tat] = useSimVar('TOTAL AIR TEMPERATURE', 'celsius', 1000); + const [inCloud] = useSimVar('AMBIENT IN CLOUD', 'boolean', 1000); + + /* ELECTRICAL */ + const [emergencyElectricGeneratorPotential] = useSimVar('L:A32NX_ELEC_EMER_GEN_POTENTIAL', 'number', 500); + const [dcESSBusPowered] = useSimVar('L:A32NX_ELEC_DC_ESS_BUS_IS_POWERED', 'bool', 500); + const [ac1BusPowered] = useSimVar('L:A32NX_ELEC_AC_1_BUS_IS_POWERED', 'bool', 500); + const [ac2BusPowered] = useSimVar('L:A32NX_ELEC_AC_2_BUS_IS_POWERED', 'bool', 500); + const emergencyGeneratorOn = emergencyElectricGeneratorPotential > 0 ? 1 : 0; + + /* ENGINE AND THROTTLE */ + + const [engine1State] = useSimVar('L:A32NX_ENGINE_STATE:1', 'enum', 500); + const [engine2State] = useSimVar('L:A32NX_ENGINE_STATE:2', 'enum', 500); + const [throttle1Position] = useSimVar('L:A32NX_AUTOTHRUST_TLA:1', 'number', 100); + const [throttle2Position] = useSimVar('L:A32NX_AUTOTHRUST_TLA:2', 'number', 100); + const [engine1ValueSwitch] = useSimVar('FUELSYSTEM VALVE SWITCH:1', 'bool', 500); + const [engine2ValueSwitch] = useSimVar('FUELSYSTEM VALVE SWITCH:2', 'bool', 500); + const [autothrustLeverWarningFlex] = useSimVar('L:A32NX_AUTOTHRUST_THRUST_LEVER_WARNING_FLEX', 'bool', 500); + const [autothrustLeverWarningTOGA] = useSimVar('L:A32NX_AUTOTHRUST_THRUST_LEVER_WARNING_TOGA', 'bool', 500); + const thrustLeverNotSet = autothrustLeverWarningFlex === 1 || autothrustLeverWarningTOGA === 1; + const [engSelectorPosition] = useSimVar('L:XMLVAR_ENG_MODE_SEL', 'enum', 1000); + const [autoThrustStatus] = useSimVar('L:A32NX_AUTOTHRUST_STATUS', 'enum', 500); + + /* FIRE */ + + const [fireButton1] = useSimVar('L:A32NX_FIRE_BUTTON_ENG1', 'bool', 500); + const [fireButton2] = useSimVar('L:A32NX_FIRE_BUTTON_ENG2', 'bool', 500); + const [fireButtonAPU] = useSimVar('L:A32NX_FIRE_BUTTON_APU', 'bool', 500); + const [eng1FireTest] = useSimVar('L:A32NX_FIRE_TEST_ENG1', 'bool', 500); + const [eng2FireTest] = useSimVar('L:A32NX_FIRE_TEST_ENG2', 'bool', 500); + const [apuFireTest] = useSimVar('L:A32NX_FIRE_TEST_APU', 'bool', 500); + const [eng1Agent1PB] = useSimVar('L:A32NX_FIRE_ENG1_AGENT1_Discharge', 'bool', 500); + const [eng1Agent2PB] = useSimVar('L:A32NX_FIRE_ENG1_AGENT2_Discharge', 'bool', 500); + const [eng2Agent1PB] = useSimVar('L:A32NX_FIRE_ENG2_AGENT1_Discharge', 'bool', 500); + const [eng2Agent2PB] = useSimVar('L:A32NX_FIRE_ENG2_AGENT2_Discharge', 'bool', 500); + const [apuAgentPB] = useSimVar('L:A32NX_FIRE_APU_AGENT1_Discharge', 'bool', 500); + const [cargoFireTest] = useSimVar('L:A32NX_FIRE_TEST_CARGO', 'bool', 500); + const [cargoFireAgentDisch] = useSimVar('L:A32NX_CARGOSMOKE_FWD_DISCHARGED', 'bool', 500); + + /* FUEL */ + const [fuel] = useSimVar('A:INTERACTIVE POINT OPEN:9', 'percent', 500); + const [fob] = useSimVar('FUEL TOTAL QUANTITY WEIGHT', 'kg', 500); + const fobRounded = Math.round(fob / 10) * 10; + const [usrStartRefueling] = useSimVar('L:A32NX_REFUEL_STARTED_BY_USR', 'bool', 500); + const [leftOuterInnerValve] = useSimVar('FUELSYSTEM VALVE OPEN:4', 'bool', 500); + const [rightOuterInnerValve] = useSimVar('FUELSYSTEM VALVE OPEN:5', 'bool', 500); + const [fuelXFeedPBOn] = useSimVar('L:XMLVAR_Momentary_PUSH_OVHD_FUEL_XFEED_Pressed', 'bool', 500); + + /* HYDRAULICS */ + const [greenHydEng1PBAuto] = useSimVar('L:A32NX_OVHD_HYD_ENG_1_PUMP_PB_IS_AUTO', 'bool', 500); + const [blueRvrLow] = useSimVar('L:A32NX_HYD_BLUE_RESERVOIR_LEVEL_IS_LOW', 'bool', 500); + const [blueElecPumpPBAuto] = useSimVar('L:A32NX_OVHD_HYD_EPUMPB_PB_IS_AUTO', 'bool', 500); + const [hydPTU] = useSimVar('L:A32NX_HYD_PTU_ON_ECAM_MEMO', 'bool', 500); + const [ratDeployed] = useSimVar('L:A32NX_HYD_RAT_STOW_POSITION', 'percent over 100', 500); + + /* LANDING GEAR AND LIGHTS */ + // const [left1LandingGear] = useSimVar('L:A32NX_LGCIU_1_LEFT_GEAR_COMPRESSED', 'bool', 500); + // const [right1LandingGear] = useSimVar('L:A32NX_LGCIU_1_RIGHT_GEAR_COMPRESSED', 'bool', 500); + // const aircraftOnGround = left1LandingGear === 1 || right1LandingGear === 1; + // FIXME The landing gear triggers the dual engine failure on loading + const aircraftOnGround = useSimVar('SIM ON GROUND', 'Bool', 500); + const [landingLight2Retracted] = useSimVar('L:LANDING_2_Retracted', 'bool', 500); + const [landingLight3Retracted] = useSimVar('L:LANDING_3_Retracted', 'bool', 500); + const [autoBrakesArmedMode] = useSimVar('L:A32NX_AUTOBRAKES_ARMED_MODE', 'enum', 500); + const [antiskidActive] = useSimVar('ANTISKID BRAKES ACTIVE', 'bool', 500); + + /* OTHER STUFF */ + + const [spoilersArmed] = useSimVar('L:A32NX_SPOILERS_ARMED', 'bool', 500); + const [seatBelt] = useSimVar('A:CABIN SEATBELTS ALERT SWITCH', 'bool', 500); + const [noSmoking] = useSimVar('L:A32NX_NO_SMOKING_MEMO', 'bool', 500); + const [noSmokingSwitchPosition] = useSimVar('L:XMLVAR_SWITCH_OVHD_INTLT_NOSMOKING_Position', 'enum', 500); + + const [strobeLightsOn] = useSimVar('L:LIGHTING_STROBE_0', 'bool', 500); + + const [gpwsFlapMode] = useSimVar('L:A32NX_GPWS_FLAP_OFF', 'bool', 500); + const [tomemo] = useSimVar('L:A32NX_FWC_TOMEMO', 'bool', 500); + const [ldgmemo] = useSimVar('L:A32NX_FWC_LDGMEMO', 'bool', 500); + + const [autoBrake] = useSimVar('L:A32NX_AUTOBRAKES_ARMED_MODE', 'enum', 500); + const [flapsHandle] = useSimVar('L:A32NX_FLAPS_HANDLE_INDEX', 'enum', 500); + const [flapsIndex] = useSimVar('L:A32NX_FLAPS_CONF_INDEX', 'number', 100); + const [toconfig] = useSimVar('L:A32NX_TO_CONFIG_NORMAL', 'bool', 100); + + const [adirsRemainingAlignTime] = useSimVar('L:A32NX_ADIRS_REMAINING_IR_ALIGNMENT_TIME', 'seconds', 1000); + const [adiru1State] = useSimVar('L:A32NX_ADIRS_ADIRU_1_STATE', 'enum', 500); + const [adiru2State] = useSimVar('L:A32NX_ADIRS_ADIRU_2_STATE', 'enum', 500); + const [adiru3State] = useSimVar('L:A32NX_ADIRS_ADIRU_3_STATE', 'enum', 500); + + const [cabinReady] = useSimVar('L:A32NX_CABIN_READY', 'bool'); + + const [speedBrake] = useSimVar('L:A32NX_SPOILERS_HANDLE_POSITION', 'number', 500); + const [parkBrake] = useSimVar('L:A32NX_PARK_BRAKE_LEVER_POS', 'bool', 500); + const [apuMasterSwitch] = useSimVar('L:A32NX_OVHD_APU_MASTER_SW_PB_IS_ON', 'bool', 500); + const [flightPhaseInhibitOverride] = useSimVar('L:A32NX_FWC_INHIBOVRD', 'bool', 500); + const [nwSteeringDisc] = useSimVar('L:A32NX_HYD_NW_STRG_DISC_ECAM_MEMO', 'bool', 500); + const [predWSOn] = useSimVar('L:A32NX_SWITCH_RADAR_PWS_Position', 'bool', 1000); + const [gpwsOff] = useSimVar('L:A32NX_GPWS_TERR_OFF', 'bool', 500); + const [tcasMode] = useSimVar('L:A32NX_TCAS_MODE', 'enum', 500); + const [tcasSensitivity] = useSimVar('L:A32NX_TCAS_SENSITIVITY', 'enum', 500); + const [compMesgCount] = useSimVar('L:A32NX_COMPANY_MSG_COUNT', 'number', 500); + const height1: Arinc429Word = useArinc429Var('L:A32NX_RA_1_RADIO_ALTITUDE'); + const height2: Arinc429Word = useArinc429Var('L:A32NX_RA_2_RADIO_ALTITUDE'); + const height1Failed = height1.isFailureWarning(); + const height2Failed = height2.isFailureWarning(); + + const [apuBleedValveOpen] = useSimVar('L:A32NX_APU_BLEED_AIR_VALVE_OPEN', 'bool', 500); + const [apuAvail] = useSimVar('L:A32NX_OVHD_APU_START_PB_IS_AVAILABLE', 'bool', 500); + + const [brakeFan] = useSimVar('L:A32NX_BRAKE_FAN', 'bool', 500); + const [dmcSwitchingKnob] = useSimVar('L:A32NX_EIS_DMC_SWITCHING_KNOB', 'enum', 500); + const [ndXfrKnob] = useSimVar('L:A32NX_ECAM_ND_XFR_SWITCHING_KNOB', 'bool', 500); + const [gpwsFlaps3] = useSimVar('L:A32NX_GPWS_FLAPS3', 'bool', 500); + const [manLandingElevation] = useSimVar('L:XMLVAR_KNOB_OVHD_CABINPRESS_LDGELEV', 'number', 500); + const [ATTKnob] = useSimVar('L:A32NX_ATT_HDG_SWITCHING_KNOB', 'enum', 500); + const [AIRKnob] = useSimVar('L:A32NX_AIR_DATA_SWITCHING_KNOB', 'enum', 500); + + const [fac1Failed] = useSimVar('L:A32NX_FBW_FAC_FAILED:1', 'bool', 500); + const [tcasFault] = useSimVar('L:A32NX_TCAS_FAULT', 'bool', 500); + + const [cabinRecircBtnOn] = useSimVar('L:A32NX_VENTILATION_CABFANS_TOGGLE', 'bool', 500); + const computedAirSpeed: Arinc429Word = useArinc429Var('L:A32NX_ADIRS_ADR_1_COMPUTED_AIRSPEED', 1000); + // Reduce number of rewrites triggered by this value + const computedAirSpeedToNearest2 = Math.round(computedAirSpeed.value / 2) * 2; + const adirsAlt: Arinc429Word = useArinc429Var('L:A32NX_ADIRS_ADR_1_ALTITUDE', 500); + + /* PACKS */ + const [crossfeed] = useSimVar('L:A32NX_PNEU_XBLEED_VALVE_OPEN', 'bool', 500); + const [eng1Bleed] = useSimVar('A:BLEED AIR ENGINE:1', 'bool'); + const [eng1BleedPbFault] = useSimVar('L:A32NX_OVHD_PNEU_ENG_1_BLEED_PB_HAS_FAULT', 'bool', 500); + const [eng2Bleed] = useSimVar('A:BLEED AIR ENGINE:2', 'bool', 100); + const [eng2BleedPbFault] = useSimVar('L:A32NX_OVHD_PNEU_ENG_2_BLEED_PB_HAS_FAULT', 'bool', 500); + const [pack1Fault] = useSimVar('L:A32NX_AIRCOND_PACK1_FAULT', 'bool'); + const [pack2Fault] = useSimVar('L:A32NX_AIRCOND_PACK2_FAULT', 'bool'); + const [pack1On] = useSimVar('L:A32NX_OVHD_COND_PACK_1_PB_IS_ON', 'bool'); + const [pack2On] = useSimVar('L:A32NX_OVHD_COND_PACK_2_PB_IS_ON', 'bool'); + const [excessPressure] = useSimVar('L:A32NX_PRESS_EXCESS_CAB_ALT', 'bool', 500); + + /* TICK CHECK */ + let showTakeoffInhibit = false; + let showLandingInhibit = false; + const [agent1Eng1Discharge, setAgent1Eng1Discharge] = useState(0); + const [agent2Eng1Discharge, setAgent2Eng1Discharge] = useState(0); + const [agent1Eng2Discharge, setAgent1Eng2Discharge] = useState(0); + const [agent2Eng2Discharge, setAgent2Eng2Discharge] = useState(0); + const [agentAPUDischarge, setAgentAPUDischarge] = useState(0); + const [iceDetected1, setIceDetected1] = useState(0); + const [iceDetected2, setIceDetected2] = useState(0); + const [iceSevereDetected, setIceSevereDetected] = useState(0); + const [iceNotDetected1, setIceNotDetected1] = useState(0); + const [iceNotDetected2, setIceNotDetected2] = useState(0); + const [packOffBleedIsAvailable1, setPackOffBleedIsAvailable1] = useState(0); + const [packOffBleedIsAvailable2, setPackOffBleedIsAvailable2] = useState(0); + const [packOffNotFailure1, setPackOffNotFailure1] = useState(0); + const [packOffNotFailure2, setPackOffNotFailure2] = useState(0); + const [cabAltSetResetState1, setCabAltSetResetState1] = useState(false); + const [cabAltSetResetState2, setCabAltSetResetState2] = useState(false); + + useUpdate((deltaTime) => { + showTakeoffInhibit = toInhibitTimer.write([3, 4, 5].includes(flightPhase) && !flightPhaseInhibitOverride, deltaTime); + showLandingInhibit = ldgInhibitTimer.write([7, 8].includes(flightPhase) && !flightPhaseInhibitOverride, deltaTime); + const agent1Eng1DischargeNode = agent1Eng1DischargeTimer.write(fireButton1 === 1, deltaTime); + if (agent1Eng1Discharge !== agent1Eng1DischargeNode) { + setAgent1Eng1Discharge(agent1Eng1DischargeNode); + } + const agent2Eng1DischargeNode = agent2Eng1DischargeTimer.write(fireButton1 === 1 && eng1Agent1PB === 1 && !aircraftOnGround, deltaTime); + if (agent2Eng1Discharge !== agent2Eng1DischargeNode) { + setAgent2Eng1Discharge(agent2Eng1DischargeNode); + } + const agent1Eng2DischargeNode = agent1Eng2DischargeTimer.write(fireButton2 === 1 && !eng1Agent1PB, deltaTime); + if (agent1Eng2Discharge !== agent1Eng2DischargeNode) { + setAgent1Eng2Discharge(agent1Eng2DischargeNode); + } + const agent2Eng2DischargeNode = agent2Eng2DischargeTimer.write(fireButton2 === 1 && eng1Agent1PB === 1, deltaTime); + if (agent2Eng2Discharge !== agent2Eng2DischargeNode) { + setAgent2Eng2Discharge(agent2Eng2DischargeNode); + } + const agentAPUDischargeNode = agentAPUDischargeTimer.write(fireButton2 === 1 && eng1Agent1PB === 1, deltaTime); + if (agentAPUDischarge !== agentAPUDischargeNode) { + setAgentAPUDischarge(agentAPUDischargeNode); + } + const iceDetected1Node = iceDetectedTimer1.write(icePercentage >= 0.1 && tat < 10 && !aircraftOnGround, deltaTime); + if (iceDetected1 !== iceDetected1Node) { + setIceDetected1(iceDetected1Node); + } + const iceDetected2Node = iceDetectedTimer2.write(iceDetected1Node && !(eng1AntiIce && eng2AntiIce), deltaTime); + if (iceDetected2 !== iceDetected2Node) { + setIceDetected2(iceDetected2Node); + } + + const iceSevereDetectedNode = iceSevereDetectedTimer.write(icePercentage >= 0.5 && tat < 10 && !aircraftOnGround, deltaTime); + if (iceSevereDetected !== iceSevereDetectedNode) { + setIceSevereDetected(iceSevereDetectedNode); + } + + const iceNotDetected1Node = iceNotDetTimer1.write(eng1AntiIce === 1 || eng2AntiIce === 1 || wingAntiIce === 1, deltaTime); + if (iceNotDetected1 !== iceNotDetected1Node) { + setIceNotDetected1(iceNotDetected1Node); + } + + const iceNotDetected2Node = iceNotDetTimer2.write(iceNotDetected1 && !(icePercentage >= 0.1 || (tat < 10 && inCloud === 1)), deltaTime); + if (iceNotDetected2 !== iceNotDetected2Node) { + setIceNotDetected2(iceNotDetected2Node); + } + + const packOffBleedIsAvailable1Node = packOffBleedAvailable1.write((eng1Bleed === 1 && !eng1BleedPbFault) || crossfeed === 1, deltaTime); + if (packOffBleedIsAvailable1 !== packOffBleedIsAvailable1Node) { + setPackOffBleedIsAvailable1(packOffBleedIsAvailable1Node); + } + + const packOffBleedIsAvailable2Node = packOffBleedAvailable2.write((eng2Bleed === 1 && !eng2BleedPbFault) || crossfeed === 1, deltaTime); + if (packOffBleedIsAvailable2 !== packOffBleedIsAvailable2Node) { + setPackOffBleedIsAvailable2(packOffBleedIsAvailable2Node); + } + + const packOffNotFailed1Node = packOffNotFailed1.write(!pack1On && !pack1Fault && packOffBleedAvailable1.read() && flightPhase === 6, deltaTime); + if (packOffNotFailure1 !== packOffNotFailed1Node) { + setPackOffNotFailure1(packOffNotFailed1Node); + } + const packOffNotFailed2Node = packOffNotFailed2.write(!pack2On && !pack2Fault && packOffBleedAvailable2.read() && flightPhase === 6, deltaTime); + if (packOffNotFailure2 !== packOffNotFailed2Node) { + setPackOffNotFailure2(packOffNotFailed2Node); + } + const cabAltSetReset1Node = cabAltSetReset1.write(adirsAlt.value > 10000 && excessPressure === 1, excessPressure === 1 && [3, 10].includes(flightPhase)); + if (cabAltSetResetState1 !== cabAltSetReset1Node) { + setCabAltSetResetState1(cabAltSetReset1Node); + } + + const cabAltSetReset2Node = cabAltSetReset2.write(adirsAlt.value > 16000 && excessPressure === 1, excessPressure === 1 && [3, 10].includes(flightPhase)); + if (cabAltSetResetState2 !== cabAltSetReset2Node) { + setCabAltSetResetState2(cabAltSetReset2Node); + } + }); + + /* FAILURES, MEMOS AND SPECIAL LINES */ + + interface EWDItem { + flightPhaseInhib: number[], + simVarIsActive: boolean, + whichCodeToReturn: any[], + codesToReturn: string[], + memoInhibit: boolean, + failure: number, + sysPage: number, + side: string + } + + interface EWDMessageDict { + [key: string] : EWDItem + } + + const EWDMessageMemos: EWDMessageDict = { + '0000140': // T.O. INHIBIT + { + flightPhaseInhib: [], + simVarIsActive: showTakeoffInhibit, + whichCodeToReturn: [0], + codesToReturn: ['000014001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000150': // LDG INHIBIT + { + flightPhaseInhib: [], + simVarIsActive: showLandingInhibit, + whichCodeToReturn: [0], + codesToReturn: ['000015001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000060': // SPEED BRK + { + flightPhaseInhib: [], + simVarIsActive: speedBrake > 0 && ![1, 8, 9, 10].includes(flightPhase), + whichCodeToReturn: [![6, 7].includes(flightPhase) ? 1 : 0], + codesToReturn: ['000006001', '000006002'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000200': // PARK BRK + { + flightPhaseInhib: [], + simVarIsActive: !!([1, 2, 9, 10].includes(flightPhase) && parkBrake === 1), + whichCodeToReturn: [0], + codesToReturn: ['000020001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000040': // NW STRG DISC + { + flightPhaseInhib: [], + simVarIsActive: nwSteeringDisc === 1, + whichCodeToReturn: [engine1State > 0 || engine2State > 1 ? 1 : 0], + codesToReturn: ['000004001', '000004002'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000160': // PTU ON + { + flightPhaseInhib: [], + simVarIsActive: hydPTU === 1, + whichCodeToReturn: [0], + codesToReturn: ['000016001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000210': // RAT OUT + { + flightPhaseInhib: [], + simVarIsActive: ratDeployed > 0, + whichCodeToReturn: [[1, 2].includes(flightPhase) ? 1 : 0], + codesToReturn: ['000021001', '000021002'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000070': // IGNITION + { + flightPhaseInhib: [], + simVarIsActive: engSelectorPosition === 2, + whichCodeToReturn: [0], + codesToReturn: ['000007001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000540': // PRED W/S OFF + { + flightPhaseInhib: [], + simVarIsActive: !!(predWSOn === 0 && ![1, 10].includes(flightPhase)), + whichCodeToReturn: [[3, 4, 5, 7, 8, 9].includes(flightPhase) || toconfig === 1 ? 1 : 0], + codesToReturn: ['000054001', '000054002'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000545': // TERR OFF + { + flightPhaseInhib: [1, 10], + simVarIsActive: !!(gpwsOff === 1 && ![1, 10].includes(flightPhase)), + whichCodeToReturn: [[3, 4, 5, 7, 8, 9].includes(flightPhase) || toconfig === 1 ? 1 : 0], + codesToReturn: ['000054501', '000054502'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000320': // TCAS STBY + { + flightPhaseInhib: [], + simVarIsActive: !!(tcasSensitivity === 1 && flightPhase !== 6), + whichCodeToReturn: [0], + codesToReturn: ['000032001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000325': // TCAS STBY in flight + { + flightPhaseInhib: [], + simVarIsActive: !!(tcasSensitivity === 1 && flightPhase === 6), + whichCodeToReturn: [0], + codesToReturn: ['000032501'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000552': // COMPANY MESSAGE + { + flightPhaseInhib: [], + simVarIsActive: [1, 2, 6, 9, 10].includes(flightPhase) && compMesgCount > 0, + whichCodeToReturn: [0], + codesToReturn: ['000055201'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000260': // ENG ANTI ICE + { + flightPhaseInhib: [3, 4, 5, 7, 8], + simVarIsActive: !!(eng1AntiIce === 1 || eng2AntiIce === 1), + whichCodeToReturn: [0], + codesToReturn: ['000026001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000270': // WING ANTI ICE + { + flightPhaseInhib: [], + simVarIsActive: wingAntiIce === 1, + whichCodeToReturn: [0], + codesToReturn: ['000027001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000275': // ICE NOT DETECTED + { + flightPhaseInhib: [1, 2, 3, 4, 8, 9, 10], + simVarIsActive: iceNotDetTimer2.read() && !aircraftOnGround, + whichCodeToReturn: [0], + codesToReturn: ['000027501'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000170': // APU AVAIL + { + flightPhaseInhib: [], + simVarIsActive: !!(apuAvail === 1 && !apuBleedValveOpen), + whichCodeToReturn: [0], + codesToReturn: ['000017001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000180': // APU BLEED + { + flightPhaseInhib: [], + simVarIsActive: !!(apuAvail === 1 && apuBleedValveOpen === 1), + whichCodeToReturn: [0], + codesToReturn: ['000018001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000080': // SEAT BELTS + { + flightPhaseInhib: [], + simVarIsActive: !!seatBelt, + whichCodeToReturn: [0], + codesToReturn: ['000008001'], + memoInhibit: !!(tomemo === 1 || ldgmemo === 1), + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000085': // PORTABLE DEVICES + { + flightPhaseInhib: [], + simVarIsActive: !!(noSmoking === 1), + whichCodeToReturn: [0], + codesToReturn: ['000008501'], + memoInhibit: !!(tomemo === 1 || ldgmemo === 1), + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000190': // LDG LT + { + flightPhaseInhib: [], + simVarIsActive: !!(!landingLight2Retracted || !landingLight3Retracted), + whichCodeToReturn: [0], + codesToReturn: ['000019001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000220': // BRAKE FAN + { + flightPhaseInhib: [], + simVarIsActive: brakeFan === 1, + whichCodeToReturn: [0], + codesToReturn: ['000022001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000290': // SWITCHING PNL + { + flightPhaseInhib: [], + simVarIsActive: !!(ndXfrKnob !== 1 || dmcSwitchingKnob !== 1), + whichCodeToReturn: [0], + codesToReturn: ['000029001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000300': // GPWS FLAPS 3 + { + flightPhaseInhib: [], + simVarIsActive: gpwsFlaps3 === 1, + whichCodeToReturn: [0], + codesToReturn: ['000030001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000022': // AUTOBRAKE + { + flightPhaseInhib: [], + simVarIsActive: [7, 8].includes(flightPhase), + whichCodeToReturn: [parseInt(autoBrakesArmedMode) - 1], + codesToReturn: ['000002201', '000002202', '000002203', '000002204'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000230': // MAN LANDING ELEVATION + { + flightPhaseInhib: [], + simVarIsActive: manLandingElevation > 0, + whichCodeToReturn: [0], + codesToReturn: ['000023001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000250': // FUEL X FEED + { + flightPhaseInhib: [], + simVarIsActive: fuelXFeedPBOn === 1, + whichCodeToReturn: [[3, 4, 5].includes(flightPhase) ? 1 : 0], + codesToReturn: ['000025001', '000025002'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + '0000680': // ADIRS SWTG + { + flightPhaseInhib: [], + simVarIsActive: !!(ATTKnob !== 1 || AIRKnob !== 1), + whichCodeToReturn: [0], + codesToReturn: ['000068001'], + memoInhibit: false, + failure: 0, + sysPage: -1, + side: 'RIGHT', + }, + }; + + useEffect(() => { + let tempMemoArrayRight:string[] = []; + + for (const [, value] of Object.entries(EWDMessageMemos)) { + if (value.simVarIsActive && !(value.memoInhibit) && !value.flightPhaseInhib.some((e) => e === flightPhase)) { + const newCode: string[] = []; + + const codeIndex = value.whichCodeToReturn.filter((e) => e !== null); + codeIndex.forEach((e: number) => { + newCode.push(value.codesToReturn[e]); + }); + + if (value.side === 'RIGHT') { + const tempArrayRight = tempMemoArrayRight.filter((e) => !value.codesToReturn.includes(e)); + tempMemoArrayRight = tempArrayRight.concat(newCode); + } + } + } + const mesgOrderLeft: string[] = []; + const mesgOrderRight: string[] = []; + for (const [, value] of Object.entries(EWDMessageMemos)) { + if (value.side === 'LEFT') { + mesgOrderLeft.push(...value.codesToReturn); + } else { + mesgOrderRight.push(...value.codesToReturn); + } + } + let orderedMemoArrayRight = mapOrder(tempMemoArrayRight, mesgOrderRight); + + const specialLines = ['000014001', '000015001', '000035001', '000036001', '220001501', '220002101']; + const filteredMemo = orderedMemoArrayRight.filter((e) => !specialLines.includes(e)); + const specLinesInMemo = orderedMemoArrayRight.filter((e) => specialLines.includes(e)); + if (specLinesInMemo.length > 0) { + orderedMemoArrayRight = [...specLinesInMemo, ...filteredMemo]; + } + setMemoMessageRight(orderedMemoArrayRight); + + // + }, [ac1BusPowered, + ac2BusPowered, + adirsMessage1(adirsRemainingAlignTime, (engine1State > 0 || engine2State > 0)), + adirsMessage2(adirsRemainingAlignTime, (engine1State > 0 || engine2State > 0)), + adiru1State, + adiru2State, + adiru3State, + agent1Eng1Discharge, + agent1Eng2Discharge, + agent2Eng1Discharge, + agent2Eng2Discharge, + agentAPUDischarge, + AIRKnob, + antiskidActive, + apuAgentPB, + apuAvail, + apuBleedValveOpen, + apuFireTest, + apuMasterSwitch, + ATTKnob, + autoBrake, + autoBrakesArmedMode, + autoThrustStatus, + blueElecPumpPBAuto, + blueRvrLow, + brakeFan, + cabAltSetResetState1, + cabAltSetResetState2, + cabinReady, + cabinRecircBtnOn, + cargoFireAgentDisch, + cargoFireTest, + compMesgCount, + computedAirSpeedToNearest2, + configPortableDevices, + dcESSBusPowered, + dmcSwitchingKnob, + emergencyGeneratorOn, + engine1ValueSwitch, + engine2ValueSwitch, + eng1Agent1PB, + eng1Agent2PB, + eng1AntiIce, + eng1FireTest, + engine1State, + eng2Agent1PB, + eng2Agent2PB, + eng2AntiIce, + eng2FireTest, + engine2State, + engSelectorPosition, + excessPressure, + fac1Failed, + fireButton1, + fireButton2, + fireButtonAPU, + flapsHandle, + flapsIndex, + flightPhase, + flightPhaseInhibitOverride, + fobRounded, + fuel, + fuelXFeedPBOn, + gpwsFlapMode, + gpwsFlaps3, + gpwsOff, + greenHydEng1PBAuto, + height1Failed, + height2Failed, + hydPTU, + iceDetectedTimer1, + iceDetectedTimer2, + iceNotDetTimer1, + iceNotDetTimer2, + iceSevereDetectedTimer, + landingLight2Retracted, + landingLight3Retracted, + ldgmemo, + leftOuterInnerValve, + manLandingElevation, + ndXfrKnob, + noSmoking, + noSmokingSwitchPosition, + nwSteeringDisc, + packOffBleedIsAvailable1, + packOffBleedIsAvailable1, + packOffNotFailure1, + packOffNotFailure2, + parkBrake, + predWSOn, + ratDeployed, + rightOuterInnerValve, + seatBelt, + showTakeoffInhibit, + showLandingInhibit, + speedBrake, + spoilersArmed, + strobeLightsOn, + tcasFault, + tcasMode, + tcasSensitivity, + toconfig, + throttle1Position, + throttle2Position, + thrustLeverNotSet, + tomemo, + unit, + usrStartRefueling, + wingAntiIce, + ]); + + useEffect(() => { + [1, 2, 3, 4, 5, 6, 7].forEach((value) => { + SimVar.SetSimVarValue(`L:A380X_EWD_RIGHT_LINE_${value}`, 'string', ''); + }); + if (memoMessageRight.length > 0) { + memoMessageRight.forEach((value, index) => { + SimVar.SetSimVarValue(`L:A380X_EWD_RIGHT_LINE_${index + 1}`, 'string', value); + }); + } + }, [memoMessageRight]); + + return null; +}; + +export default PseudoFWC; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/ThrustGauge.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/ThrustGauge.tsx new file mode 100644 index 00000000000..f15a11144c4 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/ThrustGauge.tsx @@ -0,0 +1,270 @@ +import { + GaugeComponent, + GaugeMarkerComponent, GaugeThrustComponent, splitDecimals, ThrottlePositionDonutComponent, +} from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; +import { Position, EngineNumber, FadecActive, n1Degraded } from '@instruments/common/types'; +import React from 'react'; + +const ThrustGauge: React.FC = ({ x, y, engine, active, n1Degraded }) => { + const [N1Percent] = useSimVar(`L:A32NX_ENGINE_N1:${engine}`, 'percent', 100); + const [N1Idle] = useSimVar('L:A32NX_ENGINE_IDLE_N1', 'percent', 1000); + const [Thrust] = useSimVar(`TURB ENG JET THRUST:${engine}`, 'pounds'); + const ThrustPercent = Math.round(((Thrust / 30000) * 100) * 2.8125 * 10) / 100; // Hack for now until real thrust values available + const ThrustPercentSplit = splitDecimals(ThrustPercent); + + const [engineState] = useSimVar(`L:A32NX_ENGINE_STATE:${engine}`, 'bool', 500); + const [throttlePosition] = useSimVar(`L:A32NX_AUTOTHRUST_TLA:${engine}`, 'number', 100); + // const [thrustLimit] = useSimVar('L:A32NX_AUTOTHRUST_THRUST_LIMIT', 'number', 100); + // const [thrustLimitIdle] = useSimVar('L:A32NX_AUTOTHRUST_THRUST_LIMIT_IDLE', 'number', 100); + + const availVisible = !!(N1Percent > Math.floor(N1Idle) && engineState === 2); // N1Percent sometimes does not reach N1Idle by .005 or so + const [revVisible] = useSimVar(`L:A32NX_AUTOTHRUST_REVERSE:${engine}`, 'bool', 500); + // Reverse cowl > 5% is treated like fully open, otherwise REV will not turn green for idle reverse + const [revDoorOpenPercentage] = useSimVar(`A:TURB ENG REVERSE NOZZLE PERCENT:${engine}`, 'percent', 100); + const availRevVisible = availVisible || (revVisible && [2, 3].includes(engine)); + const availRevText = availVisible ? 'AVAIL' : 'REV'; + + const radius = 64; + const startAngle = 230; + const endAngle = 90; + const min = 0; + const max = 10; + const revStartAngle = 130; + const revEndAngle = 230; + const revRadius = 58; + const revMin = 0; + const revMax = 3; + + return ( + <> + + {(!active || n1Degraded) + && ( + <> + + THR XX + + )} + {active && !n1Degraded + && ( + <> + {(!revVisible || [1, 4].includes(engine)) + && ( + <> + {ThrustPercentSplit[0]} + . + {ThrustPercentSplit[1]} + + + + + )} + + + + + + + {(!revVisible || [1, 4].includes(engine)) + && ( + <> + + + + )} + + + + )} + {active && revVisible && [2, 3].includes(engine) + && ( + <> + + {/* reverse */} + + + + + + + + + )} + + + ); +}; + +export default ThrustGauge; + +type AvailRevProps = { + x: number, + y: number, + mesg: string, + visible: boolean, + revDoorOpen: number, +}; + +const AvailRev: React.FC = ({ x, y, mesg, visible, revDoorOpen }) => ( + <> + + + {mesg === 'REV' + && 5 ? 'Green' : 'Amber'}`} x={x - 8} y={y + 9}>REV} + {mesg === 'AVAIL' + && AVAIL} + + +); diff --git a/fbw-a380x/src/systems/instruments/src/EWD/elements/ThrustRatingMode.tsx b/fbw-a380x/src/systems/instruments/src/EWD/elements/ThrustRatingMode.tsx new file mode 100644 index 00000000000..7eaf817bcaf --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/elements/ThrustRatingMode.tsx @@ -0,0 +1,55 @@ +import { useArinc429Var } from '@instruments/common/arinc429'; +import { splitDecimals } from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; +import { Arinc429Word } from '@shared/arinc429'; +import React from 'react'; + +type N1LimitProps = { + x: number, + y: number, + active: boolean, +}; + +const N1Limit: React.FC = ({ x, y, active }) => { + const [N1LimitType] = useSimVar('L:A32NX_AUTOTHRUST_THRUST_LIMIT_TYPE', 'enum', 500); + const [N1ThrustLimit] = useSimVar('L:A32NX_AUTOTHRUST_THRUST_LIMIT', 'number', 100); + const N1ThrustLimitSplit = splitDecimals(N1ThrustLimit); + const thrustLimitTypeArray = ['', 'CLB', 'MCT', 'FLX', 'TOGA', 'MREV']; + const [flexTemp] = useSimVar('L:AIRLINER_TO_FLEX_TEMP', 'number', 1000); + const sat: Arinc429Word = useArinc429Var('L:A32NX_ADIRS_ADR_1_STATIC_AIR_TEMPERATURE', 500); + const displayFlexTemp: boolean = flexTemp !== 0 && (flexTemp >= (sat.value - 10)) && N1LimitType === 3; + + return ( + <> + + {!active + && ( + <> + XX + + )} + {active + && ( + <> + {thrustLimitTypeArray[N1LimitType]} + {N1ThrustLimitSplit[0]} + . + {N1ThrustLimitSplit[1]} + % + + )} + {active && displayFlexTemp + && ( + <> + + {Math.round(flexTemp)} + °C + + + )} + + + ); +}; + +export default N1Limit; diff --git a/fbw-a380x/src/systems/instruments/src/EWD/index.tsx b/fbw-a380x/src/systems/instruments/src/EWD/index.tsx new file mode 100644 index 00000000000..52ea8562ad3 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/index.tsx @@ -0,0 +1,16 @@ +import { getRootElement } from '@instruments/common/defaults'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { render } from '../Common'; +import { renderTarget } from '../util.js'; +import { EngineWarningDisplay } from './EngineWarningDisplay'; + +import './style.scss'; + +if (renderTarget) { + render(); +} + +getRootElement().addEventListener('unload', () => { + ReactDOM.unmountComponentAtNode(renderTarget ?? document.body); +}); diff --git a/fbw-a380x/src/systems/instruments/src/EWD/style.scss b/fbw-a380x/src/systems/instruments/src/EWD/style.scss new file mode 100644 index 00000000000..aba24fd069d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/EWD/style.scss @@ -0,0 +1,216 @@ +@import "../Common/definitions"; +@import "../Common/gauges"; + +.EWDWarningTextLeft { + fill: white; + font-size: 1.55em; + letter-spacing: 0.047em; +} + +tspan { + white-space: pre; +} + +.XSmall, .F19 { + font-size: 19px !important; +} + +.Small, .F21 { + font-size: 21px !important; +} + +.Medium, .F22 { + font-size: 22px !important; +} + +.Large, .F23 { + font-size: 23px !important; +} + +.VLarge, .F24 { + font-size: 24px !important; +} + +.F25 { + font-size: 25px !important; +} + +.F26 { + font-size: 26px !important; +} + +.XLarge, .F27 { + font-size: 27px !important; +} + +.F28 { + font-size: 28px !important; +} + +.F29 { + font-size: 29px !important; +} + +.Huge, .F30 { + font-size: 30px !important; +} + +.F32 { + font-size: 32px !important; +} + +.F34 { + font-size: 34px !important; +} + +.F35 { + font-size: 35px !important; +} + +.F36 { + font-size: 36px !important; +} + +text, tspan { + fill: $display-white; + stroke: none !important; + + &.EWDWarn { + font-size: 24px; + letter-spacing: 1.75px; + } + + &.Cyan { + fill: $display-cyan !important; + } + + &.Green { + fill: $display-green !important; + } + + &.Amber { + fill: $display-amber; + } + + &.Red { + fill: $display-red; + } + + &.Magenta { + fill: $display-magenta; + } + + &.Center { + text-anchor: middle !important; + } + + &.End { + text-anchor: end !important; + } + + &.Underline { + text-decoration: underline; + text-decoration-color: $display-amber; + } +} + +.Separator { + stroke: $display-light-grey; + stroke-width: 3; + fill: none; +} + +.SeparatorMemo { + stroke: $display-light-grey; + stroke-width: 2; + fill: none; + } + +.AmberLine { + stroke: $display-amber !important; + stroke-width: 2; + fill: none; +} + +.GreenLine { + stroke: $display-green !important; + stroke-width: 2; + fill: none; +} + +.WhiteLine { + stroke: $display-white !important; + stroke-width: 2; + fill: none; +} + +.RedLine { + stroke: $display-red !important; + stroke-width: 2; + fill: none; +} + +.ThickRedLine { + stroke: $display-red !important; + stroke-width: 6 !important; + fill: $display-red; +} + +.LightGreyLine { + stroke: $display-light-grey !important; + stroke-width: 3; + fill: none; + stroke-linecap: round; +} + + +.BackgroundLine { + stroke: $display-background !important; + stroke-width: 2; + fill: $display-background; +} + +.BackgroundFill { + fill: $display-background !important; +} + +.Show { + display:block; +} + +.Hide { + display: none; +} + +.Spread { + letter-spacing: 1.6px; +} + +.DonutThrottleIndicator { + fill: none; + stroke-width: 2px; + stroke: $display-cyan; +} + + +.DarkGreyBox { + stroke: $display-grey; + fill: none; + stroke-width: 2; +} + +.AmberBox { + stroke: $display-amber; + fill: none; + stroke-width: 2; +} + +.LightGreyBox { + stroke: none; + fill: $display-light-grey; + opacity: 0.2 +} + + + + diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/Atis.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/Atis.tsx new file mode 100644 index 00000000000..8109bf50335 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/Atis.tsx @@ -0,0 +1,70 @@ +import React, { FC } from 'react'; +import { AutoPrintIcon } from './AutoPrintIcon'; +import { AutoUpdateIcon } from './AutoUpdateIcon'; +import { Button } from '../../Components/Button'; +import { Dropdown, DropdownItem } from '../../Components/Dropdown'; +import { Layer } from '../../Components/Layer'; +import { NewAtisIcon } from './NewAtisIcon'; +import { TextBox } from '../../Components/Textbox'; + +type AtisProps = { + x?: number; + y?: number; + airport: string; + arrival?: boolean; +} + +export const Atis: FC = ({ x = 0, y = 0, airport, arrival }) => { + const updatePrintTitle = ( + <> + UPDATE + OR PRINT + + ); + + // TODO add the >>>-button as soon as font is updated + // TODO update GND SYS button as soon as font is updated + // TODO other buttons based on message state + // + // + + return ( + + + EDDF DEP ATIS K 1005Z RWY 25C ILS RWY 25C + RWY 25L CLOSED TRANS LVL 5000FT TWY N1 N2 N5 + L CLSD EXPECT TKOF FROM L6 3266M AVLB IF + UNABLE DV PREFLIGHT WIND 27012KT VIS 10KM + CLOUD FEW011 BKN041 OVC054 TEMP ...... + + + + + + + DEP + ARR + + K 1005Z + + + + + + + UPDATE + PRINT + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/AutoPrintIcon.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/AutoPrintIcon.tsx new file mode 100644 index 00000000000..ea4a6eea266 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/AutoPrintIcon.tsx @@ -0,0 +1,112 @@ +import React, { FC } from 'react'; + +type AutoPrintIconProps = { + x?: number; + y?: number; + width?: number; + height?: number; +} + +export const AutoPrintIcon: FC = ({ x = 0, y = 0, width = 34, height = 34 }) => { + const ratioX = width / 34; + const ratioY = height / 34; + + return ( + <> + {/* front big */} + + {/* right side big */} + + {/* front ground */} + + {/* right side ground */} + + {/* right side top */} + + {/* top */} + + + {/* paper */} + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/AutoUpdateIcon.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/AutoUpdateIcon.tsx new file mode 100644 index 00000000000..0a9248d191f --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/AutoUpdateIcon.tsx @@ -0,0 +1,15 @@ +import React, { FC } from 'react'; + +type AutoUpdateIconProps = { + x?: number; + y?: number; + width?: number; + height?: number; +} + +export const AutoUpdateIcon: FC = ({ x = 0, y = 0, width = 38, height = 38 }) => ( + <> + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/MessageElement.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/MessageElement.tsx new file mode 100644 index 00000000000..80d1de7e002 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/MessageElement.tsx @@ -0,0 +1,19 @@ +import React, { Children, FC } from 'react'; +import { TrashbinButton } from './TrashbinButton'; +import { Layer } from '../../Components/Layer'; + +type MessageElementProps = { + x?: number; + y?: number; + width?: number; + drawSeperator?: boolean; + onDelete: () => void; +} + +export const MessageElement: FC = ({ x = 0, y = 0, width = 572, drawSeperator = true, onDelete, children }) => ( + + + {Children.map(children, (child, index) => React.cloneElement(child, { x: 10, y: index * 45 + (index !== 0 ? 43 : 38) }))} + {drawSeperator && } + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/NewAtisIcon.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/NewAtisIcon.tsx new file mode 100644 index 00000000000..e9c1ff52f6f --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/NewAtisIcon.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react'; + +type NewAtisIconProps = { + x?: number; + y?: number; + width?: number; + height?: number; +} + +export const NewAtisIcon: FC = ({ x = 0, y = 0, width = 40, height = 26 }) => ( + <> + + + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/SwitchButton.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/SwitchButton.tsx new file mode 100644 index 00000000000..be89a2c86e5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/SwitchButton.tsx @@ -0,0 +1,47 @@ +import React, { FC } from 'react'; +import { useHover } from 'use-events'; +import { Layer } from '../../Components/Layer'; + +type SwitchButtonProps = { + x: number; + y: number; + width?: number; + height?: number; + first: string; + second: string; + onClick: () => void; +} + +export const SwitchButton: FC = ({ x, y, width = 110, height = 75, first, second, onClick }) => { + const [hovered, hoverProps] = useHover(); + + return ( + + + + + + + + {first} + {second} + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/TrashbinButton.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/TrashbinButton.tsx new file mode 100644 index 00000000000..e56ffaae940 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Elements/TrashbinButton.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; +import { Button } from '../../Components/Button'; + +type TrashbinButtonProps = { + x?: number; + y?: number; + onClick?: () => void; +} + +export const TrashbinButton: FC = ({ x = 0, y = 0, onClick }) => ( + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestDepartureClearance.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestDepartureClearance.tsx new file mode 100644 index 00000000000..966896310ef --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestDepartureClearance.tsx @@ -0,0 +1,69 @@ +import React, { FC } from 'react'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageElement } from '../../Elements/MessageElement'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestDepartureClearance: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string, boxIndex: number): boolean => { + return false; + }; + + return ( + <> + + + DEPARTURE REQUEST + REQUEST WILL BE SENT TO + DEPARTURE ARPT + updateValue(value, 0)} + /> + GATE + updateValue(value, 1)} + /> + D-ATIS CODE + updateValue(value, 2)} + /> + + ACFT CODE + updateValue(value, 3)} + /> + DESTINATION + updateValue(value, 4)} + /> + + (NO NOTIFICATION REQUIRED) + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestGenericClearance.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestGenericClearance.tsx new file mode 100644 index 00000000000..49afc334857 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestGenericClearance.tsx @@ -0,0 +1,17 @@ +import React, { FC } from 'react'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { MessageVisualizationProps } from '../Registry'; + +// TODO make ATSU a singleton with access to the scratchpad +// TODO get current station from ATSU +export const RequestGenericClearance: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => ( + + + REQUEST CLEARANCE + + + WILL BE SENT TO ATC: ---- + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestOceanicClearance.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestOceanicClearance.tsx new file mode 100644 index 00000000000..ef0f566bcc9 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Clearance/RequestOceanicClearance.tsx @@ -0,0 +1,68 @@ +import React, { FC } from 'react'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageElement } from '../../Elements/MessageElement'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestOceanicClearance: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string, boxIndex: number): boolean => { + return false; + }; + + return ( + <> + + + OCEANIC REQUEST + REQUEST WILL BE SENT TO + OCEANIC CENTER + updateValue(value, 0)} + /> + + ENTRY POINT + updateValue(value, 1)} + /> + ETA + updateValue(value, 2)} + /> + MACH + updateValue(value, 3)} + /> + LEVEL + updateValue(value, 4)} + /> + + (NO NOTIFICATION REQUIRED) + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestDirect.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestDirect.tsx new file mode 100644 index 00000000000..cb5a61f6250 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestDirect.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestDirect: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadWaypoint(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM22[1].deepCopy(); + messageElements[index].message.Content[0].Value = value; + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + return ( + + + + REQUEST DIR TO + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestGroundTrack.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestGroundTrack.tsx new file mode 100644 index 00000000000..1fbd32647f3 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestGroundTrack.tsx @@ -0,0 +1,47 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestGroundTrack: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadDegree(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM71[1].deepCopy(); + messageElements[index].message.Content[0].Value = value; + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + return ( + + + REQUEST GROUND TRACK + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestHeading.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestHeading.tsx new file mode 100644 index 00000000000..3fba14612a0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestHeading.tsx @@ -0,0 +1,47 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestHeading: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadDegree(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM70[1].deepCopy(); + messageElements[index].message.Content[0].Value = value; + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + return ( + + + REQUEST HDG + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestOffset.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestOffset.tsx new file mode 100644 index 00000000000..8d758fa0622 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestOffset.tsx @@ -0,0 +1,114 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessageElement, CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestOffset: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string, boxIndex: number): boolean => { + if (value === '') { + if (boxIndex === 0 && (messageElements[index].message?.TypeId === 'DM16' || messageElements[index].message?.TypeId === 'DM16')) { + if (messageElements[index].message?.Content[0].Value === '') { + messageElements[index].message = undefined; + } else { + messageElements[index].message.Content[1].Value = ''; + } + messageElements[index].readyToSend = false; + } else if (boxIndex === 1 && messageElements[index].message !== undefined) { + const newMessage = CpdlcMessagesDownlink.DM15[1].deepCopy(); + newMessage.Content[0].Value = messageElements[index].message?.Content[1].Value || ''; + newMessage.Content[1].Value = messageElements[index].message?.Content[2].Value || ''; + messageElements[index].message = newMessage; + messageElements[index].readyToSend = newMessage.Content[0].Value !== ''; + } else { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + } + + return true; + } + + if (boxIndex === 0) { + const status = InputValidation.validateScratchpadOffset(value); + if (status === AtsuStatusCodes.Ok) { + const formattedValue = InputValidation.expandLateralOffset(value).split(' '); + if (messageElements[index].message === undefined) { + messageElements[index].message = CpdlcMessagesDownlink.DM15[1].deepCopy(); + messageElements[index].message.Content[0].Value = formattedValue[0]; + messageElements[index].message.Content[1].Value = formattedValue[1]; + } else if (messageElements[index].message?.TypeId === 'DM16' || messageElements[index].message?.TypeId === 'DM16') { + messageElements[index].message.Content[1].Value = formattedValue[0]; + messageElements[index].message.Content[2].Value = formattedValue[1]; + } else { + messageElements[index].message.Content[0].Value = formattedValue[0]; + messageElements[index].message.Content[1].Value = formattedValue[1]; + } + } + + return status === AtsuStatusCodes.Ok; + } + + const status = InputValidation.validateScratchpadWaypoint(value); + if (status === AtsuStatusCodes.Ok) { + let newMessage: CpdlcMessageElement | undefined = undefined; + if (InputValidation.validateScratchpadTime(value) === AtsuStatusCodes.Ok) { + if (messageElements[index].message === undefined || messageElements[index].message?.TypeId !== 'DM17') { + newMessage = CpdlcMessagesDownlink.DM17[1].deepCopy(); + } + } else if (messageElements[index].message === undefined || messageElements[index].message?.TypeId !== 'DM16') { + newMessage = CpdlcMessagesDownlink.DM16[1].deepCopy(); + } + + if (newMessage !== undefined) { + if (messageElements[index].message !== undefined) { + const offsetStart = messageElements[index].message?.TypeId === 'DM15' ? 0 : 1; + newMessage.Content[1].Value = messageElements[index].message?.Content[offsetStart].Value || ''; + newMessage.Content[2].Value = messageElements[index].message?.Content[offsetStart + 1].Value || ''; + } + + messageElements[index].message = newMessage; + } + messageElements[index].message.Content[0].Value = value; + messageElements[index].readyToSend = messageElements[index].message?.Content[1].Value !== ''; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + return ( + + + REQUEST OFFSET + updateValue(value, 0)} + /> + + + OF ROUTE + AT + updateValue(value, 1)} + /> + + + (POSITION OR TIME) + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestWeatherDeviation.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestWeatherDeviation.tsx new file mode 100644 index 00000000000..2ed42532215 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Lateral/RequestWeatherDeviation.tsx @@ -0,0 +1,51 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestWeatherDeviation: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadOffset(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM27[1].deepCopy(); + const formattedValue = InputValidation.formatScratchpadOffset(value); + messageElements[index].message.Content[0].Value = formattedValue[0]; + messageElements[index].message.Content[1].Value = formattedValue[1]; + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + return ( + + + REQUEST WX DEVIATION + + + UP TO + updateValue(value)} + /> + OF ROUTE + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Registry.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Registry.tsx new file mode 100644 index 00000000000..806d0cd1494 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Registry.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { FansMode } from '@atsu/com/FutureAirNavigationSystem'; +import { CpdlcMessageElement } from '@atsu/messages/CpdlcMessageElements'; +import { RequestClimb } from './Vertical/RequestClimb'; +import { RequestDescend } from './Vertical/RequestDescend'; +import { RequestLevel } from './Vertical/RequestLevel'; +import { RequestLevelBlock } from './Vertical/RequestLevelBlock'; +import { RequestCruiseClimb } from './Vertical/RequestCruiseClimb'; +import { RequestITP } from './Vertical/RequestItp'; +import { RequestDirect } from './Lateral/RequestDirect'; +import { RequestOffset } from './Lateral/RequestOffset'; +import { RequestWeatherDeviation } from './Lateral/RequestWeatherDeviation'; +import { RequestHeading } from './Lateral/RequestHeading'; +import { RequestGroundTrack } from './Lateral/RequestGroundTrack'; +import { RequestSpeed } from './Speed/RequestSpeed'; +import { RequestSpeedRange } from './Speed/RequestSpeedRange'; +import { RequestDepartureClearance } from './Clearance/RequestDepartureClearance'; +import { RequestOceanicClearance } from './Clearance/RequestOceanicClearance'; +import { RequestGenericClearance } from './Clearance/RequestGenericClearance'; +import { WhenCanWeExpectLowerLevel } from './WhenCanWe/WhenCanWeExpectLowerLevel'; +import { WhenCanWeExpectHigherLevel } from './WhenCanWe/WhenCanWeExpectHigherLevel'; +import { WhenCanWeExpectClimb } from './WhenCanWe/WhenCanWeExpectClimb'; +import { WhenCanWeExpectDescend } from './WhenCanWe/WhenCanWeExpectDescend'; +import { WhenCanWeExpectCruiseClimb } from './WhenCanWe/WhenCanWeExpectCruiseClimb'; +import { WhenCanWeExpectSpeed } from './WhenCanWe/WhenCanWeExpectSpeed'; +import { WhenCanWeExpectSpeedRange } from './WhenCanWe/WhenCanWeExpectSpeedRange'; +import { WhenCanWeExpectBackOnRoute } from './WhenCanWe/WhenCanWeExpectBackOnRoute'; + +export const MaxRequestElements = 5; + +export type MessageVisualizationProps = { + x?: number; + y?: number; + mode: FansMode; + index: number; + messageElements: { id: string, message: CpdlcMessageElement | undefined, readyToSend: boolean }[]; + onDelete: () => void; +} + +export const MessageTable: { [id: string]: { visualization: React.FC, blacklisting: string[], exchanging: string | undefined, singleMessage: boolean } } = { + RequestClimb: { visualization: RequestClimb, blacklisting: [], exchanging: 'RequestDescend', singleMessage: false }, + RequestDescend: { visualization: RequestDescend, blacklisting: [], exchanging: 'RequestClimb', singleMessage: false }, + RequestLevel: { visualization: RequestLevel, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestLevelBlock: { visualization: RequestLevelBlock, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestCruiseClimb: { visualization: RequestCruiseClimb, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestITP: { visualization: RequestITP, blacklisting: [], exchanging: undefined, singleMessage: true }, + RequestDirect: { visualization: RequestDirect, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestOffset: { visualization: RequestOffset, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestWeatherDeviation: { visualization: RequestWeatherDeviation, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestHeading: { visualization: RequestHeading, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestGroundTrack: { visualization: RequestGroundTrack, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestSpeed: { visualization: RequestSpeed, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestSpeedRange: { visualization: RequestSpeedRange, blacklisting: [], exchanging: undefined, singleMessage: false }, + RequestDepartureClearance: { visualization: RequestDepartureClearance, blacklisting: [], exchanging: undefined, singleMessage: true }, + RequestOceanicClearance: { visualization: RequestOceanicClearance, blacklisting: [], exchanging: undefined, singleMessage: true }, + RequestGenericClearance: { visualization: RequestGenericClearance, blacklisting: [], exchanging: undefined, singleMessage: false }, + WhenCanWeExpectLowerLevel: { visualization: WhenCanWeExpectLowerLevel, blacklisting: [], exchanging: 'WhenCanWeExpectHigherLevel', singleMessage: false }, + WhenCanWeExpectHigherLevel: { visualization: WhenCanWeExpectHigherLevel, blacklisting: [], exchanging: 'WhenCanWeExpectLowerLevel', singleMessage: false }, + WhenCanWeExpectClimb: { visualization: WhenCanWeExpectClimb, blacklisting: [], exchanging: 'WhenCanWeExpectDescend', singleMessage: false }, + WhenCanWeExpectDescend: { visualization: WhenCanWeExpectDescend, blacklisting: [], exchanging: 'WhenCanWeExpectClimb', singleMessage: false }, + WhenCanWeExpectCruiseClimb: { visualization: WhenCanWeExpectCruiseClimb, blacklisting: [], exchanging: undefined, singleMessage: false }, + WhenCanWeExpectSpeed: { visualization: WhenCanWeExpectSpeed, blacklisting: [], exchanging: undefined, singleMessage: false }, + WhenCanWeExpectSpeedRange: { visualization: WhenCanWeExpectSpeedRange, blacklisting: [], exchanging: undefined, singleMessage: false }, + WhenCanWeExpectBackOnRoute: { visualization: WhenCanWeExpectBackOnRoute, blacklisting: [], exchanging: undefined, singleMessage: false }, +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Speed/RequestSpeed.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Speed/RequestSpeed.tsx new file mode 100644 index 00000000000..21661a60d16 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Speed/RequestSpeed.tsx @@ -0,0 +1,61 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestSpeed: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadSpeed(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM18[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadSpeed(value); + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + let prefix: string | undefined = undefined; + let suffix: string | undefined = undefined; + if (messageElements[index].message !== undefined) { + if (messageElements[index].message?.Content[0].Value.startsWith('M')) { + prefix = 'M'; + } else if (messageElements[index].message?.Content[0].Value.length !== 0) { + suffix = 'KT'; + } + } + + return ( + + + REQUEST + + + SPD/MACH + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Speed/RequestSpeedRange.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Speed/RequestSpeedRange.tsx new file mode 100644 index 00000000000..7a64521c178 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Speed/RequestSpeedRange.tsx @@ -0,0 +1,110 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestSpeedRange: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string, boxIndex: number): boolean => { + const otherIndex = (boxIndex + 1) % 2; + + if (value === '') { + messageElements[index].readyToSend = false; + if (messageElements[index].message !== undefined && messageElements[index].message?.Content[otherIndex].Value !== '') { + messageElements[index].message.Content[boxIndex].Value = ''; + } else { + messageElements[index].message = undefined; + } + return true; + } + + if (messageElements[index].message === undefined || messageElements[index].message?.Content[otherIndex].Value === '') { + const status = InputValidation.validateScratchpadSpeed(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM19[1].deepCopy(); + messageElements[index].message.Content[boxIndex].Value = InputValidation.formatScratchpadSpeed(value); + } else { + // TODO error message + } + return status === AtsuStatusCodes.Ok; + } + + if (messageElements[index].message?.Content[otherIndex].Value !== '') { + let speedRangeString = ''; + if (otherIndex < boxIndex) { + speedRangeString = `${messageElements[index].message?.Content[otherIndex].Value}/${value}`; + } else { + speedRangeString = `${value}/${messageElements[index].message?.Content[otherIndex].Value}`; + } + + const status = InputValidation.validateScratchpadSpeedRanges(speedRangeString)[0]; + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message.Content[boxIndex].Value = InputValidation.formatScratchpadSpeed(value); + } else { + // TODO error message + } + + messageElements[index].readyToSend = messageElements[index].message?.Content[0].Value !== '' && messageElements[index].message?.Content[1].Value !== ''; + return status === AtsuStatusCodes.Ok; + } + + return false; + }; + + let lowerPrefix: string | undefined = undefined; + let lowerSuffix: string | undefined = undefined; + let upperPrefix: string | undefined = undefined; + let upperSuffix: string | undefined = undefined; + if (messageElements[index].message !== undefined) { + if (messageElements[index].message?.Content[0].Value.startsWith('M')) { + lowerPrefix = 'M'; + } else if (messageElements[index].message?.Content[0].Value.length !== 0) { + lowerSuffix = 'KT'; + } + + if (messageElements[index].message?.Content[1].Value.startsWith('M')) { + upperPrefix = 'M'; + } else if (messageElements[index].message?.Content[1].Value.length !== 0) { + upperSuffix = 'KT'; + } + } + + return ( + + + REQUEST SPD/MACH + + + FROM + updateValue(value, 0)} + /> + + + TO + updateValue(value, 1)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestClimb.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestClimb.tsx new file mode 100644 index 00000000000..41d0ba53d74 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestClimb.tsx @@ -0,0 +1,123 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { FansMode } from '@atsu/com/FutureAirNavigationSystem'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestClimb: FC = ({ x = 0, y = 0, mode, index, messageElements, onDelete }) => { + const updateValue = (value: string, boxIndex: number): boolean => { + if (value === '') { + if (boxIndex === 0) { + if (messageElements[index].message?.TypeId === 'DM9') { + messageElements[index].message.Content[0].Value = ''; + } else { + messageElements[index].message.Content[1].Value = ''; + } + messageElements[index].readyToSend = false; + } else if (messageElements[index].message?.TypeId !== 'DM9') { + const newMessage = CpdlcMessagesDownlink.DM9[1].deepCopy(); + newMessage.Content[0].Value = messageElements[index].message.Content[1].Value; + messageElements[index].message = newMessage; + } + + return true; + } + + if (boxIndex === 0) { + const status = InputValidation.validateScratchpadAltitude(value); + + if (status === AtsuStatusCodes.Ok) { + if (messageElements[index].message === undefined || messageElements[index].message?.TypeId === 'DM9') { + messageElements[index].message = CpdlcMessagesDownlink.DM9[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadAltitude(value); + } else { + messageElements[index].message.Content[1].Value = InputValidation.formatScratchpadAltitude(value); + } + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + } + + const validWaypoint = InputValidation.validateScratchpadWaypoint(value); + const validTime = InputValidation.validateScratchpadTime(value); + // TODO validate entries + if (validWaypoint === AtsuStatusCodes.Ok && (messageElements[index].message === undefined || messageElements[index].message?.TypeId !== 'DM11')) { + const newMessage = CpdlcMessagesDownlink.DM11[1].deepCopy(); + newMessage.Content[1].Value = messageElements[index].message.Content[0].Value; + messageElements[index].message = newMessage; + } else if (validTime === AtsuStatusCodes.Ok && (messageElements[index].message === undefined || messageElements[index].message?.TypeId !== 'DM13')) { + const newMessage = CpdlcMessagesDownlink.DM13[1].deepCopy(); + newMessage.Content[1].Value = messageElements[index].message.Content[0].Value; + messageElements[index].message = newMessage; + } else if (validWaypoint !== AtsuStatusCodes.Ok && validTime !== AtsuStatusCodes.Ok) { + return false; + } + + if (validWaypoint) { + messageElements[index].message.Content[0].Value = value; + } else { + messageElements[index].message.Content[0].Value = value; + } + + return true; + }; + + let requestedLevel: string | undefined = undefined; + let altitudeSuffix: string | undefined = undefined; + let isFlightlevel = false; + if (messageElements[index].message !== undefined) { + if (messageElements[index].message?.TypeId === 'DM9') { + requestedLevel = messageElements[index].message?.Content[0].Value; + } else { + requestedLevel = messageElements[index].message?.Content[1].Value; + } + } + + isFlightlevel = requestedLevel !== undefined && requestedLevel.startsWith('FL'); + if (!isFlightlevel && requestedLevel !== undefined && requestedLevel !== '') { + altitudeSuffix = requestedLevel.endsWith('FT') ? 'FT' : 'M'; + } + + return ( + + + REQUEST CLB TO + updateValue(value, 0)} + /> + + {mode === FansMode.FansA && ( + + AT + updateValue(value, 1)} + disabled={requestedLevel === undefined} + disabledBackgroundColor="black" + resetValueIfDisabled + /> + + )} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestCruiseClimb.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestCruiseClimb.tsx new file mode 100644 index 00000000000..1e5ef2c5f79 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestCruiseClimb.tsx @@ -0,0 +1,60 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestCruiseClimb: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadAltitude(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM8[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadAltitude(value); + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + let altitudeSuffix: string | undefined = undefined; + let isFlightlevel = false; + if (messageElements[index].message !== undefined) { + isFlightlevel = messageElements[index].message?.Content[0].Value.startsWith('FL') || false; + if (!isFlightlevel) { + altitudeSuffix = messageElements[index].message?.Content[0].Value.endsWith('FT') ? 'FT' : 'M'; + } + } + + return ( + + + REQUEST + + + CRUISE CLB TO + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestDescend.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestDescend.tsx new file mode 100644 index 00000000000..1de58f7842a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestDescend.tsx @@ -0,0 +1,123 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { FansMode } from '@atsu/com/FutureAirNavigationSystem'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestDescend: FC = ({ x = 0, y = 0, mode, index, messageElements, onDelete }) => { + const updateValue = (value: string, boxIndex: number): boolean => { + if (value === '') { + if (boxIndex === 0) { + if (messageElements[index].message?.TypeId === 'DM10') { + messageElements[index].message.Content[0].Value = ''; + } else { + messageElements[index].message.Content[1].Value = ''; + } + messageElements[index].readyToSend = false; + } else if (messageElements[index].message?.TypeId !== 'DM10') { + const newMessage = CpdlcMessagesDownlink.DM10[1].deepCopy(); + newMessage.Content[0].Value = messageElements[index].message.Content[1].Value; + messageElements[index].message = newMessage; + } + + return true; + } + + if (boxIndex === 0) { + const status = InputValidation.validateScratchpadAltitude(value); + + if (status === AtsuStatusCodes.Ok) { + if (messageElements[index].message === undefined || messageElements[index].message?.TypeId === 'DM10') { + messageElements[index].message = CpdlcMessagesDownlink.DM10[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadAltitude(value); + } else { + messageElements[index].message.Content[1].Value = InputValidation.formatScratchpadAltitude(value); + } + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + } + + const validWaypoint = InputValidation.validateScratchpadWaypoint(value); + const validTime = InputValidation.validateScratchpadTime(value); + // TODO validate entries + if (validWaypoint === AtsuStatusCodes.Ok && (messageElements[index].message === undefined || messageElements[index].message?.TypeId !== 'DM12')) { + const newMessage = CpdlcMessagesDownlink.DM12[1].deepCopy(); + newMessage.Content[1].Value = messageElements[index].message.Content[0].Value; + messageElements[index].message = newMessage; + } else if (validTime === AtsuStatusCodes.Ok && (messageElements[index].message === undefined || messageElements[index].message?.TypeId !== 'DM14')) { + const newMessage = CpdlcMessagesDownlink.DM14[1].deepCopy(); + newMessage.Content[1].Value = messageElements[index].message.Content[0].Value; + messageElements[index].message = newMessage; + } else if (validWaypoint !== AtsuStatusCodes.Ok && validTime !== AtsuStatusCodes.Ok) { + return false; + } + + if (validWaypoint) { + messageElements[index].message.Content[0].Value = value; + } else { + messageElements[index].message.Content[0].Value = value; + } + + return true; + }; + + let requestedLevel: string | undefined = undefined; + let altitudeSuffix: string | undefined = undefined; + let isFlightlevel = false; + if (messageElements[index].message !== undefined) { + if (messageElements[index].message?.TypeId === 'DM10') { + requestedLevel = messageElements[index].message?.Content[0].Value; + } else { + requestedLevel = messageElements[index].message?.Content[1].Value; + } + } + + isFlightlevel = requestedLevel !== undefined && requestedLevel.startsWith('FL'); + if (!isFlightlevel && requestedLevel !== undefined && requestedLevel !== '') { + altitudeSuffix = requestedLevel.endsWith('FT') ? 'FT' : 'M'; + } + + return ( + + + REQUEST DES TO + updateValue(value, 0)} + /> + + {mode === FansMode.FansA && ( + + AT + updateValue(value, 1)} + disabled={requestedLevel === undefined} + disabledBackgroundColor="black" + resetValueIfDisabled + /> + + )} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestItp.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestItp.tsx new file mode 100644 index 00000000000..60adbbedf17 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestItp.tsx @@ -0,0 +1,14 @@ +import React, { FC } from 'react'; +import { MessageElement } from '../../Elements/MessageElement'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestITP: FC = ({ x = 0, y = 0, onDelete }) => ( + <> + + + ITP REQUEST + PREPARE ITP REQUEST + IN SURV / TRAFFIC / ITP PAGE + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestLevel.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestLevel.tsx new file mode 100644 index 00000000000..bd8fb1de103 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestLevel.tsx @@ -0,0 +1,57 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestLevel: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadAltitude(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM6[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadAltitude(value); + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + let altitudeSuffix: string | undefined = undefined; + let isFlightlevel = false; + if (messageElements[index].message !== undefined) { + isFlightlevel = messageElements[index].message?.Content[0].Value.startsWith('FL') || false; + if (!isFlightlevel) { + altitudeSuffix = messageElements[index].message?.Content[0].Value.endsWith('FT') ? 'FT' : 'M'; + } + } + + return ( + + + REQUEST ALT/FL + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestLevelBlock.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestLevelBlock.tsx new file mode 100644 index 00000000000..698b45df5fd --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/Vertical/RequestLevelBlock.tsx @@ -0,0 +1,103 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const RequestLevelBlock: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string, boxIndex: number): boolean => { + const otherIndex = (boxIndex + 1) % 2; + + if (value === '') { + messageElements[index].readyToSend = false; + if (messageElements[index].message !== undefined && messageElements[index].message?.Content[otherIndex].Value !== '') { + messageElements[index].message.Content[boxIndex].Value = ''; + } else { + messageElements[index].message = undefined; + } + return true; + } + + const valueStatus = InputValidation.validateScratchpadAltitude(value); + if (valueStatus !== AtsuStatusCodes.Ok) { + // TODO scratchpad error + return false; + } + const formattedValue = InputValidation.formatScratchpadAltitude(value); + + if (messageElements[index].message === undefined) { + messageElements[index].message = CpdlcMessagesDownlink.DM7[1].deepCopy(); + messageElements[index].message.Content[boxIndex].Value = formattedValue; + return true; + } + + let status = AtsuStatusCodes.Ok; + if (otherIndex < boxIndex) { + status = InputValidation.validateAltitudeRange(messageElements[index].message?.Content[otherIndex].Value || '', formattedValue); + } else { + status = InputValidation.validateAltitudeRange(formattedValue, messageElements[index].message?.Content[otherIndex].Value || ''); + } + + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message.Content[boxIndex].Value = formattedValue; + } else { + // TODO scratchpad error + } + + messageElements[index].readyToSend = messageElements[index].message?.Content[0].Value !== '' && messageElements[index].message?.Content[1].Value !== ''; + return status === AtsuStatusCodes.Ok; + }; + + let lowerAltitudeSuffix: string | undefined = undefined; + let upperAltitudeSuffix: string | undefined = undefined; + let lowerIsFlightlevel = false; + let upperIsFlightlevel = false; + + if (messageElements[index].message !== undefined) { + lowerIsFlightlevel = messageElements[index].message?.Content[0].Value.startsWith('FL') || false; + if (!lowerIsFlightlevel && messageElements[index].message?.Content[0].Value !== '') { + lowerAltitudeSuffix = messageElements[index].message?.Content[0].Value.endsWith('FT') ? 'FT' : 'M'; + } + + upperIsFlightlevel = messageElements[index].message?.Content[1].Value.startsWith('FL') || false; + if (!upperIsFlightlevel && messageElements[index].message?.Content[1].Value !== '') { + upperAltitudeSuffix = messageElements[index].message?.Content[1].Value.endsWith('FT') ? 'FT' : 'M'; + } + } + + return ( + + + REQUEST BLOCK + updateValue(value, 0)} + /> + + + TO + updateValue(value, 1)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectBackOnRoute.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectBackOnRoute.tsx new file mode 100644 index 00000000000..668c0cd28b3 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectBackOnRoute.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { MessageVisualizationProps } from '../Registry'; + +export const WhenCanWeExpectBackOnRoute: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + if (messageElements[index].message === undefined) { + messageElements[index].message = CpdlcMessagesDownlink.DM51[1].deepCopy(); + messageElements[index].readyToSend = true; + } + + return ( + + + WHEN CAN WE EXPECT + + + BACK ON ROUTE + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectClimb.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectClimb.tsx new file mode 100644 index 00000000000..5c6365c16e5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectClimb.tsx @@ -0,0 +1,60 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const WhenCanWeExpectClimb: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadAltitude(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM87[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadAltitude(value); + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + let altitudeSuffix: string | undefined = undefined; + let isFlightlevel = false; + if (messageElements[index].message !== undefined) { + isFlightlevel = messageElements[index].message?.Content[0].Value.startsWith('FL') || false; + if (!isFlightlevel) { + altitudeSuffix = messageElements[index].message?.Content[0].Value.endsWith('FT') ? 'FT' : 'M'; + } + } + + return ( + + + WHEN CAN WE EXPECT + + + CLB TO + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectCruiseClimb.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectCruiseClimb.tsx new file mode 100644 index 00000000000..10b6f0b90e8 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectCruiseClimb.tsx @@ -0,0 +1,60 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const WhenCanWeExpectCruiseClimb: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadAltitude(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM54[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadAltitude(value); + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + let altitudeSuffix: string | undefined = undefined; + let isFlightlevel = false; + if (messageElements[index].message !== undefined) { + isFlightlevel = messageElements[index].message?.Content[0].Value.startsWith('FL') || false; + if (!isFlightlevel) { + altitudeSuffix = messageElements[index].message?.Content[0].Value.endsWith('FT') ? 'FT' : 'M'; + } + } + + return ( + + + WHEN CAN WE EXPECT + + + CRUISE CLB TO + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectDescend.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectDescend.tsx new file mode 100644 index 00000000000..6bf761dab92 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectDescend.tsx @@ -0,0 +1,60 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const WhenCanWeExpectDescend: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateValue = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadAltitude(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM88[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadAltitude(value); + messageElements[index].readyToSend = true; + } else { + // TODO scratchpad error + } + + return status === AtsuStatusCodes.Ok; + }; + + let altitudeSuffix: string | undefined = undefined; + let isFlightlevel = false; + if (messageElements[index].message !== undefined) { + isFlightlevel = messageElements[index].message?.Content[0].Value.startsWith('FL') || false; + if (!isFlightlevel) { + altitudeSuffix = messageElements[index].message?.Content[0].Value.endsWith('FT') ? 'FT' : 'M'; + } + } + + return ( + + + WHEN CAN WE EXPECT + + + DES TO + updateValue(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectHigherLevel.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectHigherLevel.tsx new file mode 100644 index 00000000000..b50991cb624 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectHigherLevel.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { MessageVisualizationProps } from '../Registry'; + +export const WhenCanWeExpectHigherLevel: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + if (messageElements[index].message === undefined) { + messageElements[index].message = CpdlcMessagesDownlink.DM53[1].deepCopy(); + messageElements[index].readyToSend = true; + } + + return ( + + + WHEN CAN WE EXPECT + + + HIGHER LEVEL + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectLowerLevel.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectLowerLevel.tsx new file mode 100644 index 00000000000..4b7f2899a3a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectLowerLevel.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { MessageVisualizationProps } from '../Registry'; + +export const WhenCanWeExpectLowerLevel: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + if (messageElements[index].message === undefined) { + messageElements[index].message = CpdlcMessagesDownlink.DM52[1].deepCopy(); + messageElements[index].readyToSend = true; + } + + return ( + + + WHEN CAN WE EXPECT + + + LOWER LEVEL + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectSpeed.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectSpeed.tsx new file mode 100644 index 00000000000..eaf5b955509 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectSpeed.tsx @@ -0,0 +1,61 @@ +import React, { FC } from 'react'; +import { InputValidation } from '@atsu/InputValidation'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const WhenCanWeExpectSpeed: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateSpeed = (value: string): boolean => { + if (value === '') { + messageElements[index].message = undefined; + messageElements[index].readyToSend = false; + return true; + } + + const status = InputValidation.validateScratchpadSpeed(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM49[1].deepCopy(); + messageElements[index].message.Content[0].Value = InputValidation.formatScratchpadSpeed(value); + messageElements[index].readyToSend = true; + } else { + // TODO set error message + } + + return status === AtsuStatusCodes.Ok; + }; + + let prefix: string | undefined = undefined; + let suffix: string | undefined = undefined; + if (messageElements[index].message !== undefined) { + if (messageElements[index].message?.Content[0].Value.startsWith('M')) { + prefix = 'M'; + } else if (messageElements[index].message?.Content[0].Value.length !== 0) { + suffix = 'KT'; + } + } + + return ( + + + WHEN CAN WE EXPECT + + + SPD/MACH + updateSpeed(value)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectSpeedRange.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectSpeedRange.tsx new file mode 100644 index 00000000000..7770fdf8381 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Messages/WhenCanWe/WhenCanWeExpectSpeedRange.tsx @@ -0,0 +1,110 @@ +import React, { FC } from 'react'; +import { AtsuStatusCodes } from '@atsu/AtsuStatusCodes'; +import { CpdlcMessagesDownlink } from '@atsu/messages/CpdlcMessageElements'; +import { InputValidation } from '@atsu/InputValidation'; +import { Layer } from '../../../Components/Layer'; +import { MessageElement } from '../../Elements/MessageElement'; +import { TextBox } from '../../../Components/Textbox'; +import { MessageVisualizationProps } from '../Registry'; + +export const WhenCanWeExpectSpeedRange: FC = ({ x = 0, y = 0, index, messageElements, onDelete }) => { + const updateSpeed = (value: string, boxIndex: number): boolean => { + const otherIndex = (boxIndex + 1) % 2; + + if (value === '') { + messageElements[index].readyToSend = false; + if (messageElements[index].message !== undefined && messageElements[index].message?.Content[otherIndex].Value !== '') { + messageElements[index].message.Content[boxIndex].Value = ''; + } else { + messageElements[index].message = undefined; + } + return true; + } + + if (messageElements[index].message === undefined || messageElements[index].message?.Content[otherIndex].Value === '') { + const status = InputValidation.validateScratchpadSpeed(value); + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message = CpdlcMessagesDownlink.DM50[1].deepCopy(); + messageElements[index].message.Content[boxIndex].Value = InputValidation.formatScratchpadSpeed(value); + } else { + // TODO error message + } + return status === AtsuStatusCodes.Ok; + } + + if (messageElements[index].message?.Content[otherIndex].Value !== '') { + let speedRangeString = ''; + if (otherIndex < boxIndex) { + speedRangeString = `${messageElements[index].message?.Content[otherIndex].Value}/${value}`; + } else { + speedRangeString = `${value}/${messageElements[index].message?.Content[otherIndex].Value}`; + } + + const status = InputValidation.validateScratchpadSpeedRanges(speedRangeString)[0]; + if (status === AtsuStatusCodes.Ok) { + messageElements[index].message.Content[boxIndex].Value = InputValidation.formatScratchpadSpeed(value); + } else { + // TODO error message + } + + messageElements[index].readyToSend = messageElements[index].message?.Content[0].Value !== '' && messageElements[index].message?.Content[1].Value !== ''; + return status === AtsuStatusCodes.Ok; + } + + return false; + }; + + let fromPrefix: string | undefined = undefined; + let fromSuffix: string | undefined = undefined; + let toPrefix: string | undefined = undefined; + let toSuffix: string | undefined = undefined; + if (messageElements[index].message !== undefined) { + if (messageElements[index].message?.Content[0].Value.startsWith('M')) { + fromPrefix = 'M'; + } else if (messageElements[index].message?.Content[0].Value.length !== 0) { + fromSuffix = 'KT'; + } + + if (messageElements[index].message?.Content[1].Value.startsWith('M')) { + toPrefix = 'M'; + } else if (messageElements[index].message?.Content[1].Value.length !== 0) { + toSuffix = 'KT'; + } + } + + return ( + + + WHEN CAN WE EXPECT SPD/MACH + + + FROM + updateSpeed(value, 0)} + /> + + + TO + updateSpeed(value, 1)} + /> + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Atis.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Atis.tsx new file mode 100644 index 00000000000..3b1db666a84 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Atis.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { Layer } from '@instruments/common/utils'; +import { Atis } from '../Elements/Atis'; +import { Button } from '../../Components/Button'; + +export const Page: FC = () => ( + <> + + + + + + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Connect/Mainpage.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Connect/Mainpage.tsx new file mode 100644 index 00000000000..09d76e94ac4 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Connect/Mainpage.tsx @@ -0,0 +1,53 @@ +import React, { FC } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Layer } from '@instruments/common/utils'; +import { Button } from '../../../Components/Button'; +import { TextBox } from '../../../Components/Textbox'; + +export const Page: FC = () => { + const history = useHistory(); + + /* TODO update notify-text */ + /* TODO update current and next station */ + /* TODO implement DISCONNECT ALL */ + /* TODO disable MAX UPLINK DELAY for FANS-B */ + + return ( + + { /* Notification menu */ } + NOTIFY TO ATC : + + + NOTIFYING + + { /* Connection status */ } + ACTIVE ATC : + EDUA + NEXT ATC : + EBBR + + + + + { /* ADS information */ } + ADS-C + + + ARMED + OFF + + ADS-C EMERGENCY + + + ARMED + OFF + + ADS CONNECTED GROUND STATIONS : + NONE + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Connect/MaxUplinkDelay.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Connect/MaxUplinkDelay.tsx new file mode 100644 index 00000000000..b008022d00c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Connect/MaxUplinkDelay.tsx @@ -0,0 +1,30 @@ +import React, { FC } from 'react'; +import { Layer } from '@instruments/common/utils'; +import { TextBox } from '../../../Components/Textbox'; + +/* TODO get delay from ATSU */ + +export const Page: FC = () => ( + <> + + MODIFY ONLY ON DEMAND OF ACTIVE ATC : + EDUU + MAX UPLINK DELAY + + SECONDS + MAX UPLINK DELAY WILL BE CANCELLED + UPON NEW ACTIVE ATC + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Emergency.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Emergency.tsx new file mode 100644 index 00000000000..231bc8a9b50 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Emergency.tsx @@ -0,0 +1,75 @@ +import React, { FC } from 'react'; +import { Layer } from '@instruments/common/utils'; +import { SwitchButton } from '../Elements/SwitchButton'; +import { Button } from '../../Components/Button'; + +export const Page: FC = () => ( + + { /* ADS-C */ } + ADS-C EMERGENCY + {}} /> + + + { /* message blocks */ } + + + + + + + + + + + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/AllMessages.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/AllMessages.tsx new file mode 100644 index 00000000000..7801a3c3019 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/AllMessages.tsx @@ -0,0 +1,3 @@ +import React, { FC } from 'react'; + +export const Page: FC = () => (ALL MSG); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/Mainpage.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/Mainpage.tsx new file mode 100644 index 00000000000..4437f43c321 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/Mainpage.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Layer } from '@instruments/common/utils'; +import { Button } from '../../../Components/Button'; + +export const Page: FC = () => { + const history = useHistory(); + + return ( + <> + + + + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/MonitoredMessages.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/MonitoredMessages.tsx new file mode 100644 index 00000000000..3e2fe1dd3af --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/MsgRecord/MonitoredMessages.tsx @@ -0,0 +1,3 @@ +import React, { FC } from 'react'; + +export const Page: FC = () => (MONITORED MSG); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Mainpage.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Mainpage.tsx new file mode 100644 index 00000000000..107f0ab48a5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Mainpage.tsx @@ -0,0 +1,29 @@ +import React, { FC } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Layer } from '@instruments/common/utils'; +import { Button } from '../../../Components/Button'; + +export const Page: FC = () => { + const history = useHistory(); + + /* TODO disable POSITION REPORT for FANS-B */ + + return ( + <> + + + + + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Modify.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Modify.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Other.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Other.tsx new file mode 100644 index 00000000000..92e141707a4 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/Other.tsx @@ -0,0 +1,56 @@ +import React, { FC } from 'react'; +import { Layer } from '@instruments/common/utils'; +import { Button } from '../../../Components/Button'; +import { Dropdown, DropdownItem } from '../../../Components/Dropdown'; + +export const Page: FC = () => ( + + + + + + SIGHTED + SIGHTED AND PASSED + NOT SIGHTED + + + ETA + REVISED ETA + TOP OF DESCENT + + + BACK ON ROUTE + PASSING POSITION + + + MAINTAINING LEVEL + LEAVING LEVEL + PREFERRED LEVEL + REACHING BLOCK + TOP OF DESCENT + + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/PositionReport.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/PositionReport.tsx new file mode 100644 index 00000000000..30c26354b6e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Report/PositionReport.tsx @@ -0,0 +1,149 @@ +import React, { useState, FC } from 'react'; +import { Layer } from '@instruments/common/utils'; +import { Button } from '../../../Components/Button'; +import { Checkbox, CheckboxItem } from '../../../Components/Checkbox'; +import { Dropdown, DropdownItem } from '../../../Components/Dropdown'; +import { SwitchButton } from '../../Elements/SwitchButton'; +import { TextBox } from '../../../Components/Textbox'; + +export const Page: FC = () => { + const [icing, setIcing] = useState(' '); + const [turbulence, setTurbulence] = useState(' '); + const [deviating, setDeviating] = useState(false); + const [climbingTo, setClimbingTo] = useState(false); + const [descendingTo, setDescendingTo] = useState(false); + + // TODO remove with real code + const [preloadingData, setPreloadingData] = useState(true); + setTimeout(() => setPreloadingData(false), 2000); + + return ( + <> + + AUTO POSITION REPORT + {}} /> + + + UTC + ALT + + OVHD + + {!preloadingData && ( + <> + + + + )} + + PPOS + + + + + TO + + {!preloadingData && } + + NEXT + + + SPD + + HDG + + + GND SPD + + TRK + + + V/S + + + WIND + + + + SAT + + + ETA DEST + + ENDURANCE + + + + setDeviating(!deviating)}>DEVIATING + + {deviating ? ( + <> + + + ) : <>} + + { + setClimbingTo(!climbingTo); + setDescendingTo(false); + }} + > + CLIMBING TO + + + {climbingTo ? ( + <> + + + ) : <>} + + { + setClimbingTo(false); + setDescendingTo(!descendingTo); + }} + > + DESCENDING TO + + + {descendingTo ? ( + <> + + + ) : <>} + + TURB + + setTurbulence(' ')}> + setTurbulence('LIGHT')}>LIGHT + setTurbulence('MODERATE')}>MODERATE + setTurbulence('SEVERE')}>SEVERE + + + ICING + + setIcing(' ')}> + setIcing('TRACE')}>TRACE + setIcing('LIGHT')}>LIGHT + setIcing('MODERATE')}>MODERATE + setIcing('SEVERE')}>SEVERE + + + + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request.tsx new file mode 100644 index 00000000000..6b7e658eff7 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request.tsx @@ -0,0 +1,97 @@ +import React, { FC, useState } from 'react'; +import { FansMode } from '@atsu/com/FutureAirNavigationSystem'; +import { CpdlcMessageElement } from '@atsu/messages/CpdlcMessageElements'; +import { Layer } from '@instruments/common/utils'; +import { Button } from '../../Components/Button'; +import { Menu as FansA } from './Request/FansA'; +import { Menu as FansB } from './Request/FansB'; +import { MaxRequestElements, MessageTable } from '../Messages/Registry'; + +export const Page: FC = () => { + const [elements, setElements] = useState<{ id: string, message: CpdlcMessageElement | undefined, readyToSend: boolean }[]>([]); + const activeFansB = false; + + const elementSelect = (id: string) => { + if (MessageTable[id].singleMessage || (elements.length !== 0 && MessageTable[elements[0].id].singleMessage)) { + setElements([{ id, message: undefined, readyToSend: false }]); + } else { + if (MessageTable[id].exchanging !== undefined) { + for (let i = 0; i < elements.length; ++i) { + if (elements[i].id === MessageTable[id].exchanging) { + const update = elements; + update.splice(i, 1, { id, message: undefined, readyToSend: false }); + return; + } + } + } + + if (MaxRequestElements > elements.length) { + const update = elements; + update.push({ id, message: undefined, readyToSend: false }); + setElements(update); + } + } + }; + + const elementDelete = (index: number) => { + if (elements.length > index) { + setElements(elements.filter((_item, j) => index !== j)); + } + }; + + // create the blacklist list + const blacklistEntries: string[] = []; + elements.forEach((element) => { + blacklistEntries.push(element.id); + blacklistEntries.push(...MessageTable[element.id].blacklisting); + }); + + // create the exchange entries + const exchangeEntries: string[] = []; + elements.forEach((element) => { + if (MessageTable[element.id].exchanging !== undefined) { + exchangeEntries.push(MessageTable[element.id].exchanging); + } + }); + + // check if freetext extensions are disabled + let disableFreetext = false; + elements.forEach((element) => { + if (MessageTable[element.id].singleMessage) disableFreetext = true; + }); + + // check if all message elements are ready to be sent + let readyToSend = elements.length !== 0; + elements.forEach((element) => { + readyToSend &&= element.readyToSend; + }); + + /* TODO send a system message after the maximum number of elements is reached */ + + return ( + + {elements.map( + (element: { id: string, message: CpdlcMessageElement | undefined, readyToSend: boolean }, elementIndex: number) => React.createElement(MessageTable[element.id].visualization, { + x: 0, + y: elementIndex * 147, + mode: FansMode.FansA, + index: elementIndex, + messageElements: elements, + onDelete: () => elementDelete(elementIndex), + }), + )} + + {activeFansB + ? + : } + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/Common.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/Common.tsx new file mode 100644 index 00000000000..445992ecd4a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/Common.tsx @@ -0,0 +1,8 @@ +export const isInList = (list: string[], id: string): boolean => list.findIndex((value) => value === id) !== -1; + +export const atLeastOnElementInList = (reference: string[], list: string[]): boolean => { + for (let i = 0; i < reference.length; ++i) { + if (isInList(list, reference[i])) return true; + } + return false; +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/FansA.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/FansA.tsx new file mode 100644 index 00000000000..4917cd25d70 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/FansA.tsx @@ -0,0 +1,163 @@ +import React, { FC } from 'react'; +import { CpdlcMessageElement } from '@atsu/messages/CpdlcMessageElements'; +import { Layer } from '@instruments/common/utils'; +import { Dropdown, DropdownItem } from '../../../Components/Dropdown'; +import { MaxRequestElements } from '../../Messages/Registry'; +import { atLeastOnElementInList, isInList } from './Common'; + +type MenuProps = { + x: number; + y: number; + elements: { id: string, message: CpdlcMessageElement | undefined, readyToSend: boolean }[]; + blacklist: string[]; + exchangelist: string[]; + disableFreetext: boolean; + onSelect: (id: string) => void; +} + +export const Menu: FC = ({ x, y, elements, blacklist, exchangelist, disableFreetext, onSelect }) => { + const title = ( + <> + WHEN CAN + WE EXPECT + + ); + + return ( + + + DUE TO WEATHER + DUE TO A/C PERFORMANCE + DUE TO TURBULENCE + DUE TO TECHNICAL + DUE TO MEDICAL + AT PILOTS DISCRETION + FREETEXT + + + FREETEXT + VOICE CONTACT + OWN SEPARATION & VMC + VMC DES + + + onSelect('WhenCanWeExpectHigherLevel')} + disabled={!isInList(exchangelist, 'WhenCanWeExpectHigherLevel') && (MaxRequestElements <= elements.length || isInList(blacklist, 'WhenCanWeExpectHigherLevel'))} + > + HIGHER ALTITUDE + + onSelect('WhenCanWeExpectLowerLevel')} + disabled={!isInList(exchangelist, 'WhenCanWeExpectLowerLevel') && (MaxRequestElements <= elements.length || isInList(blacklist, 'WhenCanWeExpectLowerLevel'))} + > + LOWER ALTITUDE + + onSelect('WhenCanWeExpectClimb')} + disabled={!isInList(exchangelist, 'WhenCanWeExpectClimb') && (MaxRequestElements <= elements.length || isInList(blacklist, 'WhenCanWeExpectClimb'))} + > + CLIMB TO + + onSelect('WhenCanWeExpectDescend')} + disabled={!isInList(exchangelist, 'WhenCanWeExpectDescend') && (MaxRequestElements <= elements.length || isInList(blacklist, 'WhenCanWeExpectDescend'))} + > + DESCEND TO + + onSelect('WhenCanWeExpectCruiseClimb')} + disabled={MaxRequestElements <= elements.length || isInList(blacklist, 'WhenCanWeExpectCruiseClimb')} + > + CRUISE CLIMB + + onSelect('WhenCanWeExpectSpeed')} + disabled={MaxRequestElements <= elements.length || isInList(blacklist, 'WhenCanWeExpectSpeed')} + > + SPEED + + onSelect('WhenCanWeExpectSpeedRange')} + disabled={MaxRequestElements <= elements.length || isInList(blacklist, 'WhenCanWeExpectSpeedRange')} + > + SPEED RANGE + + onSelect('WhenCanWeExpectBackOnRoute')} + disabled={MaxRequestElements <= elements.length || isInList(blacklist, 'WhenCanWeExpectBackOnRoute')} + > + BACK ON ROUTE + + + + onSelect('RequestDepartureClearance')} disabled={isInList(blacklist, 'RequestDepartureClearance')}>DEPARTURE + onSelect('RequestOceanicClearance')} disabled={isInList(blacklist, 'RequestOceanicClearance')}>OCEANIC + onSelect('RequestGenericClearance')} disabled={isInList(blacklist, 'RequestGenericClearance')}>GENERIC + + + onSelect('RequestSpeed')} disabled={isInList(blacklist, 'RequestSpeed')}>SPEED + onSelect('RequestSpeedRange')} disabled={isInList(blacklist, 'RequestSpeedRange')}>SPEED RANGE + + + onSelect('RequestDirect')} disabled={isInList(blacklist, 'RequestDirect')}>DIRECT TO + onSelect('RequestOffset')} disabled={isInList(blacklist, 'RequestOffset')}>OFFSET + onSelect('RequestWeatherDeviation')} disabled={isInList(blacklist, 'RequestWeatherDeviation')}>WX DEVIATION + onSelect('RequestHeading')} disabled={isInList(blacklist, 'RequestHeading')}>HEADING + onSelect('RequestGroundTrack')} disabled={isInList(blacklist, 'RequestGroundTrack')}>TRACK + SID/STAR + TAILORED ARRIVAL + REROUTING + + + onSelect('RequestClimb')} disabled={isInList(blacklist, 'RequestClimb')}>CLIMB TO + onSelect('RequestDescend')} disabled={isInList(blacklist, 'RequestDescend')}>DESCEND TO + onSelect('RequestLevel')} disabled={MaxRequestElements <= elements.length || isInList(blacklist, 'RequestLevel')}>ALT/FL + onSelect('RequestLevelBlock')} + disabled={MaxRequestElements <= elements.length || isInList(blacklist, 'RequestLevelBlock')} + > + BLOCK ALT/FL + + onSelect('RequestCruiseClimb')} + disabled={MaxRequestElements <= elements.length || isInList(blacklist, 'RequestCruiseClimb')} + > + CRUISE CLIMB + + onSelect('RequestITP')} disabled={MaxRequestElements <= elements.length || isInList(blacklist, 'RequestITP')}>ITP + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/FansB.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/FansB.tsx new file mode 100644 index 00000000000..ed0872f8838 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/Request/FansB.tsx @@ -0,0 +1,61 @@ +import React, { FC } from 'react'; +import { CpdlcMessageElement } from '@atsu/messages/CpdlcMessageElements'; +import { Layer } from '@instruments/common/utils'; +import { Dropdown, DropdownItem } from '../../../Components/Dropdown'; +import { MaxRequestElements } from '../../Messages/Registry'; +import { isInList, atLeastOnElementInList } from './Common'; + +type MenuProps = { + x: number; + y: number; + elements: { id: string, message: CpdlcMessageElement | undefined, readyToSend: boolean }[]; + blacklist: string[]; + exchangelist: string[]; + disableFreetext: boolean; + onSelect: (id: string) => void; +} + +export const Menu: FC = ({ x, y, elements, blacklist, exchangelist, disableFreetext, onSelect }) => ( + + + DUE TO WEATHER + DUE TO A/C PERFORMANCE + + + onSelect('RequestSpeed')} disabled={isInList(blacklist, 'RequestSpeed')}>SPEED + + + FREETEXT + + + onSelect('RequestDirect')} disabled={isInList(blacklist, 'RequestDirect')}>DIRECT TO + onSelect('RequestWeatherDeviation')} disabled={isInList(blacklist, 'RequestWeatherDeviation')}>WX DEVIATION + + + onSelect('RequestClimb')} disabled={isInList(blacklist, 'RequestClimb')}>CLIMB TO + onSelect('RequestDescend')} disabled={isInList(blacklist, 'RequestDescend')}>DESCEND TO + onSelect('RequestLevel')} disabled={isInList(blacklist, 'RequestLevel')}>ALT/FL + onSelect('RequestITP')} disabled={isInList(blacklist, 'RequestITP')}>ITP + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/index.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/index.tsx new file mode 100644 index 00000000000..bcf2531240d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/Pages/index.tsx @@ -0,0 +1,31 @@ +import { Page as Atis } from './Atis'; +import { Page as MaxUplinkDelay } from './Connect/MaxUplinkDelay'; +import { Page as Connect } from './Connect/Mainpage'; +import { Page as Emergency } from './Emergency'; +import { Page as AllMsg } from './MsgRecord/AllMessages'; +import { Page as MonitoredMsg } from './MsgRecord/MonitoredMessages'; +import { Page as MsgRecord } from './MsgRecord/Mainpage'; +import { Page as Report } from './Report/Mainpage'; +import { Page as Other } from './Report/Other'; +import { Page as PositionReport } from './Report/PositionReport'; +import { Page as Request } from './Request'; + +export const Pages = { + Atis, + Connect: { + MaxUplinkDelay, + Connect, + }, + Emergency, + MsgRecord: { + MsgRecord, + AllMsg, + MonitoredMsg, + }, + Report: { + Other, + PositionReport, + Report, + }, + Request, +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/index.tsx b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/index.tsx new file mode 100644 index 00000000000..fb21619d216 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/ATCCOM/index.tsx @@ -0,0 +1,268 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { Button } from '../Components/Button'; +import { Pages } from './Pages'; +import { MFDRedirect, MFDRoute } from '../Components/MFDRoute'; +import { MFDMessageArea } from '../Messages/MFDMessageArea'; +import { MFDMessagesList } from '../Messages/MFDMessagesList'; + +export const ATCCOM = () => { + const { path } = useRouteMatch(); + + return ( + <> + + + + + + + + + + + + + + ); +}; + +const ConnectButton: React.FC = () => { + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const { path } = useRouteMatch(); + const history = useHistory(); + + const active = history.location.pathname.includes('connect'); + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + return ( + + ); +}; + +const RequestButton: React.FC = () => { + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const { path } = useRouteMatch(); + const history = useHistory(); + + const active = history.location.pathname.includes('request'); + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + return ( + + ); +}; + +const ReportButton: React.FC = () => { + const textRefFirstLine = useRef(null); + const textRefSecondLine = useRef(null); + const [textBboxFirstLine, setTextBboxFirstLine] = useState(); + const [textBboxSecondLine, setTextBboxSecondLine] = useState(); + const { path } = useRouteMatch(); + const history = useHistory(); + + const active = history.location.pathname.includes('report_&_modify'); + + useEffect(() => setTextBboxFirstLine(textRefFirstLine.current?.getBBox()), [textRefFirstLine]); + useEffect(() => setTextBboxSecondLine(textRefSecondLine.current?.getBBox()), [textRefSecondLine]); + return ( + + ); +}; + +const MessageRecordButton: React.FC = () => { + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const { path } = useRouteMatch(); + const history = useHistory(); + + const active = history.location.pathname.includes('msg_record'); + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + return ( + + ); +}; + +const AtisButton: React.FC = () => { + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const { path } = useRouteMatch(); + const history = useHistory(); + + const active = history.location.pathname.includes('d-atis'); + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + return ( + + ); +}; + +const EmergencyButton: React.FC = () => { + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const { path } = useRouteMatch(); + const history = useHistory(); + + const active = history.location.pathname.includes('emergency'); + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + return ( + + ); +}; + +const StatusBar: React.FC = () => { + const history = useHistory(); + const { path } = useRouteMatch(); + + let statusText = history.location.pathname.toUpperCase().substring(path.length + 1); + if (statusText !== undefined && statusText.length > 0) statusText = statusText.replace('/_/g', ' '); + const statusBackgroundColor = statusText === 'EMERGENCY' ? '#f48244' : '#eee'; + + return ( + <> + + + {statusText} + + ); +}; + +const PagesContainer: FC = () => { + const loggedInToAtc = true; + + return ( + <> + + + + {/* Connect */} + + + {/* Request */} + + {/* Report & Modify */} + + + + {/* Message record */} + + + + {/* ATIS */} + + {/* Emergency */} + + {/* FMS message area */} + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Components/Arrows.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Components/Arrows.tsx new file mode 100644 index 00000000000..b325d858ce8 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Components/Arrows.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Layer } from './Layer'; + +type ArrowProps = { + x: number; + y: number; + width?: number; + height?: number; + angle?: number; +} + +export const Arrows = ({ x, y, angle, width = 30, height = 30 }: ArrowProps) => ( + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Components/Button.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Components/Button.tsx new file mode 100644 index 00000000000..c5dcdc07629 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Components/Button.tsx @@ -0,0 +1,106 @@ +import { useInputManager } from '@instruments/common/input'; +import React, { Children, FC, isValidElement, useState, useEffect, useRef } from 'react'; +import { useHover } from 'use-events'; +import { Layer } from './Layer'; + +type ButtonProps = { + x?: number; + y?: number; + width?: number; + height?: number; + onClick?: () => void; + fill?: string; + disabled?: boolean; + highlighted?: boolean; + textBackgroundColor?: string; + textColor?: string; + strokeWidth?: number; + defaultColor?: string; + hoverColor?: string; + activeColor?: string; +} + +export const Button: FC = ({ + x = 0, y = 0, width = 0, height = 41, children, onClick, disabled, fill, highlighted, textBackgroundColor, textColor, strokeWidth = 5, + defaultColor = 'white', hoverColor = 'cyan', activeColor = 'grey', +}) => { + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const [hovered, hoverProps] = useHover(); + const inputManager = useInputManager(); + + const handleOnClick = () => { + if (!disabled) { + inputManager.triggerUiReset(); + if (onClick) onClick(); + } + }; + + let textFill = ''; + if (textColor) { + textFill = textColor; + } else if (disabled) { + textFill = '#ababab'; + } else { + textFill = 'white'; + } + + const handleChild = (child) => { + if (isValidElement(child)) { + if (child.type.toString() === 'Symbol(react.fragment)' && child.props.children !== undefined) { + return child.props.children.map((subchild) => handleChild(subchild)); + } + if (child.type !== 'tspan') return child; + } + return {child}; + }; + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + return ( + + + + + + + + {textBackgroundColor && ( + + )} + {Children.map(children, (child) => handleChild(child))} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Components/Checkbox.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Components/Checkbox.tsx new file mode 100644 index 00000000000..3d579368460 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Components/Checkbox.tsx @@ -0,0 +1,124 @@ +import { useInputManager } from '@instruments/common/input'; +import React, { Children, isValidElement, FC, useEffect, useRef, useState } from 'react'; +import { useHover } from 'use-events'; +import { Layer } from './Layer'; + +type CheckboxProps = { + x: number; + y?: number; + strokeWidth?: number; + buttonWidth?: number; + verticalSpacing?: number; + textSpacing?: number; + uniqueSelection?: boolean; + disabled?: boolean; +} + +export const Checkbox: FC = ({ x, y = 0, strokeWidth = 2, buttonWidth = 10, verticalSpacing = 40, textSpacing = 40, uniqueSelection, disabled = false, children }) => ( + + {Children.map(children, (child, index) => { + if (isValidElement(child)) { + return React.cloneElement(child, { + y: verticalSpacing * index + 2, + strokeWidth, + buttonWidth, + textSpacing, + uniqueSelection, + disabled: child.props.disabled || disabled, + }); + } + })} + +); + +type CheckboxItemProps = { + x?: number, + y?: number; + strokeWidth?: number; + selected?: boolean; + buttonWidth?: number; + textSpacing?: number; + uniqueSelection?: boolean; + disabled?: boolean; + onSelect?: () => void; +} + +export const CheckboxItem: FC = ({ + x = 0, + y = 0, + strokeWidth = 2, + selected = false, + buttonWidth = 10, + textSpacing = 40, + uniqueSelection, + disabled = false, + onSelect, + children, +}) => { + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const [hovered, hoverRef] = useHover(); + const inputManager = useInputManager(); + + const onClick = () => { + if (!disabled) { + inputManager.triggerUiReset(); + if (onSelect) onSelect(); + } + }; + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + + return ( + + {uniqueSelection ? ( + <> + + {selected && } + {children} + + ) : ( + <> + + {selected ? ( + <> + {/* Check-icon */} + + + ) : <>} + + + {children} + + + )} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Components/Dropdown.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Components/Dropdown.tsx new file mode 100644 index 00000000000..4c2be0de58f --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Components/Dropdown.tsx @@ -0,0 +1,330 @@ +import React, { + Children, + isValidElement, + ReactNode, + useState, + FC, + useRef, + useEffect, + Dispatch, + SetStateAction, +} from 'react'; +import { useHover } from 'use-events'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useInputManager } from '@instruments/common/input'; +import { Layer } from './Layer'; +import { Button } from './Button'; + +type DropdownProps = { + x: number; + y?: number; + width?: number; + height?: number; + selectable?: boolean; + title: string | ReactNode; + dropDownWidth?: number; + active?: boolean; + disabled?: boolean; + scrollable?: boolean; + horizontal?: boolean; + expandLeft?: boolean; + showBlackBoundingBox?: boolean; +} + +const childHeight = 40; +const scrollBarWidth = 15; +let lastMousePosition = 0; +const maxHeight = 400; + +export const Dropdown: FC = ({ + x, + y = 0, + width = 192, + height = 60, + selectable, + title, + children, + dropDownWidth, + active, + disabled, + scrollable, + horizontal, + expandLeft, + showBlackBoundingBox = false, +}) => { + const [open, setOpen] = useState(false); + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const [scrollPosition, setScrollPosition] = useState(0); + const [clipPathId] = useState((Math.random() * 1000).toString()); + const inputManager = useInputManager(); + const childWidth = dropDownWidth ?? width; + + const onUiReset = (): void => { + setOpen(false); + }; + + const onButtonClick = (): void => { + if (open) { + inputManager.clearUiResetHandler(); + } else { + inputManager.triggerUiReset(); + inputManager.setUiResetHandler(onUiReset); + } + setOpen(!open); + }; + + // default is horizontal and expand to the right + let childrenRectangleY = height + 1.5; + let childrenRectangleX = 1.5; + let textAnchor = 'middle'; + let textX = 6; + if (horizontal) { + childrenRectangleY = 1.5; + if (expandLeft) { + childrenRectangleX = -childWidth + 1.5; + textX = width - 7; + textAnchor = 'end'; + } else { + childrenRectangleX = width; + textAnchor = 'start'; + textX = 7; + } + } else { + textX = width / 2 - 15; + } + + const blackBoundingBox = { topLeft: [0, 0], dimension: [width - 37, height - 8] }; + if (horizontal && expandLeft) { + blackBoundingBox.topLeft = [33, 4]; + } else { + blackBoundingBox.topLeft = [4, 4]; + } + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + if (open && disabled) setOpen(false); + return ( + + + + + + {open && ( + + + + {Children.map(children, (child, index) => { + if (isValidElement(child)) { + let childY = childHeight * index + 2 - scrollPosition; + if (!horizontal) { + childY += height; + } + return React.cloneElement(child, { + x: childrenRectangleX, + y: childY, + width: childWidth - 1.5, + centered: child.props.centered ?? (!selectable && !horizontal), + onSelect: () => { + if (!disabled) { + if (child.props.onSelect) { + child.props.onSelect(); + } + setOpen(false); + } + }, + }); + } + return null; + })} + + {scrollable && ( + + )} + + )} + + ); +}; + +export type ScrollBarProps = { + x: number; + y: number; + width?: number; + maxHeight: number; + totalChildHeight: number; + scrollPosition: number; + setScrollPosition: Dispatch>; +} +export const ScrollBar: FC = ({ x, y, width = 48, maxHeight, totalChildHeight, scrollPosition, setScrollPosition }) => { + const inputManager = useInputManager(); + const [dragging, setDragging] = useState(false); + const [hovered, hoverRef] = useHover(); + const handleMouseDown = (e: any) => { + inputManager.setMouseUpHandler(handleMouseUp); + inputManager.setMouseMoveHandler(handleMouseMove); + lastMousePosition = e.pageY; + }; + + const handleMouseUp = () => { + setDragging(false); + inputManager.clearHandlers(); + }; + + const handleMouseMove = (e: MouseEvent) => { + const delta = (e.pageY - lastMousePosition); + setScrollPosition((p) => { + let newPos = p + delta; + newPos = Math.max(0, newPos); + newPos = Math.min(newPos, totalChildHeight - maxHeight); + return newPos; + }); + lastMousePosition = e.pageY; + }; + + return ( + + ); +}; + +type DropdownItemProps = { + x?: number; + y?: number; + onSelect?: () => void; + width?: number; + centered?: boolean; + disabled?: boolean; +} +export const DropdownItem: FC = ({ x = 10, y = 0, onSelect, width = 0, centered, disabled = false, children }) => { + const [hovered, hoverProps] = useHover(); + + return ( + { + if (!disabled && onSelect) { + onSelect(); + } + }} + > + + + + + {children} + + + ); +}; + +export const DropdownLink: FC = (props) => { + const history = useHistory(); + const { path } = useRouteMatch(); + const historyPath = props.link.startsWith('/') ? props.link : `${path}/${props.link}`; + + return ( + { + if (props.onSelect) props.onSelect(); + history.push(historyPath); + }} + > + {props.children} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Components/Layer.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Components/Layer.tsx new file mode 100644 index 00000000000..824352e2b32 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Components/Layer.tsx @@ -0,0 +1,7 @@ +import React, { SVGProps, FC } from 'react'; + +export const Layer: FC & { angle?: number }> = (props) => ( + + {props.children} + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Components/MFDRoute.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Components/MFDRoute.tsx new file mode 100644 index 00000000000..e51ea65530e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Components/MFDRoute.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import {Redirect, Route, useRouteMatch} from 'react-router-dom'; + +type MFDRouteProps = { + path: string; + exact?: boolean; + component?: any; +} + +export const MFDRoute: FC = ({ path, exact, component, children }) => { + const { path: route } = useRouteMatch(); + return ( + + {children} + + ); +}; + +type MFDRedirectProps = { + to: string; +} + +export const MFDRedirect: FC = ({ to }) => { + const { path } = useRouteMatch(); + return ( + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Components/Textbox.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Components/Textbox.tsx new file mode 100644 index 00000000000..6416407dc83 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Components/Textbox.tsx @@ -0,0 +1,233 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import { useHover } from 'use-events'; +import { useInputManager } from '@instruments/common/input'; +import { Layer } from './Layer'; + +declare const Coherent: any; + +const iskeyCodePrintable = (keycode: number): boolean => ( + (keycode > 47 && keycode < 58) // number keys + || keycode === 32 || keycode === 13 // spacebar & return key(s) (if you want to allow carriage returns) + || (keycode > 64 && keycode < 91) // letter keys + || (keycode > 95 && keycode < 112) // numpad keys + || (keycode > 185 && keycode < 193) // ;=,-./` (in order) + || (keycode > 218 && keycode < 223) +); + +type TextBoxProps = { + x: number; + y: number; + width?: number; + height?: number; + maxLength?: number; + disabled?: boolean; + disabledBackgroundColor?: string; + defaultValue?: string; + onSubmit?: (value: string) => boolean; + prefix?: string; + midfix?: string; + midfixPosition?: number; + suffix?: string; + fixFontSize?: number; + hideFixesIfEmpty?: boolean; + autoFilled?: boolean; + placeholder?: string; + placeholderTextColor?: string; + textColor?: string; + textFontSize?: number; + textAnchor?: 'start' | 'middle' | 'end'; + resetValueIfDisabled?: boolean; +} +const strokeWidth = 5; + +export const TextBox: FC = ({ + x, y, width = 50, height = 41, maxLength = 8, disabled, disabledBackgroundColor, defaultValue, onSubmit, prefix, midfix, midfixPosition, suffix, + fixFontSize = 24, hideFixesIfEmpty, autoFilled, placeholder, placeholderTextColor = 'orange', textColor, textFontSize, textAnchor, resetValueIfDisabled, +}) => { + const [isCursorVisible, setCursorVisible] = useState(false); + + const [hovered, hoverProps] = useHover(); + const [value, setValue] = useState(defaultValue || ''); + const [focused, setFocused] = useState(false); + const inputManager = useInputManager(); + const textRef = useRef(null); + const prefixRef = useRef(null); + const suffixRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const [prefixBbox, setPrefixBbox] = useState(); + const [suffixBbox, setSuffixBbox] = useState(); + + // TODO support text-anchor when editing ? + const flashingBoxX = (value.length - 1) * (textBbox?.width ?? 0); + + useEffect(() => setValue(defaultValue || ''), [defaultValue]); + useEffect(() => setTextBbox(textRef.current?.getBBox()), [value, focused]); + useEffect(() => setPrefixBbox(prefixRef.current?.getBBox()), [prefixRef]); + useEffect(() => setSuffixBbox(suffixRef.current?.getBBox()), [suffixRef]); + + const focus = () => { + if (disabled) return; + inputManager.triggerUiReset(); + inputManager.setKeyboardHandler(onKeyDown); + inputManager.setMouseClickHandler(onClick); + setFocused(true); + Coherent.trigger('FOCUS_INPUT_FIELD'); + }; + + const unFocus = () => { + inputManager.clearHandlers(); + setFocused(false); + Coherent.trigger('UNFOCUS_INPUT_FIELD'); + }; + + const onClick = (e: MouseEvent) => { + if (e.button === 2) { + unFocus(); + if (onSubmit) onSubmit(''); + setValue(''); + } else if (e.button === 0) { + unFocus(); + setValue((val) => { + if (onSubmit) { + if (onSubmit(val.toString())) return val; + return value; + } + return val; + }); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + setValue((val) => { + if (e.keyCode === 8) return val.toString().substr(0, val.toString().length - 1); + if (!iskeyCodePrintable(e.keyCode) || val.toString().length >= maxLength) return val; + return (val + String.fromCharCode(e.keyCode).toUpperCase()); + }); + }; + + useEffect(() => { + const int = setInterval(() => { + setCursorVisible((v) => !v); + }, 500); + + return () => clearInterval(int); + }, []); + + const getPlaceholder = (length) => { + if (focused) { + return ''; + } + return (placeholder ?? (disabled ? '-' : 'â–¯').repeat(length)); + }; + + if (disabled && value !== defaultValue && value !== '' && resetValueIfDisabled) { + setValue(defaultValue || ''); + } + + let contentCoordinateX = 10; + if (textAnchor) { + if (textAnchor === 'start') { + contentCoordinateX = prefixBbox?.width! + 5; + } else if (textAnchor === 'middle') { + contentCoordinateX = width / 2; + } else if (textAnchor === 'end') { + contentCoordinateX = width - 5 - suffixBbox?.width!; + } + } else if (prefix) { + contentCoordinateX = width - 10; + } else if (focused) { + contentCoordinateX = 5; + } else if (autoFilled) { + contentCoordinateX = 15; + } else { + contentCoordinateX = width / 2 - (suffix ? 13 : 0); + } + + let fixesVisible = (value === '' && !hideFixesIfEmpty) || value !== ''; + if (focused && textAnchor) { + fixesVisible = false; + } + + let preMidfixText = value; + let postMidfixText = ''; + if (!disabled && fixesVisible && midfixPosition && midfixPosition <= value.length) { + preMidfixText = value.substr(0, midfixPosition); + postMidfixText = value.substr(midfixPosition, value.length); + } + + return ( + + + + + + + {!textAnchor && fixesVisible ? ( + <> + {prefix} + {suffix} + + ) : <>} + + {isCursorVisible && value && focused && } + + {textAnchor && fixesVisible && !disabled ? {prefix} : <>} + {midfix && midfixPosition && fixesVisible ? ( + <> + {preMidfixText.length !== 0 && ( + <> + {preMidfixText} + {!disabled && {midfix}} + {postMidfixText} + + )} + {preMidfixText.length === 0 && ( + <> + {getPlaceholder(midfixPosition)} + {!disabled && {midfix}} + {getPlaceholder(maxLength - midfixPosition)} + + )} + + ) : ( + <> + {value.length !== 0 ? value.substr(0, value.length - 1) : getPlaceholder(maxLength)} + {value[value.length - 1]} + + )} + {textAnchor && fixesVisible && !disabled ? {suffix} : <>} + + + {value[value.length - 1]} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Components/tabs.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Components/tabs.tsx new file mode 100644 index 00000000000..de629d1fe86 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Components/tabs.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { useHover } from '@instruments/common/hooks/index'; +import { useInputManager } from '@instruments/common/input'; +import { OnClick, OneDimensionalSize, Position, TwoDimensionalSize } from '../../Common/types'; + +export const TabSet: React.FC = ({ x, y, width, height, children: tabs }) => { + const [clipPathId] = useState(Math.round(Math.random() * 1000).toString()); + + const [activeTabIndex, setActiveTabIndex] = useState(0); + const inputManager = useInputManager(); + + const [tabCount, setTabCount] = useState(() => React.Children.count(tabs)); + const [tabWidth, setTabWidth] = useState(0); + const [tabContentWidth, setTabContentWidth] = useState(0); + const [overlap, setOverlap] = useState(0); + + const onTabClick = (index: number) => { + inputManager.triggerUiReset(); + setActiveTabIndex(index); + }; + + useEffect(() => { + setTabCount(React.Children.count(tabs)); + + const padding = 36; + + const overlap = 24; + + setTabWidth(width / tabCount); + setTabContentWidth((width / tabCount) - padding); + setOverlap(overlap); + }, [tabs]); + + return ( + <> + + + + + + {/* Frame */} + + + {React.Children.map(tabs, (child) => child)?.map((child, index) => { + if (React.isValidElement(child)) { + const tabX = x + (index * tabWidth); + + const offsetCount = tabCount - 1; + const overlapToRegain = offsetCount * overlap; + const extraWidthPerTab = overlapToRegain / tabCount; + + const xOffset = index > 0 ? (overlap - extraWidthPerTab) * index : 0; + + return React.cloneElement(child, { + key: child.props.title, + x: tabX - xOffset, + y, + size: tabContentWidth + extraWidthPerTab, + onClick: onTabClick, + active: index === activeTabIndex, + index, + }); + } + return child; + }).reverse().sort((a) => (a.props.active ? 1 : -1))} + + + ); +}; + +type TabProps = Partial + +export const Tab: React.FC = ({ x = 0, y = 0, size = 0, onClick, active, title }) => { + const [ref, isHovered] = useHover(); + const [clipPathId] = useState(Math.round(Math.random() * 1000).toString()); + + return ( + + + + + + + + {title} + + {active && } + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Airways.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Airways.tsx new file mode 100644 index 00000000000..ea50381fdf3 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Airways.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useState } from 'react'; +import { useActiveOrTemporaryFlightPlan } from '@instruments/common/flightplan'; +import { useHistory } from 'react-router-dom'; +import { NavigationDatabaseService } from '@fmgc/flightplanning/new/NavigationDatabaseService'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { Layer } from '../../../../Components/Layer'; +import { TextBox } from '../../../../Components/Textbox'; +import { Button } from '../../../../Components/Button'; +import { Arrows } from '../../../../Components/Arrows'; + +const rowSpacing = 59; + +export const Page = ({ legIndex }: { legIndex: number }) => { + const [flightPlan, isTemporary] = useActiveOrTemporaryFlightPlan(); + const [scrollIndex, setScrollIndex] = useState(0); + const [dctNext, setDctNext] = useState(false); + + const history = useHistory(); + const currentLeg = flightPlan.allLegs[legIndex]; + + useEffect(() => { + FlightPlanService.startAirwayEntry(legIndex); + }, []); + + const handleSubmitAirway = (ident: string) => { + if (ident === 'DCT') { + setDctNext(true); + } + + NavigationDatabaseService.activeDatabase.searchAirway(ident).then((airways) => { + if (flightPlan.pendingAirways) { + for (const airway of airways) { + const success = flightPlan.pendingAirways.thenAirway(airway); + + if (success) { + break; + } + } + } + }); + + return true; + }; + + const handleSubmitTo = (ident: string) => { + NavigationDatabaseService.activeDatabase.searchFix(ident).then((fixes) => { + if (flightPlan.pendingAirways) { + for (const fix of fixes) { + const success = flightPlan.pendingAirways.thenTo(fix); + + if (success) { + setDctNext(false); + break; + } + } + } + }); + + return true; + }; + + const handleScrollUp = () => setScrollIndex((old) => Math.max(0, old - 1)); + + const handleScrollDown = () => setScrollIndex((old) => { + if (flightPlan.pendingAirways) { + const elementCount = flightPlan.pendingAirways.elements.length; + + const elemsLeft = elementCount - old; + if (elemsLeft < 11) { + return old; + } + + if (old + 1 < elementCount) { + return old + 1; + } + } + + return old; + }); + + const viaToTable = () => { + const elements = flightPlan.pendingAirways?.elements; + + if (elements) { + const rows: JSX.Element[] = []; + + const elementsLeft = elements.length - scrollIndex; + const virtualScrollEnd = scrollIndex + Math.min(elementsLeft, 11); + + for (let i = scrollIndex; i < virtualScrollEnd; i++) { + const element = elements[i]; + + const virtualIndex = i - scrollIndex; + + const textColor = (element.airway && element.to) || element.isDct ? 'yellow' : 'cyan'; + + rows.push( + <> + VIA + + + + {element.airway?.turnRadius && ( + + FIXED TURN + RADIUS AIRWAY + + )} + + TO + + + {' '} + // FIXME + , + ); + } + + const spacing = (virtualScrollEnd - scrollIndex) * rowSpacing; + + rows.push( + <> + VIA + + {' '} + + {dctNext && ( + <> + TO + + + + )} + // FIXME + , + ); + + rows.length = 11; + + return rows; + } + }; + + return ( + + AIRWAYS FROM + {currentLeg.ident} + + + {viaToTable()} + + + + + {isTemporary && ( + + )} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Arrivals.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Arrivals.tsx new file mode 100644 index 00000000000..d7b41c35fb7 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Arrivals.tsx @@ -0,0 +1,154 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useActiveOrTemporaryFlightPlan, useNavDatabase } from '@instruments/common/flightplan'; +import { Approach, Arrival, Database, IlsNavaid, Runway } from 'msfs-navdata'; +import { useHistory } from 'react-router-dom'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { Layer } from '../../../../Components/Layer'; +import { Dropdown, DropdownItem } from '../../../../Components/Dropdown'; +import { Button } from '../../../../Components/Button'; + +export const Page: FC = () => { + const [flightPlan, isTemporary, version] = useActiveOrTemporaryFlightPlan(); + const database = useNavDatabase(); + const [runways, setRunways] = useState(); + const [arrivals, setArrivals] = useState(); + const [approaches, setApproaches] = useState(); + const [ls, setLs] = useState(); + + const history = useHistory(); + + useEffect(() => { + if (flightPlan.destinationAirport) { + setRunways(flightPlan.availableDestinationRunways); + setArrivals(flightPlan.availableArrivals); + setApproaches(flightPlan.availableApproaches); + + const { approach } = flightPlan; + if (approach) { + database.getIlsAtAirport(flightPlan.destinationAirport?.ident ?? '') + .then((ils) => setLs(ils.find((it) => it.runwayIdent === Database.approachToRunway(approach.ident)))); + } + } + }, [version]); + + const mainFplnColorClass = isTemporary ? 'Yellow' : 'Green'; + + return ( + + + + SELECTED ARRIVAL + + TO + + {flightPlan.destinationRunway?.ident} + + + LS + + {flightPlan.destinationRunway?.lsIdent ?? '----'} + + + RWY + + {flightPlan.destinationRunway?.ident.substring(2) ?? '---'} + + + LENGTH + + {flightPlan.destinationRunway ? Math.floor(flightPlan.destinationRunway.length) : '----'} + {flightPlan.destinationRunway && FT} + + + CRS + + {flightPlan.destinationRunway?.magneticBearing ?? '---'} + {flightPlan.destinationRunway?.magneticBearing && °} + + + APPR + + {flightPlan.approach?.ident ?? '-----'} + + + FREQ/CHAN + + {ls?.frequency ?? '---.--'} + + + VIA + + {flightPlan.approachVia?.ident ?? '-----'} + + + STAR + + {flightPlan.arrival?.ident ?? '-----'} + + + TRANS + + {flightPlan.arrivalEnrouteTransition?.ident ?? '-----'} + + + {/* RWY */} + + {runways?.map((runway) => ( + FlightPlanService.setDestinationRunway(runway.ident)}> + {runway.ident.substring(2)} + + {Math.floor(runway.length)} + FT + + + ))} + + + {/* APPR */} + + FlightPlanService.setApproach(undefined)} centered={false}>NONE + {approaches?.map((approach) => ( + FlightPlanService.setApproach(approach.ident)}>{approach.ident} + ))} + + + {/* VIA */} + + FlightPlanService.setApproachVia(undefined)} centered={false}>NONE + {flightPlan.approach?.transitions.map((trans) => ( + FlightPlanService.setApproachVia(trans.ident)}>{trans.ident} + ))} + + + + FlightPlanService.setArrival(undefined)} centered={false}>NONE + {arrivals?.map((arrival) => ( + FlightPlanService.setArrival(arrival.ident)}>{arrival.ident} + ))} + + + {/* TRANS */} + + FlightPlanService.setArrivalEnrouteTransition(undefined)} centered={false}>NONE + {flightPlan.arrival?.enrouteTransitions.map((trans) => ( + FlightPlanService.setArrivalEnrouteTransition(trans.ident)}>{trans.ident} + ))} + + + {isTemporary && ( + + )} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Departures.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Departures.tsx new file mode 100644 index 00000000000..ee83dc25e85 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/Departures.tsx @@ -0,0 +1,123 @@ +import React, { FC, useEffect, useState } from 'react'; +import { Layer } from '@instruments/common/utils'; +import { useActiveOrTemporaryFlightPlan, useNavDatabase } from '@instruments/common/flightplan'; +import { useHistory } from 'react-router-dom'; +import { Runway, Departure, IlsNavaid } from 'msfs-navdata'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { Dropdown, DropdownItem } from '../../../../Components/Dropdown'; +import { Button } from '../../../../Components/Button'; + +export const Page: FC = () => { + const [flightPlan, isTemporary, planVersion] = useActiveOrTemporaryFlightPlan(); + const database = useNavDatabase(); + const [runways, setRunways] = useState(); + const [departures, setDepartures] = useState(); + const [ls, setLs] = useState(); + + const runway = runways?.find((runway) => runway.ident === flightPlan.originRunway?.ident); + + const history = useHistory(); + + useEffect(() => { + if (flightPlan.originAirport) { + database.getIlsAtAirport(flightPlan.originAirport?.ident ?? '') + .then((ils) => setLs(ils.find((ils) => ils.runwayIdent === flightPlan.originRunway?.ident))); + } else { + setLs(undefined); + } + }, [flightPlan.originAirport, flightPlan.departureSegment, flightPlan.originRunway?.ident]); + + useEffect(() => { + if (flightPlan.originAirport) { + setRunways(flightPlan.availableOriginRunways); + setDepartures(flightPlan.availableDepartures); + } else { + setRunways([]); + setDepartures([]); + } + }, [planVersion]); + + return ( + + + + SELECTED DEPARTURE + + FROM + + {flightPlan.originAirport?.ident} + {' '} + {runway?.lsIdent && 'ILS'} + {' '} + {runway?.lsIdent} + + + RWY + {flightPlan.originRunway?.ident.substr(2) || '---'} + + LENGTH + + {runway ? Math.round(runway.length) : '----'} + {flightPlan.originRunway?.ident && FT} + + + CRS + + {runway ? Math.round(runway.magneticBearing) : '---'} + {flightPlan.originRunway?.ident && °} + + + EOSID + {flightPlan.departureSegment ? 'NONE' : '-----'} + + FREQ/CHAN + {ls?.frequency ?? '---.--'} + + SID + {flightPlan.originDeparture?.ident || '------'} + + TRANS + + {flightPlan.originDeparture?.enrouteTransitions.find((trans) => trans.ident === flightPlan.departureEnrouteTransition?.ident)?.ident || '-----'} + + + {/* RWY */} + + {runways?.map((runway) => ( + FlightPlanService.setOriginRunway(runway.ident)} centered={false}> + {runway.ident.substr(2)} + + {Math.floor(runway.length)} + FT + + + {runway.lsIdent && 'ILS'} + + + ))} + + + {/* SID */} + + FlightPlanService.setDepartureProcedure(undefined)} centered={false}>NONE + {departures?.map((departure) => ( + FlightPlanService.setDepartureProcedure(departure.ident)}>{departure.ident} + ))} + + + {/* TRANS */} + + FlightPlanService.setDepartureEnrouteTransition(undefined)} centered={false}>NONE + {flightPlan.originDeparture?.enrouteTransitions?.map((trans) => ( + FlightPlanService.setDepartureEnrouteTransition(trans.ident)}>{trans.ident} + ))} + + + {isTemporary && ( + + )} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/LegRow.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/LegRow.tsx new file mode 100644 index 00000000000..67602409930 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/LegRow.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { useHover } from 'use-events'; +import { Layer } from '../../../../Components/Layer'; + +type WaypointRowProps = { + ident: string; + identColorClass: string; + lineTopColorClass: string; + lineBottomColorClass: string; + eta?: string; + speed?: string; + altitude?: string; + index: number; + onClick?: () => void; + selected?: boolean; + followsLeg?: boolean; + precedesLeg?: boolean; + hideDiamond?: boolean; +}; + +const rowSpacing = 71; + +export const WaypointRow = ({ ident, identColorClass, lineTopColorClass, lineBottomColorClass, eta, speed, altitude, index, followsLeg, precedesLeg, hideDiamond, selected, onClick }: WaypointRowProps) => { + const [hovered, hoverRef] = useHover(); + return ( + <> + + + {ident} + + {eta} + {speed ?? '"'} + {altitude ?? '"'} + + {!hideDiamond && followsLeg && ( + + )} + {!hideDiamond && ( + <> + + + + )} + {!hideDiamond && precedesLeg && ( + + )} + + ); +}; + +type LegRowProps = { + ident: string; + bearing: number; + distance: number; + index: number; + infoColorClass: string; + lineColorClass: string; +}; + +export const LegRow = ({ ident, bearing, distance, index, infoColorClass, lineColorClass }: LegRowProps) => ( + <> + {ident} + + {bearing && `${Math.round(bearing)}°`} + + + {distance && Math.round(distance)} + + + +); + +type SpecialRowProps = { + index: number; + text: string; + onClick?: () => void; +} + +export const SpecialRow = ({ index, text, onClick }: SpecialRowProps) => ( + + + {text} + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/RevisionsMenu.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/RevisionsMenu.tsx new file mode 100644 index 00000000000..5486fb32d19 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/RevisionsMenu.tsx @@ -0,0 +1,179 @@ +import React, { FC } from 'react'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useHover } from 'use-events'; +import { useActiveOrTemporaryFlightPlan } from '@instruments/common/flightplan'; +import { OriginSegment } from '@fmgc/flightplanning/new/segments/OriginSegment'; +import { DestinationSegment } from '@fmgc/flightplanning/new/segments/DestinationSegment'; +import { ApproachSegment } from '@fmgc/flightplanning/new/segments/ApproachSegment'; +import { LegType } from 'msfs-navdata'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { FlightPlanIndex } from '@fmgc/flightplanning/new/FlightPlanManager'; +import { Layer } from '../../../../Components/Layer'; +import { WindowType } from './index'; + +const width = 287; +const height = 627; +const strokeWidth = 2; +const itemHeight = 41.8; +type RevisionItemProps = { + index?: number; + onSelect?: () => void; + disabled?: boolean; +} +export const RevisionItem: FC = ({ index = 0, onSelect, children, disabled }) => { + const [hovered, hoverProps] = useHover(); + + return ( + + + {children} + + ); +}; + +type RevisionsMenuProps = { + leg: number; + setCurrentWindow: (value: WindowType) => void; + onClose: () => void; +} + +const RevisionsMenu = ({ leg, setCurrentWindow, onClose }: RevisionsMenuProps) => { + const history = useHistory(); + const [flightPlan] = useActiveOrTemporaryFlightPlan(); + const { path } = useRouteMatch(); + const element = flightPlan.allLegs[leg]; + + if (element.isDiscontinuity) { + return <>; + } + + const departureRevisionAvailable = element.segment instanceof OriginSegment; + const arrivalRevisionAvailable = element.segment instanceof ApproachSegment || element.segment instanceof DestinationSegment; + const airwaysRevisionAvailable = element.isXF(); + + return ( + <> + + + + + + + + FROM P.POS DIR TO* + + + INSERT NEXT WPT + + FlightPlanService.deleteElementAt(leg)} + disabled={element.type === LegType.VM || element.type === LegType.FM} + > + DELETE* + + history.push(`${path}/departure`)} + disabled={!departureRevisionAvailable} + > + DEPARTURE + + history.push(`${path}/arrival`)} + disabled={!arrivalRevisionAvailable} + > + ARRIVAL + + + OFFSET + + + HOLD + + history.push(`${path}/airways`)} + disabled={!airwaysRevisionAvailable} + > + AIRWAYS + + + OVERFLY* + + + ENABLE ALTN* + + + NEW DEST + + + CONSTRAINTS + + + CMS + + + STEP ALTS + + + WIND + + + + ); +}; +export default RevisionsMenu; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/index.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/index.tsx new file mode 100644 index 00000000000..d4ac4a20169 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Fpln/index.tsx @@ -0,0 +1,271 @@ +import React, { useEffect, useState } from 'react'; +import { Layer } from '@instruments/common/utils'; +import { useActiveOrTemporaryFlightPlan } from '@instruments/common/flightplan'; +import { Route, Switch, useHistory, useRouteMatch } from 'react-router-dom'; +import { FlightPlanLeg } from '@fmgc/flightplanning/new/legs/FlightPlanLeg'; +import { MissedApproachSegment } from '@fmgc/flightplanning/new/segments/MissedApproachSegment'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { LegRow, SpecialRow, WaypointRow } from './LegRow'; +import { Arrows } from '../../../../Components/Arrows'; +import { Button } from '../../../../Components/Button'; +import { Dropdown, DropdownItem } from '../../../../Components/Dropdown'; +import { Pages } from '../../index'; +import RevisionsMenu from './RevisionsMenu'; + +const NUM_FPLN_ROWS = 9; +const NUM_FPLN_ROWS_TEMPORARY = 8; + +export enum WindowType { + None, + Revisions, +} + +export const Page: React.FC = () => { + const [flightPlan, isTemporary] = useActiveOrTemporaryFlightPlan(); + const [scrollPos, setScrollPos] = useState(0); + + useEffect(() => { + if (scrollPos > 0) { + const legIndex = scrollPos + 1; + + const legExists = flightPlan.hasElement(legIndex); + + if (legExists) { + SimVar.SetSimVarValue('L:A32NX_SELECTED_WAYPOINT_FP_INDEX', 'Number', 0); + SimVar.SetSimVarValue('L:A32NX_SELECTED_WAYPOINT_INDEX', 'Number', legIndex); + } + } + }, [scrollPos]); + + const history = useHistory(); + + const scrollPage = (direction: -1 | 1) => setScrollPos((p) => Math.max(Math.min(p + direction, flightPlan.allLegs.length - 2), 0)); + + const { path } = useRouteMatch(); + const [currentWindow, setCurrentWindow] = useState(WindowType.None); + const [selectedLeg, setSelectedLeg] = useState(); + + const selectLeg = (index: number) => { + setSelectedLeg(index); + setCurrentWindow(WindowType.Revisions); + }; + + const mainFplnLegInfoColorClass = isTemporary ? 'Yellow' : 'White'; + + const renderWindow = () => { + switch (currentWindow) { + default: + return <>; + case WindowType.Revisions: + return (selectedLeg !== undefined ? ( + { + setCurrentWindow(WindowType.None); setSelectedLeg(undefined); + }} + /> + ) : <>); + } + }; + + return ( + + + + + {selectedLeg && } + + + + {flightPlan.allLegs.map((leg, index, array) => { + const isLastLeg = index === flightPlan.legCount - 1; + + let identColorClass; + if (!leg.isDiscontinuity && leg.segment instanceof MissedApproachSegment) { + identColorClass = 'Cyan'; + } else if (index === flightPlan.activeLegIndex) { + identColorClass = 'White'; + } else if (isTemporary) { + identColorClass = 'Yellow'; + } else { + identColorClass = 'Green'; + } + + let lineColorClass; + if (isTemporary) { + lineColorClass = 'Yellow'; + } else if (!leg.isDiscontinuity && leg.segment instanceof MissedApproachSegment) { + lineColorClass = 'Cyan'; + } else { + lineColorClass = 'Green'; + } + + let waypointLineTopColorClass = lineColorClass; + if (index === flightPlan.activeLegIndex) { + waypointLineTopColorClass = 'White'; + } + + if (index - scrollPos >= 0 && index - scrollPos < (isTemporary ? NUM_FPLN_ROWS_TEMPORARY : NUM_FPLN_ROWS)) { + if (!leg.isDiscontinuity) { + return ( + <> + {index - scrollPos > 0 && array[index - 1] instanceof FlightPlanLeg && ( + + )} + selectLeg(index)} + selected={selectedLeg === index} + ident={leg.ident + (leg.overfly ? '' /* TODO delta symbol when crévin phont */ : '')} + index={index - scrollPos} + eta="08:35" + altitude="0" + speed="0" + hideDiamond={!index} + precedesLeg={array[index + 1] instanceof FlightPlanLeg && index - scrollPos < (isTemporary ? 7 : 8)} + followsLeg={array[index - 1] instanceof FlightPlanLeg && index - scrollPos > 0} + identColorClass={identColorClass} + lineTopColorClass={waypointLineTopColorClass} + lineBottomColorClass={lineColorClass} + /> + + {isLastLeg && } + + ); + } + + return ( + + ); + } + + return (<>); + })} + + {!flightPlan.alternateFlightPlan.destinationAirport ? ( + + ) : ( + flightPlan.alternateFlightPlan.allLegs.map((leg, index, array) => { + // All main fpln elements + "END OF F-PLN" marker + const mainFplnRows = flightPlan.allLegs.length + 1; + + if (mainFplnRows + index - scrollPos >= 0 && mainFplnRows + index - scrollPos < (isTemporary ? 8 : 9)) { + if (!leg.isDiscontinuity) { + return ( + <> + {index - scrollPos > 0 && array[index - 1] instanceof FlightPlanLeg && ( + + )} + selectLeg(index)} + selected={selectedLeg === index} + ident={leg.ident} + index={mainFplnRows + index - scrollPos} + eta="08:35" + altitude="0" + speed="0" + hideDiamond={!index} + precedesLeg={array[mainFplnRows + index + 1] instanceof FlightPlanLeg && mainFplnRows + index - scrollPos < (isTemporary ? 7 : 8)} + followsLeg={array[mainFplnRows + index - 1] instanceof FlightPlanLeg && mainFplnRows + index - scrollPos > 0} + identColorClass={isTemporary ? 'Yellow' : 'Cyan'} + lineTopColorClass={isTemporary ? 'Yellow' : 'Cyan'} + lineBottomColorClass={isTemporary ? 'Yellow' : 'Cyan'} + /> + + ); + } + + return ( + + ); + } + + return (<>); + }) + )} + + {isTemporary && ( + <> + + + + + )} + + {renderWindow()} + + + + 20:08 + + 74.5 + KLB + + + 6009 + NM + + + + + + + + + + + + +
+ + + + ); +}; + +const Header = () => ( + <> + FROM + TIME + + + SPD ALT + EFOB T.WIND + + + TRK + DIST + FPA + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/FuelLoad.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/FuelLoad.tsx new file mode 100644 index 00000000000..6e325161d3c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/FuelLoad.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { useActiveOrTemporaryFlightPlan } from '@instruments/common/flightplan'; +import { Layer } from '../../../Components/Layer'; +import { TextBox } from '../../../Components/Textbox'; +import { Button } from '../../../Components/Button'; + +export const Page = () => { + const [flightPlan] = useActiveOrTemporaryFlightPlan(); + + return ( + + GW + ---.- + KLB + + CG + --.- + % + + FOB + ---.- + KLB + + ZFW + + + BLOCK + + + ZFWCG + + + + + + + TAXI + + + TRIP + 173.9 + KLB + 06:14 + + RTE RSV + + + + ALTN + + 00:56 + + FINAL + + + + PAX NBR + + + CI + + + JTSN GW + + + TOW + 1035 + KLB + + 861 + KLB + + + + UTC + EFOB + + + DEST + {flightPlan.destinationAirport} + 06:14 + 67.2 + KLB + + ALTN + LFPG + 07:11 + 00.3 + KLB + + MIN FUEL AT DEST + + EXTRA + 16.5 + KLB + 00:34 + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Init.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Init.tsx new file mode 100644 index 00000000000..f9f76844ea6 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Active/Init.tsx @@ -0,0 +1,129 @@ +import React, { FC, useEffect, useState } from 'react'; +import { Layer } from '@instruments/common/utils'; +import { useHistory } from 'react-router-dom'; +import { useActiveOrTemporaryFlightPlan } from '@instruments/common/flightplan'; +import { useSimVar } from '@instruments/common/simVars'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { TextBox } from '../../../Components/Textbox'; +import { Button } from '../../../Components/Button'; + +export const Page: FC = () => { + const history = useHistory(); + const [flightPlan] = useActiveOrTemporaryFlightPlan(); + const [flightNumber, setFlightNumber] = useSimVar('ATC FLIGHT NUMBER', 'string', 250); + + const [tempOriginIcao, setTempOriginIcao] = useState(''); + const [tempDestIcao, setTempDestIcao] = useState(''); + const [tempAltnIcao, setTempAltnIcao] = useState(''); + + useEffect(() => { + if (tempOriginIcao && tempDestIcao && tempAltnIcao) { + FlightPlanService.newCityPair(tempOriginIcao, tempDestIcao, tempAltnIcao); + } + }, [tempOriginIcao, tempDestIcao, tempAltnIcao]); + + return ( + + FLT NBR + { + setFlightNumber(value); + return true; + })} + /> + + + + + FROM + { + setTempOriginIcao(ident); + return true; + }} + /> + + TO + { + setTempDestIcao(ident); + return true; + }} + /> + + ALTN + { + setTempAltnIcao(ident); + return true; + }} + /> + + CPNY RTE + + + + ALTN RTE + + + + + + CRZ FL + + + CRZ TEMP + + + CI + + + TROPO + + + TRIP WIND + + + + + + + + + + + + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Airport.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Airport.tsx new file mode 100644 index 00000000000..b6029143d53 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Airport.tsx @@ -0,0 +1,140 @@ +import React, { FC, useState } from 'react'; +import { useNavDatabase } from '@instruments/common/flightplan'; +import { MathUtils } from '@shared/MathUtils'; +import { Airport, Runway } from 'msfs-navdata'; +import { Layer } from '../../../Components/Layer'; +import { Tab, TabSet } from '../../../Components/tabs'; +import { TextBox } from '../../../Components/Textbox'; +import { Button } from '../../../Components/Button'; +import { Arrows } from '../../../Components/Arrows'; + +const xSpacing = 236; +const ySpacing = 50; + +type RunwayInfoProps = { + runways: Runway[]; + airport?: Airport; + index: number; + onIndexChange: (value: number) => void; + onRunwayList: () => void; +} +const RunwayInfo: FC = ({ runways, airport, index, onIndexChange, onRunwayList }) => { + const runway = runways[index]; + return ( + <> + RWY + {runway.ident.substr(2)} + + {index + 1} + / + {runways.length} + + + LAT/LONG + + {MathUtils.convertDMS(runway.thresholdLocation.lat, runway.thresholdLocation.lon)} + + + ELEVATION + + {/* This is not the actual elevation, fix later */} + {airport?.location.alt} + Ft + + + LENGTH + + {runway.length} + Ft + + + LS IDENT + {runway.lsIdent} + + + + + + + CRS + + {Math.round(runway.bearing).toString().padStart(3, '0')} + ° + + + ); +}; + +const RunwayList: FC<{ + runways: Runway[]; + onRunwayClick: (index: number) => void; +}> = ({ runways, onRunwayClick }) => ( + <> + {runways?.map((runway, index) => { + const xI = Math.floor(index / 8); + return ( + + ); + })} + +); + +export const Page = () => { + const [airport, setAirport] = useState(); + const [runways, setRunways] = useState([]); + const [selectedRunwayIndex, setSelectedRunwayIndex] = useState(-1); + + const database = useNavDatabase(); + + const selectAirport = async (value: string) => { + const airport = await database.getAirportByIdent(value); + setAirport(airport!); + setSelectedRunwayIndex(-1); + setRunways(await database.getRunways(value)); + }; + + const onRunwayChange = (index: number) => { + setSelectedRunwayIndex(Math.min(runways.length - 1, Math.max(0, index))); + }; + + return ( + + + + + + + ARPT IDENT + + + {airport?.airportName} + {airport && MathUtils.convertDMS(airport.location.lat, airport.location.lon)} + + + {selectedRunwayIndex >= 0 + ? ( + setSelectedRunwayIndex(-1)} + onIndexChange={onRunwayChange} + index={selectedRunwayIndex} + airport={airport} + runways={runways} + /> + ) + : } + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Navaid.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Navaid.tsx new file mode 100644 index 00000000000..2c89a49b6ad --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Navaid.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import { useNavDatabase } from '@instruments/common/flightplan'; +import { VhfNavaid, VhfNavaidType } from 'msfs-navdata'; +import { MathUtils } from '@shared/MathUtils'; +import { Layer } from '../../../Components/Layer'; +import { Tab, TabSet } from '../../../Components/tabs'; +import { Button } from '../../../Components/Button'; +import { TextBox } from '../../../Components/Textbox'; + +export const Page = () => { + const database = useNavDatabase(); + const [currentNavaid, setCurrentNavaid] = useState(); + + const hanldeNavaidSelection = async (ident: string) => { + setCurrentNavaid((await database.getNavaids(ident))[0]); + }; + + return ( + + + + + + + NAVAID IDENT + + + CLASS + {currentNavaid?.type && VhfNavaidType[currentNavaid?.type]} + + LAT/LONG + + {currentNavaid?.vorLocation && MathUtils.convertDMS(currentNavaid?.vorLocation.lat ?? 0, currentNavaid?.vorLocation.lon ?? 0)} + + + ELEVATION + {(currentNavaid?.vorLocation?.alt ?? currentNavaid?.dmeLocation?.alt) && ( + + {currentNavaid?.vorLocation?.alt ?? currentNavaid?.dmeLocation?.alt} + FT + + )} + + RWY IDENT + {} + + FREQ + {currentNavaid?.frequency} + + CAT + {} + + CRS + {currentNavaid + && ( + + {/* Add bearing here when available */} + ° + + )} + + FIG OF MERIT + {currentNavaid?.figureOfMerit} + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Status.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Status.tsx new file mode 100644 index 00000000000..419491e64ce --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Status.tsx @@ -0,0 +1,122 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { NavigationDatabase, NavigationDatabaseBackend } from '@fmgc/NavigationDatabase'; +import { NavigationDatabaseService } from '@fmgc/flightplanning/new/NavigationDatabaseService'; +import { useActiveNavDatabase } from '@instruments/common/flightplan'; +import { Button } from '../../../Components/Button'; +import { Tab, TabSet } from '../../../Components/tabs'; + +enum Months { + JAN = 1, + FEB, + MAR, + APR, + MAY, + JUN, + JUL, + AUG, + SEP, + OCT, + NOV, + DEC +} +const getCycleDates = (fromTo: string): string => `${fromTo.substr(0, 2)}${Months[Number.parseInt(fromTo.substr(2, 2))]}-${fromTo.substr(4, 2)}${Months[Number.parseInt(fromTo.substr(6, 2))]}`; + +export const Page = () => { + const [database, databaseVersion] = useActiveNavDatabase(); + + const [navIdent, setNavIdent] = useState({ + provider: 'Navigraph', + airacCycle: '2107', + dateFromTo: '1507120821', + previousFromTo: '1706150721', + }); + + useEffect(() => { + setNavIdent({ + provider: database.backend === NavigationDatabaseBackend.Navigraph + ? 'EXTERNAL (NAVIGRAPH)' + : 'MSFS', + airacCycle: '2107', + dateFromTo: '1507120821', + previousFromTo: '1706150721', + }); + }, [database, databaseVersion]); + + const handleSwap = useCallback(() => { + if (database.backend === NavigationDatabaseBackend.Navigraph) { + NavigationDatabaseService.activeDatabase = new NavigationDatabase(NavigationDatabaseBackend.Msfs); + } else { + NavigationDatabaseService.activeDatabase = new NavigationDatabase(NavigationDatabaseBackend.Navigraph); + } + }, [database, databaseVersion]); + + return ( + <> + + + + + + A380-800 + + ENGINE + TRENT 972 + + {/* Perf */} + + IDLE + PERF + +0.0 + +0.5 + + + + + + {/* Database */} + NAV DATABASE + {navIdent.provider} + + {/* Active */} + + ACTIVE + {getCycleDates(navIdent.dateFromTo)} + + {/* Second */} + SECOND + {getCycleDates(navIdent.previousFromTo)} + + + + + + {/* Pilot stored elements */} + PILOT STORED ELEMENTS + + WAYPOINTS + 00 + + NAVAIDS + 00 + + ROUTES + 00 + + RUNWAYS + 00 + + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Waypoint.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Waypoint.tsx new file mode 100644 index 00000000000..89f0491f983 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/Data/Waypoint.tsx @@ -0,0 +1,39 @@ +import { useNavDatabase } from '@instruments/common/flightplan'; +import React, { useState } from 'react'; +import { Waypoint } from 'msfs-navdata'; +import { MathUtils } from '@shared/MathUtils'; +import { Layer } from '../../../Components/Layer'; +import { Tab, TabSet } from '../../../Components/tabs'; +import { TextBox } from '../../../Components/Textbox'; + +export const Page = () => { + const database = useNavDatabase(); + const [currentWaypoint, setCurrentWaypoint] = useState(); + + const findWaypoint = async (ident: string) => { + setCurrentWaypoint((await database.getWaypoints(ident))[0]); + }; + + return ( + + + + + + WPT IDENT + findWaypoint(value)} /> + LAT + LONG + {currentWaypoint && ( + + {MathUtils.convertDMS(currentWaypoint?.location.lat, currentWaypoint?.location.lon)} + + )} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/index.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/index.tsx new file mode 100644 index 00000000000..a4fd0427680 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/Pages/index.tsx @@ -0,0 +1,27 @@ +import { Page as DataStatus } from './Data/Status'; +import { Page as DataAirport } from './Data/Airport'; +import { Page as DataWaypoint } from './Data/Waypoint'; +import { Page as DataNavaid } from './Data/Navaid'; +import { Page as ActiveFpln } from './Active/Fpln'; +import { Page as ActiveFplnDepartures } from './Active/Fpln/Departures'; +import { Page as ActiveFplnArrivals } from './Active/Fpln/Arrivals'; +import { Page as ActiveInit } from './Active/Init'; +import { Page as ActiveFuelLoad } from './Active/FuelLoad'; +import { Page as ActiveFplnAirways } from './Active/Fpln/Airways'; + +export const Pages = { + Active: { + Init: ActiveInit, + Fuelload: ActiveFuelLoad, + Fpln: ActiveFpln, + FplnDepartures: ActiveFplnDepartures, + FplnArrivals: ActiveFplnArrivals, + FplnAirways: ActiveFplnAirways, + }, + Data: { + Status: DataStatus, + Navaid: DataNavaid, + Airport: DataAirport, + Waypoint: DataWaypoint, + }, +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMS/index.tsx b/fbw-a380x/src/systems/instruments/src/MFD/FMS/index.tsx new file mode 100644 index 00000000000..62a1a80ec3b --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMS/index.tsx @@ -0,0 +1,131 @@ +import React, { FC } from 'react'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useActiveOrTemporaryFlightPlan } from '@instruments/common/flightplan'; +import { Dropdown, DropdownLink } from '../Components/Dropdown'; +import { Pages } from './Pages'; +import { MFDRedirect, MFDRoute } from '../Components/MFDRoute'; +import { MFDMessagesList } from '../Messages/MFDMessagesList'; +import { MFDMessageArea } from '../Messages/MFDMessageArea'; + +export const FMS = () => { + const { path } = useRouteMatch(); + + return ( + <> + + + + + + + + + + + + ); +}; + +const ActiveDropdown: React.FC = () => { + const history = useHistory(); + + return ( + + F-PLN + PERF + FUEL&LOAD + WIND + INIT + + ); +}; + +const PositionDropdown: React.FC = () => ( + + MONITOR + REPORT + NAVAIDS + IRS + GPS + +); + +const SecIndexDropdown: React.FC = () => ( + + SEC 1 + SEC 2 + SEC 3 + +); + +const DataDropdown: React.FC = () => ( + + STATUS + WAYPOINT + NAVAID + ROUTE + AIRPORT + PRINTER + +); + +const StatusBar: React.FC = () => { + const history = useHistory(); + const { path } = useRouteMatch(); + const [, isTemporary] = useActiveOrTemporaryFlightPlan(); + + let statusText = history.location.pathname.toUpperCase().substring(path.length + 1); + const messageOverviewPage = statusText === 'MESSAGES_LIST'; + if (statusText !== undefined && statusText.length > 0) statusText = statusText.replace('/_/g', ' '); + + return ( + <> + + + + + {statusText} + + {isTemporary && !messageOverviewPage && ( + <> + + + TMPY + + )} + + ); +}; + +const PagesContainer: FC = () => ( + <> + + + + {/* Active */} + + + PERF + + + + WING + + + + {/* Data */} + + + + + ROUTE + + + + PRINTER + + + {/* FMS message area */} + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessage.ts b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessage.ts new file mode 100644 index 00000000000..e980109ca90 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessage.ts @@ -0,0 +1,144 @@ +export enum MFDMessageId { + AircraftPositionNotValid = 1, + AdjustDesiredTrackOrHeading = 2, + AdjustingSpdDueToRta = 3, + AirwayWptDisagree = 4, + AirwaysInsertionInProgress = 5, + AlignIrs = 6, + AreaRnp = 7, + GpsPositionDisagree = 8, + LastIrsPositionDisagree = 9, + AtcFlightplanInserted = 10, + AtcFlightplanRejected = 11, + CabinRateExceeded = 12, + CheckAlternateWind = 13, + CheckApproachSelect = 14, + CheckCompanyRoute = 15, + CheckDatabaseCycle = 16, + CheckDestinationData = 17, + CheckEngineOutSpeed = 18, + CheckIrs = 19, + CheckFlightnumber = 20, + CheckIrsPosition = 21, + CheckMinimumFuelAtDestination = 22, + CheckNorthReference = 23, + CheckOnsideFms = 24, + CheckSpeedMode = 25, + CheckTakeOffData = 26, + CheckZeroFuelWeight = 27, + CompanyFlightplanAndLoadReceived = 28, + CompanyFlightplanReceived = 29, + CompanyLoadReceived = 30, + CompanyMessageInsertion = 31, + CompanyTakeOffDataReceived = 32, + CompanyWindDataReceived = 33, + CompanyWindDataReceivedSecondary = 34, + CompanyWindPending = 35, + ConstraintAboveCruise = 36, + ConstraintBefore = 37, + CostIndexInUse = 38, + CruiseAboveMaxFlightlevel = 39, + DestinationCompanyRouteDisagree = 40, + DestinationEndFuelOnBoardBelowMin = 41, + DraftWind = 42, + EnterDestinationData = 43, + EntryNotInList = 44, + EntryOutOfRange = 45, + ExpectTurnAreaExceedance = 46, + ExtendSpeedBrakes = 47, + GroundspeedBasedOnIsa = 48, + FlightNumberReceived = 49, + AircraftStatusDisagree = 50, + AircraftStatusDisagreeIndependent = 51, + FmcsPinTypeDisagree = 52, + FmcsPinTypeDisagreeIndependent = 53, + FmsDatalinkNotAvailable = 54, + FmsGrossweightDisagree = 55, + FmsPositionDisagree = 56, + FmsSpeedTargetDisagree = 57, + FormatError = 58, + FormatErrorEnterAltBefore = 59, + FlightplanElementRetained = 60, + FlightplanFull = 61, + GpsDeselected = 62, + GpsPrimary = 63, + GpsPrimaryLost = 64, + GlideDeselected = 65, + IndependentOperation = 66, + InitializeZeroFuelWeight = 67, + InsertOrEraseTemporaryFlightplan = 68, + LateralDiscontinuityAhead = 69, + MachSegmentDeleted = 70, + NavAccuracyDowngraded = 71, + NavAccuracyUpgraded = 72, + NewAccelerationAltitude = 73, + NewCruiseAltitude = 74, + NewThrustReductionAltitude = 75, + NoCompanyReply = 76, + NoFlsForApproach = 77, + NoIntersectionFound = 78, + NoNavInterception = 79, + NotAllowed = 80, + NotAllowedDatabaseAirport = 81, + NotInDatabase = 82, + NotTransmittedToAcr = 83, + PilotRoutesListFull = 84, + PlaceOrDistanceInTransition = 85, + PlaceOrWaypointDisagree = 86, + PleaseWait = 87, + PleaseWaitForCompanyFlightplan = 88, + PleaseWaitForFmsResynch = 89, + PrinterNotAvailable = 90, + ProcedureRnpExceeded = 91, + ReceivedAtcMessageInvalid = 92, + ReceivedCompanyFlightplanNotValid = 93, + ReceivedCompanyLoadDataNotValid = 94, + ReceivedCompanyWindDataNotValid = 95, + ReceivedCompanyTakeOfDataNotValid = 96, + ReceivedFlightnumberNotValid = 97, + ReenterZerofuelweight = 98, + RetractSpeedBrakes = 99, + RtaAlreadyExists = 100, + RtaDeleted = 101, + RtaNotConsidered = 102, + RouteIdentAlreadyUsed = 103, + RunwayDisagree = 104, + SelectHeadingTrackFirst = 105, + SelectTrueNorth = 106, + SetHoldSpeed = 107, + SomeRevisionNotStored = 108, + SpeedErrorAt = 109, + SpeedLimitExceeded = 110, + SpecifiedNdbNotAvailable = 111, + SpecifiedVorNotAvailable = 112, + StepAboveMaxFlightlevel = 113, + StepAhead = 114, + StepDeleted = 115, + TopOfDescendReached = 116, + TimeErrorAt = 117, + TimeMarkerReached = 118, + TimeToExit = 119, + TooSteepPathAhead = 120, + TakeOfSpeedTooLow = 121, + TakeOfTimeReached = 122, + TrueNorthEntryExpected = 123, + TuneNavaid = 124, + VSpeedDisagree = 125, + NavaidDeselected = 126, + RunwaysStorageFull = 127, + WaypointsStorageFull = 128, + NavaidsStorageFull = 129, +} + +export enum MFDMessageType { + TypeI = 0, + TypeII = 1, +} + +export interface MFDMessage { + uid: number; + messageId: MFDMessageId; + type: MFDMessageType; + cleared: boolean; + content: string[]; +} diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageArea.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageArea.tsx new file mode 100644 index 00000000000..b53474b1c91 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageArea.tsx @@ -0,0 +1,114 @@ +import { Layer } from '@instruments/common/utils'; +import React, { FC, useEffect, useRef, useState } from 'react'; +import { useHistory } from 'react-router'; +import { Button } from '../Components/Button'; +import { useMFDMessageManager } from './MFDMessageManager'; + +type MFDMessageAreaProps = { + path: string; +}; + +export const MFDMessageArea: FC = ({ path }) => { + const [backgroundColor, setBackgroundColor] = useState('#fff'); + const mfdMessageManager = useMFDMessageManager(); + const history = useHistory(); + const firstLineRef = useRef(null); + const [firstLineBbox, setFirstLineBbox] = useState(); + const secondLineRef = useRef(null); + const [secondLineBbox, setSecondLineBbox] = useState(); + const [messageUid, setMessageUid] = useState(-1); + const messageUidRef = useRef(); + messageUidRef.current = messageUid; + + useEffect(() => { + setFirstLineBbox(firstLineRef.current?.getBBox()); + setSecondLineBbox(secondLineRef.current?.getBBox()); + }, [firstLineRef, secondLineRef]); + + let message: string[] = []; + if (mfdMessageManager.typeIMessage() !== undefined) { + message = mfdMessageManager.typeIMessage()?.content || []; + + if (messageUid !== mfdMessageManager.typeIMessage()?.uid) setMessageUid(mfdMessageManager.typeIMessage()?.uid || -1); + if (backgroundColor !== '#fff') setBackgroundColor('#fff'); + } else { + let hasOpenMessages = false; + mfdMessageManager.typeIIMessageList().every((msg) => { + if (msg.cleared === false) { + if (backgroundColor !== '#f48244') setBackgroundColor('#f48244'); + if (messageUid !== msg.uid) setMessageUid(msg.uid); + + hasOpenMessages = true; + message = msg.content; + } + + return msg.cleared !== false; + }); + + if (hasOpenMessages === false && messageUid !== -1) setMessageUid(-1); + } + + const onButtonClick = () => { + if (messageUidRef.current !== undefined) { + // no shown message + if (messageUidRef.current === -1) { + history.push(`${path}/messages_list`); + } else { + mfdMessageManager.markFmsMessageAsCleared(messageUidRef.current); + } + } + }; + + let buttonText = <>; + if (messageUid !== -1) { + buttonText = ( + <> + CLEAR + INFO + + ); + } else { + buttonText = ( + <> + MSG + LIST + + ); + } + + return ( + + + + {backgroundColor && message.length >= 1 && ( + + )} + {backgroundColor && message.length > 1 && ( + + )} + + {message.length >= 1 && {message[0]}} + {message.length > 1 && {message[1]}} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageManager.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageManager.tsx new file mode 100644 index 00000000000..8f0e3627819 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageManager.tsx @@ -0,0 +1,115 @@ +import React, { createContext, FC, useContext, useRef, useState } from 'react'; +import { MFDMessage, MFDMessageId, MFDMessageType } from './MFDMessage'; +import { MFDMessageTranslation } from './MFDMessageTranslation'; + +type MFDMessageManagerType = { + setFmsMessage: (id: MFDMessageId, content?: string) => number, + markFmsMessageAsCleared: (uid: number) => void, + typeIMessage: () => MFDMessage | undefined, + typeIIMessageList: () => MFDMessage[], +} + +export const MFDMessageManagerContext = createContext({ + setFmsMessage: () => -1, + markFmsMessageAsCleared: () => null, + typeIMessage: () => undefined, + typeIIMessageList: () => [], +}); + +export const useMFDMessageManager = (): MFDMessageManagerType => useContext(MFDMessageManagerContext); + +const MaxMessageCount = 5; + +export const MFDMessageManagerProvider: FC = ({ children }) => { + const [typeIMessage, setTypeIMessage] = useState(undefined); + const [typeIIMessages, setTypeIIMessages] = useState([]); + const typeIMessageRef = useRef(); + const typeIIMessagesRef = useRef(); + typeIIMessagesRef.current = typeIIMessages; + typeIMessageRef.current = typeIMessage; + + return ( + { + const lutEntry = MFDMessageTranslation.find((entry) => entry.id === id); + if (lutEntry === undefined) return -1; + + // create the new UID + const uid = new Date().getTime(); + + // create the message + const messageContent = lutEntry.lines; + if (content !== undefined) { + if (Array.isArray(content)) { + content.forEach((entry) => { + messageContent.forEach((line) => { + line.replace('%', entry); + }); + }); + } else { + messageContent.forEach((line) => { + line.replace('%', content); + }); + } + } + + if (lutEntry.type === MFDMessageType.TypeI) { + setTypeIMessage({ + uid, + messageId: id, + type: lutEntry !== undefined ? lutEntry.type : MFDMessageType.TypeII, + cleared: false, + content: messageContent, + }); + } else { + let newList: MFDMessage[] = []; + if (typeIIMessagesRef.current) { + newList = new Array(...typeIIMessagesRef.current); + } + + while (newList.length >= MaxMessageCount && newList.length > 0) { + newList.pop(); + } + + newList.unshift({ + uid, + messageId: id, + type: lutEntry !== undefined ? lutEntry.type : MFDMessageType.TypeII, + cleared: false, + content: messageContent, + }); + + setTypeIIMessages(newList); + } + + return uid; + }, + markFmsMessageAsCleared: (uid: number): void => { + // check if it is a type I message + if (typeIMessageRef.current) { + if (typeIMessageRef.current.uid === uid) { + setTypeIMessage(undefined); + return; + } + } + + if (typeIIMessagesRef.current) { + const index = typeIIMessagesRef.current.findIndex((msg) => msg.uid === uid); + if (index >= 0) { + const newList = new Array(...typeIIMessagesRef.current); + newList[index].cleared = true; + setTypeIIMessages(newList); + } + } + }, + typeIMessage: (): MFDMessage | undefined => typeIMessageRef.current, + typeIIMessageList: (): MFDMessage[] => { + if (typeIIMessagesRef.current) return typeIIMessagesRef.current; + return []; + }, + }} + > + {children} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageTranslation.ts b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageTranslation.ts new file mode 100644 index 00000000000..518466795e8 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessageTranslation.ts @@ -0,0 +1,660 @@ +import { MFDMessageId, MFDMessageType } from './MFDMessage'; + +// wildcards need to marked with % +export const MFDMessageTranslation: { id: MFDMessageId, type: MFDMessageType, lines: string[] }[] = [ + { + id: MFDMessageId.AircraftPositionNotValid, + type: MFDMessageType.TypeII, + lines: ['ACFT POSITION NOT VALID'], + }, + { + id: MFDMessageId.AdjustDesiredTrackOrHeading, + type: MFDMessageType.TypeII, + lines: ['ADJUST DESIRED TRK OR HDG'], + }, + { + id: MFDMessageId.AdjustingSpdDueToRta, + type: MFDMessageType.TypeII, + lines: ['ADJUST SPD DUE TO RTA'], + }, + { + id: MFDMessageId.AirwayWptDisagree, + type: MFDMessageType.TypeI, + lines: ['AIRWAY / WPT DISAGREE'], + }, + { + id: MFDMessageId.AirwayWptDisagree, + type: MFDMessageType.TypeI, + lines: ['AIRWAY / WPT DISAGREE'], + }, + { + id: MFDMessageId.AirwaysInsertionInProgress, + type: MFDMessageType.TypeII, + lines: ['AIRWAYS INSERTION IN PROGRESS:', 'F-PLN REVISION NOT ALLOWED'], + }, + { + id: MFDMessageId.AlignIrs, + type: MFDMessageType.TypeII, + lines: ['ALIGN IRS'], + }, + { + id: MFDMessageId.AreaRnp, + type: MFDMessageType.TypeII, + lines: ['AREA RNP IS %'], + }, + { + id: MFDMessageId.GpsPositionDisagree, + type: MFDMessageType.TypeI, + lines: ['ARPT REF / GPS POSITION DISAGREE'], + }, + { + id: MFDMessageId.LastIrsPositionDisagree, + type: MFDMessageType.TypeI, + lines: ['ARPT REF / LAST IRS POSITION', 'DISAGREE'], + }, + { + id: MFDMessageId.AtcFlightplanInserted, + type: MFDMessageType.TypeII, + lines: ['ACT F-PLN INSERTED IN SEC 3'], + }, + { + id: MFDMessageId.AtcFlightplanInserted, + type: MFDMessageType.TypeII, + lines: ['ACT F-PLN INSERTED IN SEC 3', 'REJECTED INFO SEE SEC / INDEX'], + }, + { + id: MFDMessageId.AtcFlightplanInserted, + type: MFDMessageType.TypeII, + lines: ['ACT F-PLN INSERTED IN SEC 3', 'REJECTED INFO SEE SEC / INDEX'], + }, + { + id: MFDMessageId.CabinRateExceeded, + type: MFDMessageType.TypeII, + lines: ['CABIN RATE EXCEEDED'], + }, + { + id: MFDMessageId.CheckAlternateWind, + type: MFDMessageType.TypeII, + lines: ['CHECK ALTN WIND'], + }, + { + id: MFDMessageId.CheckApproachSelect, + type: MFDMessageType.TypeII, + lines: ['CHECK APPR SEL'], + }, + { + id: MFDMessageId.CheckCompanyRoute, + type: MFDMessageType.TypeII, + lines: ['CHECK CPNY RTE'], + }, + { + id: MFDMessageId.CheckDatabaseCycle, + type: MFDMessageType.TypeII, + lines: ['CHECK DATABASE CYCLE'], + }, + { + id: MFDMessageId.CheckDestinationData, + type: MFDMessageType.TypeII, + lines: ['CHECK DEST DATA'], + }, + { + id: MFDMessageId.CheckEngineOutSpeed, + type: MFDMessageType.TypeII, + lines: ['CHECK EO SPD SETTING'], + }, + { + id: MFDMessageId.CheckIrs, + type: MFDMessageType.TypeII, + lines: ['CHECK IRS / ARPT POSITION'], + }, + { + id: MFDMessageId.CheckFlightnumber, + type: MFDMessageType.TypeII, + lines: ['CHECK FLT NUMBER'], + }, + { + id: MFDMessageId.CheckIrsPosition, + type: MFDMessageType.TypeII, + lines: ['CHECK IRS % / FMS POSITION'], + }, + { + id: MFDMessageId.CheckMinimumFuelAtDestination, + type: MFDMessageType.TypeII, + lines: ['CHECK MIN FUEL AT DEST'], + }, + { + id: MFDMessageId.CheckNorthReference, + type: MFDMessageType.TypeII, + lines: ['CHECK NORTH REF'], + }, + { + id: MFDMessageId.CheckOnsideFms, + type: MFDMessageType.TypeII, + lines: ['CHECK ONSIDE FMS P/N'], + }, + { + id: MFDMessageId.CheckSpeedMode, + type: MFDMessageType.TypeII, + lines: ['CHECK SPD MODE'], + }, + { + id: MFDMessageId.CheckTakeOffData, + type: MFDMessageType.TypeII, + lines: ['CHECK T.O DATA'], + }, + { + id: MFDMessageId.CheckZeroFuelWeight, + type: MFDMessageType.TypeII, + lines: ['CHECK ZFW'], + }, + { + id: MFDMessageId.CompanyFlightplanAndLoadReceived, + type: MFDMessageType.TypeII, + lines: ['COMPANY F-PLN & LOAD DATA', 'RECEIVED WAITING FOR INSERTION'], + }, + { + id: MFDMessageId.CompanyFlightplanReceived, + type: MFDMessageType.TypeII, + lines: ['COMPANY F-PLN RECEIVED', 'WAITING FOR INSERTION'], + }, + { + id: MFDMessageId.CompanyLoadReceived, + type: MFDMessageType.TypeII, + lines: ['COMPANY LOAD DATA RECEIVED WAITING', 'FOR INSERTION'], + }, + { + id: MFDMessageId.CompanyMessageInsertion, + type: MFDMessageType.TypeII, + lines: ['COMPANY MSG INSERTION IN PROGRESS'], + }, + { + id: MFDMessageId.CompanyTakeOffDataReceived, + type: MFDMessageType.TypeII, + lines: ['COMPANY T.O DATA RECEIVED WAITING', 'FOR INSERTION'], + }, + { + id: MFDMessageId.CompanyWindDataReceived, + type: MFDMessageType.TypeII, + lines: ['COMPANY WIND DATA RECEIVED WAITING', 'FOR INSERTION IN ACTIVE'], + }, + { + id: MFDMessageId.CompanyWindDataReceivedSecondary, + type: MFDMessageType.TypeII, + lines: ['COMPANY WIND DATA RECEIVED WAITING', 'FOR INSERTION IN SEC %'], + }, + { + id: MFDMessageId.CompanyWindPending, + type: MFDMessageType.TypeII, + lines: ['COMPANY WIND UPLINK PENDING'], + }, + { + id: MFDMessageId.ConstraintAboveCruise, + type: MFDMessageType.TypeII, + lines: ['CONSTRAINTS ABOVE CRZ FL: DELETED'], + }, + { + id: MFDMessageId.ConstraintBefore, + type: MFDMessageType.TypeII, + lines: ['CONSTRAINTS BEFORE %: DELETED'], + }, + { + id: MFDMessageId.CostIndexInUse, + type: MFDMessageType.TypeI, + lines: ['COST INDEX-% IN USE'], + }, + { + id: MFDMessageId.CruiseAboveMaxFlightlevel, + type: MFDMessageType.TypeII, + lines: ['CRZ FL ABOVE MAX FL'], + }, + { + id: MFDMessageId.DestinationCompanyRouteDisagree, + type: MFDMessageType.TypeI, + lines: ['DEST / ALTN CPNY RTE DISAGREE'], + }, + { + id: MFDMessageId.DestinationEndFuelOnBoardBelowMin, + type: MFDMessageType.TypeII, + lines: ['DEST EFOB BELOW MIN'], + }, + { + id: MFDMessageId.DraftWind, + type: MFDMessageType.TypeI, + lines: ['DRAFT WIND INSERTED'], + }, + { + id: MFDMessageId.EnterDestinationData, + type: MFDMessageType.TypeII, + lines: ['ENTER DEST DATA'], + }, + { + id: MFDMessageId.EntryNotInList, + type: MFDMessageType.TypeI, + lines: ['ENTRY NOT IN LIST'], + }, + { + id: MFDMessageId.EntryOutOfRange, + type: MFDMessageType.TypeI, + lines: ['ENTRY OUT OF RANGE'], + }, + { + id: MFDMessageId.ExpectTurnAreaExceedance, + type: MFDMessageType.TypeII, + lines: ['EXPECT TURN AREA EXCEEDANCE'], + }, + { + id: MFDMessageId.ExtendSpeedBrakes, + type: MFDMessageType.TypeII, + lines: ['EXTEND SPD BRK'], + }, + { + id: MFDMessageId.GroundspeedBasedOnIsa, + type: MFDMessageType.TypeII, + lines: ['F-G/S BASED ON ISA'], + }, + { + id: MFDMessageId.FlightNumberReceived, + type: MFDMessageType.TypeII, + lines: ['FLT NUMBER RECEIVED'], + }, + { + id: MFDMessageId.AircraftStatusDisagree, + type: MFDMessageType.TypeII, + lines: ['FMCS ACT STATUS DISAGREE'], + }, + { + id: MFDMessageId.AircraftStatusDisagreeIndependent, + type: MFDMessageType.TypeII, + lines: ['FMCS ACFT STATUS DISAGREE', 'INDEPENDENT OPERATION'], + }, + { + id: MFDMessageId.FmcsPinTypeDisagree, + type: MFDMessageType.TypeII, + lines: ['FMCS PIN PROG TYPE DISAGREE'], + }, + { + id: MFDMessageId.FmcsPinTypeDisagreeIndependent, + type: MFDMessageType.TypeII, + lines: ['FMCS PIN PROG TYPE DISAGREE', 'INDEPENDENT OPERATION'], + }, + { + id: MFDMessageId.FmsDatalinkNotAvailable, + type: MFDMessageType.TypeII, + lines: ['FMS DATALINK NOT AVAIL'], + }, + { + id: MFDMessageId.FmsGrossweightDisagree, + type: MFDMessageType.TypeII, + lines: ['FMS1 / FMS2 GW DISAGREE'], + }, + { + id: MFDMessageId.FmsPositionDisagree, + type: MFDMessageType.TypeII, + lines: ['FMS1 / FMS2 POSITION DISAGREE'], + }, + { + id: MFDMessageId.FmsSpeedTargetDisagree, + type: MFDMessageType.TypeII, + lines: ['FMS1 / FMS2 SPD TARGET DISAGREE'], + }, + { + id: MFDMessageId.FormatError, + type: MFDMessageType.TypeI, + lines: ['FORMAT ERROR'], + }, + { + id: MFDMessageId.FormatErrorEnterAltBefore, + type: MFDMessageType.TypeI, + lines: ['FORMAT ERROR ENTER ALT BEFORE', 'PLACE/DIST'], + }, + { + id: MFDMessageId.FlightplanElementRetained, + type: MFDMessageType.TypeI, + lines: ['F-PLN ELEMENT RETAINED'], + }, + { + id: MFDMessageId.FlightplanFull, + type: MFDMessageType.TypeII, + lines: ['F-PLN FULL'], + }, + { + id: MFDMessageId.GpsDeselected, + type: MFDMessageType.TypeII, + lines: ['GPS DESELECTED'], + }, + { + id: MFDMessageId.GpsPrimary, + type: MFDMessageType.TypeII, + lines: ['GPS PRIMARY'], + }, + { + id: MFDMessageId.GpsPrimaryLost, + type: MFDMessageType.TypeII, + lines: ['GPS PRIMARY LOST'], + }, + { + id: MFDMessageId.GlideDeselected, + type: MFDMessageType.TypeII, + lines: ['GLIDE DESELECTED'], + }, + { + id: MFDMessageId.IndependentOperation, + type: MFDMessageType.TypeII, + lines: ['INDEPENDENT OPERATION'], + }, + { + id: MFDMessageId.InitializeZeroFuelWeight, + type: MFDMessageType.TypeII, + lines: ['INITIALIZE ZWF / ZFWCG'], + }, + { + id: MFDMessageId.InsertOrEraseTemporaryFlightplan, + type: MFDMessageType.TypeI, + lines: ['INSERT OR ERASE TMPY F-PLN', 'FIRST'], + }, + { + id: MFDMessageId.LateralDiscontinuityAhead, + type: MFDMessageType.TypeII, + lines: ['LATERAL DISCONTINUITY AHEAD'], + }, + { + id: MFDMessageId.MachSegmentDeleted, + type: MFDMessageType.TypeII, + lines: ['MACH SEGMENT DELETED'], + }, + { + id: MFDMessageId.NavAccuracyDowngraded, + type: MFDMessageType.TypeII, + lines: ['NAV ACCUR DOWNGRADED'], + }, + { + id: MFDMessageId.NavAccuracyUpgraded, + type: MFDMessageType.TypeII, + lines: ['NAV ACCUR UPGRADED'], + }, + { + id: MFDMessageId.NewAccelerationAltitude, + type: MFDMessageType.TypeII, + lines: ['NEW ACCEL ALT: %'], + }, + { + id: MFDMessageId.NewCruiseAltitude, + type: MFDMessageType.TypeII, + lines: ['NEW CRZ ALT: %'], + }, + { + id: MFDMessageId.NewThrustReductionAltitude, + type: MFDMessageType.TypeII, + lines: ['NEW THR RED ALT: %'], + }, + { + id: MFDMessageId.NoCompanyReply, + type: MFDMessageType.TypeII, + lines: ['NO COMPANY REPLY'], + }, + { + id: MFDMessageId.NoFlsForApproach, + type: MFDMessageType.TypeII, + lines: ['NO FLS FOR THIS APPR'], + }, + { + id: MFDMessageId.NoIntersectionFound, + type: MFDMessageType.TypeI, + lines: ['NO INTERSECTION FOUND'], + }, + { + id: MFDMessageId.NoNavInterception, + type: MFDMessageType.TypeII, + lines: ['NO NAV INTERCEPTION'], + }, + { + id: MFDMessageId.NotAllowed, + type: MFDMessageType.TypeI, + lines: ['NOT ALLOWED'], + }, + { + id: MFDMessageId.NotAllowedDatabaseAirport, + type: MFDMessageType.TypeI, + lines: ['NOT ALLOWED', 'DATABASE ARPTS ONLY'], + }, + { + id: MFDMessageId.NotInDatabase, + type: MFDMessageType.TypeI, + lines: ['NOT IN DATABASE'], + }, + { + id: MFDMessageId.NotTransmittedToAcr, + type: MFDMessageType.TypeII, + lines: ['NOT TRANSMITTED TO ACR'], + }, + { + id: MFDMessageId.PilotRoutesListFull, + type: MFDMessageType.TypeI, + lines: ['PILOT RTES LIST FULL'], + }, + { + id: MFDMessageId.PlaceOrDistanceInTransition, + type: MFDMessageType.TypeI, + lines: ['PLACE / DIST IN TRANS'], + }, + { + id: MFDMessageId.PlaceOrWaypointDisagree, + type: MFDMessageType.TypeI, + lines: ['PLACE / WPT DISAGREE'], + }, + { + id: MFDMessageId.PleaseWait, + type: MFDMessageType.TypeI, + lines: ['PLEASE WAIT'], + }, + { + id: MFDMessageId.PleaseWaitForCompanyFlightplan, + type: MFDMessageType.TypeI, + lines: ['PLEASE WAIT FOR COMPANY F-PLN', 'UPLINK'], + }, + { + id: MFDMessageId.PleaseWaitForFmsResynch, + type: MFDMessageType.TypeI, + lines: ['PLEASE WAIT FOR FMS RESYNCH'], + }, + { + id: MFDMessageId.PrinterNotAvailable, + type: MFDMessageType.TypeII, + lines: ['PRINTER NOT AVAIL'], + }, + { + id: MFDMessageId.ProcedureRnpExceeded, + type: MFDMessageType.TypeII, + lines: ['PROC RNP IS %'], + }, + { + id: MFDMessageId.ReceivedAtcMessageInvalid, + type: MFDMessageType.TypeII, + lines: ['RECEIVED ATC MSG NOT VALID'], + }, + { + id: MFDMessageId.ReceivedCompanyFlightplanNotValid, + type: MFDMessageType.TypeII, + lines: ['RECEIVED COMPANY F-PLN', 'NOT VALID'], + }, + { + id: MFDMessageId.ReceivedCompanyLoadDataNotValid, + type: MFDMessageType.TypeII, + lines: ['RECEIVED COMPANY LOAD DATA', 'NOT VALID'], + }, + { + id: MFDMessageId.ReceivedCompanyWindDataNotValid, + type: MFDMessageType.TypeII, + lines: ['RECEIVED COMPANY WIND DATA', 'NOT VALID'], + }, + { + id: MFDMessageId.ReceivedCompanyTakeOfDataNotValid, + type: MFDMessageType.TypeII, + lines: ['RECEIVED COMPANY T.O DATA', 'NOT VALID'], + }, + { + id: MFDMessageId.ReceivedFlightnumberNotValid, + type: MFDMessageType.TypeII, + lines: ['RECEIVED COMPANY T.O DATA', 'NOT VALID'], + }, + { + id: MFDMessageId.ReenterZerofuelweight, + type: MFDMessageType.TypeII, + lines: ['REENTER ZFW / ZFWCG'], + }, + { + id: MFDMessageId.RetractSpeedBrakes, + type: MFDMessageType.TypeII, + lines: ['RETRACT SPD BRK'], + }, + { + id: MFDMessageId.RtaAlreadyExists, + type: MFDMessageType.TypeI, + lines: ['RTA ALREADY EXISTING'], + }, + { + id: MFDMessageId.RtaDeleted, + type: MFDMessageType.TypeII, + lines: ['RTA DELETED'], + }, + { + id: MFDMessageId.RtaNotConsidered, + type: MFDMessageType.TypeII, + lines: ['RTA NOT CONSIDERED FOR FUEL', 'PLANNING'], + }, + { + id: MFDMessageId.RouteIdentAlreadyUsed, + type: MFDMessageType.TypeI, + lines: ['RTE IDENT ALREADY USED'], + }, + { + id: MFDMessageId.RunwayDisagree, + type: MFDMessageType.TypeII, + lines: ['RUNWAY / LS DISAGREE'], + }, + { + id: MFDMessageId.SelectHeadingTrackFirst, + type: MFDMessageType.TypeI, + lines: ['SLECT HDG OR TRK FIRST'], + }, + { + id: MFDMessageId.SelectTrueNorth, + type: MFDMessageType.TypeII, + lines: ['SELECT TRUE NORTH REF'], + }, + { + id: MFDMessageId.SetHoldSpeed, + type: MFDMessageType.TypeII, + lines: ['SET HOLD SPD'], + }, + { + id: MFDMessageId.SomeRevisionNotStored, + type: MFDMessageType.TypeII, + lines: ['SOME REVISIONS NOT STORED'], + }, + { + id: MFDMessageId.SpeedErrorAt, + type: MFDMessageType.TypeII, + lines: ['SPD ERROR AT %'], + }, + { + id: MFDMessageId.SpeedLimitExceeded, + type: MFDMessageType.TypeII, + lines: ['SPD LIMIT EXCEEDED'], + }, + { + id: MFDMessageId.SpecifiedNdbNotAvailable, + type: MFDMessageType.TypeII, + lines: ['SPECIF NDB NOT AVAIL'], + }, + { + id: MFDMessageId.SpecifiedVorNotAvailable, + type: MFDMessageType.TypeII, + lines: ['SPECIF VOR-D NOT AVAIL'], + }, + { + id: MFDMessageId.StepAboveMaxFlightlevel, + type: MFDMessageType.TypeII, + lines: ['STEP ABOVE MAX FL'], + }, + { + id: MFDMessageId.StepAhead, + type: MFDMessageType.TypeII, + lines: ['STEP AHEAD'], + }, + { + id: MFDMessageId.StepDeleted, + type: MFDMessageType.TypeII, + lines: ['STEP DELETED'], + }, + { + id: MFDMessageId.TopOfDescendReached, + type: MFDMessageType.TypeII, + lines: ['T/D REACHED'], + }, + { + id: MFDMessageId.TimeErrorAt, + type: MFDMessageType.TypeII, + lines: ['TIME ERROR AT %'], + }, + { + id: MFDMessageId.TimeMarkerReached, + type: MFDMessageType.TypeII, + lines: ['TIME MARKER REACHED'], + }, + { + id: MFDMessageId.TimeToExit, + type: MFDMessageType.TypeII, + lines: ['TIME TO EXIT'], + }, + { + id: MFDMessageId.TooSteepPathAhead, + type: MFDMessageType.TypeII, + lines: ['TOO STEEP PATH AHEAD'], + }, + { + id: MFDMessageId.TakeOfSpeedTooLow, + type: MFDMessageType.TypeII, + lines: ['T.O SPEED TOO LOW -', 'CHECK TOW & T.O DATA'], + }, + { + id: MFDMessageId.TakeOfTimeReached, + type: MFDMessageType.TypeII, + lines: ['T.O TIME REACHED'], + }, + { + id: MFDMessageId.TrueNorthEntryExpected, + type: MFDMessageType.TypeI, + lines: ['TRUE NORTH REFERENCED ENTRY', 'EXPECTED'], + }, + { + id: MFDMessageId.TuneNavaid, + type: MFDMessageType.TypeII, + lines: ['TUNE % %'], + }, + { + id: MFDMessageId.VSpeedDisagree, + type: MFDMessageType.TypeII, + lines: ['V1/VR/V2 DISAGREE'], + }, + { + id: MFDMessageId.NavaidDeselected, + type: MFDMessageType.TypeI, + lines: ['% IS DESELECTED'], + }, + { + id: MFDMessageId.RunwaysStorageFull, + type: MFDMessageType.TypeI, + lines: ['10 RWYS MAX : ALL IN USE'], + }, + { + id: MFDMessageId.WaypointsStorageFull, + type: MFDMessageType.TypeI, + lines: ['50 WPTS MAX : ALL IN USE'], + }, + { + id: MFDMessageId.NavaidsStorageFull, + type: MFDMessageType.TypeI, + lines: ['20 NAVAIDS MAX : ALL IN USE'], + }, +]; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessagesList.tsx b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessagesList.tsx new file mode 100644 index 00000000000..726380e0d91 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/Messages/MFDMessagesList.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import { useHistory } from 'react-router'; +import { Layer } from '@instruments/common/utils'; +import { Button } from '../Components/Button'; +import { useMFDMessageManager } from './MFDMessageManager'; + +export const MFDMessagesList: FC = () => { + const mfdMessageManager = useMFDMessageManager(); + const history = useHistory(); + + let messageY = 80; + return ( + + {mfdMessageManager.typeIIMessageList().map((message) => { + let lineY = 0; + const retval = ( + <> + + {message.content.map((line) => { + const text = {line}; + lineY += 32; + return text; + })} + + ); + + messageY += 90; + return retval; + })} + {mfdMessageManager.typeIIMessageList().length !== 0 && ( + + )} + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/MFD/MultiFunctionDisplay.tsx b/fbw-a380x/src/systems/instruments/src/MFD/MultiFunctionDisplay.tsx new file mode 100644 index 00000000000..6cb2265f645 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/MultiFunctionDisplay.tsx @@ -0,0 +1,112 @@ +import useMouse from '@react-hook/mouse-position'; +import { InputManagerProvider } from '@instruments/common/input'; +import { useSimVar } from '@instruments/common/simVars'; +import React, { FC, useRef, useState } from 'react'; +import { Redirect, Route, Switch, useHistory } from 'react-router-dom'; +import { FlightPlanProvider } from '@instruments/common/flightplan'; +import { Position } from '@instruments/common/types'; +import { CdsDisplayUnit, DisplayUnitID } from '@instruments/common/CdsDisplayUnit'; +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { useUpdate } from '@instruments/common/hooks'; +import { EfisSymbols } from '@fmgc/efis/EfisSymbols'; +import { Dropdown, DropdownLink } from './Components/Dropdown'; +import { MFDMessageManagerProvider } from './Messages/MFDMessageManager'; +import { FMS } from './FMS'; +import { ATCCOM } from './ATCCOM'; + +import './styles.scss'; + +export interface MultiFunctionDisplayProps { + displayUnitID: DisplayUnitID, +} + +export const MultiFunctionDisplay: FC = ({ displayUnitID }) => { + const ref = useRef(null); + const mouse = useMouse(ref, { + fps: 165, + enterDelay: 100, + leaveDelay: 100, + }); + const [hideCursor, setHideCursor] = useState(false); + const [flightNumber] = useSimVar('ATC FLIGHT NUMBER', 'string', 250); + + const [guidanceController] = useState(() => { + if (displayUnitID === DisplayUnitID.FoMfd) { + return null; + } + + const controller = new GuidanceController(); + + controller.init(); + + return controller; + }); + + const [efisSymbols] = useState(() => (guidanceController ? new EfisSymbols(guidanceController) : null)); + + useUpdate((deltaTime) => { + if (displayUnitID === DisplayUnitID.FoMfd) { + return; + } + + efisSymbols?.update(deltaTime); + guidanceController?.update(deltaTime); + }); + + return ( + + + + + + {flightNumber} + + + + + + + + + + + + + + + {!hideCursor && } + + + + + ); +}; + +const SystemDropdown: React.FC = () => { + const history = useHistory(); + let title = ''; + + if (history.location.pathname.includes('fms')) { + title = 'FMS 1'; + } else if (history.location.pathname.includes('atccom')) { + title = 'ATCCOM'; + } + + return ( + + FMS 1 + ATCCOM + + ); +}; + +export const Cursor: React.FC = ({ x, y }) => ( + + + + + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/config.json b/fbw-a380x/src/systems/instruments/src/MFD/config.json new file mode 100644 index 00000000000..60b345ff6d3 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/config.json @@ -0,0 +1,9 @@ +{ + "index": "./index.tsx", + "isInteractive": true, + "name": "MFD", + "dimensions": { + "width": 768, + "height": 1024 + } +} diff --git a/fbw-a380x/src/systems/instruments/src/MFD/index.tsx b/fbw-a380x/src/systems/instruments/src/MFD/index.tsx new file mode 100644 index 00000000000..a423f1a1c40 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { getRootElement } from '@instruments/common/defaults'; +import { HashRouter as Router } from 'react-router-dom'; +import ReactDOM from 'react-dom'; +import '../index.scss'; +import { FlightPlanService } from '@fmgc/flightplanning/new/FlightPlanService'; +import { NavigationDatabase, NavigationDatabaseBackend } from '@fmgc/NavigationDatabase'; +import { NavigationDatabaseService } from '@fmgc/flightplanning/new/NavigationDatabaseService'; +import { DisplayUnitID } from '@instruments/common/CdsDisplayUnit'; +import { render } from '../Common'; +import { MultiFunctionDisplay } from './MultiFunctionDisplay'; +import { renderTarget } from '../util.js'; + +NavigationDatabaseService.activeDatabase = new NavigationDatabase(NavigationDatabaseBackend.Navigraph); +FlightPlanService.createFlightPlans(); + +if (renderTarget) { + let displayUnitID = DisplayUnitID.CaptMfd; + + const url = getRootElement().getAttribute('url'); + + if (url) { + const parsedUrl = new URL(url); + + if (parsedUrl) { + const idString = new URLSearchParams(parsedUrl.search).get('duID'); + + if (idString) { + const numID = parseInt(idString); + + if (!Number.isNaN(numID)) { + displayUnitID = numID as DisplayUnitID; + } + } + } + } + + console.log(`Initializing as DU#: ${displayUnitID} (${DisplayUnitID[displayUnitID]})`); + + render(); +} + +getRootElement().addEventListener('unload', () => { + ReactDOM.unmountComponentAtNode(renderTarget ?? document.body); +}); diff --git a/fbw-a380x/src/systems/instruments/src/MFD/styles.scss b/fbw-a380x/src/systems/instruments/src/MFD/styles.scss new file mode 100644 index 00000000000..4752b7bca6d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/MFD/styles.scss @@ -0,0 +1,99 @@ +.Magenta { + fill: none; + stroke: #ff94ff; +} + +text.Magenta { + fill: #ff94ff; + stroke: none; +} + +.Cyan { + fill: none; + stroke: #00ffff; +} + +.Cyan.Fill { + fill: #00ffff; + stroke: none; +} + +text.Cyan { + fill: #00ffff; + stroke: none; +} + +tspan.Cyan { + fill: #00ffff; + stroke: none; +} + +.White { + fill: none; + stroke: #ffffff; +} + +.White.Fill { + fill: #ffffff; + stroke: none; +} + +text.White { + fill: #ffffff; + stroke: none; +} + +.Green { + stroke: #00ff00; + fill: none; +} + +text.Green { + fill: #00ff00; + stroke: none; +} + +.Amber { + stroke: #e68000; + fill: none; +} + +text.Amber { + fill: #e68000; + stroke: none; +} + +.Yellow { + stroke: #ffff00; + fill: none; +} + +.Yellow.Fill { + fill: #ffff00; + stroke: none; +} + +text.Yellow { + fill: #ffff00; + stroke: none; +} + +.Red { + stroke: #ff0000; + fill: none; +} + +.Red.Fill { + fill: #ff0000; + stroke: none; +} + +text.Red { + fill: #ff0000; + stroke: none; +} + +.Grey.Fill { + fill: #787878; + stroke: none; +} diff --git a/fbw-a380x/src/systems/instruments/src/OIT/Components/Button.tsx b/fbw-a380x/src/systems/instruments/src/OIT/Components/Button.tsx new file mode 100644 index 00000000000..18821eeeaf5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/Components/Button.tsx @@ -0,0 +1,67 @@ +import React, { Children, FC, isValidElement } from 'react'; +import { useHover } from 'use-events'; +import { Layer } from '@instruments/common/utils'; + +type ButtonProps = { + x?: number; + y?: number; + width?: number; + height?: number; + onClick?: () => void; + fill?: string; + disabled?: boolean; + highlighted?: boolean; + gradient?: boolean; + forceHover?: boolean; +} +const strokeWidth = 3; +export const Button: FC = ({ x = 0, y = 0, width = 0, height = 41, children, onClick, disabled, fill, highlighted, gradient, forceHover }) => { + const [hovered, hoverProps] = useHover(); + + return ( + + + + + {(gradient) && ( + <> + + + + + )} + + + {Children.map(children, (child) => { + if (isValidElement(child) && child.type !== 'tspan') { + return child; + } + return {child}; + })} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/OIT/Components/Dropdown.tsx b/fbw-a380x/src/systems/instruments/src/OIT/Components/Dropdown.tsx new file mode 100644 index 00000000000..12de2c36b60 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/Components/Dropdown.tsx @@ -0,0 +1,232 @@ +import React, { + Children, + isValidElement, + ReactNode, + useState, + FC, + useRef, + useEffect, + Dispatch, + SetStateAction, +} from 'react'; +import { useHover } from 'use-events'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useInputManager } from '@instruments/common/input'; +import { Layer } from './Layer'; +import { Button } from './Button'; + +type DropdownProps = { + x: number, + y?: number, + width?: number, + height?: number, + fill?: string, + selectable?: boolean, + title: string | ReactNode, + dropDownWidth?: number, + active?: boolean, + disabled?: boolean, + scrollable?: boolean, + clipItems?: boolean, + maxHeight?: number, +} +const scrollBarWidth = 15; +let lastMousePosition = 0; + +// eslint-disable-next-line max-len +export const Dropdown: FC = ({ x, y = 0, width = 192, height = 60, dropDownWidth = 192, fill, selectable, title, children, active, disabled, scrollable, clipItems = true, maxHeight = Infinity }) => { + const [open, setOpen] = useState(false); + const textRef = useRef(null); + const [textBbox, setTextBbox] = useState(); + const [scrollPosition, setScrollPosition] = useState(0); + const [clipPathId] = useState((Math.random() * 1000).toString()); + const [hovered, hoverProps] = useHover(); + const childYPositions = useRef([2]); + + Children.forEach(children, (child, index) => { + if (isValidElement(child)) { + console.log(childYPositions.current); + childYPositions.current[index + 1] = child.props.height + (childYPositions.current[index]); + } + }); + + useEffect(() => setTextBbox(textRef.current?.getBBox()), [textRef]); + if (open && disabled) setOpen(false); + + let totalHeight = 1; + Children.forEach(children, (child: React.ReactElement) => { + totalHeight += child.props.height ?? 0; + }); + + return ( + + + )} + + + + + {open && ( + setOpen(false)}> + + + { + Children.map(children, (child, index) => { + if (isValidElement(child)) { + return React.cloneElement(child, { + y: height + childYPositions.current[index], + width: child.props.width ? child.props.width : (dropDownWidth ?? width) - (scrollable ? scrollBarWidth + 3 : 0), + centered: child.props.centered ?? !selectable, + }); + } + return <>; + }) + } + + {scrollable + && ( + + )} + + )} + + ); +}; + +export type ScrollBarProps = { + x: number; + y: number; + width?: number; + maxHeight: number; + totalChildHeight: number; + scrollPosition: number; + setScrollPosition: Dispatch>; +} +export const ScrollBar: FC = ({ x, y, width = 48, maxHeight, totalChildHeight, scrollPosition, setScrollPosition }) => { + const inputManager = useInputManager(); + const [dragging, setDragging] = useState(false); + const [hovered, hoverRef] = useHover(); + const handleMouseDown = (e: any) => { + inputManager.setMouseUpHandler(handleMouseUp); + inputManager.setMouseMoveHandler(handleMouseMove); + lastMousePosition = e.pageY; + }; + + const handleMouseUp = () => { + setDragging(false); + inputManager.clearHandlers(); + }; + + const handleMouseMove = (e: MouseEvent) => { + const delta = (e.pageY - lastMousePosition); + setScrollPosition((p) => { + let newPos = p + delta; + newPos = Math.max(0, newPos); + newPos = Math.min(newPos, totalChildHeight - maxHeight); + return newPos; + }); + lastMousePosition = e.pageY; + }; + + return ( + + ); +}; + +type DropdownItemProps = { + y?: number; + onSelect?: () => void; + width?: number; + height?: number; + centered?: boolean; +} + +export const DropdownItem: FC = ({ y = 0, onSelect, width = 0, height = 0, centered, children }) => { + const [hovered, hoverProps] = useHover(); + + return ( + + + {children} + + ); +}; + +export const DropdownLink: FC = (props) => { + const history = useHistory(); + const { path } = useRouteMatch(); + return ( history.push(path + props.link)}>{props.children}); +}; + +export const DropdownDivider = ({ y = 0, width = 0, height = 1 }: DropdownItemProps) => ( + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/OIT/Components/Layer.tsx b/fbw-a380x/src/systems/instruments/src/OIT/Components/Layer.tsx new file mode 100644 index 00000000000..824352e2b32 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/Components/Layer.tsx @@ -0,0 +1,7 @@ +import React, { SVGProps, FC } from 'react'; + +export const Layer: FC & { angle?: number }> = (props) => ( + + {props.children} + +); diff --git a/fbw-a380x/src/systems/instruments/src/OIT/OnboardInformationTerminal.tsx b/fbw-a380x/src/systems/instruments/src/OIT/OnboardInformationTerminal.tsx new file mode 100644 index 00000000000..5f7fd20c91e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/OnboardInformationTerminal.tsx @@ -0,0 +1,130 @@ +import React, { createContext, useContext } from 'react'; +import { useHistory, useRouteMatch, Redirect, Route, Switch } from 'react-router-dom'; +import { Pages } from './Pages'; + +import './index.scss'; +import { Dropdown, DropdownDivider, DropdownItem, DropdownLink } from './Components/Dropdown'; +import { Button } from './Components/Button'; + +enum OITDisplayPosition { + Captain='CAPTAIN', + FO='F/O', + Backup='BACKUP' +} +type OITContextType = { + displayPosition: OITDisplayPosition +} + +export const OITContext = createContext(undefined as any); + +export const useOITContext = () => useContext(OITContext); + +export const OnboardInformationTerminal: React.FC = () => { + const history = useHistory(); + const { path } = useRouteMatch(); + return ( + + + + + {/* fix prefixes for this */} + {history.location.pathname.toUpperCase().substring(path.length)} + + + + + + ); +}; + +const PagesContainer: React.FC = () => ( + + + + + + + + + + + +); + +const MenuDropdown: React.FC = () => ( + <> + + FLT OPS MENU + + + + FLT FOLDER + TERML CHART + OPS LIBRARY + + + + T.O PERF + LOADSHEET + LDG PERF + IN-FLT PERF + + + + FLT OPS STS + LOAD BOX + EXPORT BOX + + + + EXIT SESSION + + +); + +const FunctionDropdown: React.FC = () => ( + <> + + HOME + PREVIOUS + NEXT + + + + PRINT + STORE + UPDATE + + + + UNDO + REDO + + + + HELP + + + + HIDE SWITCHING BAR + + + + CLOSE APPLICATION + + +); + +const MessageDropdown: React.FC = () => ( + <> + 0 MSG + + + MESSAGE + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/LoadBox.tsx b/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/LoadBox.tsx new file mode 100644 index 00000000000..b2bb25a88ab --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/LoadBox.tsx @@ -0,0 +1,11 @@ +import React, { FC } from 'react'; +import { useHistory } from 'react-router-dom'; + +export const LoadBoxPage: FC = () => { + const history = useHistory(); + return ( + <> + LOAD BOX + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/Login.tsx b/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/Login.tsx new file mode 100644 index 00000000000..04b2ba5cba6 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/Login.tsx @@ -0,0 +1,26 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useOITContext } from '../../OnboardInformationTerminal'; +import { Button } from '../../Components/Button'; + +export const LoginPage: FC = () => { + const history = useHistory(); + const { displayPosition } = useOITContext(); + return ( + <> + FLT OPS Domain + Login Page + {displayPosition} + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/Menu.tsx b/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/Menu.tsx new file mode 100644 index 00000000000..df12ed14a04 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/Menu.tsx @@ -0,0 +1,11 @@ +import React, { FC } from 'react'; +import { useHistory } from 'react-router-dom'; + +export const MenuPage: FC = () => { + const history = useHistory(); + return ( + <> + FLT OPS MENU + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/STS.tsx b/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/STS.tsx new file mode 100644 index 00000000000..be6cc26943f --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/Pages/FlightOps/STS.tsx @@ -0,0 +1,71 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useOITContext } from '../../OnboardInformationTerminal'; +import { Button } from '../../Components/Button'; +import { Dropdown, DropdownItem } from '../../Components/Dropdown'; + +export const STSPage: FC = () => { + const history = useHistory(); + const { displayPosition } = useOITContext(); + return ( + <> + { /* Main Section */ } + + + + + + + + + + + + ); +}; + +const AircraftInfo: React.FC = () => ( + <> + ACFT REGISTRATION + + + + idk what goes here + + + + + + MSN + 9804 + + OIS VERSION + 14-JUL-08 V2.0 + +); + +const NavInfo: React.FC = () => ( + <> + ACTIVE + CHARTS + 07-AUG-08 27-AUG-08 + +); + +const FlightInfo: React.FC = () => ( + <> + FLT NBR + + AIB123 + + { /* The style for this dropdown is not finished, don't have enough references to be able to correctly stylize it */ } + FROM + + + TO + + +); diff --git a/fbw-a380x/src/systems/instruments/src/OIT/Pages/index.tsx b/fbw-a380x/src/systems/instruments/src/OIT/Pages/index.tsx new file mode 100644 index 00000000000..0c2aef562df --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/Pages/index.tsx @@ -0,0 +1,6 @@ +import { LoginPage } from './FlightOps/Login'; +import { STSPage } from './FlightOps/STS'; +import { MenuPage } from './FlightOps/Menu'; +import { LoadBoxPage } from './FlightOps/LoadBox'; + +export const Pages = { LoginPage, STSPage, MenuPage, LoadBoxPage }; diff --git a/fbw-a380x/src/systems/instruments/src/OIT/config.json b/fbw-a380x/src/systems/instruments/src/OIT/config.json new file mode 100644 index 00000000000..dd22b8a2bdb --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/config.json @@ -0,0 +1,8 @@ +{ + "index": "./index.tsx", + "isInteractive": true, + "dimensions": { + "width": 1024, + "height": 768 + } +} diff --git a/fbw-a380x/src/systems/instruments/src/OIT/index.scss b/fbw-a380x/src/systems/instruments/src/OIT/index.scss new file mode 100644 index 00000000000..6f83e3816b4 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/index.scss @@ -0,0 +1,3 @@ +text { + font-family: "Helvetica"; +} diff --git a/fbw-a380x/src/systems/instruments/src/OIT/index.tsx b/fbw-a380x/src/systems/instruments/src/OIT/index.tsx new file mode 100644 index 00000000000..624707213fb --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { getRootElement } from '@instruments/common/defaults'; +import { HashRouter as Router } from 'react-router-dom'; +import ReactDOM from 'react-dom'; +import { renderTarget } from '../util.js'; +import { OnboardInformationTerminal } from './OnboardInformationTerminal'; +import { render } from '../Common'; + +if (renderTarget) { + render(); +} + +getRootElement().addEventListener('unload', () => { + ReactDOM.unmountComponentAtNode(renderTarget ?? document.body); +}); diff --git a/fbw-a380x/src/systems/instruments/src/PFD/.eslintrc.js b/fbw-a380x/src/systems/instruments/src/PFD/.eslintrc.js new file mode 100644 index 00000000000..6fd0979d8af --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/.eslintrc.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = { + + extends: '../../../../.eslintrc.js', + + // overrides airbnb, use sparingly + rules: { 'react/no-unknown-property': 'off', 'react/style-prop-object': 'off' }, + +}; diff --git a/fbw-a380x/src/systems/instruments/src/PFD/AltitudeIndicator.tsx b/fbw-a380x/src/systems/instruments/src/PFD/AltitudeIndicator.tsx new file mode 100644 index 00000000000..8f5b4b180b9 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/AltitudeIndicator.tsx @@ -0,0 +1,707 @@ +import { ClockEvents, DisplayComponent, EventBus, FSComponent, Subject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@shared/arinc429'; +import { VerticalMode } from '@shared/autopilot'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; +import { DigitalAltitudeReadout } from './DigitalAltitudeReadout'; +import { SimplaneValues } from './shared/SimplaneValueProvider'; +import { VerticalTape } from './VerticalTape'; +import { Arinc429Values } from './shared/ArincValueProvider'; + +const DisplayRange = 600; +const ValueSpacing = 100; +const DistanceSpacing = 7.5; + +class LandingElevationIndicator extends DisplayComponent<{bus: EventBus}> { + private landingElevationIndicator = FSComponent.createRef(); + + private altitude = 0; + + private landingElevation = 0; + + private flightPhase = 0; + + private delta = 0; + + private handleLandingElevation() { + const delta = this.altitude - this.landingElevation; + const offset = (delta - DisplayRange) * DistanceSpacing / ValueSpacing; + this.delta = delta; + if (delta > DisplayRange || (this.flightPhase !== 7 && this.flightPhase !== 8)) { + this.landingElevationIndicator.instance.classList.add('HiddenElement'); + } else { + this.landingElevationIndicator.instance.classList.remove('HiddenElement'); + } + this.landingElevationIndicator.instance.setAttribute('d', `m130.85 123.56h-13.096v${offset}h13.096z`); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('fwcFlightPhase').whenChanged().handle((fp) => { + this.flightPhase = fp; + + if ((fp !== 7 && fp !== 8) || this.delta > DisplayRange) { + this.landingElevationIndicator.instance.classList.add('HiddenElement'); + } else { + this.landingElevationIndicator.instance.classList.remove('HiddenElement'); + } + }); + + sub.on('landingElevation').whenChanged().handle((le) => { + this.landingElevation = le; + this.handleLandingElevation(); + }); + + sub.on('altitudeAr').handle((a) => { + this.altitude = a.value; + this.handleLandingElevation(); + }); + } + + render(): VNode { + return ( + + ); + } +} + +class RadioAltIndicator extends DisplayComponent<{ bus: EventBus, filteredRadioAltitude: Subscribable }> { + private visibilitySub = Subject.create('hidden'); + + private offsetSub = Subject.create(''); + + private radioAltitude = new Arinc429Word(0); + + private setOffset() { + if (this.props.filteredRadioAltitude.get() > DisplayRange || this.radioAltitude.isFailureWarning() || this.radioAltitude.isNoComputedData()) { + this.visibilitySub.set('hidden'); + } else { + this.visibilitySub.set('visible'); + const offset = (this.props.filteredRadioAltitude.get() - DisplayRange) * DistanceSpacing / ValueSpacing; + this.offsetSub.set(`m131.15 123.56h2.8709v${offset}h-2.8709z`); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + this.props.filteredRadioAltitude.sub((_filteredRadioAltitude) => { + this.setOffset(); + }, true); + + sub.on('chosenRa').handle((ra) => { + this.radioAltitude = ra; + this.setOffset(); + }); + } + + render(): VNode { + return ( + + ); + } +} + +interface AltitudeIndicatorProps { + + bus: EventBus; +} + +export class AltitudeIndicator extends DisplayComponent { + private subscribable = Subject.create(0); + + private tapeRef = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const pf = this.props.bus.getSubscriber(); + + pf.on('altitudeAr').handle((a) => { + if (a.isNormalOperation()) { + this.subscribable.set(a.value); + this.tapeRef.instance.style.display = 'inline'; + } else { + this.tapeRef.instance.style.display = 'none'; + } + }); + } + + render(): VNode { + return ( + + + + + + + + + + ); + } +} + +class AltTapeBackground extends DisplayComponent { + render(): VNode { + return (); + } +} + + interface AltitudeIndicatorOfftapeProps { + bus: EventBus; + filteredRadioAltitude: Subscribable; +} + +export class AltitudeIndicatorOfftape extends DisplayComponent { + private abnormal = FSComponent.createRef(); + + private tcasFailed = FSComponent.createRef(); + + private normal = FSComponent.createRef(); + + private altitude = Subject.create(0); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('altitudeAr').handle((altitude) => { + if (!altitude.isNormalOperation()) { + this.normal.instance.style.display = 'none'; + this.abnormal.instance.removeAttribute('style'); + } else { + this.altitude.set(altitude.value); + this.abnormal.instance.style.display = 'none'; + this.normal.instance.removeAttribute('style'); + } + }); + + sub.on('tcasFail').whenChanged().handle((tcasFailed) => { + if (tcasFailed) { + this.tcasFailed.instance.style.display = 'inline'; + } else { + this.tcasFailed.instance.style.display = 'none'; + } + }); + } + + render(): VNode { + return ( + + <> + + + + + ALT + + + T + C + A + S + + + + + + + + + + + + + ); + } +} + +interface SelectedAltIndicatorProps { + bus: EventBus +} + +class SelectedAltIndicator extends DisplayComponent { + private mode: 'QNH' | 'QFE' | 'STD' = 'QNH'; + + private selectedAltLowerGroupRef = FSComponent.createRef(); + + private selectedAltLowerText = FSComponent.createRef(); + + private selectedAltLowerFLText = FSComponent.createRef(); + + private selectedAltUpperGroupRef = FSComponent.createRef(); + + private selectedAltUpperText = FSComponent.createRef(); + + private selectedAltUpperFLText = FSComponent.createRef(); + + private targetGroupRef = FSComponent.createRef(); + + private blackFill = FSComponent.createRef(); + + private targetSymbolRef = FSComponent.createRef(); + + private altTapeTargetText = FSComponent.createRef(); + + private altitude = new Arinc429Word(0); + + private targetAltitudeSelected = 0; + + private shownTargetAltitude = 0; + + private constraint = 0; + + private textSub = Subject.create(''); + + private isManaged = false; + + private activeVerticalMode = 0; + + private handleAltManagedChange() { + // TODO find proper logic for this (what happens when a constraint is sent by the fms but vertical mode is not managed) + const clbActive = this.activeVerticalMode !== VerticalMode.OP_CLB && this.activeVerticalMode !== VerticalMode.OP_DES + && this.activeVerticalMode !== VerticalMode.VS && this.activeVerticalMode !== VerticalMode.FPA; + + const selectedAltIgnored = this.activeVerticalMode >= VerticalMode.GS_CPT && this.activeVerticalMode < VerticalMode.ROLL_OUT || this.activeVerticalMode === VerticalMode.FINAL; + + this.isManaged = this.constraint > 0 && clbActive; + + this.shownTargetAltitude = this.updateTargetAltitude(this.targetAltitudeSelected, this.isManaged, this.constraint); + + if (selectedAltIgnored) { + this.selectedAltLowerFLText.instance.classList.remove('Cyan'); + this.selectedAltLowerFLText.instance.classList.remove('Magenta'); + this.selectedAltLowerFLText.instance.classList.add('White'); + + this.selectedAltLowerText.instance.classList.remove('Cyan'); + this.selectedAltLowerText.instance.classList.remove('Magenta'); + this.selectedAltLowerText.instance.classList.add('White'); + + this.selectedAltUpperFLText.instance.classList.remove('Cyan'); + this.selectedAltUpperFLText.instance.classList.remove('Magenta'); + this.selectedAltUpperFLText.instance.classList.add('White'); + + this.selectedAltUpperText.instance.classList.remove('Cyan'); + this.selectedAltUpperText.instance.classList.remove('Magenta'); + this.selectedAltUpperText.instance.classList.add('White'); + + this.altTapeTargetText.instance.classList.remove('Cyan'); + this.altTapeTargetText.instance.classList.add('White'); + + this.targetSymbolRef.instance.classList.remove('Cyan'); + this.targetSymbolRef.instance.classList.remove('Magenta'); + + this.targetSymbolRef.instance.classList.add('White'); + } else if (this.isManaged) { + this.selectedAltLowerFLText.instance.classList.remove('Cyan'); + this.selectedAltLowerFLText.instance.classList.remove('White'); + this.selectedAltLowerFLText.instance.classList.add('Magenta'); + + this.selectedAltLowerText.instance.classList.remove('Cyan'); + this.selectedAltLowerText.instance.classList.remove('White'); + this.selectedAltLowerText.instance.classList.add('Magenta'); + + this.selectedAltUpperFLText.instance.classList.remove('Cyan'); + this.selectedAltUpperFLText.instance.classList.remove('White'); + this.selectedAltUpperFLText.instance.classList.add('Magenta'); + + this.selectedAltUpperText.instance.classList.remove('Cyan'); + this.selectedAltUpperText.instance.classList.remove('White'); + this.selectedAltUpperText.instance.classList.add('Magenta'); + + this.altTapeTargetText.instance.classList.remove('Cyan'); + this.altTapeTargetText.instance.classList.remove('White'); + this.altTapeTargetText.instance.classList.add('Magenta'); + + this.targetSymbolRef.instance.classList.remove('Cyan'); + this.targetSymbolRef.instance.classList.remove('White'); + this.targetSymbolRef.instance.classList.add('Magenta'); + } else { + this.selectedAltLowerFLText.instance.classList.add('Cyan'); + this.selectedAltLowerFLText.instance.classList.remove('Magenta'); + this.selectedAltLowerFLText.instance.classList.remove('White'); + + this.selectedAltLowerText.instance.classList.add('Cyan'); + this.selectedAltLowerText.instance.classList.remove('Magenta'); + this.selectedAltLowerText.instance.classList.remove('White'); + + this.selectedAltUpperFLText.instance.classList.add('Cyan'); + this.selectedAltUpperFLText.instance.classList.remove('Magenta'); + this.selectedAltUpperFLText.instance.classList.remove('White'); + + this.selectedAltUpperText.instance.classList.add('Cyan'); + this.selectedAltUpperText.instance.classList.remove('Magenta'); + this.selectedAltUpperText.instance.classList.remove('White'); + + this.altTapeTargetText.instance.classList.add('Cyan'); + this.altTapeTargetText.instance.classList.remove('Magenta'); + this.altTapeTargetText.instance.classList.remove('White'); + + this.targetSymbolRef.instance.classList.add('Cyan'); + this.targetSymbolRef.instance.classList.remove('Magenta'); + this.targetSymbolRef.instance.classList.remove('White'); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('activeVerticalMode').whenChanged().handle((v) => { + this.activeVerticalMode = v; + this.handleAltManagedChange(); + this.getOffset(); + this.handleAltitudeDisplay(); + this.setText(); + }); + + sub.on('selectedAltitude').whenChanged().handle((m) => { + this.targetAltitudeSelected = m; + this.handleAltManagedChange(); + this.getOffset(); + this.handleAltitudeDisplay(); + this.setText(); + }); + + sub.on('altConstraint').whenChanged().handle((m) => { + this.constraint = m; + this.handleAltManagedChange(); + this.getOffset(); + this.handleAltitudeDisplay(); + this.setText(); + }); + + sub.on('altitudeAr').withArinc429Precision(2).handle((a) => { + this.altitude = a; + this.handleAltitudeDisplay(); + this.getOffset(); + }); + + sub.on('baroMode').whenChanged().handle((m) => { + this.mode = m; + + if (this.mode === 'STD') { + this.selectedAltLowerFLText.instance.style.visibility = 'visible'; + this.selectedAltUpperFLText.instance.style.visibility = 'visible'; + } else { + this.selectedAltLowerFLText.instance.style.visibility = 'hidden'; + this.selectedAltUpperFLText.instance.style.visibility = 'hidden'; + } + this.handleAltitudeDisplay(); + this.setText(); + }); + } + + private updateTargetAltitude(targetAltitude: number, isManaged: boolean, constraint: number) { + return isManaged ? constraint : targetAltitude; + } + + private handleAltitudeDisplay() { + if (this.altitude.value - this.shownTargetAltitude > DisplayRange) { + this.selectedAltLowerGroupRef.instance.style.display = 'block'; + this.selectedAltUpperGroupRef.instance.style.display = 'none'; + this.targetGroupRef.instance.style.display = 'none'; + } else if (this.altitude.value - this.shownTargetAltitude < -DisplayRange) { + this.targetGroupRef.instance.style.display = 'none'; + this.selectedAltUpperGroupRef.instance.style.display = 'block'; + this.selectedAltLowerGroupRef.instance.style.display = 'none'; + } else { + this.selectedAltUpperGroupRef.instance.style.display = 'none'; + this.selectedAltLowerGroupRef.instance.style.display = 'none'; + this.targetGroupRef.instance.style.display = 'inline'; + } + } + + private setText() { + let boxLength = 19.14; + let text = '0'; + if (this.mode === 'STD') { + text = Math.round(this.shownTargetAltitude / 100).toString().padStart(3, '0'); + boxLength = 12.5; + } else { + text = Math.round(this.shownTargetAltitude).toString().padStart(5, ' '); + } + this.textSub.set(text); + this.blackFill.instance.setAttribute('d', `m117.75 77.784h${boxLength}v6.0476h-${boxLength}z`); + } + + private getOffset() { + const offset = (this.altitude.value - this.shownTargetAltitude) * DistanceSpacing / ValueSpacing; + this.targetGroupRef.instance.style.transform = `translate3d(0px, ${offset}px, 0px)`; + } + + render(): VNode | null { + return ( + <> + + {this.textSub} + FL + + + {this.textSub} + FL + + + + + {this.textSub} + + + ); + } +} + +interface AltimeterIndicatorProps { + altitude: Subscribable, + bus: EventBus, +} + +class AltimeterIndicator extends DisplayComponent { + private mode = Subject.create(''); + + private text = Subject.create(''); + + private pressure = 0; + + private unit = ''; + + private transAlt = 0; + + private transAltAppr = 0; + + private flightPhase = 0; + + private stdGroup = FSComponent.createRef(); + + private qfeGroup = FSComponent.createRef(); + + private qfeBorder = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('baroMode').whenChanged().handle((m) => { + if (m === 'QFE') { + this.mode.set(m); + this.stdGroup.instance.classList.add('HiddenElement'); + this.qfeGroup.instance.classList.remove('HiddenElement'); + this.qfeBorder.instance.classList.remove('HiddenElement'); + } else if (m === 'QNH') { + this.mode.set(m); + this.stdGroup.instance.classList.add('HiddenElement'); + this.qfeGroup.instance.classList.remove('HiddenElement'); + this.qfeBorder.instance.classList.add('HiddenElement'); + } else if (m === 'STD') { + this.mode.set(m); + this.stdGroup.instance.classList.remove('HiddenElement'); + this.qfeGroup.instance.classList.add('HiddenElement'); + this.qfeBorder.instance.classList.add('HiddenElement'); + } else { + this.mode.set(m); + this.stdGroup.instance.classList.add('HiddenElement'); + this.qfeGroup.instance.classList.add('HiddenElement'); + this.qfeBorder.instance.classList.add('HiddenElement'); + } + this.getText(); + }); + + sub.on('fmgcFlightPhase').whenChanged().handle((fp) => { + this.flightPhase = fp; + + this.handleBlink(); + }); + + sub.on('transAlt').whenChanged().handle((ta) => { + this.transAlt = ta; + + this.handleBlink(); + this.getText(); + }); + + sub.on('transAltAppr').whenChanged().handle((ta) => { + this.transAltAppr = ta; + + this.handleBlink(); + this.getText(); + }); + + sub.on('units').whenChanged().handle((u) => { + this.unit = u; + this.getText(); + }); + + sub.on('pressure').whenChanged().handle((p) => { + this.pressure = p; + this.getText(); + }); + + this.props.altitude.sub((_a) => { + this.handleBlink(); + }); + } + + private handleBlink() { + if (this.mode.get() === 'STD') { + if (this.flightPhase > 3 && this.transAltAppr > this.props.altitude.get() && this.transAltAppr !== 0) { + this.stdGroup.instance.classList.add('BlinkInfinite'); + } else { + this.stdGroup.instance.classList.remove('BlinkInfinite'); + } + } else if (this.flightPhase <= 3 && this.transAlt < this.props.altitude.get() && this.transAlt !== 0) { + this.qfeGroup.instance.classList.add('BlinkInfinite'); + } else { + this.qfeGroup.instance.classList.remove('BlinkInfinite'); + } + } + + private getText() { + if (this.pressure !== null) { + if (this.unit === 'millibar') { + this.text.set(Math.round(this.pressure).toString()); + } else { + this.text.set(this.pressure.toFixed(2)); + } + } else { + this.text.set(''); + } + } + + render(): VNode { + return ( + <> + + + STD + + + + + + {this.mode} + {this.text} + + + + ); + } +} + + interface MetricAltIndicatorState { + altitude: Arinc429Word; + MDA: number; + targetAltSelected: number; + targetAltManaged: number; + altIsManaged: boolean; + metricAltToggle: boolean; +} + +class MetricAltIndicator extends DisplayComponent<{ bus: EventBus }> { + private needsUpdate = false; + + private metricAlt = FSComponent.createRef(); + + private metricAltText = FSComponent.createRef(); + + private metricAltTargetText = FSComponent.createRef(); + + private state: MetricAltIndicatorState = { + altitude: new Arinc429Word(0), + MDA: 0, + targetAltSelected: 0, + targetAltManaged: 0, + altIsManaged: false, + metricAltToggle: false, + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('mda').whenChanged().handle((mda) => { + this.state.MDA = mda; + this.needsUpdate = true; + }); + + sub.on('altitudeAr').handle((a) => { + this.state.altitude = a; + this.needsUpdate = true; + }); + + sub.on('selectedAltitude').whenChanged().handle((m) => { + this.state.targetAltSelected = m; + this.needsUpdate = true; + }); + sub.on('altConstraint').handle((m) => { + this.state.targetAltManaged = m; + this.needsUpdate = true; + }); + + sub.on('metricAltToggle').whenChanged().handle((m) => { + this.state.metricAltToggle = m; + this.needsUpdate = true; + }); + + sub.on('realTime').handle(this.updateState.bind(this)); + } + + private updateState(_time: number) { + if (this.needsUpdate) { + this.needsUpdate = false; + const showMetricAlt = this.state.metricAltToggle; + if (!showMetricAlt) { + this.metricAlt.instance.style.display = 'none'; + } else { + this.metricAlt.instance.style.display = 'inline'; + const currentMetricAlt = Math.round(this.state.altitude.value * 0.3048 / 10) * 10; + this.metricAltText.instance.textContent = currentMetricAlt.toString(); + + const targetMetric = Math.round((this.state.altIsManaged ? this.state.targetAltManaged : this.state.targetAltSelected) * 0.3048 / 10) * 10; + this.metricAltTargetText.instance.textContent = targetMetric.toString(); + + if (this.state.altIsManaged) { + this.metricAltTargetText.instance.classList.replace('Cyan', 'Magenta'); + } else { + this.metricAltTargetText.instance.classList.replace('Magenta', 'Cyan'); + } + + if (this.state.altitude.value > this.state.MDA) { + this.metricAltText.instance.classList.replace('Green', 'Amber'); + } else { + this.metricAltText.instance.classList.replace('Amber', 'Green'); + } + } + } + } + + render(): VNode { + return ( + + + M + 0 + + 0 + M + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/AttitudeIndicatorFixed.tsx b/fbw-a380x/src/systems/instruments/src/PFD/AttitudeIndicatorFixed.tsx new file mode 100644 index 00000000000..fdb55757a56 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/AttitudeIndicatorFixed.tsx @@ -0,0 +1,465 @@ +import { DisplayComponent, EventBus, FSComponent, Subject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@shared/arinc429'; +import { getDisplayIndex } from 'instruments/src/PFD/PFD'; +import { FlightPathDirector } from './FlightPathDirector'; +import { FlightPathVector } from './FlightPathVector'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; + +interface AttitudeIndicatorFixedUpperProps { + bus: EventBus; +} + +export class AttitudeIndicatorFixedUpper extends DisplayComponent { + private roll = new Arinc429Word(0); + + private pitch = new Arinc429Word(0); + + private visibilitySub = Subject.create('hidden'); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('rollAr').handle((roll) => { + this.roll = roll; + if (!this.roll.isNormalOperation()) { + this.visibilitySub.set('hidden'); + } else { + this.visibilitySub.set('visible'); + } + }); + + sub.on('pitchAr').handle((pitch) => { + this.pitch = pitch; + if (!this.pitch.isNormalOperation()) { + this.visibilitySub.set('hidden'); + } else { + this.visibilitySub.set('visible'); + } + }); + } + + render(): VNode { + return ( + + + + + + + + + + + + + + + + + + + + ); + } +} + +interface AttitudeIndicatorFixedCenterProps { + bus: EventBus; + isAttExcessive: Subscribable; +} + +export class AttitudeIndicatorFixedCenter extends DisplayComponent { + private roll = new Arinc429Word(0); + + private pitch = new Arinc429Word(0); + + private visibilitySub = Subject.create('hidden'); + + private failureVis = Subject.create('hidden'); + + private fdVisibilitySub = Subject.create('hidden'); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('rollAr').handle((r) => { + this.roll = r; + if (!this.roll.isNormalOperation()) { + this.visibilitySub.set('display:none'); + this.failureVis.set('display:block'); + this.fdVisibilitySub.set('display:none'); + } else { + this.visibilitySub.set('display:inline'); + this.failureVis.set('display:none'); + if (!this.props.isAttExcessive.get()) { + this.fdVisibilitySub.set('display:inline'); + } + } + }); + + sub.on('pitchAr').handle((p) => { + this.pitch = p; + + if (!this.pitch.isNormalOperation()) { + this.visibilitySub.set('display:none'); + this.failureVis.set('display:block'); + this.fdVisibilitySub.set('display:none'); + } else { + this.visibilitySub.set('display:inline'); + this.failureVis.set('display:none'); + if (!this.props.isAttExcessive.get()) { + this.fdVisibilitySub.set('display:inline'); + } + } + }); + + this.props.isAttExcessive.sub((a) => { + if (a) { + this.fdVisibilitySub.set('display:none'); + } else if (this.roll.isNormalOperation() && this.pitch.isNormalOperation()) { + this.fdVisibilitySub.set('display:inline'); + } + }); + } + + render(): VNode { + return ( + <> + ATT + + + + + + + + + + + + + + + + + + + + + + + + ); + } +} + +class FDYawBar extends DisplayComponent<{ bus: EventBus }> { + private lateralMode = 0; + + private fdYawCommand = 0; + + private fdActive = false; + + private yawRef = FSComponent.createRef(); + + private isActive(): boolean { + if (!this.fdActive || !(this.lateralMode === 40 || this.lateralMode === 33 || this.lateralMode === 34)) { + return false; + } + return true; + } + + private setOffset() { + const offset = -Math.max(Math.min(this.fdYawCommand, 45), -45) * 0.44; + if (this.isActive()) { + this.yawRef.instance.style.visibility = 'visible'; + this.yawRef.instance.style.transform = `translate3d(${offset}px, 0px, 0px)`; + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('fdYawCommand').handle((fy) => { + this.fdYawCommand = fy; + + if (this.isActive()) { + this.setOffset(); + } else { + this.yawRef.instance.style.visibility = 'hidden'; + } + }); + + sub.on('activeLateralMode').whenChanged().handle((lm) => { + this.lateralMode = lm; + + if (this.isActive()) { + this.setOffset(); + } else { + this.yawRef.instance.style.visibility = 'hidden'; + } + }); + + // FIXME, differentiate properly (without duplication) + sub.on('fd1Active').whenChanged().handle((fd) => { + if (getDisplayIndex() === 1) { + this.fdActive = fd; + + if (this.isActive()) { + this.setOffset(); + } else { + this.yawRef.instance.style.visibility = 'hidden'; + } + } + }); + + sub.on('fd2Active').whenChanged().handle((fd) => { + if (getDisplayIndex() === 2) { + this.fdActive = fd; + + if (this.isActive()) { + this.setOffset(); + } else { + this.yawRef.instance.style.visibility = 'hidden'; + } + } + }); + } + + render(): VNode { + return ( + + ); + } +} + +class FlightDirector extends DisplayComponent<{ bus: EventBus }> { + private lateralMode = 0; + + private verticalMode = 0; + + private fdActive = false; + + private trkFpaActive = false; + + private fdBank = 0; + + private fdPitch = 0; + + private fdRef = FSComponent.createRef(); + + private lateralRef1 = FSComponent.createRef(); + + private lateralRef2 = FSComponent.createRef(); + + private verticalRef1 = FSComponent.createRef(); + + private verticalRef2 = FSComponent.createRef(); + + private handleFdState() { + const [toggled, showLateral, showVertical] = this.isActive(); + + let FDRollOffset = 0; + let FDPitchOffset = 0; + + if (toggled && showLateral) { + const FDRollOrder = this.fdBank; + FDRollOffset = Math.min(Math.max(FDRollOrder, -45), 45) * 0.44; + + this.lateralRef1.instance.setAttribute('visibility', 'visible'); + this.lateralRef1.instance.style.transform = `translate3d(${FDRollOffset}px, 0px, 0px)`; + + this.lateralRef2.instance.setAttribute('visibility', 'visible'); + this.lateralRef2.instance.style.transform = `translate3d(${FDRollOffset}px, 0px, 0px)`; + } else { + this.lateralRef1.instance.setAttribute('visibility', 'hidden'); + this.lateralRef2.instance.setAttribute('visibility', 'hidden'); + } + + if (toggled && showVertical) { + const FDPitchOrder = this.fdPitch; + FDPitchOffset = Math.min(Math.max(FDPitchOrder, -22.5), 22.5) * 0.89; + + this.verticalRef1.instance.setAttribute('visibility', 'visible'); + this.verticalRef1.instance.style.transform = `translate3d(0px, ${FDPitchOffset}px, 0px)`; + + this.verticalRef2.instance.setAttribute('visibility', 'visible'); + this.verticalRef2.instance.style.transform = `translate3d(0px, ${FDPitchOffset}px, 0px)`; + } else { + this.verticalRef1.instance.setAttribute('visibility', 'hidden'); + this.verticalRef2.instance.setAttribute('visibility', 'hidden'); + } + } + + private isActive(): [boolean, boolean, boolean] { + const toggled = this.fdActive && !this.trkFpaActive; + + const showLateralFD = this.lateralMode !== 0 && this.lateralMode !== 34 && this.lateralMode !== 40; + const showVerticalFD = this.verticalMode !== 0 && this.verticalMode !== 34; + + return [toggled, showLateralFD, showVerticalFD]; + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('fd1Active').whenChanged().handle((fd) => { + if (getDisplayIndex() === 1) { + this.fdActive = fd; + + if (this.isActive()[0]) { + this.fdRef.instance.style.display = 'inline'; + } else { + this.fdRef.instance.style.display = 'none'; + } + } + }); + + sub.on('fd2Active').whenChanged().handle((fd) => { + if (getDisplayIndex() === 2) { + this.fdActive = fd; + + if (this.isActive()[0]) { + this.fdRef.instance.style.display = 'inline'; + } else { + this.fdRef.instance.style.display = 'none'; + } + } + }); + + sub.on('trkFpaActive').whenChanged().handle((tr) => { + this.trkFpaActive = tr; + + if (this.isActive()[0]) { + this.fdRef.instance.style.display = 'inline'; + } else { + this.fdRef.instance.style.display = 'none'; + } + }); + + sub.on('fdBank').withPrecision(2).handle((fd) => { + this.fdBank = fd; + + this.handleFdState(); + }); + sub.on('fdPitch').withPrecision(2).handle((fd) => { + this.fdPitch = fd; + + this.handleFdState(); + }); + + sub.on('activeLateralMode').whenChanged().handle((vm) => { + this.lateralMode = vm; + + this.handleFdState(); + }); + + sub.on('activeVerticalMode').whenChanged().handle((lm) => { + this.verticalMode = lm; + + this.handleFdState(); + }); + } + + render(): VNode | null { + return ( + + + + + + + + + + + + + + + ); + } +} + +class SidestickIndicator extends DisplayComponent<{ bus: EventBus }> { + private sideStickX = 0; + + private sideStickY = 0; + + private onGround = true; + + private crossHairRef = FSComponent.createRef(); + + private onGroundForVisibility = Subject.create('visible'); + + private engOneRunning = false; + + private engTwoRunning = false; + + private handleSideStickIndication() { + const oneEngineRunning = this.engOneRunning || this.engTwoRunning; + + if (!this.onGround || !oneEngineRunning) { + this.onGroundForVisibility.set('hidden'); + } else { + this.onGroundForVisibility.set('visible'); + this.crossHairRef.instance.style.transform = `translate3d(${this.sideStickX}px, ${this.sideStickY}px, 0px)`; + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('noseGearCompressed').whenChanged().handle((g) => { + this.onGround = g; + this.handleSideStickIndication(); + }); + + sub.on('sideStickX').whenChanged().handle((x) => { + this.sideStickX = x * 29.56; + this.handleSideStickIndication(); + }); + + sub.on('sideStickY').whenChanged().handle((y) => { + this.sideStickY = -y * 23.02; + this.handleSideStickIndication(); + }); + + sub.on('engOneRunning').whenChanged().handle((e) => { + this.engOneRunning = e; + this.handleSideStickIndication(); + }); + + sub.on('engTwoRunning').whenChanged().handle((e) => { + this.engTwoRunning = e; + this.handleSideStickIndication(); + }); + } + + render(): VNode { + return ( + + + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/AttitudeIndicatorHorizon.tsx b/fbw-a380x/src/systems/instruments/src/PFD/AttitudeIndicatorHorizon.tsx new file mode 100644 index 00000000000..302c9344faf --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/AttitudeIndicatorHorizon.tsx @@ -0,0 +1,591 @@ +import { ClockEvents, DisplayComponent, EventBus, FSComponent, Subject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@shared/arinc429'; + +import { + calculateHorizonOffsetFromPitch, + calculateVerticalOffsetFromRoll, + LagFilter, + getSmallestAngle, +} from './PFDUtils'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { HorizontalTape } from './HorizontalTape'; +import { SimplaneValues } from './shared/SimplaneValueProvider'; +import { getDisplayIndex } from './PFD'; + +const DisplayRange = 35; +const DistanceSpacing = 15; +const ValueSpacing = 10; + +class HeadingBug extends DisplayComponent<{bus: EventBus, isCaptainSide: boolean, yOffset: Subscribable}> { + private isActive = false; + + private selectedHeading = 0; + + private heading = new Arinc429Word(0); + + private horizonHeadingBug = FSComponent.createRef(); + + private yOffset = 0; + + private calculateAndSetOffset() { + const headingDelta = getSmallestAngle(this.selectedHeading, this.heading.value); + + const offset = headingDelta * DistanceSpacing / ValueSpacing; + + if (Math.abs(offset) <= DisplayRange + 10) { + this.horizonHeadingBug.instance.classList.remove('HiddenElement'); + this.horizonHeadingBug.instance.style.transform = `translate3d(${offset}px, ${this.yOffset}px, 0px)`; + } else { + this.horizonHeadingBug.instance.classList.add('HiddenElement'); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('selectedHeading').whenChanged().handle((s) => { + this.selectedHeading = s; + if (this.isActive) { + this.calculateAndSetOffset(); + } + }); + + sub.on('headingAr').handle((h) => { + this.heading = h; + if (this.isActive) { + this.calculateAndSetOffset(); + } + }); + + sub.on(this.props.isCaptainSide ? 'fd1Active' : 'fd2Active').whenChanged().handle((fd) => { + this.isActive = !fd; + if (this.isActive) { + this.horizonHeadingBug.instance.classList.remove('HiddenElement'); + } else { + this.horizonHeadingBug.instance.classList.add('HiddenElement'); + } + }); + + this.props.yOffset.sub((yOffset) => { + this.yOffset = yOffset; + if (this.isActive) { + this.calculateAndSetOffset(); + } + }); + } + + render(): VNode { + return ( + + + + + ); + } +} + +interface HorizonProps { + bus: EventBus; + instrument: BaseInstrument; + isAttExcessive: Subscribable; + filteredRadioAlt: Subscribable; + +} + +export class Horizon extends DisplayComponent { + private pitchGroupRef = FSComponent.createRef(); + + private rollGroupRef = FSComponent.createRef(); + + private yOffset = Subject.create(0); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const apfd = this.props.bus.getSubscriber(); + + apfd.on('pitchAr').withArinc429Precision(3).handle((pitch) => { + const multiplier = 1000; + const currentValueAtPrecision = Math.round(pitch.value * multiplier) / multiplier; + if (pitch.isNormalOperation()) { + this.pitchGroupRef.instance.style.display = 'block'; + + this.pitchGroupRef.instance.style.transform = `translate3d(0px, ${calculateHorizonOffsetFromPitch(currentValueAtPrecision)}px, 0px)`; + } else { + this.pitchGroupRef.instance.style.display = 'none'; + } + const yOffset = Math.max(Math.min(calculateHorizonOffsetFromPitch(currentValueAtPrecision), 31.563), -31.563); + this.yOffset.set(yOffset); + }); + + apfd.on('rollAr').withArinc429Precision(2).handle((roll) => { + const multiplier = 100; + const currentValueAtPrecision = Math.round(roll.value * multiplier) / multiplier; + if (roll.isNormalOperation()) { + this.rollGroupRef.instance.style.display = 'block'; + + this.rollGroupRef.instance.setAttribute('transform', `rotate(${-currentValueAtPrecision} 68.814 80.730)`); + } else { + this.rollGroupRef.instance.style.display = 'none'; + } + }); + } + + render(): VNode { + return ( + + ); + } +} + +class TailstrikeIndicator extends DisplayComponent<{bus: EventBus}> { + private tailStrike = FSComponent.createRef(); + + private needsUpdate = false; + + private tailStrikeConditions = { + altitude: new Arinc429Word(0), + speed: 0, + tla1: 0, + tla2: 0, + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('chosenRa').handle((ra) => { + this.tailStrikeConditions.altitude = ra; + this.needsUpdate = true; + }); + + sub.on('tla1').whenChanged().handle((tla) => { + this.tailStrikeConditions.tla1 = tla; + this.needsUpdate = true; + }); + sub.on('tla2').whenChanged().handle((tla) => { + this.tailStrikeConditions.tla2 = tla; + this.needsUpdate = true; + }); + + sub.on('speedAr').whenChanged().handle((speed) => { + this.tailStrikeConditions.speed = speed.value; + this.needsUpdate = true; + }); + + sub.on('realTime').onlyAfter(2).handle(this.hideShow.bind(this)); + } + + private hideShow(_time: number) { + if (this.needsUpdate) { + this.needsUpdate = false; + if (this.tailStrikeConditions.altitude.value > 400 || this.tailStrikeConditions.speed < 50 || this.tailStrikeConditions.tla1 >= 35 || this.tailStrikeConditions.tla2 >= 35) { + this.tailStrike.instance.style.display = 'none'; + } else { + this.tailStrike.instance.style.display = 'inline'; + } + } + } + + render(): VNode { + return ( + + ); + } +} + +class RadioAltAndDH extends DisplayComponent<{ bus: EventBus, filteredRadioAltitude: Subscribable, attExcessive: Subscribable }> { + private daRaGroup = FSComponent.createRef(); + + private roll = new Arinc429Word(0); + + private dh = 0; + + private filteredRadioAltitude = 0; + + private radioAltitude = new Arinc429Word(0); + + private transAlt = 0; + + private transAltAppr = 0; + + private fmgcFlightPhase = 0; + + private altitude = new Arinc429Word(0); + + private attDhText = FSComponent.createRef(); + + private radioAltText = Subject.create('0') + + private radioAlt = FSComponent.createRef(); + + private classSub = Subject.create(''); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('rollAr').handle((roll) => { + this.roll = roll; + }); + + sub.on('dh').whenChanged().handle((dh) => { + this.dh = dh; + }); + + sub.on('transAlt').whenChanged().handle((ta) => { + this.transAlt = ta; + }); + + sub.on('transAltAppr').whenChanged().handle((ta) => { + this.transAltAppr = ta; + }); + + sub.on('fmgcFlightPhase').whenChanged().handle((fp) => { + this.fmgcFlightPhase = fp; + }); + + sub.on('altitudeAr').handle((a) => { + this.altitude = a; + }); + + sub.on('chosenRa').handle((ra) => { + if (!this.props.attExcessive.get()) { + this.radioAltitude = ra; + const raFailed = !this.radioAltitude.isFailureWarning(); + const raHasData = !this.radioAltitude.isNoComputedData(); + const raValue = this.filteredRadioAltitude; + const verticalOffset = calculateVerticalOffsetFromRoll(this.roll.value); + const chosenTransalt = this.fmgcFlightPhase <= 3 ? this.transAlt : this.transAltAppr; + const belowTransitionAltitude = chosenTransalt !== 0 && (!this.altitude.isNoComputedData() && !this.altitude.isNoComputedData()) && this.altitude.value < chosenTransalt; + let size = 'FontLarge'; + const DHValid = this.dh >= 0; + + let text = ''; + let color = 'Amber'; + + if (raHasData) { + if (raFailed) { + if (raValue < 2500) { + if (raValue > 400 || (raValue > this.dh + 100 && DHValid)) { + color = 'Green'; + } + if (raValue < 400) { + size = 'FontLargest'; + } + if (raValue < 5) { + text = Math.round(raValue).toString(); + } else if (raValue <= 50) { + text = (Math.round(raValue / 5) * 5).toString(); + } else if (raValue > 50 || (raValue > this.dh + 100 && DHValid)) { + text = (Math.round(raValue / 10) * 10).toString(); + } + } + } else { + color = belowTransitionAltitude ? 'Red Blink9Seconds' : 'Red'; + text = 'RA'; + } + } + + this.daRaGroup.instance.style.transform = `translate3d(0px, ${-verticalOffset}px, 0px)`; + if (raFailed && DHValid && raValue <= this.dh) { + this.attDhText.instance.style.visibility = 'visible'; + } else { + this.attDhText.instance.style.visibility = 'hidden'; + } + this.radioAltText.set(text); + this.classSub.set(`${size} ${color} MiddleAlign TextOutline`); + } + }); + + this.props.filteredRadioAltitude.sub((fra) => { + this.filteredRadioAltitude = fra; + }, true); + + this.props.attExcessive.sub((ae) => { + if (ae) { + this.radioAlt.instance.style.visibility = 'hidden'; + } else { + this.radioAlt.instance.style.visibility = 'visible'; + } + }); + } + + render(): VNode { + return ( + + + DH + + {this.radioAltText} + + ); + } +} + +interface SideslipIndicatorProps { + bus: EventBus; + instrument: BaseInstrument; +} + +class SideslipIndicator extends DisplayComponent { + private sideslipIndicatorFilter = new LagFilter(0.8); + + private classNameSub = Subject.create('Yellow'); + + private rollTriangle = FSComponent.createRef(); + + private slideSlip = FSComponent.createRef(); + + private onGround = true; + + private leftMainGearCompressed = true; + + private rightMainGearCompressed = true; + + private roll = new Arinc429Word(0); + + private betaTargetActive = 0; + + private beta = 0; + + private betaTarget = 0; + + private latAcc = 0; + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('leftMainGearCompressed').whenChanged().handle((og) => { + this.leftMainGearCompressed = og; + this.onGround = this.rightMainGearCompressed || og; + this.determineSlideSlip(); + }); + + sub.on('rightMainGearCompressed').whenChanged().handle((og) => { + this.rightMainGearCompressed = og; + this.onGround = this.leftMainGearCompressed || og; + this.determineSlideSlip(); + }); + + sub.on('rollAr').withArinc429Precision(2).handle((roll) => { + this.roll = roll; + this.determineSlideSlip(); + }); + + sub.on('beta').withPrecision(2).handle((beta) => { + this.beta = beta; + this.determineSlideSlip(); + }); + + sub.on('betaTargetActive').whenChanged().handle((betaTargetActive) => { + this.betaTargetActive = betaTargetActive; + this.determineSlideSlip(); + }); + + sub.on('betaTarget').withPrecision(2).handle((betaTarget) => { + this.betaTarget = betaTarget; + this.determineSlideSlip(); + }); + + sub.on('latAcc').atFrequency(2).handle((latAcc) => { + this.latAcc = latAcc; + this.determineSlideSlip(); + }); + } + + private determineSlideSlip() { + const multiplier = 100; + const currentValueAtPrecision = Math.round(this.roll.value * multiplier) / multiplier; + const verticalOffset = calculateVerticalOffsetFromRoll(currentValueAtPrecision); + let offset = 0; + + if (this.onGround) { + // on ground, lateral g is indicated. max 0.3g, max deflection is 15mm + const latAcc = Math.round(this.latAcc * multiplier) / multiplier;// SimVar.GetSimVarValue('ACCELERATION BODY X', 'G Force'); + const accInG = Math.min(0.3, Math.max(-0.3, latAcc)); + offset = -accInG * 15 / 0.3; + } else { + const beta = this.beta; + const betaTarget = this.betaTarget; + offset = Math.max(Math.min(beta - betaTarget, 15), -15); + } + + const betaTargetActive = this.betaTargetActive === 1; + const SIIndexOffset = this.sideslipIndicatorFilter.step(offset, this.props.instrument.deltaTime / 1000); + + this.rollTriangle.instance.style.transform = `translate3d(0px, ${verticalOffset.toFixed(2)}px, 0px)`; + this.classNameSub.set(`${betaTargetActive ? 'Cyan' : 'Yellow'}`); + this.slideSlip.instance.style.transform = `translate3d(${SIIndexOffset}px, 0px, 0px)`; + } + + render(): VNode { + return ( + + + + + ); + } +} + +class RisingGround extends DisplayComponent<{ bus: EventBus, filteredRadioAltitude: Subscribable }> { + private radioAlt = new Arinc429Word(0); + + private lastPitch = new Arinc429Word(0); + + private horizonGroundRectangle = FSComponent.createRef(); + + private setOffset() { + const targetPitch = (this.radioAlt.isNoComputedData() || this.radioAlt.isFailureWarning()) ? 200 : 0.1 * this.props.filteredRadioAltitude.get(); + + const targetOffset = Math.max(Math.min(calculateHorizonOffsetFromPitch(this.lastPitch.value + targetPitch) - 31.563, 0), -63.093); + this.horizonGroundRectangle.instance.style.transform = `translate3d(0px, ${targetOffset.toFixed(2)}px, 0px)`; + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('pitchAr').handle((pitch) => { + this.lastPitch = pitch; + }); + + sub.on('chosenRa').handle((p) => { + this.radioAlt = p; + this.setOffset(); + }); + + this.props.filteredRadioAltitude.sub((_fra) => { + this.setOffset(); + }); + } + + render(): VNode { + return ( + + + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/DigitalAltitudeReadout.tsx b/fbw-a380x/src/systems/instruments/src/PFD/DigitalAltitudeReadout.tsx new file mode 100644 index 00000000000..66ec206d106 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/DigitalAltitudeReadout.tsx @@ -0,0 +1,326 @@ +import { DisplayComponent, EventBus, FSComponent, NodeReference, Subject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; + +const TensDigits = (value: number) => { + let text: string; + if (value < 0) { + text = (value + 100).toString(); + } else if (value >= 100) { + text = (value - 100).toString().padEnd(2, '0'); + } else { + text = value.toString().padEnd(2, '0'); + } + + return text; +}; + +const HundredsDigit = (value: number) => { + let text: string; + if (value < 0) { + text = (value + 1).toString(); + } else if (value >= 10) { + text = (value - 10).toString(); + } else { + text = value.toString(); + } + + return text; +}; +const ThousandsDigit = (value: number) => { + let text: string; + if (!Number.isNaN(value)) { + text = (value % 10).toString(); + } else { + text = ''; + } + + return text; +}; +const TenThousandsDigit = (value: number) => { + let text: string; + if (!Number.isNaN(value)) { + text = value.toString(); + } else { + text = ''; + } + return text; +}; + +interface DigitalAltitudeReadoutProps { + bus: EventBus; +} + +export class DigitalAltitudeReadout extends DisplayComponent { + private mda = 0; + + private isNegativeSub = Subject.create('hidden') + + private colorSub = Subject.create('') + + private showThousandsZeroSub = Subject.create(false); + + private tenDigitsSub = Subject.create(0); + + private hundredsValue = Subject.create(0); + + private hundredsPosition = Subject.create(0); + + private thousandsValue = Subject.create(0); + + private thousandsPosition = Subject.create(0); + + private tenThousandsValue = Subject.create(0); + + private tenThousandsPosition = Subject.create(0); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('mda').whenChanged().handle((mda) => { + this.mda = mda; + const color = this.mda !== 0 ? 'Amber' : 'Green'; + this.colorSub.set(color); + }); + + sub.on('altitudeAr').handle((altitude) => { + const isNegative = altitude.value < 0; + this.isNegativeSub.set(isNegative ? 'visible' : 'hidden'); + + const color = (this.mda !== 0 && altitude.value < this.mda) ? 'Amber' : 'Green'; + this.colorSub.set(color); + + const absAlt = Math.abs(Math.max(Math.min(altitude.value, 50000), -1500)); + const tensDigits = absAlt % 100; + this.tenDigitsSub.set(tensDigits); + + const HundredsValue = Math.floor((absAlt / 100) % 10); + this.hundredsValue.set(HundredsValue); + let HundredsPosition = 0; + if (tensDigits > 80) { + HundredsPosition = tensDigits / 20 - 4; + this.hundredsPosition.set(HundredsPosition); + } else { + this.hundredsPosition.set(0); + } + + const ThousandsValue = Math.floor((absAlt / 1000) % 10); + this.thousandsValue.set(ThousandsValue); + let ThousandsPosition = 0; + if (HundredsValue >= 9) { + ThousandsPosition = HundredsPosition; + this.thousandsPosition.set(ThousandsPosition); + } else { + this.thousandsPosition.set(0); + } + + const TenThousandsValue = Math.floor((absAlt / 10000) % 10); + this.tenThousandsValue.set(TenThousandsValue); + let TenThousandsPosition = 0; + if (ThousandsValue >= 9) { + TenThousandsPosition = ThousandsPosition; + } + + this.tenThousandsPosition.set(TenThousandsPosition); + const showThousandsZero = TenThousandsValue !== 0; + + this.showThousandsZeroSub.set(showThousandsZero); + }); + } + + render(): VNode { + return ( + + + + + + + + + + + + + ); + } +} + +interface DrumProperties { + type: string, + displayRange: number, + amount: number, + valueSpacing: number, + distanceSpacing: number, + position: Subscribable, + value: Subscribable, + color: Subscribable, + getText: any, + showZero?: Subscribable; +} +class Drum extends DisplayComponent { + private digitRefElements: NodeReference[] = []; + + private buildElements(amount: number) { + const highestPosition = Math.round((this.position + this.props.displayRange) / this.props.valueSpacing) * this.props.valueSpacing; + + const highestValue = Math.round((this.value + this.props.displayRange) / this.props.valueSpacing) * this.props.valueSpacing; + + const graduationElements: SVGTextElement[] = []; + + for (let i = 0; i < amount; i++) { + const elementPosition = highestPosition - i * this.props.valueSpacing; + const offset = -elementPosition * this.props.distanceSpacing / this.props.valueSpacing; + + let elementVal = highestValue - i * this.props.valueSpacing; + if (!this.showZero && elementVal === 0) { + elementVal = NaN; + } + + const digitRef = FSComponent.createRef(); + + if (this.props.type === 'hundreds') { + graduationElements.push(); + } else if (this.props.type === 'thousands') { + graduationElements.push(); + } else if (this.props.type === 'ten-thousands') { + graduationElements.push(); + } else if (this.props.type === 'tens') { + graduationElements.push(); + } + this.digitRefElements.push(digitRef); + } + + return graduationElements; + } + + private getOffset(position: number) { + const className = `translate(0 ${position * this.props.distanceSpacing / this.props.valueSpacing})`; + + this.gRef.instance.setAttribute('transform', className); + } + + private updateValue() { + let highestPosition = Math.round((this.position + this.props.displayRange) / this.props.valueSpacing) * this.props.valueSpacing; + if (highestPosition > this.position + this.props.displayRange) { + highestPosition -= this.props.valueSpacing; + } + + let highestValue = Math.round((this.value + this.props.displayRange) / this.props.valueSpacing) * this.props.valueSpacing; + if (highestValue > this.value + this.props.displayRange) { + highestValue -= this.props.valueSpacing; + } + + for (let i = 0; i < this.props.amount; i++) { + let elementVal = highestValue - i * this.props.valueSpacing; + const elementPosition = highestPosition - i * this.props.valueSpacing; + const offset = -elementPosition * this.props.distanceSpacing / this.props.valueSpacing; + if (!this.showZero && elementVal === 0) { + elementVal = NaN; + } + + const text = this.props.getText(elementVal); + + this.digitRefElements[i].instance.setAttribute('transform', `translate(0 ${offset})`); + if (this.digitRefElements[i].instance.textContent !== text + ) { + this.digitRefElements[i].instance.textContent = text; + } + this.digitRefElements[i].instance.classList.replace('Green', this.color); + this.digitRefElements[i].instance.classList.replace('Amber', this.color); + } + } + + private position = 0; + + private value = 0; + + private color = 'Green' + + private showZero = true; + + private gRef = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.props.position.sub((p) => { + this.position = p; + this.getOffset(p); + }, true); + this.props.value.sub((p) => { + this.value = p; + this.updateValue(); + }, true); + this.props.color.sub((p) => { + this.color = p; + this.updateValue(); + }); + this.props.showZero?.sub((p) => { + this.showZero = p; + this.updateValue(); + }, true); + } + + render(): VNode { + return ( + + {this.buildElements(this.props.amount)} + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/FMA.tsx b/fbw-a380x/src/systems/instruments/src/PFD/FMA.tsx new file mode 100644 index 00000000000..af86f7a8d27 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/FMA.tsx @@ -0,0 +1,1587 @@ +import { ComponentProps, DisplayComponent, EventBus, FSComponent, Subject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { ArmedLateralMode, ArmedVerticalMode, isArmed, LateralMode, VerticalMode } from '@shared/autopilot'; + +import { Arinc429Values } from './shared/ArincValueProvider'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; + +abstract class ShowForSecondsComponent extends DisplayComponent { + private timeout: number = 0; + + private displayTimeInSeconds; + + protected modeChangedPathRef = FSComponent.createRef(); + + protected isShown = false; + + protected constructor(props: T, displayTimeInSeconds: number) { + super(props); + this.displayTimeInSeconds = displayTimeInSeconds; + } + + public displayModeChangedPath = (cancel = false) => { + if (cancel || !this.isShown) { + clearTimeout(this.timeout); + this.modeChangedPathRef.instance.classList.remove('ModeChangedPath'); + } else { + this.modeChangedPathRef.instance.classList.add('ModeChangedPath'); + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + this.modeChangedPathRef.instance.classList.remove('ModeChangedPath'); + }, this.displayTimeInSeconds * 1000) as unknown as number; + } + } +} + +export class FMA extends DisplayComponent<{ bus: EventBus, isAttExcessive: Subscribable }> { + private activeLateralMode: number = 0; + + private activeVerticalMode: number = 0; + + private armedVerticalModeSub = Subject.create(0); + + private athrModeMessage = 0; + + private machPreselVal = 0; + + private speedPreselVal = 0; + + private setHoldSpeed = false; + + private tcasRaInhibited = Subject.create(false); + + private trkFpaDeselected = Subject.create(false); + + private firstBorderRef = FSComponent.createRef(); + + private secondBorderRef = FSComponent.createRef(); + + private AB3Message = Subject.create(false); + + private handleFMABorders() { + const sharedModeActive = this.activeLateralMode === 32 || this.activeLateralMode === 33 + || this.activeLateralMode === 34 || (this.activeLateralMode === 20 && this.activeVerticalMode === 24); + const BC3Message = getBC3Message(this.props.isAttExcessive.get(), this.armedVerticalModeSub.get(), + this.setHoldSpeed, this.trkFpaDeselected.get(), this.tcasRaInhibited.get())[0] !== null; + + const engineMessage = this.athrModeMessage; + const AB3Message = (this.machPreselVal !== -1 + || this.speedPreselVal !== -1) && !BC3Message && engineMessage === 0; + + let secondBorder: string; + if (sharedModeActive && !this.props.isAttExcessive.get()) { + secondBorder = ''; + } else if (BC3Message) { + secondBorder = 'm66.241 0.33732v15.766'; + } else { + secondBorder = 'm66.241 0.33732v20.864'; + } + + let firstBorder: string; + if (AB3Message && !this.props.isAttExcessive.get()) { + firstBorder = 'm33.117 0.33732v15.766'; + } else { + firstBorder = 'm33.117 0.33732v20.864'; + } + + this.AB3Message.set(AB3Message); + this.firstBorderRef.instance.setAttribute('d', firstBorder); + this.secondBorderRef.instance.setAttribute('d', secondBorder); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + this.props.isAttExcessive.sub((_a) => { + this.handleFMABorders(); + }); + + sub.on('fmaVerticalArmed').whenChanged().handle((a) => { + this.armedVerticalModeSub.set(a); + this.handleFMABorders(); + }); + + sub.on('activeLateralMode').whenChanged().handle((activeLateralMode) => { + this.activeLateralMode = activeLateralMode; + this.handleFMABorders(); + }); + sub.on('activeVerticalMode').whenChanged().handle((activeVerticalMode) => { + this.activeVerticalMode = activeVerticalMode; + this.handleFMABorders(); + }); + + sub.on('speedPreselVal').whenChanged().handle((s) => { + this.speedPreselVal = s; + this.handleFMABorders(); + }); + + sub.on('machPreselVal').whenChanged().handle((m) => { + this.machPreselVal = m; + this.handleFMABorders(); + }); + + sub.on('setHoldSpeed').whenChanged().handle((shs) => { + this.setHoldSpeed = shs; + this.handleFMABorders(); + }); + + sub.on('tcasRaInhibited').whenChanged().handle((tra) => { + this.tcasRaInhibited.set(tra); + this.handleFMABorders(); + }); + + sub.on('trkFpaDeselectedTCAS').whenChanged().handle((trk) => { + this.trkFpaDeselected.set(trk); + this.handleFMABorders(); + }); + } + + render(): VNode { + return ( + + + + + + + + + + + + + ); + } +} + +class Row1 extends DisplayComponent<{bus:EventBus, isAttExcessive: Subscribable}> { + private b1Cell = FSComponent.createRef(); + + private c1Cell = FSComponent.createRef(); + + private D1D2Cell = FSComponent.createRef(); + + private BC1Cell = FSComponent.createRef(); + + private cellsToHide = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.props.isAttExcessive.sub((a) => { + if (a) { + this.cellsToHide.instance.style.display = 'none'; + this.b1Cell.instance.displayModeChangedPath(true); + this.c1Cell.instance.displayModeChangedPath(true); + this.BC1Cell.instance.displayModeChangedPath(true); + } else { + this.cellsToHide.instance.style.display = 'inline'; + this.b1Cell.instance.displayModeChangedPath(); + this.c1Cell.instance.displayModeChangedPath(); + this.BC1Cell.instance.displayModeChangedPath(); + } + }); + } + + render(): VNode { + return ( + + + + + + + + + + + + ); + } +} + +class Row2 extends DisplayComponent<{bus:EventBus, isAttExcessive: Subscribable}> { + private cellsToHide = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.props.isAttExcessive.sub((a) => { + if (a) { + this.cellsToHide.instance.style.display = 'none'; + } else { + this.cellsToHide.instance.style.display = 'inline'; + } + }); + } + + render(): VNode { + return ( + + + + + + + + + ); + } +} + +class A2Cell extends DisplayComponent<{ bus:EventBus }> { + private text = Subject.create(''); + + private className = Subject.create('FontMedium MiddleAlign Cyan'); + + private autoBrkRef = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('autoBrakeMode').whenChanged().handle((am) => { + switch (am) { + case 0: + this.text.set(''); + break; + case 1: + this.text.set('BRK LO '); + break; + case 2: + this.text.set('BRK MED '); + break; + case 3: + // MAX will be shown in 3rd row + this.text.set(''); + break; + default: + break; + } + }); + + sub.on('autoBrakeActive').whenChanged().handle((am) => { + if (am) { + this.autoBrkRef.instance.style.visibility = 'hidden'; + } else { + this.autoBrkRef.instance.style.visibility = 'visible'; + } + }); + + sub.on('AThrMode').whenChanged().handle((athrMode) => { + // ATHR mode overrides BRK LO and MED memo + if (athrMode > 0 && athrMode <= 6) { + this.autoBrkRef.instance.style.visibility = 'hidden'; + } else { + this.autoBrkRef.instance.style.visibility = 'visible'; + } + }); + } + + render(): VNode { + return ( + {this.text} + ); + } +} + +class Row3 extends DisplayComponent<{ bus:EventBus, isAttExcessive: Subscribable, AB3Message: Subscribable }> { + private cellsToHide = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.props.isAttExcessive.sub((a) => { + if (a) { + this.cellsToHide.instance.style.display = 'none'; + } else { + this.cellsToHide.instance.style.display = 'inline'; + } + }); + } + + render(): VNode { + return ( + + + + + + + + + + ); + } +} + +interface CellProps extends ComponentProps { + bus: EventBus; +} + +class A1A2Cell extends ShowForSecondsComponent { + private athrMode = 0; + + private cellRef = FSComponent.createRef(); + + private flexTemp = 0; + + private autoBrakeActive = false; + + private autoBrakeMode = 0; + + constructor(props) { + super(props, 9); + } + + private setText() { + let text: string = ''; + this.isShown = true; + + switch (this.athrMode) { + case 1: + this.displayModeChangedPath(true); + text = ` + + MAN + TOGA + `; + break; + case 2: + this.displayModeChangedPath(true); + text = ` + + MAN + GA SOFT + `; + break; + case 3: + this.displayModeChangedPath(true); + const FlexTemp = Math.round(this.flexTemp); + const FlexText = FlexTemp >= 0 ? (`+${FlexTemp}`) : FlexTemp.toString(); + text = ` + + MAN + + FLX + ${FlexText} + + `; + + break; + case 4: + this.displayModeChangedPath(true); + text = ` + + MAN + DTO + `; + break; + case 5: + this.displayModeChangedPath(true); + text = ` + + MAN + MCT + `; + break; + case 6: + this.displayModeChangedPath(true); + text = ` + + MAN + THR + `; + break; + case 7: + text = 'SPEED'; + this.displayModeChangedPath(); + break; + case 8: + text = 'MACH'; + this.displayModeChangedPath(); + break; + case 9: + text = 'THR MCT'; + this.displayModeChangedPath(); + break; + case 10: + text = 'THR CLB'; + this.displayModeChangedPath(); + break; + case 11: + text = 'THR LVR'; + this.displayModeChangedPath(); + break; + case 12: + text = 'THR IDLE'; + this.displayModeChangedPath(); + break; + case 13: + this.displayModeChangedPath(true); + text = ` + + A.FLOOR + `; + break; + case 14: + this.displayModeChangedPath(true); + text = ` + + TOGA LK + `; + break; + default: + if (this.autoBrakeActive) { + switch (this.autoBrakeMode) { + case 1: + text = 'BRK LO'; + this.displayModeChangedPath(); + break; + case 2: + text = 'BRK MED'; + this.displayModeChangedPath(); + break; + case 3: + text = 'BRK MAX'; + this.displayModeChangedPath(); + break; + default: + text = ''; + this.isShown = false; + this.displayModeChangedPath(true); + } + } else { + text = ''; + this.isShown = false; + this.displayModeChangedPath(true); + } + } + + this.cellRef.instance.innerHTML = text; + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('flexTemp').whenChanged().handle((f) => { + this.flexTemp = f; + this.setText(); + }); + + sub.on('AThrMode').whenChanged().handle((athrMode) => { + this.athrMode = athrMode; + this.setText(); + }); + + sub.on('autoBrakeActive').whenChanged().handle((am) => { + this.autoBrakeActive = am; + this.setText(); + }); + + sub.on('autoBrakeMode').whenChanged().handle((a) => { + this.autoBrakeMode = a; + }); + } + + render(): VNode { + return ( + <> + + + + ); + } +} + +interface A3CellProps extends CellProps { + AB3Message: Subscribable; +} + +class A3Cell extends DisplayComponent { + private classSub = Subject.create(''); + + private textSub = Subject.create(''); + + private autobrakeMode = 0; + + private AB3Message = false; + + private onUpdateAthrModeMessage(message: number) { + let text: string = ''; + let className: string = ''; + switch (message) { + case 1: + text = 'THR LK'; + className = 'Amber BlinkInfinite'; + break; + case 2: + text = 'LVR TOGA'; + className = 'White BlinkInfinite'; + break; + case 3: + text = 'LVR CLB'; + className = 'White BlinkInfinite'; + break; + case 4: + text = 'LVR MCT'; + className = 'White BlinkInfinite'; + break; + case 5: + text = 'LVR ASYM'; + className = 'Amber'; + break; + default: + text = ''; + } + + this.textSub.set(text); + this.classSub.set(`FontMedium MiddleAlign ${className}`); + } + + private handleAutobrakeMode() { + if (this.autobrakeMode === 3 && !this.AB3Message) { + this.textSub.set('BRK MAX'); + this.classSub.set('FontMedium MiddleAlign Cyan'); + } else { + this.textSub.set(''); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('athrModeMessage').whenChanged().handle((m) => { + this.onUpdateAthrModeMessage(m); + }); + + sub.on('autoBrakeMode').whenChanged().handle((am) => { + this.autobrakeMode = am; + this.handleAutobrakeMode(); + }); + + this.props.AB3Message.sub((ab3) => { + this.AB3Message = ab3; + this.handleAutobrakeMode(); + }); + + sub.on('autoBrakeActive').whenChanged().handle((a) => { + if (a) { + this.classSub.set('HiddenElement'); + } + }); + } + + render(): VNode { + return ( + {this.textSub} + ); + } +} + +class AB3Cell extends DisplayComponent { + private speedPreselVal = -1; + + private machPreselVal = -1; + + private athrModeMessage = 0; + + private textSub = Subject.create(''); + + private getText() { + if (this.athrModeMessage === 0) { + if (this.speedPreselVal !== -1 && this.machPreselVal === -1) { + const text = Math.round(this.speedPreselVal); + this.textSub.set(`SPEED SEL ${text}`); + } else if (this.machPreselVal !== -1 && this.speedPreselVal === -1) { + this.textSub.set(`MACH SEL ${this.machPreselVal.toFixed(2)}`); + } else if (this.machPreselVal === -1 && this.speedPreselVal === -1) { + this.textSub.set(''); + } + } else { + this.textSub.set(''); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('speedPreselVal').whenChanged().handle((m) => { + this.speedPreselVal = m; + this.getText(); + }); + + sub.on('machPreselVal').whenChanged().handle((m) => { + this.machPreselVal = m; + this.getText(); + }); + + sub.on('athrModeMessage').whenChanged().handle((m) => { + this.athrModeMessage = m; + this.getText(); + }); + } + + render(): VNode { + return ( + {this.textSub} + ); + } +} + +class B1Cell extends ShowForSecondsComponent { + private boxClassSub = Subject.create(''); + + private boxPathStringSub = Subject.create(''); + + private activeVerticalModeSub = Subject.create(0); + + private speedProtectionPathRef = FSComponent.createRef(); + + private inModeReversionPathRef = FSComponent.createRef(); + + private fmaTextRef = FSComponent.createRef(); + + private selectedVS = 0; + + private inSpeedProtection = false; + + private fmaModeReversion = false; + + private expediteMode = false; + + private crzAltMode = false; + + private tcasModeDisarmed = false; + + private FPA = 0; + + constructor(props: CellProps) { + super(props, 10); + } + + private getText(): boolean { + let text: string; + let additionalText: string = ''; + + this.isShown = true; + switch (this.activeVerticalModeSub.get()) { + case VerticalMode.GS_TRACK: + text = 'G/S'; + break; + /* case 2: + text = 'F-G/S'; + break; */ + case VerticalMode.GS_CPT: + text = 'G/S*'; + break; + /* case 4: + text = 'F-G/S*'; + break; */ + case VerticalMode.SRS: + case VerticalMode.SRS_GA: + text = 'SRS'; + break; + case VerticalMode.TCAS: + text = 'TCAS'; + break; + /* case 9: + text = 'FINAL'; + break; */ + case VerticalMode.DES: + text = 'DES'; + break; + case VerticalMode.OP_DES: + if (this.expediteMode) { + text = 'EXP DES'; + } else { + text = 'OP DES'; + } + break; + case VerticalMode.CLB: + text = 'CLB'; + break; + case VerticalMode.OP_CLB: + if (this.expediteMode) { + text = 'EXP CLB'; + } else { + text = 'OP CLB'; + } + break; + case VerticalMode.ALT: + if (this.crzAltMode) { + text = 'ALT CRZ'; + } else { + text = 'ALT'; + } + break; + case VerticalMode.ALT_CPT: + text = 'ALT*'; + break; + case VerticalMode.ALT_CST_CPT: + text = 'ALT CST*'; + break; + case VerticalMode.ALT_CST: + text = 'ALT CST'; + break; + /* case 18: + text = 'ALT CRZ'; + break; */ + case VerticalMode.FPA: { + const FPAText = `${(this.FPA >= 0 ? '+' : '')}${(Math.round(this.FPA * 10) / 10).toFixed(1)}°`; + + text = 'FPA'; + additionalText = FPAText; + break; + } + case VerticalMode.VS: { + const VSText = `${(this.selectedVS >= 0 ? '+' : '')}${Math.round(this.selectedVS).toString()}`.padStart(5, ' '); + + text = 'V/S'; + + additionalText = VSText; + break; + } + default: + text = ''; + this.isShown = false; + this.displayModeChangedPath(true); + } + + const inSpeedProtection = this.inSpeedProtection && (this.activeVerticalModeSub.get() === 14 || this.activeVerticalModeSub.get() === 15); + + if (inSpeedProtection || this.fmaModeReversion) { + this.boxClassSub.set('NormalStroke None'); + } else { + this.boxClassSub.set('NormalStroke White'); + } + + if (inSpeedProtection) { + this.speedProtectionPathRef.instance.setAttribute('visibility', 'visible'); + } else { + this.speedProtectionPathRef.instance.setAttribute('visibility', 'hidden'); + } + + const boxPathString = this.activeVerticalModeSub.get() === 50 && this.tcasModeDisarmed ? 'm34.656 1.8143h29.918v13.506h-29.918z' : 'm34.656 1.8143h29.918v6.0476h-29.918z'; + + this.boxPathStringSub.set(boxPathString); + + this.fmaTextRef.instance.innerHTML = `${text}${additionalText}`; + + return text.length > 0; + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('activeVerticalMode').whenChanged().handle((activeVerticalMode) => { + this.activeVerticalModeSub.set(activeVerticalMode); + this.getText(); + this.displayModeChangedPath(); + }); + + sub.on('selectedFpa').whenChanged().handle((fpa) => { + this.FPA = fpa; + this.getText(); + }); + + sub.on('apVsSelected').whenChanged().handle((svs) => { + this.selectedVS = svs; + this.getText(); + }); + + sub.on('fmaModeReversion').whenChanged().handle((reversion) => { + this.fmaModeReversion = reversion; + if (reversion) { + this.inModeReversionPathRef.instance.setAttribute('visibility', 'visible'); + } else { + this.inModeReversionPathRef.instance.setAttribute('visibility', 'hidden'); + } + this.getText(); + }); + + sub.on('fmaSpeedProtection').whenChanged().handle((protection) => { + this.inSpeedProtection = protection; + this.getText(); + }); + + sub.on('expediteMode').whenChanged().handle((e) => { + this.expediteMode = e; + this.getText(); + }); + + sub.on('crzAltMode').whenChanged().handle((c) => { + this.crzAltMode = c; + this.getText(); + }); + + sub.on('tcasModeDisarmed').whenChanged().handle((t) => { + this.tcasModeDisarmed = t; + this.getText(); + }); + } + + render(): VNode { + return ( + + + + + + + + + + {/* set directly via innerhtml as tspan was invisble for some reason when set here */} + + + + ); + } +} + +class B2Cell extends DisplayComponent { + private text1Sub = Subject.create(''); + + private text2Sub = Subject.create(''); + + private classSub = Subject.create(''); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('fmaVerticalArmed').whenChanged().handle((fmv) => { + const altArmed = (fmv >> 0) & 1; + const altCstArmed = (fmv >> 1) & 1; + const clbArmed = (fmv >> 2) & 1; + const desArmed = (fmv >> 3) & 1; + const gsArmed = (fmv >> 4) & 1; + const finalArmed = (fmv >> 5) & 1; + + let text1: string; + let color1 = 'Cyan'; + if (clbArmed) { + text1 = 'CLB'; + } else if (desArmed) { + text1 = 'DES'; + } else if (altCstArmed) { + text1 = 'ALT'; + color1 = 'Magenta'; + } else if (altArmed) { + text1 = 'ALT'; + } else { + text1 = ''; + } + + let text2; + if (gsArmed) { + text2 = 'G/S'; + } else if (finalArmed) { + text2 = 'FINAL'; + } else { + text2 = ''; + } + + this.text1Sub.set(text1); + this.text2Sub.set(text2); + this.classSub.set(`FontMedium MiddleAlign ${color1}`); + }); + } + + render(): VNode { + return ( + + {this.text1Sub} + {this.text2Sub} + + ); + } +} + +class C1Cell extends ShowForSecondsComponent { + private textSub = Subject.create(''); + + private activeLateralMode = 0; + + private activeVerticalMode = 0; + + private armedVerticalMode = 0; + + constructor(props: CellProps) { + super(props, 10); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('activeLateralMode').whenChanged().handle((lm) => { + this.activeLateralMode = lm; + + const isShown = this.updateText(); + + if (isShown) { + this.displayModeChangedPath(); + } else { + this.displayModeChangedPath(true); + } + }); + + sub.on('activeVerticalMode').whenChanged().handle((lm) => { + this.activeVerticalMode = lm; + + const isShown = this.updateText(); + + if (isShown) { + this.displayModeChangedPath(); + } else { + this.displayModeChangedPath(true); + } + }); + + sub.on('fmaVerticalArmed').whenChanged().handle((va) => { + this.armedVerticalMode = va; + + const hasChanged = this.updateText(); + + if (hasChanged) { + this.displayModeChangedPath(); + } else { + this.displayModeChangedPath(true); + } + }); + } + + private updateText(): boolean { + const finalArmed = (this.armedVerticalMode >> 5) & 1; + + let text: string; + this.isShown = true; + if (this.activeLateralMode === LateralMode.GA_TRACK) { + text = 'GA TRK'; + } else if (this.activeLateralMode === LateralMode.LOC_CPT) { + text = 'LOC *'; + } else if (this.activeLateralMode === LateralMode.HDG) { + text = 'HDG'; + } else if (this.activeLateralMode === LateralMode.RWY) { + text = 'RWY'; + } else if (this.activeLateralMode === LateralMode.RWY_TRACK) { + text = 'RWY TRK'; + } else if (this.activeLateralMode === LateralMode.TRACK) { + text = 'TRACK'; + } else if (this.activeLateralMode === LateralMode.LOC_TRACK) { + text = 'LOC'; + } else if (this.activeLateralMode === LateralMode.NAV && !finalArmed && this.activeVerticalMode !== VerticalMode.FINAL) { + text = 'NAV'; + } else if (this.activeLateralMode === LateralMode.NAV && finalArmed && this.activeVerticalMode !== VerticalMode.FINAL) { + text = 'APP NAV'; + } else { + text = ''; + this.isShown = false; + } + + const hasChanged = text.length > 0 && text !== this.textSub.get(); + + if (hasChanged || text.length === 0) { + this.textSub.set(text); + } + return hasChanged; + } + + render(): VNode { + // case 2: + // text = 'LOC B/C*'; + // id = 2; + // break; + // case 4: + // text = 'F-LOC*'; + // id = 4; + // break; + // case 9: + // text = 'LOC B/C'; + // id = 9; + // break; + // case 11: + // text = 'F-LOC'; + // id = 11; + // break; + // case 12: + // text = 'APP NAV'; + // id = 12; + // break; + + return ( + + + {this.textSub} + + ); + } +} + +class C2Cell extends DisplayComponent { + private fmaLateralArmed: number = 0; + + private fmaVerticalArmed: number = 0; + + private activeVerticalMode: number = 0; + + private textSub = Subject.create(''); + + private getText() { + const navArmed = isArmed(this.fmaLateralArmed, ArmedLateralMode.NAV); + const locArmed = isArmed(this.fmaLateralArmed, ArmedLateralMode.LOC); + + const finalArmed = isArmed(this.fmaVerticalArmed, ArmedVerticalMode.FINAL); + + let text: string = ''; + if (locArmed) { + // case 1: + // text = 'LOC B/C'; + // break; + text = 'LOC'; + // case 3: + // text = 'F-LOC'; + // break; + } else if (navArmed && (finalArmed || this.activeVerticalMode === VerticalMode.FINAL)) { + text = 'APP NAV'; + } else if (navArmed) { + text = 'NAV'; + } + this.textSub.set(text); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('fmaLateralArmed').whenChanged().handle((fla) => { + this.fmaLateralArmed = fla; + this.getText(); + }); + + sub.on('fmaVerticalArmed').whenChanged().handle((fva) => { + this.fmaVerticalArmed = fva; + this.getText(); + }); + + sub.on('activeVerticalMode').whenChanged().handle((avm) => { + this.activeVerticalMode = avm; + this.getText(); + }); + } + + render(): VNode { + return ( + {this.textSub} + ); + } +} + +class BC1Cell extends ShowForSecondsComponent { + private lastLateralMode = 0; + + private lastVerticalMode = 0; + + private textSub = Subject.create(''); + + constructor(props: CellProps) { + super(props, 9); + } + + private setText() { + let text: string; + this.isShown = true; + if (this.lastVerticalMode === VerticalMode.ROLL_OUT) { + text = 'ROLL OUT'; + } else if (this.lastVerticalMode === VerticalMode.FLARE) { + text = 'FLARE'; + } else if (this.lastVerticalMode === VerticalMode.LAND) { + text = 'LAND'; + } else if (this.lastVerticalMode === VerticalMode.FINAL && this.lastLateralMode === LateralMode.NAV) { + text = 'FINAL APP'; + } else { + text = ''; + } + if (text !== '') { + this.displayModeChangedPath(); + } else { + this.isShown = false; + this.displayModeChangedPath(true); + } + this.textSub.set(text); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('activeVerticalMode').whenChanged().handle((v) => { + this.lastVerticalMode = v; + this.setText(); + }); + + sub.on('activeLateralMode').whenChanged().handle((l) => { + this.lastLateralMode = l; + this.setText(); + }); + } + + render(): VNode { + return ( + + + {this.textSub} + + ); + } +} + +const getBC3Message = (isAttExcessive: boolean, armedVerticalMode: number, setHoldSpeed: boolean, trkFpaDeselectedTCAS: boolean, tcasRaInhibited: boolean) => { + const armedVerticalBitmask = armedVerticalMode; + const TCASArmed = (armedVerticalBitmask >> 6) & 1; + + let text: string; + let className: string; + // All currently unused message are set to false + if (false) { + text = 'MAN PITCH TRIM ONLY'; + className = 'Red Blink9Seconds'; + } else if (false) { + text = 'USE MAN PITCH TRIM'; + className = 'PulseAmber9Seconds Amber'; + } else if (false) { + text = 'FOR GA: SET TOGA'; + className = 'PulseAmber9Seconds Amber'; + } else if (TCASArmed && !isAttExcessive) { + text = ' TCAS '; + className = 'Cyan'; + } else if (false) { + text = 'DISCONNECT AP FOR LDG'; + className = 'PulseAmber9Seconds Amber'; + } else if (tcasRaInhibited && !isAttExcessive) { + text = 'TCAS RA INHIBITED'; + className = 'White'; + } else if (trkFpaDeselectedTCAS && !isAttExcessive) { + text = 'TRK FPA DESELECTED'; + className = 'White'; + } else if (false) { + text = 'SET GREEN DOT SPEED'; + className = 'White'; + } else if (false) { + text = 'T/D REACHED'; + className = 'White'; + } else if (false) { + text = 'MORE DRAG'; + className = 'White'; + } else if (false) { + text = 'CHECK SPEED MODE'; + className = 'White'; + } else if (false) { + text = 'CHECK APPR SELECTION'; + className = 'White'; + } else if (false) { + text = 'TURN AREA EXCEEDANCE'; + className = 'White'; + } else if (setHoldSpeed) { + text = 'SET HOLD SPEED'; + className = 'White'; + } else if (false) { + text = 'VERT DISCONT AHEAD'; + className = 'Amber'; + } else if (false) { + text = 'FINAL APP SELECTED'; + className = 'White'; + } else { + return [null, null]; + } + + return [text, className]; +}; + +class BC3Cell extends DisplayComponent<{ isAttExcessive: Subscribable, bus: EventBus, }> { + private bc3Cell = FSComponent.createRef(); + + private classNameSub = Subject.create(''); + + private isAttExcessive = false; + + private armedVerticalMode = 0; + + private setHoldSpeed = false; + + private tcasRaInhibited = false; + + private trkFpaDeselected = false; + + private fillBC3Cell() { + const [text, className] = getBC3Message(this.isAttExcessive, this.armedVerticalMode, this.setHoldSpeed, this.trkFpaDeselected, this.tcasRaInhibited); + this.classNameSub.set(`FontMedium MiddleAlign ${className}`); + if (text !== null) { + this.bc3Cell.instance.innerHTML = text; + } else { + this.bc3Cell.instance.innerHTML = ''; + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + this.props.isAttExcessive.sub((e) => { + this.isAttExcessive = e; + this.fillBC3Cell(); + }); + + sub.on('fmaVerticalArmed').whenChanged().handle((v) => { + this.armedVerticalMode = v; + this.fillBC3Cell(); + }); + + sub.on('setHoldSpeed').whenChanged().handle((shs) => { + this.setHoldSpeed = shs; + this.fillBC3Cell(); + }); + + sub.on('tcasRaInhibited').whenChanged().handle((tra) => { + this.tcasRaInhibited = tra; + this.fillBC3Cell(); + }); + + sub.on('trkFpaDeselectedTCAS').whenChanged().handle((trk) => { + this.trkFpaDeselected = trk; + this.fillBC3Cell(); + }); + } + + render(): VNode { + return ( + + ); + } +} + +class D1D2Cell extends ShowForSecondsComponent { + private text1Sub = Subject.create(''); + + private text2Sub = Subject.create(''); + + constructor(props: CellProps) { + super(props, 9); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('approachCapability').whenChanged().handle((c) => { + let text1: string; + let text2: string | undefined; + + this.isShown = true; + switch (c) { + case 1: + text1 = 'CAT1'; + break; + case 2: + text1 = 'CAT2'; + break; + case 3: + text1 = 'CAT3'; + text2 = 'SINGLE'; + break; + case 4: + text1 = 'CAT3'; + text2 = 'DUAL'; + break; + case 5: + text1 = 'AUTO'; + text2 = 'LAND'; + break; + case 6: + text1 = 'F-APP'; + break; + case 7: + text1 = 'F-APP'; + text2 = '+ RAW'; + break; + case 8: + text1 = 'RAW'; + text2 = 'ONLY'; + break; + default: + text1 = ''; + } + + this.text1Sub.set(text1); + + if (text2) { + this.text2Sub.set(text2); + this.modeChangedPathRef.instance.setAttribute('d', 'm104.1 1.8143h27.994v13.506h-27.994z'); + } else { + this.text2Sub.set(''); + this.modeChangedPathRef.instance.setAttribute('d', 'm104.1 1.8143h27.994v6.0476h-27.994z'); + } + if (text1.length === 0 && !text2) { + this.isShown = false; + } + this.displayModeChangedPath(); + }); + } + + render(): VNode { + return ( + + {this.text1Sub} + {this.text2Sub} + + + ); + } +} + +class D3Cell extends DisplayComponent<{bus: EventBus}> { + private textRef = FSComponent.createRef(); + + private classNameSub = Subject.create(''); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('mda').whenChanged().handle((mda) => { + if (mda !== 0) { + const MDAText = Math.round(mda).toString().padStart(6, ' '); + + this.textRef.instance.innerHTML = `BARO${MDAText}`; + } else { + this.textRef.instance.innerHTML = ''; + } + }); + + sub.on('dh').whenChanged().handle((dh) => { + let fontSize = 'FontSmallest'; + + if (dh !== -1 && dh !== -2) { + const DHText = Math.round(dh).toString().padStart(4, ' '); + + this.textRef.instance.innerHTML = ` + RADIO${DHText} + `; + } else if (dh === -2) { + this.textRef.instance.innerHTML = 'NO DH'; + fontSize = 'FontMedium'; + } else { + this.textRef.instance.innerHTML = ''; + } + this.classNameSub.set(`${fontSize} MiddleAlign White`); + }); + } + + render(): VNode { + return ( + + ); + } +} + +class E1Cell extends ShowForSecondsComponent { + private ap1Active = false; + + private ap2Active = false; + + private textSub = Subject.create(''); + + constructor(props: CellProps) { + super(props, 9); + } + + private setText() { + let text: string; + this.isShown = true; + if (this.ap1Active && !this.ap2Active) { + text = 'AP1'; + } else if (this.ap2Active && !this.ap1Active) { + text = 'AP2'; + } else if (!this.ap2Active && !this.ap1Active) { + text = ''; + this.isShown = false; + } else { + text = 'AP1+2'; + } + this.displayModeChangedPath(); + this.textSub.set(text); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('ap1Active').whenChanged().handle((ap) => { + this.ap1Active = ap; + this.displayModeChangedPath(); + this.setText(); + }); + + sub.on('ap2Active').whenChanged().handle((ap) => { + this.ap2Active = ap; + this.displayModeChangedPath(); + this.setText(); + }); + } + + render(): VNode { + return ( + + + {this.textSub} + + ); + } +} + +class E2Cell extends ShowForSecondsComponent { + private fd1Active = false; + + private fd2Active = false; + + private ap1Active = false; + + private ap2Active = false; + + private textSub = Subject.create(''); + + constructor(props: CellProps) { + super(props, 9); + } + + private getText() { + this.isShown = true; + if (!this.ap1Active && !this.ap2Active && !this.fd1Active && !this.fd2Active) { + this.isShown = false; + this.textSub.set(''); + } else { + const text = `${this.fd1Active ? '1' : '-'} FD ${this.fd2Active ? '2' : '-'}`; + this.textSub.set(text); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('fd1Active').whenChanged().handle((fd) => { + this.fd1Active = fd; + if (fd || this.fd2Active) { + this.displayModeChangedPath(); + } else { + this.displayModeChangedPath(true); + } + this.getText(); + }); + + sub.on('ap1Active').whenChanged().handle((fd) => { + this.ap1Active = fd; + this.getText(); + }); + + sub.on('ap2Active').whenChanged().handle((fd) => { + this.ap2Active = fd; + this.getText(); + }); + + sub.on('fd2Active').whenChanged().handle((fd) => { + this.fd2Active = fd; + if (fd || this.fd1Active) { + this.displayModeChangedPath(); + } else { + this.displayModeChangedPath(true); + } + this.getText(); + }); + } + + render(): VNode { + return ( + + + {this.textSub} + + + ); + } +} + +class E3Cell extends ShowForSecondsComponent { + private classSub = Subject.create(''); + + constructor(props: CellProps) { + super(props, 9); + } + + private getClass(athrStatus: number): string { + let className: string = ''; + this.isShown = true; + switch (athrStatus) { + case 1: + className = 'Cyan'; + break; + case 2: + className = 'White'; + break; + default: + this.isShown = false; + className = 'HiddenElement'; + } + return className; + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('athrStatus').whenChanged().handle((a) => { + const className = this.getClass(a); + this.classSub.set(`FontMedium MiddleAlign ${className}`); + if (className !== 'HiddenElement') { + this.displayModeChangedPath(); + } else { + this.displayModeChangedPath(true); + } + }); + } + + render(): VNode { + return ( + + + A/THR + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/FlightPathDirector.tsx b/fbw-a380x/src/systems/instruments/src/PFD/FlightPathDirector.tsx new file mode 100644 index 00000000000..287e9e12bea --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/FlightPathDirector.tsx @@ -0,0 +1,183 @@ +import { ClockEvents, DisplayComponent, EventBus, FSComponent, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@shared/arinc429'; +import { getDisplayIndex } from './PFD'; +import { calculateHorizonOffsetFromPitch } from './PFDUtils'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; + +const DistanceSpacing = 15; +const ValueSpacing = 10; + +interface FlightPathVectorData { + roll: Arinc429Word; + pitch: Arinc429Word; + fpa: Arinc429Word; + da: Arinc429Word; + activeVerticalMode: number; + activeLateralMode: number; + fdRoll: number; + fdPitch: number; + fdActive: boolean; +} + +export class FlightPathDirector extends DisplayComponent<{bus: EventBus, isAttExcessive: Subscribable}> { + private data: FlightPathVectorData = { + roll: new Arinc429Word(0), + pitch: new Arinc429Word(0), + fpa: new Arinc429Word(0), + da: new Arinc429Word(0), + fdPitch: 0, + fdRoll: 0, + fdActive: true, + activeLateralMode: 0, + activeVerticalMode: 0, + } + + private isTrkFpaActive = false; + + private needsUpdate = false; + + private isVisible = false; + + private birdPath = FSComponent.createRef(); + + private birdPathWings = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('fd1Active').whenChanged().handle((fd) => { + if (getDisplayIndex() === 1) { + this.data.fdActive = fd; + this.needsUpdate = true; + } + }); + + sub.on('fd2Active').whenChanged().handle((fd) => { + if (getDisplayIndex() === 2) { + this.data.fdActive = fd; + this.needsUpdate = true; + } + }); + sub.on('trkFpaActive').whenChanged().handle((a) => { + this.isTrkFpaActive = a; + this.needsUpdate = true; + }); + + sub.on('fpa').handle((fpa) => { + this.data.fpa = fpa; + this.needsUpdate = true; + }); + + sub.on('da').handle((da) => { + this.data.da = da; + this.needsUpdate = true; + }); + + sub.on('activeVerticalMode').whenChanged().handle((vm) => { + this.data.activeLateralMode = vm; + this.needsUpdate = true; + }); + + sub.on('activeLateralMode').whenChanged().handle((lm) => { + this.data.activeLateralMode = lm; + this.needsUpdate = true; + }); + + sub.on('fdPitch').handle((fdp) => { + this.data.fdPitch = fdp; + this.needsUpdate = true; + }); + + sub.on('fdBank').handle((fdr) => { + this.data.fdRoll = fdr; + this.needsUpdate = true; + }); + + sub.on('rollAr').handle((r) => { + this.data.roll = r; + this.needsUpdate = true; + }); + + sub.on('pitchAr').handle((p) => { + this.data.pitch = p; + this.needsUpdate = true; + }); + + sub.on('realTime').handle((_t) => { + this.handlePath(); + if (this.needsUpdate && this.isVisible) { + this.moveBird(); + } + }); + + this.props.isAttExcessive.sub((_a) => { + this.needsUpdate = true; + }, true); + } + + private handlePath() { + const showLateralFD = this.data.activeLateralMode !== 0 && this.data.activeLateralMode !== 34 && this.data.activeLateralMode !== 40; + const showVerticalFD = this.data.activeVerticalMode !== 0 && this.data.activeVerticalMode !== 34; + const daAndFpaValid = this.data.fpa.isNormalOperation() && this.data.da.isNormalOperation(); + + if (!showVerticalFD && !showLateralFD || !this.isTrkFpaActive + || !this.data.fdActive || !daAndFpaValid || this.props.isAttExcessive.get()) { + this.birdPath.instance.style.visibility = 'hidden'; + this.isVisible = false; + } else { + this.birdPath.instance.style.visibility = 'visible'; + this.isVisible = true; + } + } + + private moveBird() { + if (this.data.fdActive && this.isTrkFpaActive) { + const FDRollOrder = this.data.fdRoll; + const FDRollOrderLim = Math.max(Math.min(FDRollOrder, 45), -45); + const FDPitchOrder = this.data.fdPitch; + const FDPitchOrderLim = Math.max(Math.min(FDPitchOrder, 22.5), -22.5) * 1.9; + + const daLimConv = Math.max(Math.min(this.data.da.value, 21), -21) * DistanceSpacing / ValueSpacing; + const pitchSubFpaConv = (calculateHorizonOffsetFromPitch(this.data.pitch.value) - calculateHorizonOffsetFromPitch(this.data.fpa.value)); + const rollCos = Math.cos(this.data.roll.value * Math.PI / 180); + const rollSin = Math.sin(-this.data.roll.value * Math.PI / 180); + + const FDRollOffset = FDRollOrderLim * 0.77; + const xOffsetFpv = daLimConv * rollCos - pitchSubFpaConv * rollSin; + const yOffsetFpv = pitchSubFpaConv * rollCos + daLimConv * rollSin; + + const xOffset = xOffsetFpv - FDPitchOrderLim * rollSin; + const yOffset = yOffsetFpv + FDPitchOrderLim * rollCos; + + this.birdPath.instance.style.transform = `translate3d(${xOffset}px, ${yOffset}px, 0px)`; + this.birdPathWings.instance.setAttribute('transform', `rotate(${FDRollOffset} 15.5 15.5)`); + } + this.needsUpdate = false; + } + + render(): VNode { + return ( + + + + + + + + + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/FlightPathVector.tsx b/fbw-a380x/src/systems/instruments/src/PFD/FlightPathVector.tsx new file mode 100644 index 00000000000..b235b08d59a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/FlightPathVector.tsx @@ -0,0 +1,122 @@ +import { ClockEvents, DisplayComponent, EventBus, FSComponent, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@shared/arinc429'; +import { calculateHorizonOffsetFromPitch } from './PFDUtils'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; + +const DistanceSpacing = 15; +const ValueSpacing = 10; + +interface FlightPathVectorData { + roll: Arinc429Word; + pitch: Arinc429Word; + fpa: Arinc429Word; + da: Arinc429Word; +} + +export class FlightPathVector extends DisplayComponent<{bus: EventBus}> { + private bird = FSComponent.createRef(); + + private fpvFlag = FSComponent.createRef(); + + private isTrkFpaActive = false; + + private data: FlightPathVectorData = { + roll: new Arinc429Word(0), + pitch: new Arinc429Word(0), + fpa: new Arinc429Word(0), + da: new Arinc429Word(0), + } + + private needsUpdate = false; + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('trkFpaActive').whenChanged().handle((a) => { + this.isTrkFpaActive = a; + if (this.isTrkFpaActive) { + this.moveBird(); + this.bird.instance.classList.remove('HiddenElement'); + } else { + this.bird.instance.classList.add('HiddenElement'); + } + }); + + sub.on('fpa').handle((fpa) => { + this.data.fpa = fpa; + this.needsUpdate = true; + }); + + sub.on('da').handle((da) => { + this.data.da = da; + this.needsUpdate = true; + }); + + sub.on('rollAr').handle((r) => { + this.data.roll = r; + this.needsUpdate = true; + }); + + sub.on('pitchAr').handle((p) => { + this.data.pitch = p; + this.needsUpdate = true; + }); + + sub.on('realTime').handle((_t) => { + if (this.needsUpdate) { + this.needsUpdate = false; + + const daAndFpaValid = this.data.fpa.isNormalOperation() && this.data.da.isNormalOperation(); + if (this.isTrkFpaActive && daAndFpaValid) { + this.fpvFlag.instance.style.visibility = 'hidden'; + this.bird.instance.classList.remove('HiddenElement'); + this.moveBird(); + } else if (this.isTrkFpaActive && this.data.pitch.isNormalOperation() && this.data.roll.isNormalOperation()) { + this.fpvFlag.instance.style.visibility = 'visible'; + this.bird.instance.classList.add('HiddenElement'); + } + } + }); + } + + private moveBird() { + const daLimConv = Math.max(Math.min(this.data.da.value, 21), -21) * DistanceSpacing / ValueSpacing; + const pitchSubFpaConv = (calculateHorizonOffsetFromPitch(this.data.pitch.value) - calculateHorizonOffsetFromPitch(this.data.fpa.value)); + const rollCos = Math.cos(this.data.roll.value * Math.PI / 180); + const rollSin = Math.sin(-this.data.roll.value * Math.PI / 180); + + const xOffset = daLimConv * rollCos - pitchSubFpaConv * rollSin; + const yOffset = pitchSubFpaConv * rollCos + daLimConv * rollSin; + + this.bird.instance.style.transform = `translate3d(${xOffset}px, ${yOffset}px, 0px)`; + } + + render(): VNode { + return ( + <> + + + + + + + + + + + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/HeadingIndicator.tsx b/fbw-a380x/src/systems/instruments/src/PFD/HeadingIndicator.tsx new file mode 100644 index 00000000000..2f9ffc759f0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/HeadingIndicator.tsx @@ -0,0 +1,341 @@ +import { DisplayComponent, EventBus, FSComponent, HEvent, Subject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { HorizontalTape } from './HorizontalTape'; +import { getSmallestAngle } from './PFDUtils'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { SimplaneValues } from './shared/SimplaneValueProvider'; +import { getDisplayIndex } from './PFD'; + +const DisplayRange = 24; +const DistanceSpacing = 7.555; +const ValueSpacing = 5; + +interface HeadingTapeProps { + bus: EventBus; + failed: Subscribable; +} + +export class HeadingTape extends DisplayComponent { + private headingTapeRef = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.props.failed.sub((failed) => { + if (failed) { + this.headingTapeRef.instance.style.visibility = 'hidden'; + } else { + this.headingTapeRef.instance.style.visibility = 'visible'; + } + }); + } + + render(): VNode { + return ( + + + + + ); + } +} + +export class HeadingOfftape extends DisplayComponent<{ bus: EventBus, failed: Subscribable}> { + private normalRef = FSComponent.createRef(); + + private abnormalRef = FSComponent.createRef(); + + private heading = Subject.create(0); + + private ILSCourse = Subject.create(0); + + private lsPressed = Subject.create(false); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('headingAr').handle((h) => { + this.heading.set(h.value); + + if (h.isNormalOperation()) { + this.normalRef.instance.style.visibility = 'visible'; + this.abnormalRef.instance.style.visibility = 'hidden'; + } else { + this.normalRef.instance.style.visibility = 'hidden'; + this.abnormalRef.instance.style.visibility = 'visible'; + } + }); + + sub.on('ilsCourse').whenChanged().handle((n) => { + this.ILSCourse.set(n); + }); + + sub.on('hEvent').handle((eventName) => { + if (eventName === `A320_Neo_PFD_BTN_LS_${getDisplayIndex()}`) { + this.lsPressed.set(!this.lsPressed.get()); + } + }); + } + + render(): VNode { + return ( + <> + + + + HDG + + + + + + + + + + ); + } +} + +interface SelectedHeadingProps { + bus: EventBus; + heading: Subscribable; +} + +class SelectedHeading extends DisplayComponent { + private selectedHeading = NaN; + + private showSelectedHeading = 0; + + private targetIndicator = FSComponent.createRef(); + + private headingTextRight = FSComponent.createRef(); + + private headingTextLeft = FSComponent.createRef(); + + private text = Subject.create(''); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + const spsub = this.props.bus.getSubscriber(); + + spsub.on('selectedHeading').whenChanged().handle((h) => { + if (this.showSelectedHeading === 1) { + this.selectedHeading = h; + this.handleDelta(this.props.heading.get(), this.selectedHeading); + } else { + this.selectedHeading = NaN; + } + }); + + sub.on('showSelectedHeading').whenChanged().handle((sh) => { + this.showSelectedHeading = sh; + if (this.showSelectedHeading === 0) { + this.selectedHeading = NaN; + } + this.handleDelta(this.props.heading.get(), this.selectedHeading); + }); + + this.props.heading.sub((h) => { + this.handleDelta(h, this.selectedHeading); + }, true); + } + + private handleDelta(heading: number, selectedHeading: number) { + const headingDelta = getSmallestAngle(selectedHeading, heading); + + this.text.set(Math.round(selectedHeading).toString().padStart(3, '0')); + + if (Number.isNaN(selectedHeading)) { + this.headingTextLeft.instance.classList.add('HiddenElement'); + this.targetIndicator.instance.classList.add('HiddenElement'); + this.headingTextRight.instance.classList.add('HiddenElement'); + return; + } + + if (Math.abs(headingDelta) < DisplayRange) { + const offset = headingDelta * DistanceSpacing / ValueSpacing; + + this.targetIndicator.instance.style.transform = `translate3d(${offset}px, 0px, 0px)`; + this.targetIndicator.instance.classList.remove('HiddenElement'); + this.headingTextRight.instance.classList.add('HiddenElement'); + this.headingTextLeft.instance.classList.add('HiddenElement'); + return; + } + this.targetIndicator.instance.classList.add('HiddenElement'); + + if (headingDelta > 0) { + this.headingTextRight.instance.classList.remove('HiddenElement'); + this.headingTextLeft.instance.classList.add('HiddenElement'); + } else { + this.headingTextRight.instance.classList.add('HiddenElement'); + this.headingTextLeft.instance.classList.remove('HiddenElement'); + } + } + + render(): VNode { + return ( + + <> + + {this.text} + {this.text} + + ); + } +} + +interface GroundTrackBugProps { + heading: Subscribable; + bus: EventBus; +} + +class GroundTrackBug extends DisplayComponent { + private trackIndicator = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('groundTrackAr').handle((groundTrack) => { + // if (groundTrack.isNormalOperation()) { + const offset = getSmallestAngle(groundTrack.value, this.props.heading.get()) * DistanceSpacing / ValueSpacing; + this.trackIndicator.instance.style.display = 'inline'; + this.trackIndicator.instance.style.transform = `translate3d(${offset}px, 0px, 0px)`; + // } else { + // this.trackIndicator.instance.style.display = 'none'; + // } + }); + } + + render(): VNode { + return ( + + + + + ); + } +} + +class QFUIndicator extends DisplayComponent<{ ILSCourse: Subscribable, heading: Subscribable, lsPressed: Subscribable }> { + private qfuContainer = FSComponent.createRef(); + + private ilsCourseRight = FSComponent.createRef(); + + private ilsCourseLeft = FSComponent.createRef(); + + private ilsCoursePointer = FSComponent.createRef(); + + private heading = 0; + + private ilsCourse = -1; + + private lsPressed = false; + + private text = Subject.create(''); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.props.heading.sub((h) => { + this.heading = h; + + const delta = getSmallestAngle(this.ilsCourse, this.heading); + this.text.set(Math.round(this.ilsCourse).toString().padStart(3, '0')); + + if (this.ilsCourse < 0) { + this.qfuContainer.instance.classList.add('HiddenElement'); + } else if (this.lsPressed) { + this.qfuContainer.instance.classList.remove('HiddenElement'); + if (Math.abs(delta) > DisplayRange) { + if (delta > 0) { + this.ilsCourseRight.instance.classList.remove('HiddenElement'); + this.ilsCourseLeft.instance.classList.add('HiddenElement'); + this.ilsCoursePointer.instance.classList.add('HiddenElement'); + } else { + this.ilsCourseLeft.instance.classList.remove('HiddenElement'); + this.ilsCourseRight.instance.classList.add('HiddenElement'); + this.ilsCoursePointer.instance.classList.add('HiddenElement'); + } + } else { + const offset = getSmallestAngle(this.ilsCourse, this.heading) * DistanceSpacing / ValueSpacing; + this.ilsCoursePointer.instance.style.transform = `translate3d(${offset}px, 0px, 0px)`; + this.ilsCoursePointer.instance.classList.remove('HiddenElement'); + this.ilsCourseRight.instance.classList.add('HiddenElement'); + this.ilsCourseLeft.instance.classList.add('HiddenElement'); + } + } + }); + + this.props.ILSCourse.sub((c) => { + this.ilsCourse = c; + + const delta = getSmallestAngle(this.ilsCourse, this.heading); + this.text.set(Math.round(this.ilsCourse).toString().padStart(3, '0')); + + if (c < 0) { + this.qfuContainer.instance.classList.add('HiddenElement'); + } else if (this.lsPressed) { + this.qfuContainer.instance.classList.remove('HiddenElement'); + if (Math.abs(delta) > DisplayRange) { + if (delta > 0) { + this.ilsCourseRight.instance.classList.remove('HiddenElement'); + this.ilsCourseLeft.instance.classList.add('HiddenElement'); + this.ilsCoursePointer.instance.classList.add('HiddenElement'); + } else { + this.ilsCourseLeft.instance.classList.remove('HiddenElement'); + this.ilsCourseRight.instance.classList.add('HiddenElement'); + this.ilsCoursePointer.instance.classList.add('HiddenElement'); + } + } else { + const offset = getSmallestAngle(this.ilsCourse, this.heading) * DistanceSpacing / ValueSpacing; + this.ilsCoursePointer.instance.style.transform = `translate3d(${offset}px, 0px, 0px)`; + this.ilsCoursePointer.instance.classList.remove('HiddenElement'); + this.ilsCourseRight.instance.classList.add('HiddenElement'); + this.ilsCourseLeft.instance.classList.add('HiddenElement'); + } + } + }); + + this.props.lsPressed.sub((ls) => { + this.lsPressed = ls; + if (ls) { + this.qfuContainer.instance.classList.remove('HiddenElement'); + } else { + this.qfuContainer.instance.classList.add('HiddenElement'); + } + }); + } + + render(): VNode { + return ( + + + + + + + + {this.text} + + + + {this.text} + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/HorizontalTape.tsx b/fbw-a380x/src/systems/instruments/src/PFD/HorizontalTape.tsx new file mode 100644 index 00000000000..dee50569406 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/HorizontalTape.tsx @@ -0,0 +1,180 @@ +import { EventBus, DisplayComponent, FSComponent, NodeReference, VNode, Subscribable } from '@microsoft/msfs-sdk'; +import { Arinc429Values } from './shared/ArincValueProvider'; + +interface HorizontalTapeProps { + displayRange: number; + valueSpacing: number; + distanceSpacing: number; + type: 'horizon' | 'headingTape' + bus: EventBus; + yOffset?: Subscribable; +} +export class HorizontalTape extends DisplayComponent { + private refElement = FSComponent.createRef(); + + private tapeOffset=0; + + private tickNumberRefs: NodeReference[] = []; + + private currentDrawnHeading = 0; + + private yOffset = 0; + + private buildHorizonTicks():{ticks: SVGPathElement[], labels?: SVGTextElement[]} { + const result = { ticks: [] as SVGPathElement[], labels: [] as SVGTextElement[] }; + + result.ticks.push(); + + for (let i = 0; i < 6; i++) { + const headingOffset = (1 + i) * this.props.valueSpacing; + const dX = this.props.distanceSpacing / this.props.valueSpacing * headingOffset; + + if (headingOffset % 10 === 0) { + result.ticks.push(); + result.ticks.unshift(); + } + } + + return result; + } + + private buildHeadingTicks(): { ticks: SVGLineElement[], labels: SVGTextElement[] } { + const result = { + ticks: [] as SVGLineElement[], + labels: [] as SVGTextElement[], + }; + + const tickLength = 4; + let textRef = FSComponent.createRef(); + + result.ticks.push(); + + result.labels.push( + + 360 + + , + + ); + this.tickNumberRefs.push(textRef); + + for (let i = 0; i < 6; i++) { + const headingOffset = (1 + i) * this.props.valueSpacing; + const dX = this.props.distanceSpacing / this.props.valueSpacing * headingOffset; + + if (headingOffset % 10 === 0) { + result.ticks.push(); + result.ticks.unshift(); + } else { + result.ticks.push(); + result.ticks.unshift(); + } + + if (headingOffset % 10 === 0) { + textRef = FSComponent.createRef(); + + result.labels.unshift( + + {headingOffset} + + , + ); + this.tickNumberRefs.unshift(textRef); + textRef = FSComponent.createRef(); + result.labels.push( + + {(360 - headingOffset)} + + , + ); + this.tickNumberRefs.push(textRef); + } + } + + return result; + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const pf = this.props.bus.getSubscriber(); + + this.props.yOffset?.sub((yOffset) => { + this.yOffset = yOffset; + this.refElement.instance.style.transform = `translate3d(${this.tapeOffset}px, ${yOffset}px, 0px)`; + }); + + pf.on('headingAr').handle((newVal) => { + const multiplier = 100; + const currentValueAtPrecision = Math.round(newVal.value * multiplier) / multiplier; + const tapeOffset = -currentValueAtPrecision % 10 * this.props.distanceSpacing / this.props.valueSpacing; + + if (currentValueAtPrecision / 10 >= this.currentDrawnHeading + 1 || currentValueAtPrecision / 10 <= this.currentDrawnHeading) { + this.currentDrawnHeading = Math.floor(currentValueAtPrecision / 10); + + const start = 330 + (this.currentDrawnHeading) * 10; + + this.tickNumberRefs.forEach((t, index) => { + const scrollerValue = t.instance; + if (scrollerValue !== null) { + const hdg = (start + index * 10) % 360; + if (hdg % 10 === 0) { + const content = hdg !== 0 ? (hdg / 10).toFixed(0) : '0'; + if (scrollerValue.textContent !== content) { + scrollerValue.textContent = content; + } + } else { + scrollerValue.textContent = ''; + } + if (hdg % 30 === 0) { + scrollerValue.classList.remove('FontSmallest'); + scrollerValue.classList.add('FontMedium'); + } else { + scrollerValue.classList.add('FontSmallest'); + scrollerValue.classList.remove('FontMedium'); + } + } + }); + } + this.tapeOffset = tapeOffset; + + this.refElement.instance.style.transform = `translate3d(${tapeOffset}px, ${this.yOffset}px, 0px)`; + }); + } + + render(): VNode { + const tapeContent = this.props.type === 'horizon' ? this.buildHorizonTicks() : this.buildHeadingTicks(); + + return ( + + + + {tapeContent.ticks} + {this.props.type === 'headingTape' && tapeContent.labels} + + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/LandingSystemIndicator.tsx b/fbw-a380x/src/systems/instruments/src/PFD/LandingSystemIndicator.tsx new file mode 100644 index 00000000000..3fd9070522e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/LandingSystemIndicator.tsx @@ -0,0 +1,486 @@ +import { DisplayComponent, EventBus, FSComponent, HEvent, Subject, VNode } from '@microsoft/msfs-sdk'; +import { getDisplayIndex } from 'instruments/src/PFD/PFD'; +import { Arinc429Word } from '@shared/arinc429'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; +import { LagFilter } from './PFDUtils'; + +export class LandingSystem extends DisplayComponent<{ bus: EventBus, instrument: BaseInstrument }> { + private lsButtonPressedVisibility = false; + + private xtkValid = Subject.create(false); + + private ldevRequest = false; + + private lsGroupRef = FSComponent.createRef(); + + private gsReferenceLine = FSComponent.createRef(); + + private deviationGroup = FSComponent.createRef(); + + private ldevRef = FSComponent.createRef(); + + private vdevRef = FSComponent.createRef(); + + private altitude = Arinc429Word.empty(); + + private handleGsReferenceLine() { + if (this.lsButtonPressedVisibility || (this.altitude.isNormalOperation())) { + this.gsReferenceLine.instance.style.display = 'inline'; + } else if (!this.lsButtonPressedVisibility) { + this.gsReferenceLine.instance.style.display = 'none'; + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('hEvent').handle((eventName) => { + if (eventName === `A320_Neo_PFD_BTN_LS_${getDisplayIndex()}`) { + this.lsButtonPressedVisibility = !this.lsButtonPressedVisibility; + SimVar.SetSimVarValue(`L:BTN_LS_${getDisplayIndex()}_FILTER_ACTIVE`, 'Bool', this.lsButtonPressedVisibility); + + this.lsGroupRef.instance.style.display = this.lsButtonPressedVisibility ? 'inline' : 'none'; + this.handleGsReferenceLine(); + } + }); + + sub.on(getDisplayIndex() === 1 ? 'ls1Button' : 'ls2Button').whenChanged().handle((lsButton) => { + this.lsButtonPressedVisibility = lsButton; + this.lsGroupRef.instance.style.display = this.lsButtonPressedVisibility ? 'inline' : 'none'; + this.deviationGroup.instance.style.display = this.lsButtonPressedVisibility ? 'none' : 'inline'; + this.handleGsReferenceLine(); + }); + + sub.on('altitudeAr').handle((altitude) => { + this.altitude = altitude; + this.handleGsReferenceLine(); + }); + + sub.on(getDisplayIndex() === 1 ? 'ldevRequestLeft' : 'ldevRequestRight').whenChanged().handle((ldevRequest) => { + this.ldevRequest = ldevRequest; + this.updateLdevVisibility(); + }); + + sub.on('xtk').whenChanged().handle((xtk) => { + this.xtkValid.set(Math.abs(xtk) > 0); + }); + + this.xtkValid.sub(() => { + this.updateLdevVisibility(); + }); + } + + updateLdevVisibility() { + this.ldevRef.instance.style.display = this.ldevRequest && this.xtkValid ? 'inline' : 'none'; + } + + render(): VNode { + return ( + <> + + + + + ); + } +} + +class LandingSystemInfo extends DisplayComponent<{ bus: EventBus }> { + private hasDme = false; + + private identText = Subject.create(''); + + private freqTextLeading = Subject.create(''); + + private freqTextTrailing = Subject.create(''); + + private navFreq = 0; + + private dme = 0; + + private dmeVisibilitySub = Subject.create('hidden'); + + private destRef = FSComponent.createRef(); + + private lsInfoGroup = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + // normally the ident and freq should be always displayed when an ILS freq is set, but currently it only show when we have a signal + sub.on('hasLoc').whenChanged().handle((hasLoc) => { + if (hasLoc) { + this.lsInfoGroup.instance.style.display = 'inline'; + } else { + this.lsInfoGroup.instance.style.display = 'none'; + } + }); + + sub.on('hasDme').whenChanged().handle((hasDme) => { + this.hasDme = hasDme; + this.updateContents(); + }); + + sub.on('navIdent').whenChanged().handle((navIdent) => { + this.identText.set(navIdent); + this.updateContents(); + }); + + sub.on('navFreq').whenChanged().handle((navFreq) => { + this.navFreq = navFreq; + this.updateContents(); + }); + + sub.on('dme').whenChanged().handle((dme) => { + this.dme = dme; + this.updateContents(); + }); + } + + private updateContents() { + const freqTextSplit = (Math.round(this.navFreq * 1000) / 1000).toString().split('.'); + this.freqTextLeading.set(freqTextSplit[0] === '0' ? '' : freqTextSplit[0]); + if (freqTextSplit[1]) { + this.freqTextTrailing.set(`.${freqTextSplit[1].padEnd(2, '0')}`); + } else { + this.freqTextTrailing.set(''); + } + + let distLeading = ''; + let distTrailing = ''; + if (this.hasDme) { + this.dmeVisibilitySub.set('display: inline'); + const dist = Math.round(this.dme * 10) / 10; + + if (dist < 20) { + const distSplit = dist.toString().split('.'); + + distLeading = distSplit[0]; + distTrailing = `.${distSplit.length > 1 ? distSplit[1] : '0'}`; + } else { + distLeading = Math.round(dist).toString(); + distTrailing = ''; + } + // eslint-disable-next-line max-len + this.destRef.instance.innerHTML = `${distLeading}${distTrailing}`; + } else { + this.dmeVisibilitySub.set('display: none'); + } + } + + render(): VNode { + return ( + + {this.identText} + {this.freqTextLeading} + {this.freqTextTrailing} + + + + NM + + + + ); + } +} + +class LocalizerIndicator extends DisplayComponent<{bus: EventBus, instrument: BaseInstrument}> { + private lagFilter = new LagFilter(1.5); + + private rightDiamond = FSComponent.createRef(); + + private leftDiamond = FSComponent.createRef(); + + private locDiamond = FSComponent.createRef(); + + private diamondGroup = FSComponent.createRef(); + + private handleNavRadialError(radialError: number): void { + const deviation = this.lagFilter.step(radialError, this.props.instrument.deltaTime / 1000); + const dots = deviation / 0.8; + + if (dots > 2) { + this.rightDiamond.instance.classList.remove('HiddenElement'); + this.leftDiamond.instance.classList.add('HiddenElement'); + this.locDiamond.instance.classList.add('HiddenElement'); + } else if (dots < -2) { + this.rightDiamond.instance.classList.add('HiddenElement'); + this.leftDiamond.instance.classList.remove('HiddenElement'); + this.locDiamond.instance.classList.add('HiddenElement'); + } else { + this.locDiamond.instance.classList.remove('HiddenElement'); + this.rightDiamond.instance.classList.add('HiddenElement'); + this.leftDiamond.instance.classList.add('HiddenElement'); + this.locDiamond.instance.style.transform = `translate3d(${dots * 30.221 / 2}px, 0px, 0px)`; + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('hasLoc').whenChanged().handle((hasLoc) => { + if (hasLoc) { + this.diamondGroup.instance.classList.remove('HiddenElement'); + this.props.bus.on('navRadialError', this.handleNavRadialError.bind(this)); + } else { + this.diamondGroup.instance.classList.add('HiddenElement'); + this.lagFilter.reset(); + this.props.bus.off('navRadialError', this.handleNavRadialError.bind(this)); + } + }); + } + + render(): VNode { + return ( + + + + + + + + + + + + + ); + } +} + +class GlideSlopeIndicator extends DisplayComponent<{bus: EventBus, instrument: BaseInstrument}> { + private lagFilter = new LagFilter(1.5); + + private upperDiamond = FSComponent.createRef(); + + private lowerDiamond = FSComponent.createRef(); + + private glideSlopeDiamond = FSComponent.createRef(); + + private diamondGroup = FSComponent.createRef(); + + private hasGlideSlope = false; + + private handleGlideSlopeError(glideSlopeError: number): void { + const deviation = this.lagFilter.step(glideSlopeError, this.props.instrument.deltaTime / 1000); + const dots = deviation / 0.4; + + if (dots > 2) { + this.upperDiamond.instance.classList.remove('HiddenElement'); + this.lowerDiamond.instance.classList.add('HiddenElement'); + this.glideSlopeDiamond.instance.classList.add('HiddenElement'); + } else if (dots < -2) { + this.upperDiamond.instance.classList.add('HiddenElement'); + this.lowerDiamond.instance.classList.remove('HiddenElement'); + this.glideSlopeDiamond.instance.classList.add('HiddenElement'); + } else { + this.upperDiamond.instance.classList.add('HiddenElement'); + this.lowerDiamond.instance.classList.add('HiddenElement'); + this.glideSlopeDiamond.instance.classList.remove('HiddenElement'); + this.glideSlopeDiamond.instance.style.transform = `translate3d(0px, ${dots * 30.238 / 2}px, 0px)`; + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('hasGlideslope').whenChanged().handle((hasGlideSlope) => { + this.hasGlideSlope = hasGlideSlope; + if (hasGlideSlope) { + this.diamondGroup.instance.classList.remove('HiddenElement'); + } else { + this.diamondGroup.instance.classList.add('HiddenElement'); + this.lagFilter.reset(); + } + }); + + sub.on('glideSlopeError').handle((gs) => { + if (this.hasGlideSlope) { + this.handleGlideSlopeError(gs); + } + }); + } + + render(): VNode { + return ( + + + + + + + + + + + + ); + } +} + +class VDevIndicator extends DisplayComponent<{bus: EventBus}> { + private VDevSymbolLower = FSComponent.createRef(); + + private VDevSymbolUpper = FSComponent.createRef(); + + private VDevSymbol = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + // TODO use correct simvar once RNAV is implemented + const deviation = 0; + const dots = deviation / 100; + + if (dots > 2) { + this.VDevSymbolLower.instance.style.visibility = 'visible'; + this.VDevSymbolUpper.instance.style.visibility = 'hidden'; + this.VDevSymbol.instance.style.visibility = 'hidden'; + } else if (dots < -2) { + this.VDevSymbolLower.instance.style.visibility = 'hidden'; + this.VDevSymbolUpper.instance.style.visibility = 'visible'; + this.VDevSymbol.instance.style.visibility = 'hidden'; + } else { + this.VDevSymbolLower.instance.style.visibility = 'hidden'; + this.VDevSymbolUpper.instance.style.visibility = 'hidden'; + this.VDevSymbol.instance.style.visibility = 'visible'; + this.VDevSymbol.instance.style.transform = `translate3d(0px, ${dots * 30.238 / 2}px, 0px)`; + } + } + + render(): VNode { + return ( + + ); + } +} + +class LDevIndicator extends DisplayComponent<{bus: EventBus}> { + private LDevSymbolLeft = FSComponent.createRef(); + + private LDevSymbolRight = FSComponent.createRef(); + + private LDevSymbol = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('xtk').whenChanged().withPrecision(3).handle((xtk) => { + const dots = xtk / 0.1; + + if (dots > 2) { + this.LDevSymbolRight.instance.style.visibility = 'visible'; + this.LDevSymbolLeft.instance.style.visibility = 'hidden'; + this.LDevSymbol.instance.style.visibility = 'hidden'; + } else if (dots < -2) { + this.LDevSymbolRight.instance.style.visibility = 'hidden'; + this.LDevSymbolLeft.instance.style.visibility = 'visible'; + this.LDevSymbol.instance.style.visibility = 'hidden'; + } else { + this.LDevSymbolRight.instance.style.visibility = 'hidden'; + this.LDevSymbolLeft.instance.style.visibility = 'hidden'; + this.LDevSymbol.instance.style.visibility = 'visible'; + this.LDevSymbol.instance.style.transform = `translate3d(${dots * 30.238 / 2}px, 0px, 0px)`; + } + }); + } + + render(): VNode { + return ( + + L/DEV + + + + + + + + + + ); + } +} + +class MarkerBeaconIndicator extends DisplayComponent<{ bus: EventBus }> { + private classNames = Subject.create('HiddenElement'); + + private markerText = Subject.create(''); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + const baseClass = 'FontLarge StartAlign'; + + sub.on('markerBeacon').whenChanged().handle((markerState) => { + if (markerState === 0) { + this.classNames.set(`${baseClass} HiddenElement`); + } else if (markerState === 1) { + this.classNames.set(`${baseClass} Cyan OuterMarkerBlink`); + this.markerText.set('OM'); + } else if (markerState === 2) { + this.classNames.set(`${baseClass} Amber MiddleMarkerBlink`); + this.markerText.set('MM'); + } else { + this.classNames.set(`${baseClass} White InnerMarkerBlink`); + this.markerText.set('IM'); + } + }); + } + + render(): VNode { + return ( + {this.markerText} + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/PFD.svg b/fbw-a380x/src/systems/instruments/src/PFD/PFD.svg new file mode 100644 index 00000000000..53143e64beb --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/PFD.svg @@ -0,0 +1,2860 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 10 + 10 + 20 + 20 + 30 + 30 + 50 + 50 + 80 + 80 + 10 + 10 + 20 + 20 + 30 + 30 + 50 + 50 + 80 + 80 + + + + SI + + + + + + + + + + + + + + + + DH + 2500 + + + + + + + + + + + + + + 22 + + + + 23 + + + + 25 + + + + 26 + + + + 24 + + + + + + + + + + + + + + + + + + + + + + + + + F + + + + 1 + + + + 260 + + + + 280 + + + + 300 + + + + 320 + + + + 340 + + + + + + + + + + + 195 + + + + + + + + + + + + + 190 + + + + + 200 + + + + + 9000 + + + + + + + + + + THR IDLE + FLX +60 + LVR CLB + SPEED SEL 210 + OP DES + + + + + + + + + + + + + + HDG + ROLL OUT + NAV + SET HOLD SPEED + CAT3 + DUAL + AP1+2 + 1 FD 2 + A/THR + BARO 230 + ALT + G/S + + + + + + + + + + + + + + + + + + + + + + + + + + + HCM + 110 + .50 + + 40.1 + NM + + DECEL + + + MM + + + + + + + + + + + + + + + + + + + + + + V/DEV + + + + + + + + + + L/DEV + + + + + + + + + + + + + + STD + + + FPV + FD + + + + + + + + + + + + + + + + + + QNH + 1009 + + + + M + 10090 + + 10360 + M + + + + + + + + + + + + + + + + + + 1 + 2 + 6 + 1 + 2 + 6 + + + + 23 + + + + + + + + + + .785 + 150 + 150 + 146 + SPD + LIM + + + + + 270 + 270 + + + 270 + + + + 270 + + + GPS + SPD + ALT + ATT + HDG + + + + + + + + 120 + FL + + + 120 + FL + + + + + + 2 + 2 + 4 + 5 + 3 + 3 + 00 + 80 + 20 + 40 + + + + N + E + G + + + + + V + / + S + + + PFD.dxf - scale = 1.000000, origin = (0.000000, 0.000000), method = file + + + diff --git a/fbw-a380x/src/systems/instruments/src/PFD/PFD.tsx b/fbw-a380x/src/systems/instruments/src/PFD/PFD.tsx new file mode 100644 index 00000000000..5090a47bb44 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/PFD.tsx @@ -0,0 +1,155 @@ +import { A320Failure, FailuresConsumer } from '@flybywiresim/failures'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { ClockEvents, ComponentProps, DisplayComponent, EventBus, FSComponent, Subject, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@shared/arinc429'; +import { LagFilter } from './PFDUtils'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { DisplayUnit } from './shared/displayUnit'; +import './style.scss'; +import { AltitudeIndicator, AltitudeIndicatorOfftape } from './AltitudeIndicator'; +import { AttitudeIndicatorFixedCenter, AttitudeIndicatorFixedUpper } from './AttitudeIndicatorFixed'; +import { FMA } from './FMA'; +import { HeadingOfftape, HeadingTape } from './HeadingIndicator'; +import { Horizon } from './AttitudeIndicatorHorizon'; +import { LandingSystem } from './LandingSystemIndicator'; +import { AirspeedIndicator, AirspeedIndicatorOfftape, MachNumber } from './SpeedIndicator'; +import { VerticalSpeedIndicator } from './VerticalSpeedIndicator'; + +export const getDisplayIndex = () => { + const url = document.getElementsByTagName('a32nx-pfd')[0].getAttribute('url'); + const duId = url ? parseInt(url.substring(url.length - 1), 10) : -1; + switch(duId) { + case 0: + return 1; + case 3: + return 2; + default: + return 0; + } +}; + +interface PFDProps extends ComponentProps { + bus: EventBus; + instrument: BaseInstrument; +} + +export class PFDComponent extends DisplayComponent { + private headingFailed = Subject.create(true); + + private displayFailed = Subject.create(false); + + private isAttExcessive = Subject.create(false); + + private pitch = new Arinc429Word(0); + + private roll = new Arinc429Word(0); + + private ownRadioAltitude = new Arinc429Word(0); + + private filteredRadioAltitude = Subject.create(0); + + private radioAltitudeFilter = new LagFilter(5); + + private failuresConsumer; + + constructor(props: PFDProps) { + super(props); + this.failuresConsumer = new FailuresConsumer('A32NX'); + } + + public onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.failuresConsumer.register(getDisplayIndex() === 1 ? A320Failure.LeftPfdDisplay : A320Failure.RightPfdDisplay); + + const sub = this.props.bus.getSubscriber(); + + sub.on('headingAr').handle((h) => { + if (this.headingFailed.get() !== h.isNormalOperation()) { + this.headingFailed.set(!h.isNormalOperation()); + } + }); + + sub.on('rollAr').handle((r) => { + this.roll = r; + }); + + sub.on('pitchAr').handle((p) => { + this.pitch = p; + }); + + sub.on('realTime').atFrequency(1).handle((_t) => { + this.failuresConsumer.update(); + this.displayFailed.set(this.failuresConsumer.isActive(getDisplayIndex() === 1 ? A320Failure.LeftPfdDisplay : A320Failure.RightPfdDisplay)); + if (!this.isAttExcessive.get() && ((this.pitch.isNormalOperation() + && (this.pitch.value > 25 || this.pitch.value < -13)) || (this.roll.isNormalOperation() && Math.abs(this.roll.value) > 45))) { + this.isAttExcessive.set(true); + } else if (this.isAttExcessive.get() && this.pitch.isNormalOperation() && this.pitch.value < 22 && this.pitch.value > -10 + && this.roll.isNormalOperation() && Math.abs(this.roll.value) < 40) { + this.isAttExcessive.set(false); + } + }); + + sub.on('chosenRa').handle((ra) => { + this.ownRadioAltitude = ra; + const filteredRadioAltitude = this.radioAltitudeFilter.step(this.ownRadioAltitude.value, this.props.instrument.deltaTime / 1000); + this.filteredRadioAltitude.set(filteredRadioAltitude); + }); + } + + render(): VNode { + return ( + + + + + + + + + + + + + + + + + + + + + + + + MEMO NOT AVAIL + LIMITATIONS NOT AVAIL + + + + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/PFDUtils.tsx b/fbw-a380x/src/systems/instruments/src/PFD/PFDUtils.tsx new file mode 100644 index 00000000000..da37b1d3315 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/PFDUtils.tsx @@ -0,0 +1,136 @@ +export const calculateHorizonOffsetFromPitch = (pitch: number) => { + if (pitch > -5 && pitch <= 20) { + return pitch * 1.8; + } if (pitch > 20 && pitch <= 30) { + return -0.04 * pitch ** 2 + 3.4 * pitch - 16; + } if (pitch > 30) { + return 20 + pitch; + } if (pitch < -5 && pitch >= -15) { + return 0.04 * pitch ** 2 + 2.2 * pitch + 1; + } + return pitch - 8; +}; + +export const calculateVerticalOffsetFromRoll = (roll: number) => { + let offset = 0; + + if (Math.abs(roll) > 60) { + offset = Math.max(0, 41 - 35.87 / Math.sin(Math.abs(roll) / 180 * Math.PI)); + } + return offset; +}; + +export const SmoothSin = (origin: number, destination: number, smoothFactor: number, dTime: number) => { + if (origin === undefined) { + return destination; + } + if (Math.abs(destination - origin) < Number.EPSILON) { + return destination; + } + const delta = destination - origin; + let result = origin + delta * Math.sin(Math.min(smoothFactor * dTime, 1.0) * Math.PI / 2.0); + if ((origin < destination && result > destination) || (origin > destination && result < destination)) { + result = destination; + } + return result; +}; + +export class LagFilter { + private PreviousInput: number; + + private PreviousOutput: number; + + private TimeConstant: number; + + constructor(timeConstant: number) { + this.PreviousInput = 0; + this.PreviousOutput = 0; + + this.TimeConstant = timeConstant; + } + + reset() { + this.PreviousInput = 0; + this.PreviousOutput = 0; + } + + /** + * + * @param input Input to filter + * @param deltaTime in seconds + * @returns {number} Filtered output + */ + step(input: number, deltaTime: number): number { + const filteredInput = !Number.isNaN(input) ? input : 0; + + const scaledDeltaTime = deltaTime * this.TimeConstant; + const sum0 = scaledDeltaTime + 2; + + const output = (filteredInput + this.PreviousInput) * scaledDeltaTime / sum0 + + (2 - scaledDeltaTime) / sum0 * this.PreviousOutput; + + this.PreviousInput = filteredInput; + + if (Number.isFinite(output)) { + this.PreviousOutput = output; + return output; + } + return 0; + } +} + +export class RateLimiter { + private PreviousOutput: number; + + private RisingRate: number; + + private FallingRate: number; + + constructor(risingRate: number, fallingRate: number) { + this.PreviousOutput = 0; + + this.RisingRate = risingRate; + this.FallingRate = fallingRate; + } + + step(input: number, deltaTime: number) { + const filteredInput = !Number.isNaN(input) ? input : 0; + + const subInput = filteredInput - this.PreviousOutput; + + const scaledUpper = deltaTime * this.RisingRate; + const scaledLower = deltaTime * this.FallingRate; + + const output = this.PreviousOutput + Math.max(Math.min(scaledUpper, subInput), scaledLower); + this.PreviousOutput = output; + return output; + } +} + +/** + * Gets the smallest angle between two angles + * @param angle1 First angle in degrees + * @param angle2 Second angle in degrees + * @returns {number} Smallest angle between angle1 and angle2 in degrees + */ +export const getSmallestAngle = (angle1: number, angle2: number) : number => { + let smallestAngle = angle1 - angle2; + if (smallestAngle > 180) { + smallestAngle -= 360; + } else if (smallestAngle < -180) { + smallestAngle += 360; + } + return smallestAngle; +}; + +export const isCaptainSide = (displayIndex: number | undefined) => displayIndex === 1; + +export const getSupplier = (displayIndex: number | undefined, knobValue: number) => { + const adirs3ToCaptain = 0; + const adirs3ToFO = 2; + + if (isCaptainSide(displayIndex)) { + return knobValue === adirs3ToCaptain ? 3 : 1; + } + return knobValue === adirs3ToFO ? 3 : 2; +}; diff --git a/fbw-a380x/src/systems/instruments/src/PFD/SpeedIndicator.tsx b/fbw-a380x/src/systems/instruments/src/PFD/SpeedIndicator.tsx new file mode 100644 index 00000000000..731f57a09d7 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/SpeedIndicator.tsx @@ -0,0 +1,969 @@ +import { ClockEvents, DisplayComponent, EventBus, FSComponent, NodeReference, Subject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@shared/arinc429'; +import { LagFilter, RateLimiter, SmoothSin } from './PFDUtils'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; +import { VerticalTape } from './VerticalTape'; +import { SimplaneValues } from './shared/SimplaneValueProvider'; +import { Arinc429Values } from './shared/ArincValueProvider'; + +const ValueSpacing = 10; +const DistanceSpacing = 10; +const DisplayRange = 42; + +class V1BugElement extends DisplayComponent<{bus: EventBus}> { + private offsetSub = Subject.create('translate3d(0px, 0px, 0px)'); + + private visibilitySub = Subject.create('hidden'); + + private flightPhase = 0; + + private v1Speed = 0; + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const pf = this.props.bus.getSubscriber(); + + pf.on('v1').whenChanged().handle((g) => { + this.v1Speed = g; + this.getV1Offset(); + this.getV1Visibility(); + }); + + pf.on('fwcFlightPhase').whenChanged().handle((g) => { + this.flightPhase = g; + this.getV1Visibility(); + }); + } + + private getV1Offset() { + const offset = -this.v1Speed * DistanceSpacing / ValueSpacing; + this.offsetSub.set(`transform:translate3d(0px, ${offset}px, 0px)`); + } + + private getV1Visibility() { + if (this.flightPhase <= 4 && this.v1Speed !== 0) { + this.visibilitySub.set('visible'); + } else { + this.visibilitySub.set('hidden'); + } + } + + render(): VNode { + return ( + + + 1 + + ); + } +} + +class VRBugElement extends DisplayComponent<{bus: EventBus}> { + private offsetSub = Subject.create(''); + + private visibilitySub = Subject.create('hidden'); + + private flightPhase = 0; + + private vrSpeed = 0; + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const pf = this.props.bus.getSubscriber(); + + pf.on('vr').whenChanged().handle((g) => { + this.vrSpeed = g; + this.getVrOffset(); + this.getVrVisibility(); + }); + + pf.on('fwcFlightPhase').whenChanged().handle((g) => { + this.flightPhase = g; + this.getVrVisibility(); + }); + } + + private getVrOffset() { + const offset = -this.vrSpeed * DistanceSpacing / ValueSpacing; + this.offsetSub.set(`translate(0 ${offset})`); + } + + private getVrVisibility() { + if (this.flightPhase <= 4 && this.vrSpeed !== 0) { + this.visibilitySub.set('visible'); + } else { + this.visibilitySub.set('hidden'); + } + } + + render(): VNode { + return ( + + ); + } +} + +interface AirspeedIndicatorProps { + airspeedAcc?: number; + FWCFlightPhase?: number; + altitude?: Arinc429Word; + VLs?: number; + VMax?: number; + showBars?: boolean; + bus: EventBus; + instrument: BaseInstrument; +} + +export class AirspeedIndicator extends DisplayComponent { + private speedSub = Subject.create(0); + + private speedTapeOutlineRef: NodeReference = FSComponent.createRef(); + + private speedTapeElements: NodeReference = FSComponent.createRef(); + + private failedGroup: NodeReference = FSComponent.createRef(); + + private alphaProtRef: NodeReference[] = []; + + private vMaxRef: NodeReference[] = []; + + private showBarsRef = FSComponent.createRef(); + + private barberPoleRef = FSComponent.createRef(); + + private vfeNext = FSComponent.createRef(); + + private altitude = new Arinc429Word(0); + + private flapHandleIndex = 0; + + private lastAlphaProtSub = Subject.create(0); + + private barTimeout= 0; + + private onGround = Subject.create(true); + + private airSpeed = new Arinc429Word(0); + + private vMax = 0; + + private leftMainGearCompressed: boolean; + + private rightMainGearCompressed: boolean; + + private setOutline() { + let airspeedValue: number; + if (this.airSpeed.isFailureWarning() || (this.airSpeed.isNoComputedData() && !this.onGround.get())) { + airspeedValue = NaN; + } else if (this.airSpeed.isNoComputedData()) { + airspeedValue = 30; + } else { + airspeedValue = this.airSpeed.value; + } + this.speedSub.set(airspeedValue); + + if (Number.isNaN(airspeedValue)) { + this.speedTapeElements.instance.classList.add('HiddenElement'); + this.failedGroup.instance.classList.remove('HiddenElement'); + } else { + this.speedTapeElements.instance.classList.remove('HiddenElement'); + this.failedGroup.instance.classList.add('HiddenElement'); + } + + const length = 42.9 + Math.max(Math.max(Math.min(Number.isNaN(airspeedValue) ? 100 : airspeedValue, 72.1), 30) - 30, 0); + this.speedTapeOutlineRef.instance.setAttribute('d', `m19.031 38.086v${length}`); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const pf = this.props.bus.getSubscriber(); + + pf.on('vfeNext').whenChanged().handle((vfe) => { + if (this.altitude.value < 15000 && this.flapHandleIndex < 4) { + const offset = -vfe * DistanceSpacing / ValueSpacing; + this.vfeNext.instance.classList.remove('HiddenElement'); + this.vfeNext.instance.style.transform = `translate3d(0px, ${offset}px, 0px)`; + } else { + this.vfeNext.instance.classList.add('HiddenElement'); + } + }); + + pf.on('altitudeAr').withArinc429Precision(2).handle((a) => { + this.altitude = a; + if (this.altitude.isNormalOperation() && this.altitude.value < 15000 && this.flapHandleIndex < 4) { + this.vfeNext.instance.classList.remove('HiddenElement'); + } else { + this.vfeNext.instance.classList.add('HiddenElement'); + } + }); + + pf.on('flapHandleIndex').whenChanged().handle((a) => { + this.flapHandleIndex = a; + if (this.altitude.isNormalOperation() && this.altitude.value < 15000 && this.flapHandleIndex < 4) { + this.vfeNext.instance.classList.remove('HiddenElement'); + } else { + this.vfeNext.instance.classList.add('HiddenElement'); + } + }); + + pf.on('speedAr').handle((airSpeed) => { + this.airSpeed = airSpeed; + this.setOutline(); + this.vMaxRef.forEach((el, index) => { + const isInRange = this.vMax <= this.speedSub.get() + DisplayRange; + if (isInRange) { + let elementValue = this.vMax + 5.040 * index; + + let offset = -elementValue * DistanceSpacing / ValueSpacing; + // if the lowest bug is below the speedtape place it on top again + if (-offset < this.speedSub.get() - 45) { + elementValue = (this.vMax + 5.040 * (index + 30)); + + offset = -elementValue * DistanceSpacing / ValueSpacing; + } + el.instance.style.transform = `translate3d(0px, ${offset}px, 0px)`; + el.instance.style.visibility = 'visible'; + } else { + el.instance.style.visibility = 'hidden'; + } + }); + }); + + pf.on('alphaProt').withPrecision(2).handle((a) => { + this.alphaProtRef.forEach((el, index) => { + const elementValue = a + -1 * 2.923 * index; + const offset = -elementValue * DistanceSpacing / ValueSpacing; + el.instance.style.transform = `translate3d(0px, ${offset}px, 0px)`; + }); + + this.lastAlphaProtSub.set(a); + }); + + pf.on('vMax').whenChanged().handle((vMax) => { + this.vMax = vMax; + }); + + pf.on('leftMainGearCompressed').whenChanged().handle((g) => { + this.leftMainGearCompressed = g; + this.onGround.set(this.rightMainGearCompressed || g); + this.setOutline(); + }); + + pf.on('rightMainGearCompressed').whenChanged().handle((g) => { + this.rightMainGearCompressed = g; + this.onGround.set(this.leftMainGearCompressed || g); + this.setOutline(); + }); + + // showBars replacement + this.onGround.sub((g) => { + if (g) { + this.showBarsRef.instance.style.display = 'none'; + this.barberPoleRef.instance.style.display = 'none'; + clearTimeout(this.barTimeout); + } else { + this.barTimeout = setTimeout(() => { + this.showBarsRef.instance.style.display = 'block'; + this.barberPoleRef.instance.style.display = 'block'; + }, 10000) as unknown as number; + } + this.setOutline(); + }); + } + + private createAlphaProtBarberPole() { + const group: SVGGElement[] = []; + for (let i = 0; i < 10; i++) { + const apref = FSComponent.createRef(); + group.push( + + + ); + , + ); + this.alphaProtRef.push(apref); + } + return group; + } + + private createVMaxBarberPole() { + const path: SVGGElement[] = []; + for (let i = 0; i < 30; i++) { + const vMaxRef = FSComponent.createRef(); + path.push( + , + ); + this.vMaxRef.push(vMaxRef); + } + return path; + } + + render(): VNode { + const length = 42.9 + Math.max(Math.max(Math.min(100, 72.1), 30) - 30, 0); + return ( + + <> + + + + SPD + + + + + + + + + + + {this.createVMaxBarberPole()} + {this.createAlphaProtBarberPole()} + + + + + + + + + + + + + + + + + + + + + ); + } +} + +class FlapsSpeedPointBugs extends DisplayComponent<{bus: EventBus}> { + private greenDotBug = FSComponent.createRef(); + + private flapsBug = FSComponent.createRef(); + + private slatBug = FSComponent.createRef(); + + render(): VNode { + return ( + <> + + + + + + + F + + + + S + + + ); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('flapHandleIndex').whenChanged().handle((f) => { + if (f === 0) { + this.greenDotBug.instance.style.visibility = 'visible'; + this.flapsBug.instance.style.visibility = 'hidden'; + this.slatBug.instance.style.visibility = 'hidden'; + } else if (f === 1) { + this.greenDotBug.instance.style.visibility = 'hidden'; + this.flapsBug.instance.style.visibility = 'hidden'; + this.slatBug.instance.style.visibility = 'visible'; + } else if (f === 2 || f === 3) { + this.greenDotBug.instance.style.visibility = 'hidden'; + this.flapsBug.instance.style.visibility = 'visible'; + this.slatBug.instance.style.visibility = 'hidden'; + } else { + this.greenDotBug.instance.style.visibility = 'hidden'; + this.flapsBug.instance.style.visibility = 'hidden'; + this.slatBug.instance.style.visibility = 'hidden'; + } + }); + + sub.on('greenDotSpeed').whenChanged() + .handle((gd) => { + this.greenDotBug.instance.style.transform = `translate3d(0px,${getSpeedTapeOffset(gd)}px, 0px`; + }); + sub.on('slatSpeed').whenChanged() + .handle((sls) => { + this.slatBug.instance.style.transform = `translate3d(0px,${getSpeedTapeOffset(sls)}px, 0px`; + }); + sub.on('fSpeed').whenChanged() + .handle((fs) => { + this.flapsBug.instance.style.transform = `translate3d(0px,${getSpeedTapeOffset(fs)}px, 0px`; + }); + } +} + +const getSpeedTapeOffset = (speed: number): number => -speed * DistanceSpacing / ValueSpacing; + +export class AirspeedIndicatorOfftape extends DisplayComponent<{ bus: EventBus }> { + private lowerRef = FSComponent.createRef(); + + private offTapeRef = FSComponent.createRef(); + + private offTapeFailedRef = FSComponent.createRef(); + + private decelRef = FSComponent.createRef(); + + private onGround = true; + + private leftMainGearCompressed = true; + + private rightMainGearCompressed = true; + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('leftMainGearCompressed').whenChanged().handle((g) => { + this.leftMainGearCompressed = g; + this.onGround = this.rightMainGearCompressed || g; + }); + + sub.on('rightMainGearCompressed').whenChanged().handle((g) => { + this.rightMainGearCompressed = g; + this.onGround = this.leftMainGearCompressed || g; + }); + + sub.on('speedAr').handle((speed) => { + let airspeedValue: number; + if (speed.isFailureWarning() || (speed.isNoComputedData() && !this.onGround)) { + airspeedValue = NaN; + } else if (speed.isNoComputedData()) { + airspeedValue = 30; + } else { + airspeedValue = speed.value; + } + if (Number.isNaN(airspeedValue)) { + this.offTapeRef.instance.classList.add('HiddenElement'); + this.offTapeFailedRef.instance.classList.remove('HiddenElement'); + } else { + this.offTapeRef.instance.classList.remove('HiddenElement'); + this.offTapeFailedRef.instance.classList.add('HiddenElement'); + + const clampedSpeed = Math.max(Math.min(airspeedValue, 660), 30); + const showLower = clampedSpeed > 72; + + if (showLower) { + this.lowerRef.instance.setAttribute('visibility', 'visible'); + } else { + this.lowerRef.instance.setAttribute('visibility', 'hidden'); + } + } + }); + + sub.on('autoBrakeDecel').whenChanged().handle((a) => { + if (a) { + this.decelRef.instance.style.visibility = 'visible'; + } else { + this.decelRef.instance.style.visibility = 'hidden'; + } + }); + } + + render(): VNode { + return ( + <> + + + + + + + + DECEL + + + + + + + ); + } +} + +class SpeedTrendArrow extends DisplayComponent<{ airspeed: Subscribable, instrument: BaseInstrument, bus: EventBus }> { + private refElement = FSComponent.createRef(); + + private arrowBaseRef = FSComponent.createRef(); + + private arrowHeadRef = FSComponent.createRef(); + + private offset = Subject.create(''); + + private pathString = Subject.create(''); + + private lagFilter = new LagFilter(1.6); + + private airspeedAccRateLimiter = new RateLimiter(1.2, -1.2); + + private previousAirspeed = 0; + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('realTime').handle((_t) => { + const deltaTime = this.props.instrument.deltaTime; + const clamped = Math.max(this.props.airspeed.get(), 30); + const airspeedAcc = (clamped - this.previousAirspeed) / deltaTime * 1000; + this.previousAirspeed = clamped; + + let filteredAirspeedAcc = this.lagFilter.step(airspeedAcc, deltaTime / 1000); + filteredAirspeedAcc = this.airspeedAccRateLimiter.step(filteredAirspeedAcc, deltaTime / 1000); + + const targetSpeed = filteredAirspeedAcc * 10; + + if (Math.abs(targetSpeed) < 1) { + this.refElement.instance.style.visibility = 'hidden'; + } else { + this.refElement.instance.style.visibility = 'visible'; + let pathString; + const sign = Math.sign(filteredAirspeedAcc); + + const offset = -targetSpeed * DistanceSpacing / ValueSpacing; + const neutralPos = 80.823; + if (sign > 0) { + pathString = `m15.455 ${neutralPos + offset} l -1.2531 2.4607 M15.455 ${neutralPos + offset} l 1.2531 2.4607`; + } else { + pathString = `m15.455 ${neutralPos + offset} l 1.2531 -2.4607 M15.455 ${neutralPos + offset} l -1.2531 -2.4607`; + } + + this.offset.set(`m15.455 80.823v${offset.toFixed(10)}`); + + this.pathString.set(pathString); + } + }); + } + + render(): VNode | null { + return ( + + + + + ); + } +} + +interface VLSState { + alphaProtSpeed: number; + airSpeed: number; + vls: number; +} +class VLsBar extends DisplayComponent<{ bus: EventBus }> { + private previousTime = (new Date() as any).appTime(); + + private vlsPath = Subject.create(''); + + private vlsState: VLSState = { + alphaProtSpeed: 0, + airSpeed: 0, + vls: 0, + } + + private smoothSpeeds = (vlsDestination: number) => { + const currentTime = (new Date() as any).appTime(); + const deltaTime = currentTime - this.previousTime; + + const seconds = deltaTime / 1000; + const vls = SmoothSin(this.vlsState.vls, vlsDestination, 0.5, seconds); + this.previousTime = currentTime; + return vls; + }; + + private setVlsPath(vls: number) { + const airSpeed = this.vlsState.airSpeed; + + const VLsPos = (airSpeed - vls) * DistanceSpacing / ValueSpacing + 80.818; + const offset = (vls - this.vlsState.alphaProtSpeed) * DistanceSpacing / ValueSpacing; + + this.vlsPath.set(`m19.031 ${VLsPos}h 1.9748v${offset}`); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('alphaProt').handle((a) => { + this.vlsState.alphaProtSpeed = a; + this.setVlsPath(this.vlsState.vls); + }); + + sub.on('speedAr').withArinc429Precision(2).handle((s) => { + this.vlsState.airSpeed = s.value; + this.setVlsPath(this.vlsState.vls); + }); + + sub.on('vls').handle((vls) => { + const smoothedVls = this.smoothSpeeds(vls); + this.setVlsPath(smoothedVls); + this.vlsState.vls = smoothedVls; + }); + } + + render(): VNode { + return ; + } +} + +class VAlphaLimBar extends DisplayComponent<{ bus: EventBus }> { + private VAlimIndicator = FSComponent.createRef(); + + private airSpeed = new Arinc429Word(0); + + private vAlphaLim = 0; + + private setAlphaLimBarPath() { + if (this.vAlphaLim - this.airSpeed.value < -DisplayRange) { + this.VAlimIndicator.instance.style.visibility = 'hidden'; + } else { + this.VAlimIndicator.instance.style.visibility = 'visible'; + + const delta = this.airSpeed.value - DisplayRange - this.vAlphaLim; + const offset = delta * DistanceSpacing / ValueSpacing; + + this.VAlimIndicator.instance.setAttribute('d', `m19.031 123.56h3.425v${offset}h-3.425z`); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('speedAr').withArinc429Precision(2).handle((s) => { + this.airSpeed = s; + this.setAlphaLimBarPath(); + }); + + sub.on('alphaLim').withPrecision(2).handle((al) => { + this.vAlphaLim = al; + this.setAlphaLimBarPath(); + }); + } + + render(): VNode { + return ; + } +} + +class V1Offtape extends DisplayComponent<{ bus: EventBus }> { + private v1TextRef = FSComponent.createRef(); + + private v1Speed = 0; + + onAfterRender() { + const sub = this.props.bus.getSubscriber(); + + sub.on('speed').handle((s) => { + const speed = new Arinc429Word(s); + if (this.v1Speed - speed.value > DisplayRange) { + this.v1TextRef.instance.style.visibility = 'visible'; + } else { + this.v1TextRef.instance.style.visibility = 'hidden'; + } + }); + + sub.on('v1').whenChanged().handle((v1) => { + this.v1Speed = v1; + this.v1TextRef.instance.textContent = Math.round(v1).toString(); + }); + + sub.on('fwcFlightPhase').whenChanged().handle((p) => { + if (p <= 4) { + this.v1TextRef.instance.style.visibility = 'visible'; + } else { + this.v1TextRef.instance.style.visibility = 'hidden'; + } + }); + } + + render() { + return ( + 0 + ); + } +} + +interface SpeedStateInfo { + targetSpeed: number; + managedTargetSpeed: number; + holdValue: number; + isSpeedManaged: boolean; + isMach: boolean; + speed: Arinc429Word; + + } + +class SpeedTarget extends DisplayComponent <{ bus: EventBus }> { + private upperBoundRef = FSComponent.createRef(); + + private lowerBoundRef = FSComponent.createRef(); + + private speedTargetRef = FSComponent.createRef(); + + private currentVisible: NodeReference = this.upperBoundRef; + + private textSub = Subject.create('0'); + + private decelActive = false; + + private needsUpdate = true; + + private speedState: SpeedStateInfo = { + speed: new Arinc429Word(0), + targetSpeed: 100, + managedTargetSpeed: 100, + holdValue: 100, + isSpeedManaged: false, + isMach: false, + } + + private handleManagedSpeed() { + if (this.speedState.isSpeedManaged) { + this.currentVisible.instance.classList.replace('Cyan', 'Magenta'); + const text = Math.round(this.speedState.managedTargetSpeed).toString().padStart(3, '0'); + this.textSub.set(text); + } else { + this.currentVisible.instance.classList.replace('Magenta', 'Cyan'); + const text = Math.round(this.speedState.managedTargetSpeed).toString().padStart(3, '0'); + this.textSub.set(text); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + this.needsUpdate = true; + + const sub = this.props.bus.getSubscriber(); + + sub.on('isSelectedSpeed').whenChanged().handle((s) => { + this.speedState.isSpeedManaged = !s; + this.needsUpdate = true; + }); + + sub.on('speedAr').withArinc429Precision(2).handle((s) => { + this.speedState.speed = s; + + this.needsUpdate = true; + }); + + sub.on('holdValue').whenChanged().handle((s) => { + this.speedState.holdValue = s; + this.needsUpdate = true; + }); + + sub.on('machActive').whenChanged().handle((s) => { + this.speedState.isMach = s; + this.needsUpdate = true; + }); + + sub.on('targetSpeedManaged').whenChanged().handle((s) => { + this.speedState.managedTargetSpeed = s; + this.needsUpdate = true; + }); + + sub.on('autoBrakeDecel').whenChanged().handle((a) => { + this.decelActive = a; + this.needsUpdate = true; + }); + + sub.on('realTime').handle(this.onFrameUpdate.bind(this)); + } + + private onFrameUpdate(_realTime: number): void { + if (this.needsUpdate === true) { + this.needsUpdate = false; + + this.determineTargetSpeed(); + const inRange = this.handleLowerUpperBound(); + this.handleManagedSpeed(); + + if (inRange) { + const multiplier = 100; + const currentValueAtPrecision = Math.round(this.speedState.speed.value * multiplier) / multiplier; + const offset = (currentValueAtPrecision - (this.speedState.isSpeedManaged + ? this.speedState.managedTargetSpeed : this.speedState.targetSpeed)) * DistanceSpacing / ValueSpacing; + this.speedTargetRef.instance.style.transform = `translate3d(0px, ${offset}px, 0px)`; + } else { + const text = Math.round(this.speedState.isSpeedManaged ? this.speedState.managedTargetSpeed : this.speedState.targetSpeed).toString().padStart(3, '0'); + this.textSub.set(text); + } + } + } + + private determineTargetSpeed() { + const isSelected = !this.speedState.isSpeedManaged; + if (isSelected) { + if (this.speedState.isMach) { + const holdValue = this.speedState.holdValue; + this.speedState.targetSpeed = SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', holdValue === null ? undefined : holdValue); + } else { + this.speedState.targetSpeed = this.speedState.holdValue; + } + } + } + + private handleLowerUpperBound(): boolean { + let inRange = false; + + const currentTargetSpeed = this.speedState.isSpeedManaged ? this.speedState.managedTargetSpeed : this.speedState.targetSpeed; + if (this.speedState.speed.value - currentTargetSpeed > DisplayRange) { + this.upperBoundRef.instance.style.visibility = 'visible'; + this.lowerBoundRef.instance.style.visibility = 'hidden'; + this.speedTargetRef.instance.style.visibility = 'hidden'; + this.currentVisible = this.upperBoundRef; + } else if (this.speedState.speed.value - currentTargetSpeed < -DisplayRange && !this.decelActive) { + this.lowerBoundRef.instance.style.visibility = 'visible'; + this.upperBoundRef.instance.style.visibility = 'hidden'; + this.speedTargetRef.instance.style.visibility = 'hidden'; + this.currentVisible = this.lowerBoundRef; + } else if (Math.abs(this.speedState.speed.value - currentTargetSpeed) < DisplayRange) { + this.lowerBoundRef.instance.style.visibility = 'hidden'; + this.upperBoundRef.instance.style.visibility = 'hidden'; + this.speedTargetRef.instance.style.visibility = 'visible'; + this.currentVisible = this.speedTargetRef; + inRange = true; + } else { + this.lowerBoundRef.instance.style.visibility = 'hidden'; + this.upperBoundRef.instance.style.visibility = 'hidden'; + this.speedTargetRef.instance.style.visibility = 'hidden'; + } + return inRange; + } + + render(): VNode { + return ( + <> + {this.textSub} + {this.textSub} + + + ); + } +} + +export class MachNumber extends DisplayComponent<{bus: EventBus}> { + private machTextSub = Subject.create(''); + + private failedRef = FSComponent.createRef(); + + private showMach = false; + + private onGround = false; + + private leftMainGearCompressed = true; + + private rightMainGearCompressed = true; + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('machAr').handle((mach) => { + if (!mach.isNormalOperation() && !this.onGround) { + this.machTextSub.set(''); + this.failedRef.instance.style.display = 'inline'; + return; + } + this.failedRef.instance.style.display = 'none'; + const machPermille = Math.round(mach.valueOr(0) * 1000); + if (this.showMach && machPermille < 450) { + this.showMach = false; + this.machTextSub.set(''); + } else if (!this.showMach && machPermille > 500) { + this.showMach = true; + } + if (this.showMach) { + this.machTextSub.set(`.${machPermille}`); + } + }); + + sub.on('leftMainGearCompressed').whenChanged().handle((g) => { + this.leftMainGearCompressed = g; + this.onGround = this.rightMainGearCompressed || g; + }); + + sub.on('rightMainGearCompressed').whenChanged().handle((g) => { + this.rightMainGearCompressed = g; + this.onGround = this.leftMainGearCompressed || g; + }); + } + + render(): VNode { + return ( + <> + MACH + {this.machTextSub} + + ); + } +} + +class VProtBug extends DisplayComponent<{bus: EventBus}> { + private vMaxBug = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + const sub = this.props.bus.getSubscriber(); + + sub.on('vMax').whenChanged().handle((vm) => { + const showVProt = vm > 240; + const offset = -(vm + 6) * DistanceSpacing / ValueSpacing; + + if (showVProt) { + this.vMaxBug.instance.classList.remove('HiddenElement'); + this.vMaxBug.instance.style.transform = `translate3d(0px, ${offset}px, 0px)`; + } else { + this.vMaxBug.instance.classList.add('HiddenElement'); + } + }); + } + + render(): VNode { + return ( + + + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/VerticalSpeedIndicator.tsx b/fbw-a380x/src/systems/instruments/src/PFD/VerticalSpeedIndicator.tsx new file mode 100644 index 00000000000..d7d51e0640b --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/VerticalSpeedIndicator.tsx @@ -0,0 +1,459 @@ +import { ClockEvents, ComponentProps, DisplayComponent, EventBus, FSComponent, Subject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@shared/arinc429'; +import { Arinc429Values } from './shared/ArincValueProvider'; +import { PFDSimvars } from './shared/PFDSimvarPublisher'; +import { LagFilter } from './PFDUtils'; + +interface VerticalSpeedIndicatorProps { + bus: EventBus, + instrument: BaseInstrument, + filteredRadioAltitude: Subscribable, +} + +interface TcasState { + tcasState: number; + isTcasCorrective: boolean; + tcasRedZoneL: number; + tcasRedZoneH: number; + tcasGreenZoneL: number; + tcasGreenZoneH: number; +} + +export class VerticalSpeedIndicator extends DisplayComponent { + private yOffsetSub = Subject.create(0); + + private needleColour = Subject.create('Green'); + + private radioAlt = new Arinc429Word(0); + + private vsFailed = FSComponent.createRef(); + + private vsNormal = FSComponent.createRef(); + + private lagFilter = new LagFilter(2); + + private needsUpdate = false; + + private vspeedTcas = FSComponent.createRef(); + + private filteredRadioAltitude = 0; + + private tcasState: TcasState = { + tcasState: 0, + isTcasCorrective: false, + tcasRedZoneL: 0, + tcasRedZoneH: 0, + tcasGreenZoneL: 0, + tcasGreenZoneH: 0, + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('tcasState').whenChanged().handle((s) => { + this.tcasState.tcasState = s; + this.needsUpdate = true; + }); + + sub.on('tcasCorrective').whenChanged().handle((s) => { + this.tcasState.isTcasCorrective = s; + this.needsUpdate = true; + }); + sub.on('tcasRedZoneL').whenChanged().handle((s) => { + this.tcasState.tcasRedZoneL = s; + this.needsUpdate = true; + }); + sub.on('tcasRedZoneH').whenChanged().handle((s) => { + this.tcasState.tcasRedZoneH = s; + this.needsUpdate = true; + }); + sub.on('tcasGreenZoneL').whenChanged().handle((s) => { + this.tcasState.tcasGreenZoneL = s; + this.needsUpdate = true; + }); + sub.on('tcasGreenZoneH').whenChanged().handle((s) => { + this.tcasState.tcasGreenZoneH = s; + this.needsUpdate = true; + }); + + sub.on('realTime').handle((_r) => { + if (this.needsUpdate) { + if (this.tcasState.tcasState === 2) { + this.needleColour.set('White'); + } + this.vspeedTcas.instance.update(this.tcasState); + } + }); + + sub.on('vs').withArinc429Precision(2).handle((vs) => { + const filteredVS = this.lagFilter.step(vs.value, this.props.instrument.deltaTime / 1000); + + const absVSpeed = Math.abs(filteredVS); + + if (!vs.isNormalOperation()) { + this.vsFailed.instance.style.visibility = 'visible'; + this.vsNormal.instance.style.visibility = 'hidden'; + } else { + this.vsFailed.instance.style.visibility = 'hidden'; + this.vsNormal.instance.style.visibility = 'visible'; + } + + const radioAltitudeValid = !this.radioAlt.isNoComputedData() && !this.radioAlt.isFailureWarning(); + if (this.tcasState.tcasState !== 2) { + if ( + absVSpeed >= 6000 + || (vs.value <= -2000 && radioAltitudeValid && this.filteredRadioAltitude <= 2500 && this.filteredRadioAltitude >= 1000) + || (vs.value <= -1200 && radioAltitudeValid && this.filteredRadioAltitude <= 1000) + ) { + this.needleColour.set('Amber'); + } else { + this.needleColour.set('Green'); + } + } + + const sign = Math.sign(filteredVS); + + if (absVSpeed < 1000) { + this.yOffsetSub.set(filteredVS / 1000 * -27.22); + } else if (absVSpeed < 2000) { + this.yOffsetSub.set((filteredVS - sign * 1000) / 1000 * -10.1 - sign * 27.22); + } else if (absVSpeed < 6000) { + this.yOffsetSub.set((filteredVS - sign * 2000) / 4000 * -10.1 - sign * 37.32); + } else { + this.yOffsetSub.set(sign * -47.37); + } + }); + + sub.on('chosenRa').handle((ra) => { + this.radioAlt = ra; + }); + + this.props.filteredRadioAltitude.sub((filteredRadioAltitude) => { + this.filteredRadioAltitude = filteredRadioAltitude; + }); + } + + render(): VNode { + return ( + + + + + V + / + S + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 6 + 1 + 2 + 6 + + + + + (c === 'White' ? 'Green' : c))} /> + + + ); + } +} + +class VSpeedNeedle extends DisplayComponent<{ yOffset: Subscribable, needleColour: Subscribable }> { + private outLineRef = FSComponent.createRef(); + + private indicatorRef = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const dxFull = 12; + const dxBorder = 5; + const centerX = 162.74; + const centerY = 80.822; + + this.props.yOffset.sub((yOffset) => { + const path = `m${centerX - dxBorder} ${centerY + dxBorder / dxFull * yOffset} l ${dxBorder - dxFull} ${(1 - dxBorder / dxFull) * yOffset}`; + + this.outLineRef.instance.setAttribute('d', path); + this.indicatorRef.instance.setAttribute('d', path); + }); + + this.props.needleColour.sub((colour) => { + this.indicatorRef.instance.setAttribute('class', `HugeStroke ${colour}`); + }, true); + } + + render(): VNode | null { + return ( + <> + + + + ); + } +} + +class VSpeedText extends DisplayComponent<{ bus: EventBus, yOffset: Subscribable, textColour: Subscribable }> { + private vsTextRef = FSComponent.createRef(); + + private groupRef = FSComponent.createRef(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + + sub.on('vs').handle((vs) => { + const absVSpeed = Math.abs(vs.value); + + if (absVSpeed < 200) { + this.groupRef.instance.setAttribute('visibility', 'hidden'); + return; + } + this.groupRef.instance.setAttribute('visibility', 'visible'); + + const sign = Math.sign(vs.value); + + const textOffset = this.props.yOffset.get() - sign * 2.4; + + const text = (Math.round(absVSpeed / 100) < 10 ? '0' : '') + Math.round(absVSpeed / 100).toString(); + this.vsTextRef.instance.textContent = text; + this.groupRef.instance.setAttribute('transform', `translate(0 ${textOffset})`); + }); + + this.props.textColour.sub((colour) => { + const className = `FontSmallest MiddleAlign ${colour}`; + this.vsTextRef.instance.setAttribute('class', className); + }, true); + } + + render(): VNode { + return ( + + + + + ); + } +} + +interface VSpeedTcasProps extends ComponentProps { + bus: EventBus; +} +class VSpeedTcas extends DisplayComponent { + private tcasGroup = FSComponent.createRef(); + + private nonCorrective = FSComponent.createRef(); + + private background = FSComponent.createRef(); + + private redZoneElement = FSComponent.createRef(); + + private greenZoneElement = FSComponent.createRef(); + + private redZone = Subject.create(-1); + + private redZoneHigh = Subject.create(-1); + + private greenZone = Subject.create(-1); + + private greenZoneHigh = Subject.create(-1); + + private extended = Subject.create(false); + + private isCorrective = Subject.create(false); + + public update(state: TcasState) { + if (state.tcasState !== 2) { + this.tcasGroup.instance.classList.add('HiddenElement'); + this.nonCorrective.instance.classList.add('HiddenElement'); + } else if (state.isTcasCorrective) { + this.tcasGroup.instance.classList.remove('HiddenElement'); + this.background.instance.classList.remove('HiddenElement'); + this.redZone.set(state.tcasRedZoneL); + this.redZoneHigh.set(state.tcasRedZoneH); + this.greenZone.set(state.tcasGreenZoneL); + this.greenZoneHigh.set(state.tcasGreenZoneH); + this.nonCorrective.instance.classList.add('HiddenElement'); + } else { + this.background.instance.classList.add('HiddenElement'); + this.nonCorrective.instance.classList.add('HiddenElement'); + + this.isCorrective.set(false); + this.extended.set(false); + this.redZone.set(state.tcasRedZoneL); + this.redZoneHigh.set(state.tcasRedZoneH); + } + } + + render(): VNode { + return ( + <> + + + + + + + + + + ); + } +} + +interface VSpeedTcasZoneProps extends ComponentProps { + zoneBoundLow: Subscribable; + zoneBoundHigh: Subscribable; + zoneClass: string; + isCorrective: Subscribable; + extended: Subscribable; +} +class VSpeedTcasZone extends DisplayComponent { + private zoneUpper =0; + + private zoneLower = 0; + + private extended =false; + + private isCorrective = false; + + private path = FSComponent.createRef(); + + private getYoffset = (VSpeed: number) => { + const absVSpeed = Math.abs(VSpeed); + const sign = Math.sign(VSpeed); + + if (absVSpeed < 1000) { + return VSpeed / 1000 * -27.22; + } + if (absVSpeed < 2000) { + return (VSpeed - sign * 1000) / 1000 * -10.1 - sign * 27.22; + } + if (absVSpeed < 6000) { + return (VSpeed - sign * 2000) / 4000 * -10.1 - sign * 37.32; + } + return sign * -47.37; + }; + + private drawTcasZone() { + if (this.zoneLower !== -1 && this.zoneUpper !== -1) { + let y1; + let y2; + let y3; + let y4; + + if (this.zoneLower >= 6000) { + y1 = 29.92; + } else if (this.zoneLower <= -6000) { + y1 = 131.72; + } else { + y1 = 80.822 + this.getYoffset(this.zoneLower); + } + + if (this.zoneUpper >= 6000) { + y2 = 29.92; + } else if (this.zoneUpper <= -6000) { + y2 = 131.72; + } else { + y2 = 80.822 + this.getYoffset(this.zoneUpper); + } + + if ((Math.abs(this.zoneUpper) > 1750 && Math.abs(this.zoneUpper) > Math.abs(this.zoneLower)) + || (this.isCorrective && this.props.zoneClass === 'Fill Red')) { + y3 = y2; + } else { + // y3 = 80.822 + getYoffset(zoneBounds[1] / 2); + y3 = 80.822; + } + + if (Math.abs(this.zoneLower) > 1750 && Math.abs(this.zoneLower) > Math.abs(this.zoneUpper) + || (this.isCorrective && this.props.zoneClass === 'Fill Red')) { + y4 = y1; + } else { + // y4 = 80.822 + getYoffset(zoneBounds[0] / 2); + y4 = 80.822; + } + + const x1 = 151.84; + const x2 = this.extended ? 162.74 : 157.3804; + + this.path.instance.setAttribute('d', `m${x1},${y1} L${x1},${y2} L${x2},${y3} L${x2},${y4} L${x1},${y1}z`); + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.props.zoneBoundLow.sub((z) => { + this.zoneLower = z; + this.drawTcasZone(); + }); + + this.props.zoneBoundHigh.sub((z) => { + this.zoneUpper = z; + this.drawTcasZone(); + }); + + this.props.extended.sub((z) => { + this.extended = z; + this.drawTcasZone(); + }); + + this.props.isCorrective.sub((z) => { + this.isCorrective = z; + this.drawTcasZone(); + }); + } + + render(): VNode { + return ( + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/VerticalTape.tsx b/fbw-a380x/src/systems/instruments/src/PFD/VerticalTape.tsx new file mode 100644 index 00000000000..948fe7c1942 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/VerticalTape.tsx @@ -0,0 +1,149 @@ +import { DisplayComponent, FSComponent, NodeReference, Subscribable, VNode } from '@microsoft/msfs-sdk'; + +interface VerticalTapeProps { + displayRange: number; + valueSpacing: number; + distanceSpacing: number; + tapeValue: Subscribable; + lowerLimit: number; + upperLimit: number; + type: 'altitude' | 'speed'; +} + +export class VerticalTape extends DisplayComponent { + private refElement = FSComponent.createRef(); + + private tickRefs: NodeReference[] = []; + + private buildSpeedGraduationPoints(): NodeReference[] { + const numTicks = Math.round(this.props.displayRange * 2 / this.props.valueSpacing); + + const clampedValue = Math.max(Math.min(this.props.tapeValue.get(), this.props.upperLimit), this.props.lowerLimit); + + let lowestValue = Math.max(Math.round((clampedValue - this.props.displayRange) / this.props.valueSpacing) * this.props.valueSpacing, this.props.lowerLimit); + if (lowestValue < this.props.tapeValue.get() - this.props.displayRange) { + lowestValue += this.props.valueSpacing; + } + + const graduationPoints = []; + + for (let i = 0; i < numTicks; i++) { + const elementValue = lowestValue + i * this.props.valueSpacing; + if (elementValue <= (this.props.upperLimit ?? Infinity)) { + const offset = -elementValue * this.props.distanceSpacing / this.props.valueSpacing; + const element = { elementValue, offset }; + if (element) { + let text = ''; + if (elementValue % 20 === 0) { + text = Math.abs(elementValue).toString().padStart(3, '0'); + } + + const tickRef = FSComponent.createRef(); + graduationPoints.push( + + + {text} + , + ); + this.tickRefs.push(tickRef); + } + } + } + return graduationPoints; + } + + private buildAltitudeGraduationPoints(): NodeReference[] { + const numTicks = Math.round(this.props.displayRange * 2 / this.props.valueSpacing); + + const clampedValue = Math.max(Math.min(this.props.tapeValue.get(), this.props.upperLimit), this.props.lowerLimit); + + let lowestValue = Math.max(Math.round((clampedValue - this.props.displayRange) / this.props.valueSpacing) * this.props.valueSpacing, this.props.lowerLimit); + if (lowestValue < this.props.tapeValue.get() - this.props.displayRange) { + lowestValue += this.props.valueSpacing; + } + + const graduationPoints = []; + + for (let i = 0; i < numTicks; i++) { + const elementValue = lowestValue + i * this.props.valueSpacing; + if (elementValue <= (this.props.upperLimit ?? Infinity)) { + const offset = -elementValue * this.props.distanceSpacing / this.props.valueSpacing; + const element = { elementValue, offset }; + if (element) { + let text = ''; + if (elementValue % 500 === 0) { + text = (Math.abs(elementValue) / 100).toString().padStart(3, '0'); + } + const tickRef = FSComponent.createRef(); + + graduationPoints.push( + + + + {text} + , + ); + this.tickRefs.push(tickRef); + } + } + } + return graduationPoints; + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.props.tapeValue.sub((newValue) => { + const multiplier = 100; + const currentValueAtPrecision = Math.round(newValue * multiplier) / multiplier; + const clampedValue = Math.max(Math.min(currentValueAtPrecision, this.props.upperLimit ?? Infinity), this.props.lowerLimit ?? -Infinity); + + let lowestValue = Math.max(Math.round((clampedValue - this.props.displayRange) / this.props.valueSpacing) * this.props.valueSpacing, this.props.lowerLimit); + if (lowestValue < currentValueAtPrecision - this.props.displayRange) { + lowestValue += this.props.valueSpacing; + } + + for (let i = 0; i < this.tickRefs.length - 1; i++) { + const elementValue = lowestValue + i * this.props.valueSpacing; + if (elementValue <= (this.props.upperLimit ?? Infinity)) { + const offset = -elementValue * this.props.distanceSpacing / this.props.valueSpacing; + const element = { elementValue, offset }; + if (element) { + this.tickRefs[i].instance.setAttribute('transform', `translate(0 ${offset})`); + + let text = ''; + if (this.props.type === 'speed') { + if (elementValue % 20 === 0) { + text = Math.abs(elementValue).toString().padStart(3, '0'); + } + } else if (this.props.type === 'altitude') { + if (elementValue % 500 === 0) { + text = (Math.abs(elementValue) / 100).toString().padStart(3, '0'); + this.tickRefs[i].instance.getElementsByTagName('path')[0].classList.remove('HiddenElement'); + } else { + this.tickRefs[i].instance.getElementsByTagName('path')[0].classList.add('HiddenElement'); + } + } + + if (this.tickRefs[i].instance.getElementsByTagName('text')[0].textContent !== text) { + this.tickRefs[i].instance.getElementsByTagName('text')[0].textContent = text; + } + } + } + } + + this.refElement.instance.style.transform = `translate3d(0px, ${clampedValue * this.props.distanceSpacing / this.props.valueSpacing}px, 0px)`; + }, true); + } + + render(): VNode { + return ( + + {this.props.type === 'altitude' && this.buildAltitudeGraduationPoints()} + {this.props.type === 'speed' && this.buildSpeedGraduationPoints()} + {this.props.children} + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/animations.scss b/fbw-a380x/src/systems/instruments/src/PFD/animations.scss new file mode 100644 index 00000000000..97f9bd61f66 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/animations.scss @@ -0,0 +1,71 @@ +@keyframes blinking { + 0% {opacity: 0;} + 50% {opacity: 0;} + 51% {opacity: 1;} + 100% {opacity: 1;} +} + +@mixin GenericPulsingStroke($color, $name) { + @keyframes #{$name} { + 0% {stroke: scale-color($color, $lightness: -30%);} + 50% {stroke: scale-color($color, $lightness: -30%);} + 51% {stroke: scale-color($color, $lightness: 30%);} + 100% {stroke: scale-color($color, $lightness: 30%);} + } + animation-name: $name; +} +@mixin GenericPulsingFill($color, $name) { + @keyframes #{$name} { + 0% {fill: scale-color($color, $lightness: -30%);} + 50% {fill: scale-color($color, $lightness: -30%);} + 51% {fill: scale-color($color, $lightness: 30%);} + 100% {fill: scale-color($color, $lightness: 30%);} + } + animation-name: $name; +} + +@keyframes OuterMarkerAnim { + 0% {opacity: 0;} + 33% {opacity: 0;} + 34% {opacity: 1;} + 100% {opacity: 1;} +} + +@keyframes MiddleMarkerAnim { + 0% {opacity: 0} + 10% {opacity: 0} + 11% {opacity: 1} + 27% {opacity: 1} + 28% {opacity: 0} + 44% {opacity: 0} + 45% {opacity: 1} + 100% {opacity: 1} +} + +.BlinkInfinite { + animation-name: blinking; + animation-duration: 1s; + animation-iteration-count: infinite; +} + +.Blink9Seconds { + animation-name: blinking; + animation-duration: 1s; + animation-iteration-count: 9; +} + +.OuterMarkerBlink { + animation-name: OuterMarkerAnim; + animation-duration: 460ms; + animation-iteration-count: infinite; +} +.MiddleMarkerBlink { + animation-name: MiddleMarkerAnim; + animation-duration: 730ms; + animation-iteration-count: infinite; +} +.InnerMarkerBlink { + animation-name: blinking; + animation-duration: 200ms; + animation-iteration-count: infinite; +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/config.json b/fbw-a380x/src/systems/instruments/src/PFD/config.json new file mode 100644 index 00000000000..ade492578f8 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/config.json @@ -0,0 +1,4 @@ +{ + "index": "./instrument.tsx", + "isInteractive": false +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/instrument.tsx b/fbw-a380x/src/systems/instruments/src/PFD/instrument.tsx new file mode 100644 index 00000000000..06e39e85e25 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/instrument.tsx @@ -0,0 +1,224 @@ +import { Clock, FSComponent, EventBus, HEventPublisher } from '@microsoft/msfs-sdk'; +import { PFDComponent } from './PFD'; +import { AdirsValueProvider } from './shared/AdirsValueProvider'; +import { ArincValueProvider } from './shared/ArincValueProvider'; +import { PFDSimvarPublisher } from './shared/PFDSimvarPublisher'; +import { SimplaneValueProvider } from './shared/SimplaneValueProvider'; + +import './style.scss'; + +class A32NX_PFD extends BaseInstrument { + private bus: EventBus; + + private simVarPublisher: PFDSimvarPublisher; + + private readonly hEventPublisher; + + private readonly arincProvider: ArincValueProvider; + + private readonly simplaneValueProvider: SimplaneValueProvider; + + private readonly clock: Clock; + + private readonly adirsValueProvider: AdirsValueProvider; + + /** + * "mainmenu" = 0 + * "loading" = 1 + * "briefing" = 2 + * "ingame" = 3 + */ + private gameState = 0; + + constructor() { + super(); + this.bus = new EventBus(); + this.simVarPublisher = new PFDSimvarPublisher(this.bus); + this.hEventPublisher = new HEventPublisher(this.bus); + this.arincProvider = new ArincValueProvider(this.bus); + this.simplaneValueProvider = new SimplaneValueProvider(this.bus); + this.clock = new Clock(this.bus); + this.adirsValueProvider = new AdirsValueProvider(this.bus, this.simVarPublisher); + } + + get templateID(): string { + return 'A32NX_PFD'; + } + + public getDeltaTime() { + return this.deltaTime; + } + + public onInteractionEvent(args: string[]): void { + this.hEventPublisher.dispatchHEvent(args[0]); + } + + public connectedCallback(): void { + super.connectedCallback(); + + this.arincProvider.init(); + this.clock.init(); + + this.simVarPublisher.subscribe('elec'); + this.simVarPublisher.subscribe('elecFo'); + + this.simVarPublisher.subscribe('coldDark'); + this.simVarPublisher.subscribe('potentiometerCaptain'); + this.simVarPublisher.subscribe('potentiometerFo'); + this.simVarPublisher.subscribe('pitch'); + this.simVarPublisher.subscribe('roll'); + this.simVarPublisher.subscribe('heading'); + this.simVarPublisher.subscribe('altitude'); + this.simVarPublisher.subscribe('speed'); + this.simVarPublisher.subscribe('alphaProt'); + this.simVarPublisher.subscribe('noseGearCompressed'); + this.simVarPublisher.subscribe('leftMainGearCompressed'); + this.simVarPublisher.subscribe('rightMainGearCompressed'); + this.simVarPublisher.subscribe('activeLateralMode'); + this.simVarPublisher.subscribe('activeVerticalMode'); + this.simVarPublisher.subscribe('fmaModeReversion'); + this.simVarPublisher.subscribe('fmaSpeedProtection'); + this.simVarPublisher.subscribe('AThrMode'); + this.simVarPublisher.subscribe('apVsSelected'); + this.simVarPublisher.subscribe('approachCapability'); + this.simVarPublisher.subscribe('ap1Active'); + this.simVarPublisher.subscribe('ap2Active'); + this.simVarPublisher.subscribe('fmaVerticalArmed'); + this.simVarPublisher.subscribe('fmaLateralArmed'); + this.simVarPublisher.subscribe('fd1Active'); + this.simVarPublisher.subscribe('fd2Active'); + this.simVarPublisher.subscribe('athrStatus'); + this.simVarPublisher.subscribe('athrModeMessage'); + this.simVarPublisher.subscribe('machPreselVal'); + this.simVarPublisher.subscribe('speedPreselVal'); + this.simVarPublisher.subscribe('mda'); + this.simVarPublisher.subscribe('dh'); + this.simVarPublisher.subscribe('attHdgKnob'); + this.simVarPublisher.subscribe('airKnob'); + this.simVarPublisher.subscribe('vsBaro'); + this.simVarPublisher.subscribe('vsInert'); + this.simVarPublisher.subscribe('sideStickY'); + this.simVarPublisher.subscribe('sideStickX'); + this.simVarPublisher.subscribe('fdYawCommand'); + this.simVarPublisher.subscribe('fdBank'); + this.simVarPublisher.subscribe('fdPitch'); + this.simVarPublisher.subscribe('hasLoc'); + this.simVarPublisher.subscribe('hasDme'); + this.simVarPublisher.subscribe('navIdent'); + this.simVarPublisher.subscribe('navFreq'); + this.simVarPublisher.subscribe('dme'); + this.simVarPublisher.subscribe('navRadialError'); + this.simVarPublisher.subscribe('hasGlideslope'); + this.simVarPublisher.subscribe('glideSlopeError'); + this.simVarPublisher.subscribe('markerBeacon'); + this.simVarPublisher.subscribe('v1'); + this.simVarPublisher.subscribe('fwcFlightPhase'); + this.simVarPublisher.subscribe('fmgcFlightPhase'); + + this.simVarPublisher.subscribe('vr'); + + this.simVarPublisher.subscribe('vMax'); + + this.simVarPublisher.subscribe('isAltManaged'); + + this.simVarPublisher.subscribe('mach'); + this.simVarPublisher.subscribe('flapHandleIndex'); + + this.simVarPublisher.subscribe('greenDotSpeed'); + + this.simVarPublisher.subscribe('slatSpeed'); + + this.simVarPublisher.subscribe('fSpeed'); + this.simVarPublisher.subscribe('transAlt'); + this.simVarPublisher.subscribe('transAltAppr'); + + this.simVarPublisher.subscribe('groundTrack'); + this.simVarPublisher.subscribe('showSelectedHeading'); + this.simVarPublisher.subscribe('altConstraint'); + this.simVarPublisher.subscribe('trkFpaActive'); + this.simVarPublisher.subscribe('aoa'); + this.simVarPublisher.subscribe('groundHeadingTrue'); + this.simVarPublisher.subscribe('groundTrackTrue'); + + this.simVarPublisher.subscribe('selectedFpa'); + this.simVarPublisher.subscribe('targetSpeedManaged'); + this.simVarPublisher.subscribe('vfeNext'); + this.simVarPublisher.subscribe('ilsCourse'); + this.simVarPublisher.subscribe('tla1'); + this.simVarPublisher.subscribe('tla2'); + this.simVarPublisher.subscribe('metricAltToggle'); + this.simVarPublisher.subscribe('landingElevation'); + + this.simVarPublisher.subscribe('tcasState'); + this.simVarPublisher.subscribe('tcasCorrective'); + this.simVarPublisher.subscribe('tcasRedZoneL'); + this.simVarPublisher.subscribe('tcasRedZoneH'); + this.simVarPublisher.subscribe('tcasGreenZoneL'); + this.simVarPublisher.subscribe('tcasGreenZoneH'); + this.simVarPublisher.subscribe('tcasFail'); + this.simVarPublisher.subscribe('engOneRunning'); + this.simVarPublisher.subscribe('engTwoRunning'); + this.simVarPublisher.subscribe('expediteMode'); + this.simVarPublisher.subscribe('setHoldSpeed'); + this.simVarPublisher.subscribe('vls'); + this.simVarPublisher.subscribe('alphaLim'); + this.simVarPublisher.subscribe('trkFpaDeselectedTCAS'); + this.simVarPublisher.subscribe('tcasRaInhibited'); + this.simVarPublisher.subscribe('groundSpeed'); + this.simVarPublisher.subscribe('radioAltitude1'); + this.simVarPublisher.subscribe('radioAltitude2'); + this.simVarPublisher.subscribe('radioAltitude3'); + + this.simVarPublisher.subscribe('beta'); + this.simVarPublisher.subscribe('betaTargetActive'); + this.simVarPublisher.subscribe('betaTarget'); + this.simVarPublisher.subscribe('latAcc'); + this.simVarPublisher.subscribe('crzAltMode'); + this.simVarPublisher.subscribe('tcasModeDisarmed'); + this.simVarPublisher.subscribe('flexTemp'); + this.simVarPublisher.subscribe('autoBrakeMode'); + this.simVarPublisher.subscribe('autoBrakeActive'); + this.simVarPublisher.subscribe('autoBrakeDecel'); + this.simVarPublisher.subscribe('fpaRaw'); + this.simVarPublisher.subscribe('daRaw'); + this.simVarPublisher.subscribe('ls1Button'); + this.simVarPublisher.subscribe('ls2Button'); + this.simVarPublisher.subscribe('xtk'); + this.simVarPublisher.subscribe('ldevRequestLeft'); + this.simVarPublisher.subscribe('ldevRequestRight'); + + FSComponent.render(, document.getElementById('PFD_CONTENT')); + } + + /** + * A callback called when the instrument gets a frame update. + */ + public Update(): void { + super.Update(); + + if (this.gameState !== 3) { + const gamestate = this.getGameState(); + if (gamestate === 3) { + this.simVarPublisher.startPublish(); + this.hEventPublisher.startPublish(); + this.adirsValueProvider.start(); + } + this.gameState = gamestate; + } else { + this.simVarPublisher.onUpdate(); + this.simplaneValueProvider.onUpdate(); + this.clock.onUpdate(); + } + } + + protected onFlightStart() { + super.onFlightStart(); + if (SimVar.GetSimVarValue('L:A32NX_IS_READY', 'number') !== 1) { + // set ready signal that JS code is initialized and flight is actually started + // -> user pressed 'READY TO FLY' button + SimVar.SetSimVarValue('L:A32NX_IS_READY', 'number', 1); + } + } +} + +registerInstrument('a32nx-pfd', A32NX_PFD); diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/AdirsValueProvider.tsx b/fbw-a380x/src/systems/instruments/src/PFD/shared/AdirsValueProvider.tsx new file mode 100644 index 00000000000..5a960f6aa06 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/AdirsValueProvider.tsx @@ -0,0 +1,45 @@ +import { EventBus, SimVarValueType } from '@microsoft/msfs-sdk'; +import { getDisplayIndex } from '../PFD'; +import { PFDSimvarPublisher, PFDSimvars } from './PFDSimvarPublisher'; + +export class AdirsValueProvider { + constructor(private readonly bus: EventBus, private readonly pfdSimvar: PFDSimvarPublisher) { + + } + + public start() { + const sub = this.bus.getSubscriber(); + const displayIndex = getDisplayIndex(); + + sub.on('attHdgKnob').whenChanged().handle((k) => { + const inertialSource = getSupplier(displayIndex, k); + this.pfdSimvar.updateSimVarSource('vsInert', { name: `L:A32NX_ADIRS_IR_${inertialSource}_VERTICAL_SPEED`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('pitch', { name: `L:A32NX_ADIRS_IR_${inertialSource}_PITCH`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('roll', { name: `L:A32NX_ADIRS_IR_${inertialSource}_ROLL`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('heading', { name: `L:A32NX_ADIRS_IR_${inertialSource}_HEADING`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('groundTrack', { name: `L:A32NX_ADIRS_IR_${inertialSource}_TRACK`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('fpaRaw', { name: `L:A32NX_ADIRS_IR_${inertialSource}_FLIGHT_PATH_ANGLE`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('daRaw', { name: `L:A32NX_ADIRS_IR_${inertialSource}_DRIFT_ANGLE`, type: SimVarValueType.Number }); + }); + + sub.on('airKnob').whenChanged().handle((a) => { + const airSource = getSupplier(displayIndex, a); + this.pfdSimvar.updateSimVarSource('speed', { name: `L:A32NX_ADIRS_ADR_${airSource}_COMPUTED_AIRSPEED`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('vsBaro', { name: `L:A32NX_ADIRS_ADR_${airSource}_BAROMETRIC_VERTICAL_SPEED`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('altitude', { name: `L:A32NX_ADIRS_ADR_${airSource}_ALTITUDE`, type: SimVarValueType.Number }); + this.pfdSimvar.updateSimVarSource('mach', { name: `L:A32NX_ADIRS_ADR_${airSource}_MACH`, type: SimVarValueType.Number }); + }); + } +} + +const isCaptainSide = (displayIndex: number | undefined) => displayIndex === 1; + +const getSupplier = (displayIndex: number | undefined, knobValue: number) => { + const adirs3ToCaptain = 0; + const adirs3ToFO = 2; + + if (isCaptainSide(displayIndex)) { + return knobValue === adirs3ToCaptain ? 3 : 1; + } + return knobValue === adirs3ToFO ? 3 : 2; +}; diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/ArincValueProvider.ts b/fbw-a380x/src/systems/instruments/src/PFD/shared/ArincValueProvider.ts new file mode 100644 index 00000000000..fa55ad4ac7a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/ArincValueProvider.ts @@ -0,0 +1,216 @@ +import { EventBus, Publisher } from '@microsoft/msfs-sdk'; +import { getDisplayIndex } from 'instruments/src/PFD/PFD'; +import { Arinc429Word } from '@shared/arinc429'; +import { PFDSimvars } from './PFDSimvarPublisher'; + +export interface Arinc429Values { + pitchAr: Arinc429Word; + rollAr: Arinc429Word; + altitudeAr: Arinc429Word; + groundTrackAr: Arinc429Word; + headingAr: Arinc429Word; + speedAr: Arinc429Word; + machAr: Arinc429Word; + vs: Arinc429Word; + gs: Arinc429Word; + chosenRa: Arinc429Word; + fpa: Arinc429Word; + da: Arinc429Word; + +} +export class ArincValueProvider { + private roll = new Arinc429Word(0); + + private pitch = new Arinc429Word(0); + + private groundTrack = new Arinc429Word(0); + + private heading = new Arinc429Word(0); + + private speed = new Arinc429Word(0); + + private altitude = new Arinc429Word(0); + + private mach = new Arinc429Word(0); + + private vsInert = new Arinc429Word(0); + + private vsBaro = new Arinc429Word(0); + + private groundSpeed = new Arinc429Word(0); + + private radioAltitude1 = new Arinc429Word(0); + + private radioAltitude2 = new Arinc429Word(0); + + private radioAltitude3 = new Arinc429Word(0); + + private fpa = new Arinc429Word(0); + + private da = new Arinc429Word(0); + + constructor(private readonly bus: EventBus) { + + } + + public init() { + const publisher = this.bus.getPublisher(); + const subscriber = this.bus.getSubscriber(); + + subscriber.on('pitch').handle((p) => { + this.pitch = new Arinc429Word(p); + publisher.pub('pitchAr', this.pitch); + }); + subscriber.on('roll').handle((p) => { + this.roll = new Arinc429Word(p); + publisher.pub('rollAr', this.roll); + }); + subscriber.on('groundTrack').handle((gt) => { + this.groundTrack = new Arinc429Word(gt); + publisher.pub('groundTrackAr', this.groundTrack); + }); + subscriber.on('heading').handle((h) => { + this.heading = new Arinc429Word(h); + publisher.pub('headingAr', this.heading); + }); + + subscriber.on('speed').handle((s) => { + this.speed = new Arinc429Word(s); + publisher.pub('speedAr', this.speed); + }); + + subscriber.on('altitude').handle((a) => { + this.altitude = new Arinc429Word(a); + publisher.pub('altitudeAr', this.altitude); + }); + + subscriber.on('mach').handle((m) => { + this.mach = new Arinc429Word(m); + publisher.pub('machAr', this.mach); + }); + + subscriber.on('vsInert').handle((ivs) => { + this.vsInert = new Arinc429Word(ivs); + + if (this.vsInert.isNormalOperation()) { + publisher.pub('vs', this.vsInert); + } + }); + + subscriber.on('vsBaro').handle((vsb) => { + this.vsBaro = new Arinc429Word(vsb); + if (!this.vsInert.isNormalOperation()) { + publisher.pub('vs', this.vsBaro); + } + }); + + subscriber.on('groundSpeed').handle((gs) => { + this.groundSpeed = new Arinc429Word(gs); + publisher.pub('gs', this.groundSpeed); + }); + + subscriber.on('radioAltitude1').handle((ra) => { + this.radioAltitude1 = new Arinc429Word(ra); + this.determineAndPublishChosenRadioAltitude(publisher); + }); + + subscriber.on('radioAltitude2').handle((ra) => { + this.radioAltitude2 = new Arinc429Word(ra); + this.determineAndPublishChosenRadioAltitude(publisher); + }); + + subscriber.on('radioAltitude3').handle((ra) => { + this.radioAltitude3 = new Arinc429Word(ra); + this.determineAndPublishChosenRadioAltitude(publisher); + }); + + subscriber.on('fpaRaw').handle((fpa) => { + this.fpa = new Arinc429Word(fpa); + publisher.pub('fpa', this.fpa); + }); + + subscriber.on('daRaw').handle((da) => { + this.da = new Arinc429Word(da); + publisher.pub('da', this.da); + }); + } + + private determineAndPublishChosenRadioAltitude(publisher: Publisher) { + const validRaMap = [ + this.radioAltitude1, + this.radioAltitude2, + this.radioAltitude3, + ].map((ra) => !ra.isFailureWarning() && !ra.isNoComputedData()); + const validCount = validRaMap.filter(x => !!x).length; + + let chosenRas = [this.radioAltitude1, this.radioAltitude2]; // Default: 1 gets 1, 2 gets 2 + if (validCount === 3) { + // pick the median + const heights = [ + this.radioAltitude1, + this.radioAltitude2, + this.radioAltitude3, + ].sort((a, b) => a.value - b.value); + chosenRas = [heights[1], heights[1]]; + } else if (validCount === 2) { + if (!validRaMap[0]) { + // fail PFD 1 to RA 3 + chosenRas = [this.radioAltitude3, this.radioAltitude2]; + } + else if (!validRaMap[1]) { + // fail PFD 2 to RA 3 + chosenRas = [this.radioAltitude1, this.radioAltitude3]; + } + // otherwise stick with the default (PFD 1 to 1, PFD 2 to 2) + } else if (validCount === 1) { + if (validRaMap[0]) { + // both get RA 1 + chosenRas = [this.radioAltitude1, this.radioAltitude1]; + } + else if (validRaMap[1]) { + // both get RA 2 + chosenRas = [this.radioAltitude2, this.radioAltitude2]; + } + else { + // both get RA 3 + chosenRas = [this.radioAltitude3, this.radioAltitude3]; + } + } else { + // at this point all have either NCD or FW + // try to fail back a bit more intelligently around FWs to prioritize NCDs + const nonFailedMap = [ + this.radioAltitude1, + this.radioAltitude2, + this.radioAltitude3, + ].map((ra) => !ra.isFailureWarning()); + const nonFailedCount = nonFailedMap.filter(x => !!x).length; + if (nonFailedCount === 2) { + if (!nonFailedMap[0]) { + // fail PFD 1 to RA 3 + chosenRas = [this.radioAltitude3, this.radioAltitude2]; + } + else if (!nonFailedMap[1]) { + // fail PFD 2 to RA 3 + chosenRas = [this.radioAltitude1, this.radioAltitude3]; + } + } + else if (nonFailedCount === 1) { + if (nonFailedMap[0]) { + // both get RA 1 + chosenRas = [this.radioAltitude1, this.radioAltitude1]; + } + else if (nonFailedMap[1]) { + // both get RA 2 + chosenRas = [this.radioAltitude2, this.radioAltitude2]; + } + else { + // both get RA 3 + chosenRas = [this.radioAltitude3, this.radioAltitude3]; + } + } + // don't do anything in case of 3 FWs or 0 FWs and stick to the default + } + + publisher.pub('chosenRa', getDisplayIndex() === 1 ? chosenRas[0] : chosenRas[1]); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/PFDSimvarPublisher.tsx b/fbw-a380x/src/systems/instruments/src/PFD/shared/PFDSimvarPublisher.tsx new file mode 100644 index 00000000000..c0a6fc52bfe --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/PFDSimvarPublisher.tsx @@ -0,0 +1,362 @@ +import { EventBus, SimVarDefinition, SimVarValueType, SimVarPublisher } from '@microsoft/msfs-sdk'; + +export interface PFDSimvars { + coldDark: number; + elec: number; + elecFo: number; + potentiometerCaptain: number; + potentiometerFo: number; + pitch: number; + roll: number; + heading: number; + altitude: number; + speed: number; + alphaProt: number; + noseGearCompressed: boolean; + leftMainGearCompressed: boolean; + rightMainGearCompressed: boolean; + activeLateralMode: number; + activeVerticalMode: number; + fmaModeReversion: boolean; + fmaSpeedProtection: boolean; + AThrMode: number; + apVsSelected: number; + approachCapability: number; + ap1Active: boolean; + ap2Active: boolean; + fmaVerticalArmed: number; + fmaLateralArmed: number; + fd1Active: boolean; + fd2Active: boolean; + athrStatus: number; + athrModeMessage: number; + machPreselVal: number; + speedPreselVal: number; + mda: number; + dh: number; + attHdgKnob: number; + airKnob: number; + vsBaro: number; + vsInert: number; + sideStickX: number; + sideStickY: number; + fdYawCommand: number; + fdBank: number; + fdPitch: number; + v1: number; + vr:number; + fwcFlightPhase: number; + fmgcFlightPhase: number; + hasLoc: boolean; + hasDme: boolean; + navIdent: string; + navFreq: number; + dme: number; + navRadialError: number; + hasGlideslope: boolean; + glideSlopeError: number; + markerBeacon: number; + isAltManaged: boolean; + vMax: number; + targetSpeedManaged: number; + mach: number; + flapHandleIndex: number; + greenDotSpeed: number; + slatSpeed: number; + fSpeed: number; + transAlt: number; + transAltAppr: number; + groundTrack: number; + showSelectedHeading: number; + altConstraint: number; + trkFpaActive: boolean; + aoa: number; + groundHeadingTrue: number; + groundTrackTrue: number; + selectedFpa: number; + vfeNext: number; + ilsCourse: number; + metricAltToggle: boolean; + tla1: number; + tla2: number; + landingElevation: number; + tcasState: number; + tcasCorrective: boolean; + tcasRedZoneL: number; + tcasRedZoneH: number; + tcasGreenZoneL: number; + tcasGreenZoneH: number; + tcasFail: boolean; + engOneRunning: boolean; + engTwoRunning: boolean; + expediteMode: boolean; + setHoldSpeed: boolean; + vls: number; + alphaLim: number; + trkFpaDeselectedTCAS: boolean; + tcasRaInhibited: boolean; + groundSpeed: number; + radioAltitude1: number; + radioAltitude2: number; + radioAltitude3: number; + beta: number; + betaTargetActive: number; + betaTarget: number; + latAcc: number; + crzAltMode: boolean; + tcasModeDisarmed: boolean; + flexTemp: number; + autoBrakeMode: number; + autoBrakeActive: boolean; + autoBrakeDecel: boolean; + fpaRaw: number; + daRaw: number; + ls1Button: boolean; + ls2Button: boolean; + xtk: number; + ldevRequestLeft: boolean; + ldevRequestRight: boolean; + } + +export enum PFDVars { + coldDark = 'L:A32NX_COLD_AND_DARK_SPAWN', + elec = 'L:A32NX_ELEC_AC_ESS_BUS_IS_POWERED', + elecFo = 'L:A32NX_ELEC_AC_2_BUS_IS_POWERED', + potentiometerCaptain = 'LIGHT POTENTIOMETER:88', + potentiometerFo = 'LIGHT POTENTIOMETER:90', + pitch = 'L:A32NX_ADIRS_IR_1_PITCH', + roll = 'L:A32NX_ADIRS_IR_1_ROLL', + heading = 'L:A32NX_ADIRS_IR_1_HEADING', + altitude = 'L:A32NX_ADIRS_ADR_1_ALTITUDE', + speed = 'L:A32NX_ADIRS_ADR_1_COMPUTED_AIRSPEED', + alphaProt = 'L:A32NX_SPEEDS_ALPHA_PROTECTION', + noseGearCompressed = 'L:A32NX_LGCIU_1_NOSE_GEAR_COMPRESSED', + leftMainGearCompressed = 'L:A32NX_LGCIU_1_LEFT_GEAR_COMPRESSED', + rightMainGearCompressed = 'L:A32NX_LGCIU_1_RIGHT_GEAR_COMPRESSED', + activeLateralMode = 'L:A32NX_FMA_LATERAL_MODE', + activeVerticalMode = 'L:A32NX_FMA_VERTICAL_MODE', + fmaModeReversion = 'L:A32NX_FMA_MODE_REVERSION', + fmaSpeedProtection = 'L:A32NX_FMA_SPEED_PROTECTION_MODE', + AThrMode = 'L:A32NX_AUTOTHRUST_MODE', + apVsSelected = 'L:A32NX_AUTOPILOT_VS_SELECTED', + approachCapability = 'L:A32NX_ApproachCapability', + ap1Active = 'L:A32NX_AUTOPILOT_1_ACTIVE', + ap2Active = 'L:A32NX_AUTOPILOT_2_ACTIVE', + fmaVerticalArmed = 'L:A32NX_FMA_VERTICAL_ARMED', + fmaLateralArmed = 'L:A32NX_FMA_LATERAL_ARMED', + fd1Active = 'AUTOPILOT FLIGHT DIRECTOR ACTIVE:1', + fd2Active = 'AUTOPILOT FLIGHT DIRECTOR ACTIVE:2', + athrStatus = 'L:A32NX_AUTOTHRUST_STATUS', + athrModeMessage = 'L:A32NX_AUTOTHRUST_MODE_MESSAGE', + machPreselVal = 'L:A32NX_MachPreselVal', + speedPreselVal = 'L:A32NX_SpeedPreselVal', + mda = 'L:AIRLINER_MINIMUM_DESCENT_ALTITUDE', + dh = 'L:AIRLINER_DECISION_HEIGHT', + attHdgKnob = 'L:A32NX_ATT_HDG_SWITCHING_KNOB', + airKnob = 'L:A32NX_AIR_DATA_SWITCHING_KNOB', + vsBaro = 'L:A32NX_ADIRS_ADR_1_BAROMETRIC_VERTICAL_SPEED', + vsInert = 'L:A32NX_ADIRS_IR_1_VERTICAL_SPEED', + sideStickX = 'L:A32NX_SIDESTICK_POSITION_X', + sideStickY = 'L:A32NX_SIDESTICK_POSITION_Y', + fdYawCommand = 'L:A32NX_FLIGHT_DIRECTOR_YAW', + fdBank = 'L:A32NX_FLIGHT_DIRECTOR_BANK', + fdPitch = 'L:A32NX_FLIGHT_DIRECTOR_PITCH', + v1 = 'L:AIRLINER_V1_SPEED', + vr = 'L:AIRLINER_VR_SPEED', + fwcFlightPhase = 'L:A32NX_FWC_FLIGHT_PHASE', + fmgcFlightPhase = 'L:A32NX_FMGC_FLIGHT_PHASE', + hasLoc = 'L:A32NX_RADIO_RECEIVER_LOC_IS_VALID', + hasDme = 'NAV HAS DME:3', + navIdent = 'NAV IDENT:3', + navFreq = 'NAV FREQUENCY:3', + dme = 'NAV DME:3', + navRadialError = 'L:A32NX_RADIO_RECEIVER_LOC_DEVIATION', + hasGlideslope = 'L:A32NX_RADIO_RECEIVER_GS_IS_VALID', + glideSlopeError = 'L:A32NX_RADIO_RECEIVER_GS_DEVIATION', + markerBeacon = 'MARKER BEACON STATE', + isAltManaged = 'L:A32NX_FCU_ALT_MANAGED', + targetSpeedManaged = 'L:A32NX_SPEEDS_MANAGED_PFD', + vMax = 'L:A32NX_SPEEDS_VMAX', + mach = 'L:A32NX_ADIRS_ADR_1_MACH', + flapHandleIndex = 'L:A32NX_FLAPS_HANDLE_INDEX', + greenDotSpeed = 'L:A32NX_SPEEDS_GD', + slatSpeed = 'L:A32NX_SPEEDS_S', + fSpeed = 'L:A32NX_SPEEDS_F', + transAlt = 'L:AIRLINER_TRANS_ALT', + transAltAppr = 'L:AIRLINER_APPR_TRANS_ALT', + groundTrack = 'L:A32NX_ADIRS_IR_1_TRACK', + showSelectedHeading = 'L:A320_FCU_SHOW_SELECTED_HEADING', + altConstraint = 'L:A32NX_FG_ALTITUDE_CONSTRAINT', + trkFpaActive = 'L:A32NX_TRK_FPA_MODE_ACTIVE', + aoa = 'INCIDENCE ALPHA', + groundHeadingTrue = 'GPS GROUND TRUE HEADING', + groundTrackTrue = 'GPS GROUND TRUE TRACK', + selectedFpa = 'L:A32NX_AUTOPILOT_FPA_SELECTED', + vfeNext = 'L:A32NX_SPEEDS_VFEN', + ilsCourse = 'L:A32NX_FM_LS_COURSE', + metricAltToggle = 'L:A32NX_METRIC_ALT_TOGGLE', + tla1='L:A32NX_AUTOTHRUST_TLA:1', + tla2='L:A32NX_AUTOTHRUST_TLA:2', + landingElevation = 'L:A32NX_PRESS_AUTO_LANDING_ELEVATION', + tcasState = 'L:A32NX_TCAS_STATE', + tcasCorrective = 'L:A32NX_TCAS_RA_CORRECTIVE', + tcasRedZoneL = 'L:A32NX_TCAS_VSPEED_RED:1', + tcasRedZoneH = 'L:A32NX_TCAS_VSPEED_RED:2', + tcasGreenZoneL = 'L:A32NX_TCAS_VSPEED_GREEN:1', + tcasGreenZoneH = 'L:A32NX_TCAS_VSPEED_GREEN:2', + tcasFail = 'L:A32NX_TCAS_FAULT', + engOneRunning = 'GENERAL ENG COMBUSTION:1', + engTwoRunning = 'GENERAL ENG COMBUSTION:2', + expediteMode = 'L:A32NX_FMA_EXPEDITE_MODE', + setHoldSpeed = 'L:A32NX_PFD_MSG_SET_HOLD_SPEED', + vls = 'L:A32NX_SPEEDS_VLS', + alphaLim = 'L:A32NX_SPEEDS_ALPHA_MAX', + trkFpaDeselectedTCAS= 'L:A32NX_AUTOPILOT_TCAS_MESSAGE_TRK_FPA_DESELECTION', + tcasRaInhibited = 'L:A32NX_AUTOPILOT_TCAS_MESSAGE_RA_INHIBITED', + groundSpeed = 'L:A32NX_ADIRS_IR_1_GROUND_SPEED', + radioAltitude1 = 'L:A32NX_RA_1_RADIO_ALTITUDE', + radioAltitude2 = 'L:A32NX_RA_2_RADIO_ALTITUDE', + radioAltitude3 = 'L:A32NX_RA_3_RADIO_ALTITUDE', + beta = 'INCIDENCE BETA', + betaTargetActive = 'L:A32NX_BETA_TARGET_ACTIVE', + betaTarget = 'L:A32NX_BETA_TARGET', + latAcc = 'ACCELERATION BODY X', + crzAltMode = 'L:A32NX_FMA_CRUISE_ALT_MODE', + tcasModeDisarmed = 'L:A32NX_AUTOPILOT_TCAS_MESSAGE_DISARM', + flexTemp = 'L:AIRLINER_TO_FLEX_TEMP', + autoBrakeMode = 'L:A32NX_AUTOBRAKES_ARMED_MODE', + autoBrakeActive = 'L:A32NX_AUTOBRAKES_ACTIVE', + autoBrakeDecel = 'L:A32NX_AUTOBRAKES_DECEL_LIGHT', + fpaRaw = 'L:A32NX_ADIRS_IR_1_FLIGHT_PATH_ANGLE', + daRaw = 'L:A32NX_ADIRS_IR_1_DRIFT_ANGLE', + ls1Button = 'L:BTN_LS_1_FILTER_ACTIVE', + ls2Button = 'L:BTN_LS_2_FILTER_ACTIVE', + xtk = 'L:A32NX_FG_CROSS_TRACK_ERROR', + ldevLeft = 'L:A32NX_FMGC_L_LDEV_REQUEST', + ldevRight = 'L:A32NX_FMGC_R_LDEV_REQUEST', + } + +/** A publisher to poll and publish nav/com simvars. */ +export class PFDSimvarPublisher extends SimVarPublisher { + private static simvars = new Map([ + ['coldDark', { name: PFDVars.coldDark, type: SimVarValueType.Number }], + ['elec', { name: PFDVars.elec, type: SimVarValueType.Bool }], + ['elecFo', { name: PFDVars.elecFo, type: SimVarValueType.Bool }], + ['potentiometerCaptain', { name: PFDVars.potentiometerCaptain, type: SimVarValueType.Number }], + ['potentiometerFo', { name: PFDVars.potentiometerFo, type: SimVarValueType.Number }], + ['pitch', { name: PFDVars.pitch, type: SimVarValueType.Number }], + ['roll', { name: PFDVars.roll, type: SimVarValueType.Number }], + ['heading', { name: PFDVars.heading, type: SimVarValueType.Number }], + ['altitude', { name: PFDVars.altitude, type: SimVarValueType.Number }], + ['speed', { name: PFDVars.speed, type: SimVarValueType.Number }], + ['alphaProt', { name: PFDVars.alphaProt, type: SimVarValueType.Number }], + ['noseGearCompressed', { name: PFDVars.noseGearCompressed, type: SimVarValueType.Bool }], + ['leftMainGearCompressed', { name: PFDVars.leftMainGearCompressed, type: SimVarValueType.Bool }], + ['rightMainGearCompressed', { name: PFDVars.rightMainGearCompressed, type: SimVarValueType.Bool }], + ['activeLateralMode', { name: PFDVars.activeLateralMode, type: SimVarValueType.Number }], + ['activeVerticalMode', { name: PFDVars.activeVerticalMode, type: SimVarValueType.Number }], + ['fmaModeReversion', { name: PFDVars.fmaModeReversion, type: SimVarValueType.Bool }], + ['fmaSpeedProtection', { name: PFDVars.fmaSpeedProtection, type: SimVarValueType.Bool }], + ['AThrMode', { name: PFDVars.AThrMode, type: SimVarValueType.Number }], + ['apVsSelected', { name: PFDVars.apVsSelected, type: SimVarValueType.FPM }], + ['approachCapability', { name: PFDVars.approachCapability, type: SimVarValueType.Number }], + ['ap1Active', { name: PFDVars.ap1Active, type: SimVarValueType.Bool }], + ['ap2Active', { name: PFDVars.ap2Active, type: SimVarValueType.Bool }], + ['fmaVerticalArmed', { name: PFDVars.fmaVerticalArmed, type: SimVarValueType.Number }], + ['fmaLateralArmed', { name: PFDVars.fmaLateralArmed, type: SimVarValueType.Number }], + ['fd1Active', { name: PFDVars.fd1Active, type: SimVarValueType.Bool }], + ['fd2Active', { name: PFDVars.fd2Active, type: SimVarValueType.Bool }], + ['athrStatus', { name: PFDVars.athrStatus, type: SimVarValueType.Number }], + ['athrModeMessage', { name: PFDVars.athrModeMessage, type: SimVarValueType.Number }], + ['machPreselVal', { name: PFDVars.machPreselVal, type: SimVarValueType.Number }], + ['speedPreselVal', { name: PFDVars.speedPreselVal, type: SimVarValueType.Knots }], + ['mda', { name: PFDVars.mda, type: SimVarValueType.Feet }], + ['dh', { name: PFDVars.dh, type: SimVarValueType.Feet }], + ['attHdgKnob', { name: PFDVars.attHdgKnob, type: SimVarValueType.Enum }], + ['airKnob', { name: PFDVars.airKnob, type: SimVarValueType.Enum }], + ['vsBaro', { name: PFDVars.vsBaro, type: SimVarValueType.Number }], + ['vsInert', { name: PFDVars.vsInert, type: SimVarValueType.Number }], + ['sideStickX', { name: PFDVars.sideStickX, type: SimVarValueType.Number }], + ['sideStickY', { name: PFDVars.sideStickY, type: SimVarValueType.Number }], + ['fdYawCommand', { name: PFDVars.fdYawCommand, type: SimVarValueType.Number }], + ['fdBank', { name: PFDVars.fdBank, type: SimVarValueType.Number }], + ['fdPitch', { name: PFDVars.fdPitch, type: SimVarValueType.Number }], + ['v1', { name: PFDVars.v1, type: SimVarValueType.Knots }], + ['vr', { name: PFDVars.vr, type: SimVarValueType.Knots }], + ['fwcFlightPhase', { name: PFDVars.fwcFlightPhase, type: SimVarValueType.Number }], + ['fmgcFlightPhase', { name: PFDVars.fmgcFlightPhase, type: SimVarValueType.Enum }], + ['hasLoc', { name: PFDVars.hasLoc, type: SimVarValueType.Bool }], + ['hasDme', { name: PFDVars.hasDme, type: SimVarValueType.Bool }], + ['navIdent', { name: PFDVars.navIdent, type: SimVarValueType.String }], + ['navFreq', { name: PFDVars.navFreq, type: SimVarValueType.MHz }], + ['dme', { name: PFDVars.dme, type: SimVarValueType.NM }], + ['navRadialError', { name: PFDVars.navRadialError, type: SimVarValueType.Degree }], + ['hasGlideslope', { name: PFDVars.hasGlideslope, type: SimVarValueType.Bool }], + ['glideSlopeError', { name: PFDVars.glideSlopeError, type: SimVarValueType.Degree }], + ['markerBeacon', { name: PFDVars.markerBeacon, type: SimVarValueType.Enum }], + ['isAltManaged', { name: PFDVars.isAltManaged, type: SimVarValueType.Bool }], + ['targetSpeedManaged', { name: PFDVars.targetSpeedManaged, type: SimVarValueType.Knots }], + ['vMax', { name: PFDVars.vMax, type: SimVarValueType.Number }], + ['mach', { name: PFDVars.mach, type: SimVarValueType.Number }], + ['flapHandleIndex', { name: PFDVars.flapHandleIndex, type: SimVarValueType.Number }], + ['greenDotSpeed', { name: PFDVars.greenDotSpeed, type: SimVarValueType.Number }], + ['slatSpeed', { name: PFDVars.slatSpeed, type: SimVarValueType.Number }], + ['fSpeed', { name: PFDVars.fSpeed, type: SimVarValueType.Number }], + ['transAlt', { name: PFDVars.transAlt, type: SimVarValueType.Number }], + ['transAltAppr', { name: PFDVars.transAltAppr, type: SimVarValueType.Number }], + ['groundTrack', { name: PFDVars.groundTrack, type: SimVarValueType.Number }], + ['showSelectedHeading', { name: PFDVars.showSelectedHeading, type: SimVarValueType.Number }], + ['altConstraint', { name: PFDVars.altConstraint, type: SimVarValueType.Feet }], + ['trkFpaActive', { name: PFDVars.trkFpaActive, type: SimVarValueType.Bool }], + ['aoa', { name: PFDVars.aoa, type: SimVarValueType.Degree }], + ['groundHeadingTrue', { name: PFDVars.groundHeadingTrue, type: SimVarValueType.Degree }], + ['groundTrackTrue', { name: PFDVars.groundTrackTrue, type: SimVarValueType.Degree }], + ['selectedFpa', { name: PFDVars.selectedFpa, type: SimVarValueType.Degree }], + ['vfeNext', { name: PFDVars.vfeNext, type: SimVarValueType.Number }], + ['ilsCourse', { name: PFDVars.ilsCourse, type: SimVarValueType.Number }], + ['metricAltToggle', { name: PFDVars.metricAltToggle, type: SimVarValueType.Bool }], + ['tla1', { name: PFDVars.tla1, type: SimVarValueType.Number }], + ['tla2', { name: PFDVars.tla2, type: SimVarValueType.Number }], + ['landingElevation', { name: PFDVars.landingElevation, type: SimVarValueType.Feet }], + ['tcasState', { name: PFDVars.tcasState, type: SimVarValueType.Enum }], + ['tcasCorrective', { name: PFDVars.tcasCorrective, type: SimVarValueType.Bool }], + ['tcasRedZoneL', { name: PFDVars.tcasRedZoneL, type: SimVarValueType.Number }], + ['tcasRedZoneH', { name: PFDVars.tcasRedZoneH, type: SimVarValueType.Number }], + ['tcasGreenZoneL', { name: PFDVars.tcasGreenZoneL, type: SimVarValueType.Number }], + ['tcasGreenZoneH', { name: PFDVars.tcasGreenZoneH, type: SimVarValueType.Number }], + ['tcasFail', { name: PFDVars.tcasFail, type: SimVarValueType.Bool }], + ['engOneRunning', { name: PFDVars.engOneRunning, type: SimVarValueType.Bool }], + ['engTwoRunning', { name: PFDVars.engTwoRunning, type: SimVarValueType.Bool }], + ['expediteMode', { name: PFDVars.expediteMode, type: SimVarValueType.Bool }], + ['setHoldSpeed', { name: PFDVars.setHoldSpeed, type: SimVarValueType.Bool }], + ['vls', { name: PFDVars.vls, type: SimVarValueType.Number }], + ['alphaLim', { name: PFDVars.alphaLim, type: SimVarValueType.Number }], + ['trkFpaDeselectedTCAS', { name: PFDVars.trkFpaDeselectedTCAS, type: SimVarValueType.Bool }], + ['tcasRaInhibited', { name: PFDVars.tcasRaInhibited, type: SimVarValueType.Bool }], + ['groundSpeed', { name: PFDVars.groundSpeed, type: SimVarValueType.Number }], + ['radioAltitude1', { name: PFDVars.radioAltitude1, type: SimVarValueType.Number }], + ['radioAltitude2', { name: PFDVars.radioAltitude2, type: SimVarValueType.Number }], + ['radioAltitude3', { name: PFDVars.radioAltitude3, type: SimVarValueType.Number }], + ['beta', { name: PFDVars.beta, type: SimVarValueType.Degree }], + ['betaTargetActive', { name: PFDVars.betaTargetActive, type: SimVarValueType.Number }], + ['betaTarget', { name: PFDVars.betaTarget, type: SimVarValueType.Number }], + ['latAcc', { name: PFDVars.latAcc, type: SimVarValueType.GForce }], + ['crzAltMode', { name: PFDVars.crzAltMode, type: SimVarValueType.Bool }], + ['tcasModeDisarmed', { name: PFDVars.tcasModeDisarmed, type: SimVarValueType.Bool }], + ['flexTemp', { name: PFDVars.flexTemp, type: SimVarValueType.Number }], + ['autoBrakeMode', { name: PFDVars.autoBrakeMode, type: SimVarValueType.Number }], + ['autoBrakeActive', { name: PFDVars.autoBrakeActive, type: SimVarValueType.Bool }], + ['autoBrakeDecel', { name: PFDVars.autoBrakeDecel, type: SimVarValueType.Bool }], + ['fpaRaw', { name: PFDVars.fpaRaw, type: SimVarValueType.Number }], + ['daRaw', { name: PFDVars.daRaw, type: SimVarValueType.Number }], + ['ls1Button', { name: PFDVars.ls1Button, type: SimVarValueType.Bool }], + ['ls2Button', { name: PFDVars.ls2Button, type: SimVarValueType.Bool }], + ['xtk', { name: PFDVars.xtk, type: SimVarValueType.NM }], + ['ldevRequestLeft', { name: PFDVars.ldevLeft, type: SimVarValueType.Bool }], + ['ldevRequestRight', { name: PFDVars.ldevRight, type: SimVarValueType.Bool }], + ]) + + public constructor(bus: EventBus) { + super(PFDSimvarPublisher.simvars, bus); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/SimplaneValueProvider.ts b/fbw-a380x/src/systems/instruments/src/PFD/shared/SimplaneValueProvider.ts new file mode 100644 index 00000000000..aa274d15112 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/SimplaneValueProvider.ts @@ -0,0 +1,41 @@ +import { EventBus, Publisher } from '@microsoft/msfs-sdk'; + +export interface SimplaneValues { + units: string; + pressure: number; + machActive: boolean; + holdValue: number; + airSpeedHoldValue: number; + isSelectedSpeed: boolean; + selectedHeading: number; + selectedAltitude: number; + baroMode: 'QNH' | 'QFE' | 'STD'; + +} +export class SimplaneValueProvider { + private publisher: Publisher; + + constructor(private readonly bus: EventBus) { + this.publisher = this.bus.getPublisher(); + } + + public onUpdate() { + const units = Simplane.getPressureSelectedUnits(); + const pressure = Simplane.getPressureValue(units); + const isSelected = Simplane.getAutoPilotAirspeedSelected(); + const isMach = Simplane.getAutoPilotMachModeActive(); + const selectedHeading = Simplane.getAutoPilotSelectedHeadingLockValue(false) || 0; + const selectedAltitude = Simplane.getAutoPilotDisplayedAltitudeLockValue(); + const holdValue = isMach ? Simplane.getAutoPilotMachHoldValue() : Simplane.getAutoPilotAirspeedHoldValue(); + const baroMode = Simplane.getPressureSelectedMode(Aircraft.A320_NEO) as 'QNH' | 'QFE' | 'STD'; + + this.publisher.pub('units', units); + this.publisher.pub('pressure', pressure); + this.publisher.pub('isSelectedSpeed', isSelected); + this.publisher.pub('machActive', isMach); + this.publisher.pub('holdValue', holdValue); + this.publisher.pub('selectedHeading', selectedHeading); + this.publisher.pub('selectedAltitude', selectedAltitude); + this.publisher.pub('baroMode', baroMode); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/common.scss b/fbw-a380x/src/systems/instruments/src/PFD/shared/common.scss new file mode 100644 index 00000000000..f7d6adc013b --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/common.scss @@ -0,0 +1,23 @@ +@import "definitions"; + +.SelfTest { + position: absolute; + left: 0%; + top: 0%; + width: 100%; + height: 100%; + border: none; +} + +.SelfTestBackground { + fill: $display-background; +} + +.SelfTestText { + font-size: 26px; + fill: $display-green; + visibility: visible !important; + + text-anchor: middle; + font-family: "Ecam", monospace; +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/definitions.scss b/fbw-a380x/src/systems/instruments/src/PFD/shared/definitions.scss new file mode 100644 index 00000000000..350d56de1b2 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/definitions.scss @@ -0,0 +1,20 @@ +$font-size-small: 14px; +$font-size-medium: 16px; +$font-size-large: 17px; +$font-size-larger: 18px; +$font-size-xlarge: 20px; +$font-size-huge: 22px; +$font-size-title: 24px; + +$display-white: #ffffff; +$display-grey: #787878; +$display-dark-grey: #b3b3b3; +$display-light-grey: lightgray; +$display-amber: #e68000; +$display-cyan: #00ffff; +$display-green: #00ff00; +$display-magenta: #ff94ff; +$display-red: #ff0000; +$display-yellow: #ffff00; + +$display-background: #040405; diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/displayUnit.tsx b/fbw-a380x/src/systems/instruments/src/PFD/shared/displayUnit.tsx new file mode 100644 index 00000000000..efa08a0b036 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/displayUnit.tsx @@ -0,0 +1,148 @@ +import { ClockEvents, DisplayComponent, EventBus, FSComponent, Subscribable, VNode } from '@microsoft/msfs-sdk'; + +import './common.scss'; + +import { NXDataStore } from '@shared/persistence'; +import { PFDSimvars } from './PFDSimvarPublisher'; +import { getDisplayIndex } from '../PFD'; + +type DisplayUnitProps = { + bus: EventBus, + failed: Subscribable; +} + +enum DisplayUnitState { + On, + Off, + Selftest, + Standby +} + +export class DisplayUnit extends DisplayComponent { + private state: DisplayUnitState = SimVar.GetSimVarValue('L:A32NX_COLD_AND_DARK_SPAWN', 'Bool') ? DisplayUnitState.Off : DisplayUnitState.Standby; + + private electricityState: number = 0; + + private potentiometer: number = 0; + + private timeOut: number = 0; + + private selfTestRef = FSComponent.createRef(); + + private pfdRef = FSComponent.createRef(); + + private backLightBleedRef = FSComponent.createRef(); + + private isHomeCockpitMode = false; + + private failed = false; + + public onAfterRender(node: VNode): void { + super.onAfterRender(node); + + const sub = this.props.bus.getSubscriber(); + const isCaptainSide = getDisplayIndex() === 1; + + sub.on(isCaptainSide ? 'potentiometerCaptain' : 'potentiometerFo').whenChanged().handle((value) => { + this.potentiometer = value; + this.updateState(); + }); + + sub.on(isCaptainSide ? 'elec' : 'elecFo').whenChanged().handle((value) => { + this.electricityState = value; + this.updateState(); + }); + + sub.on('realTime').atFrequency(1).handle((_t) => { + // override MSFS menu animations setting for this instrument + if (!document.documentElement.classList.contains('animationsEnabled')) { + document.documentElement.classList.add('animationsEnabled'); + } + }); + + this.props.failed.sub((f) => { + this.failed = f; + this.updateState(); + }); + + NXDataStore.getAndSubscribe('HOME_COCKPIT_ENABLED', (_key, val) => { + this.isHomeCockpitMode = val === '1'; + this.updateState(); + }, '0'); + } + + setTimer(time: number) { + this.timeOut = window.setTimeout(() => { + if (this.state === DisplayUnitState.Standby) { + this.state = DisplayUnitState.Off; + } if (this.state === DisplayUnitState.Selftest) { + this.state = DisplayUnitState.On; + } + this.updateState(); + }, time * 1000); + } + + updateState() { + if (this.state !== DisplayUnitState.Off && this.failed) { + this.state = DisplayUnitState.Off; + clearTimeout(this.timeOut); + } else if (this.state === DisplayUnitState.On && (this.potentiometer === 0 || this.electricityState === 0)) { + this.state = DisplayUnitState.Standby; + this.setTimer(10); + } else if (this.state === DisplayUnitState.Standby && (this.potentiometer !== 0 && this.electricityState !== 0)) { + this.state = DisplayUnitState.On; + clearTimeout(this.timeOut); + } else if (this.state === DisplayUnitState.Off && (this.potentiometer !== 0 && this.electricityState !== 0 && !this.failed)) { + this.state = DisplayUnitState.Selftest; + this.setTimer(parseInt(NXDataStore.get('CONFIG_SELF_TEST_TIME', '15'))); + } else if (this.state === DisplayUnitState.Selftest && (this.potentiometer === 0 || this.electricityState === 0)) { + this.state = DisplayUnitState.Off; + clearTimeout(this.timeOut); + } + + if (this.state === DisplayUnitState.Selftest) { + this.selfTestRef.instance.style.display = 'block'; + this.pfdRef.instance.style.display = 'none'; + this.backLightBleedRef.instance.style.display = this.isHomeCockpitMode ? 'none' : 'block'; + } else if (this.state === DisplayUnitState.On) { + this.selfTestRef.instance.style.display = 'none'; + this.pfdRef.instance.style.display = 'block'; + this.backLightBleedRef.instance.style.display = this.isHomeCockpitMode ? 'none' : 'block'; + } else { + this.selfTestRef.instance.style.display = 'none'; + this.pfdRef.instance.style.display = 'none'; + this.backLightBleedRef.instance.style.display = 'none'; + } + } + + render(): VNode { + return ( + + <> +
+ + + + + + SAFETY TEST IN PROGRESS + + + (MAX 30 SECONDS) + + + +
{this.props.children}
+ + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/pixels.scss b/fbw-a380x/src/systems/instruments/src/PFD/shared/pixels.scss new file mode 100644 index 00000000000..b0e404ecf88 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/pixels.scss @@ -0,0 +1,10 @@ +.BacklightBleed { + content: ''; + width: 100%; + height: 100%; + position: absolute; + z-index: 998; + opacity: 0.75; + background-color: rgba(0, 0, 255, 0.025); + box-shadow: inset 0px 0px 30px 10px rgba(0, 75, 255, 0.04); +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/style.scss b/fbw-a380x/src/systems/instruments/src/PFD/style.scss new file mode 100644 index 00000000000..59bb48ff345 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/style.scss @@ -0,0 +1,270 @@ +@import "shared/definitions"; +@import "animations"; + +@font-face { + font-family: "Ecam"; + //noinspection CssUnknownTarget + src: url("/Fonts/ECAMFontRegular.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} + +.pfd-svg { + position: absolute; + width: 768px; + height: 1024px; + background: $display-background; + font-family: "Ecam", monospace !important; +} + +.PulseCyanFill { + @include GenericPulsingFill($display-cyan, pulse-cyan-fill); + animation-duration: 1s; + animation-iteration-count: infinite; +} + +.PulseAmber9Seconds { + @include GenericPulsingFill($display-amber, pulse-amber-fill); + animation-duration: 1s; + animation-iteration-count: 9; +} + +.BarAmber { + fill: $display-amber; + stroke: $display-amber; + stroke-width: 0.13px; +} +.BarRed { + fill: $display-red; + stroke: $display-red; + stroke-width: 0.11px; +} + +.SmallStroke { + stroke-width: 0.10mm; + stroke-linecap: round; +} + +.NormalStroke { + stroke-width: 0.16mm; + stroke-linecap: round; +} + +.ThickStroke { + stroke-width: 0.22mm; + stroke-linecap: round; +} + +.HugeStroke { + stroke-width: 0.32mm; + stroke-linecap: round; +} + +.SmallOutline { + stroke-width: 0.09mm; + stroke: $display-background !important; + paint-order: markers stroke fill; +} +.NormalOutline { + stroke-width: 0.21mm; + stroke: $display-background; + fill: none; + stroke-linecap: round; +} +.ThickOutline { + stroke-width: 0.29mm; + stroke: $display-background; + fill: none; + stroke-linecap: round; +} +.HugeOutline { + stroke-width: 0.39mm; + stroke: $display-background; + fill: none; + stroke-linecap: round; +} + +.CornerRound { + stroke-linejoin: round; +} + +.TextOutline { + paint-order: stroke fill markers; + + stroke-width: 0.05mm; + stroke: $display-background !important; +} + +.FontLargest { + font-size: 7px; +} + +.FontLarge { + font-size: 6.5px; +} + +.FontMedium { + font-size: 6px; +} + +.FontIntermediate { + font-size: 5.5px; +} + +.FontSmall { + font-size: 5px; +} + +.FontSmallest { + font-size: 4.5px; +} + +.FontTiny { + font-size: 4px; +} + +.StartAlign { + text-align: start; + text-anchor: start; +} +.MiddleAlign { + text-align: center; + text-anchor: middle; +} +.EndAlign { + text-align: end; + text-anchor: end; +} + +.Magenta { + fill: none; + stroke: $display-magenta; +} +text.Magenta { + fill: $display-magenta; + stroke: none; +} + +.Cyan { + fill: none; + stroke: $display-cyan; +} +text.Cyan { + fill: $display-cyan; + stroke: none; +} +tspan.Cyan { + fill: $display-cyan; + stroke: none; +} + +.None { + fill: none; + stroke: none; +} + +.White { + fill: none; + stroke: $display-white; +} +.White.Fill { + fill: $display-white; + stroke: none; +} +text.White { + fill: $display-white; + stroke: none; +} + +.Green { + stroke: $display-green; + fill: none; +} + +.Green.Fill { + fill: $display-green; + stroke: none; +} + +text.Green { + fill: $display-green; + stroke: none; +} + +.Amber { + stroke: $display-amber; + fill: none; +} +text.Amber { + fill: $display-amber; + stroke: none; +} + +.Yellow { + stroke: $display-yellow; + fill: none; +} +.Yellow.Fill { + fill: $display-yellow; + stroke: none; +} +text.Yellow { + fill: $display-yellow; + stroke: none; +} + +.Red { + stroke: $display-red; + fill: none; +} +.Red.Fill { + fill: $display-red; + stroke: none; +} +text.Red { + fill: $display-red; + stroke: none; +} + +.Grey { + stroke: $display-grey; + fill: none; +} + +.EarthFill { + fill: #9c480c; +} +.SkyFill { + fill: #0698ff; +} +.BlackFill { + fill: $display-background; +} + +.TapeBackground { + fill: $display-grey; + stroke: none; +} +.BackgroundFill { + fill: $display-background; + stroke: none; + fill-rule: evenodd; +} + +.ModeChangedPath { + visibility: visible !important; +} + +.HiddenElement { + display: none; +} + +.BacklightBleed { + width: 100%; + height: 100%; + position: absolute; + z-index: 998; + opacity: 0.75; + background-color: rgba(0, 0, 255, 0.025); + box-shadow: inset 0px 0px 30px 10px rgba(0, 75, 255, 0.04); +} diff --git a/fbw-a380x/src/systems/instruments/src/PFD/tsconfig.json b/fbw-a380x/src/systems/instruments/src/PFD/tsconfig.json new file mode 100644 index 00000000000..efc24f00dc4 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/PFD/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.json", + + "compilerOptions": { + "incremental": false /* Enables incremental builds */, + "target": "es2017" /* Specifies the ES2017 target, compatible with Coherent GT */, + "module": "es2015" /* Ensures that modules are at least es2015 */, + "strict": false /* Enables strict type checking, highly recommended but optional */, + "esModuleInterop": true /* Emits additional JS to work with CommonJS modules */, + "skipLibCheck": true /* Skip type checking on library .d.ts files */, + "forceConsistentCasingInFileNames": true /* Ensures correct import casing */, + "moduleResolution": "node" /* Enables compatibility with MSFS SDK bare global imports */, + "jsxFactory": "FSComponent.buildComponent" /* Required for FSComponent framework JSX */, + "jsxFragmentFactory": "FSComponent.Fragment" /* Required for FSComponent framework JSX */, + "jsx": "react" /* Required for FSComponent framework JSX */ + } +} diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Components/ActiveFrequency.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Components/ActiveFrequency.tsx new file mode 100644 index 00000000000..b756e9ab50a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Components/ActiveFrequency.tsx @@ -0,0 +1,12 @@ +import React, { FC } from 'react'; +import { Layer } from './Layer'; + +type ActiveFrequencyProps = { + x?: number; + y?: number; + value: string; +} + +export const ActiveFrequency: FC = (props) => ( + {props.value} +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Components/Arrow.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Components/Arrow.tsx new file mode 100644 index 00000000000..e2675d33349 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Components/Arrow.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Layer } from './Layer'; + +type ArrowProps = { + x: number; + y: number; + width?: number; + height?: number; + angle?: number; + length?: number +} + +export const Arrow = ({ x, y, angle, width = 30, height = 30, length = 30 }: ArrowProps) => ( + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Components/Layer.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Components/Layer.tsx new file mode 100644 index 00000000000..824352e2b32 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Components/Layer.tsx @@ -0,0 +1,7 @@ +import React, { SVGProps, FC } from 'react'; + +export const Layer: FC & { angle?: number }> = (props) => ( + + {props.children} + +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Components/MessageArea.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Components/MessageArea.tsx new file mode 100644 index 00000000000..1c8ed760acc --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Components/MessageArea.tsx @@ -0,0 +1,17 @@ +import React, { FC } from 'react'; +import { useSimVar } from '../../Common/simVars'; +import { Layer } from './Layer'; + +type MessageAreaProps = { + showSquawk: boolean; +} + +export const MessageArea: FC = (props) => { + const [squawkCode, setSquawkCode] = useSimVar('TRANSPONDER CODE', 'BCO16'); + return ( + + + {props.showSquawk && {`SQUAWK : ${squawkCode}`}} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Components/StandbyFrequency.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Components/StandbyFrequency.tsx new file mode 100644 index 00000000000..8a55abb35d0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Components/StandbyFrequency.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import { Layer } from './Layer'; + +type FrequencyLabelProps = { + valid?: boolean; + value?: string; +} + +type StandbyFrequencyProps = { + x: number; + y: number; + selected?: boolean; + valid?: boolean; + label?: string; + value?: string; +} + +const FrequencyLabel: FC = (props) => ( + {props.value} +); + +export const StandbyFrequency: FC = (props) => ( + + {props.selected && STBY} + {props.selected && } + + +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Components/TextRow.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Components/TextRow.tsx new file mode 100644 index 00000000000..7624641a499 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Components/TextRow.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; +import { Layer } from './Layer'; + +type TextRowProps = { + left?: string; + leftFill?: string; + center?: string; + centerFill?: string; + right?: string; + rightFill?: string; +} + +export const TextRow: FC = (props) => ( + + {props.left || ''} + {props.center || ''} + {props.right || ''} + +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Pages/HfPage.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Pages/HfPage.tsx new file mode 100644 index 00000000000..d645deed2b0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Pages/HfPage.tsx @@ -0,0 +1,54 @@ +import React, { FC, useState } from 'react'; +import { ActiveFrequency } from '../Components/ActiveFrequency'; +import { StandbyFrequency } from '../Components/StandbyFrequency'; +import { TextRow } from '../Components/TextRow'; +import { useInteractionEvent } from '../../Common/hooks'; +import { Layer } from '../Components/Layer'; +import { Arrow } from '../Components/Arrow'; + +type HfTransceiverLabelProps = { + x?: number; + y?: number; + text: string; +} + +type HfCellProps = { + y?: number; + transceiver: number; + enabled: boolean; +} + +const HfTransceiverLabel: FC = (props) => ( + + {props.text} + +); + +const HfCell: FC = (props) => ( + + + + + {/* {props.enabled && } */} + + +); + +const AmToggleCell = (props) => ( + +); + +export const HfPage = () => { + const [amEnabled, setAmEnabled] = useState(false); + const [currentHf, setCurrentHF] = useState(1); + useInteractionEvent('A380X_RMPL_LSK1_PRESSED', () => setCurrentHF(1)); + useInteractionEvent('A380X_RMPL_LSK2_PRESSED', () => setCurrentHF(2)); + useInteractionEvent('A380X_RMPL_LSK3_PRESSED', () => setAmEnabled(old => !old)); + return ( + + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Pages/Menu/Datalink/DatalinkRouterPage.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Pages/Menu/Datalink/DatalinkRouterPage.tsx new file mode 100644 index 00000000000..fd139ce836d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Pages/Menu/Datalink/DatalinkRouterPage.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export const DatalinkRouterPage = () => ( + + Datalink Router + +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Pages/Menu/MenuPage.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Pages/Menu/MenuPage.tsx new file mode 100644 index 00000000000..64dd367959e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Pages/Menu/MenuPage.tsx @@ -0,0 +1,15 @@ +import { useInteractionEvent } from '@instruments/common/hooks'; +import React from 'react'; +import { Redirect, Route, Switch, useHistory } from 'react-router-dom'; +import { Layer } from '../../Components/Layer'; + +export const MenuPage = () => { + const history = useHistory(); + useInteractionEvent(`A380X_RMPL_LSK2_PRESSED`, () => history.push('/nav')); + return ( + + MENU + {"DATALINK STATUS >"} + + ) +}; diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Pages/NavPage.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Pages/NavPage.tsx new file mode 100644 index 00000000000..1348fd81fe4 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Pages/NavPage.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export const NavPage = () => ( + + NAV + +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Pages/SqwkPage.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Pages/SqwkPage.tsx new file mode 100644 index 00000000000..73ce8e283bb --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Pages/SqwkPage.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export const SqwkPage = () => ( + + SQWK + +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Pages/TelPage.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Pages/TelPage.tsx new file mode 100644 index 00000000000..28362324d33 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Pages/TelPage.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export const TelPage = () => ( + + TEL + +); diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Pages/VhfPage.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Pages/VhfPage.tsx new file mode 100644 index 00000000000..99f23bb7b53 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Pages/VhfPage.tsx @@ -0,0 +1,177 @@ +import React, { useState } from 'react'; +import { ActiveFrequency } from '../Components/ActiveFrequency'; +import { StandbyFrequency } from '../Components/StandbyFrequency'; +import { useSimVar, useSplitSimVar } from '../../Common/simVars'; +import { useInteractionEvent } from '../../Common/hooks'; + +const setCharAt = (str: string, index: number, chr: string): string => { + if (index > str.length - 1) return str; + return str.substring(0, index) + chr + str.substring(index + 1); +}; + +const formatFrequency = (frequency: number): string => Math.max(118, frequency / 1000000).toFixed(3).padEnd(7, '0'); + +const formatTempFrequency = (frequency: number[]): string => { + let value = '---.---'; + for (let i = 0; i < frequency.length; i++) { + value = setCharAt(value, i + (i > 2 ? 1 : 0), frequency[i].toString()); + } + return value; +}; + +const validateVhfFrequency = (frequency: number[]) => { + let value = 0; + for (let i = 0; i < frequency.length; i++) { + value += frequency[i] * (10 ** (5 - i)); + } + if (value < 118000 || value >= 137000 || value % 5 !== 0) { + return null; + } + return value; +}; + +/** + * React hook for the active VHF frequency SimVar, set via a K SimVar. + * @param transceiver The VHF transceiver to use (VHF 1, 2, or 3). + */ +const useActiveVhfFrequency = (transceiver: number) => { + const variableReadName = `COM ACTIVE FREQUENCY:${transceiver}`; + const variableWriteName = `K:COM${transceiver === 1 ? '' : transceiver}_RADIO_SET_HZ`; + return useSplitSimVar(variableReadName, 'Hz', variableWriteName, 'Hz', 100); +}; + +/** + * React hook for the standby VHF frequency SimVar, set via a K SimVar. + * A custom SimVar is used for abnormal side/transceiver pairs (e.g. VHF 2 for left RMP). + * @param side The RMP side (e.g. 'L' or 'R'). + * @param transceiver The VHF transceiver to use (VHF 1, 2, or 3). + */ +const useStandbyVhfFrequency = (side: string, transceiver: number) => { + let variableReadName = `COM STANDBY FREQUENCY:${transceiver}`; + let variableWriteName = `K:COM${transceiver === 1 ? '' : transceiver}_STBY_RADIO_SET_HZ`; + + // Use custom SimVars for abnormal standby frequency. + // Allows true-to-life independent standby frequencies per RMP. + // Be sure to update this if if we ever add a third "C"-side RMP. + if ( + (side === 'L' && transceiver !== 1) + || (side === 'R' && transceiver !== 2) + ) { + variableReadName = `L:A32NX_RMP_${side}_VHF${transceiver}_STANDBY_FREQUENCY`; + variableWriteName = variableReadName; + } + + return useSplitSimVar(variableReadName, 'Hz', variableWriteName, 'Hz', 100); +}; + +const VhfTransceiverLabel = (props) => ( +
+ {props.text} +
+); + +const VhfCell = (props) => { + const [standbyFrequency, setStandbyFrequency] = useStandbyVhfFrequency(props.side, props.transceiver); + const [activeFrequency, setActiveFrequency] = useActiveVhfFrequency(props.transceiver); + if (props.transceiver !== 3) { + useInteractionEvent(`A380_RMP${props.side}_LSK${props.transceiver}_PRESSED`, () => { + if (props.selected) { + const validatedFrequency = validateVhfFrequency(props.tempFrequency); + if (validatedFrequency != null) { + setStandbyFrequency(validatedFrequency * 1000); + } + } + }); + useInteractionEvent(`A380_RMP${props.side}_ADK${props.transceiver}_PRESSED`, () => { + setActiveFrequency(standbyFrequency); + setStandbyFrequency(activeFrequency); + }); + } + return ( +
+ + + +
+ ); +}; + +export const VhfPage = (props) => { + const [selected, setSelected] = useSimVar(`L:A380X_RMP${props.side}_SELECTED_VHF`, 'enum'); + const trySetSelected = (newValue: number) => { + if (newValue === selected) { + setSelected(0); + setTempFrequency([]); + } else { + setSelected(newValue); + } + }; + useInteractionEvent(`A380_RMP${props.side}_LSK1_PRESSED`, () => { + trySetSelected(1); + }); + useInteractionEvent(`A380_RMP${props.side}_LSK2_PRESSED`, () => { + trySetSelected(2); + }); + /* useInteractionEvent(`A380_RMP${props.side}_LSK3_PRESSED`, () => { + trySetSelected(3); + }); */ + const [tempFrequency, setTempFrequency] = useState([]); + const tryEnterDigit = (digit: number) => { + if (selected) { + if (tempFrequency.length >= 6 || tempFrequency.length === 0) { + if (digit > 1) { + setTempFrequency([1, digit]); + } else { + setTempFrequency([digit]); + } + } else { + tempFrequency.push(digit); + } + } + }; + useInteractionEvent(`A380_RMP${props.side}_0_PRESSED`, () => { + tryEnterDigit(0); + }); + useInteractionEvent(`A380_RMP${props.side}_1_PRESSED`, () => { + tryEnterDigit(1); + }); + useInteractionEvent(`A380_RMP${props.side}_2_PRESSED`, () => { + tryEnterDigit(2); + }); + useInteractionEvent(`A380_RMP${props.side}_3_PRESSED`, () => { + tryEnterDigit(3); + }); + useInteractionEvent(`A380_RMP${props.side}_4_PRESSED`, () => { + tryEnterDigit(4); + }); + useInteractionEvent(`A380_RMP${props.side}_5_PRESSED`, () => { + tryEnterDigit(5); + }); + useInteractionEvent(`A380_RMP${props.side}_6_PRESSED`, () => { + tryEnterDigit(6); + }); + useInteractionEvent(`A380_RMP${props.side}_7_PRESSED`, () => { + tryEnterDigit(7); + }); + useInteractionEvent(`A380_RMP${props.side}_8_PRESSED`, () => { + tryEnterDigit(8); + }); + useInteractionEvent(`A380_RMP${props.side}_9_PRESSED`, () => { + tryEnterDigit(9); + }); + useInteractionEvent(`A380_RMP${props.side}_CLR_PRESSED`, () => { + if (tempFrequency.length > 0) { + tempFrequency.pop(); + } + }); + return ( + + INOP + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/RMP/Pages/index.tsx b/fbw-a380x/src/systems/instruments/src/RMP/Pages/index.tsx new file mode 100644 index 00000000000..22abe092071 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/Pages/index.tsx @@ -0,0 +1,19 @@ +import { VhfPage } from './VhfPage'; +import { HfPage } from './HfPage'; +import { SqwkPage } from './SqwkPage'; +import { TelPage } from './TelPage'; +import { MenuPage } from './Menu/MenuPage'; +import { NavPage } from './NavPage'; +import { DatalinkRouterPage } from './Menu/Datalink/DatalinkRouterPage'; + +export const Pages = { + Vhf: VhfPage, + Hf: HfPage, + Tel: TelPage, + Sqwk: SqwkPage, + Menu: { + index: MenuPage, + DatalinkRouter: DatalinkRouterPage + }, + Nav: NavPage, +}; diff --git a/fbw-a380x/src/systems/instruments/src/RMP/RadioManagementPanel.tsx b/fbw-a380x/src/systems/instruments/src/RMP/RadioManagementPanel.tsx new file mode 100644 index 00000000000..352eddceda4 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/RadioManagementPanel.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Redirect, Route, Switch, useHistory } from 'react-router-dom'; +import { VhfPage } from './Pages/VhfPage'; +import { MessageArea } from './Components/MessageArea'; +import { HfPage } from './Pages/HfPage'; +import { useInteractionEvent } from '../Common/hooks'; +import { SqwkPage } from './Pages/SqwkPage'; +import { TelPage } from './Pages/TelPage'; +import { MenuPage } from './Pages/Menu/MenuPage'; +import { NavPage } from './Pages/NavPage'; + +export const RadioManagementPanel = (props) => { + const history = useHistory(); + + useInteractionEvent(`A380X_RMP${props.side}_VHF_PRESSED`, () => history.push('/vhf')); + useInteractionEvent(`A380X_RMP${props.side}_HF_PRESSED`, () => history.push('/hf')); + useInteractionEvent(`A380X_RMP${props.side}_TEL_PRESSED`, () => history.push('/tel')); + useInteractionEvent(`A380X_RMP${props.side}_SQWK_PRESSED`, () => history.push('/sqwk')); + useInteractionEvent(`A380X_RMP${props.side}_MENU_PRESSED`, () => history.push('/menu')); + useInteractionEvent(`A380X_RMP${props.side}_NAV_PRESSED`, () => history.push('/nav')); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/RMP/config.json b/fbw-a380x/src/systems/instruments/src/RMP/config.json new file mode 100644 index 00000000000..260da3388a2 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/config.json @@ -0,0 +1,4 @@ +{ + "index": "./index.tsx", + "isInteractive": true +} diff --git a/fbw-a380x/src/systems/instruments/src/RMP/index.css b/fbw-a380x/src/systems/instruments/src/RMP/index.css new file mode 100644 index 00000000000..72eab9f09ae --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/index.css @@ -0,0 +1,163 @@ +@font-face { + font-family: "RMP-10"; + src: url("/Fonts/FBW-Display-RMP-10.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} +@font-face { + font-family: "RMP-11"; + src: url("/Fonts/FBW-Display-RMP-11.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} +@font-face { + font-family: "RMP-13"; + src: url("/Fonts/FBW-Display-RMP-13.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} +@font-face { + font-family: "RMP-16"; + src: url("/Fonts/FBW-Display-RMP-16.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} +@font-face { + font-family: "RMP-19"; + src: url("/Fonts/FBW-Display-RMP-19.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} + +.rmp { + font-family: "RMP-19"; + font-size: 80px; +} + +.cell { + height: 157px; + position: relative; +} + +.separator { + border-bottom: 2px solid white; +} + +.active-frequency { + font-size: 80px; + position: absolute; + bottom: 3px; + left: 10px; +} + +.standby-frequency { + position: absolute; + bottom: 5%; + right: 1%; + width: 30%; + height: 90%; +} + +.selected { + border: 2px solid cyan; + box-sizing: border-box; +} + +.standby-frequency-label { + font-size: 40px; + position: absolute; + bottom: 10%; + left: 0; + width: 100%; + text-align: center; +} + +.selected .standby-frequency-label { + color: cyan; +} + +.invalid { + color: darkorange !important; +} + +.hidden { + display: none; +} + +.message-area { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 40px; + border-top: 2px solid white; + font-size: 30px; + padding-left: 10px; +} + +.vhf-transceiver-label { + font-size: 40px; + position: absolute; + bottom: 25%; + left: 50%; +} + +.cyan { + color: cyan; +} + +.green { + color: lime; +} + +.white { + color: white; +} + +.text-row { + height: 157px; + position: relative; + font-size: 40px; +} + +.text-row .left { + text-align: left; + left: 2%; +} + +.text-row .right { + text-align: right; + right: 2%; +} + +.text-row .center { + text-align: center; + right: 0; + width: 100%; +} + +.text-row div { + position: absolute; + bottom: 35%; +} + +.sqwk-page .standby-frequency { + right: 35%; +} + +/*.sqwk-page .standby-frequency-label { + font-size: 40px; + bottom: 5%; + left: 12%; + color: cyan; +}*/ + +.standby-frequency-edit-label { + position: absolute; + top: 15%; + left: 0; + width: 100%; + text-align: center; + font-size: 30px; + color: white; +} diff --git a/fbw-a380x/src/systems/instruments/src/RMP/index.tsx b/fbw-a380x/src/systems/instruments/src/RMP/index.tsx new file mode 100644 index 00000000000..82d2ac61974 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/RMP/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { HashRouter } from 'react-router-dom'; +import { render } from '@instruments/common/index'; +import { RadioManagementPanel } from './RadioManagementPanel'; + +import './index.css'; + +render( + + + , +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/ApuPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/ApuPage.tsx new file mode 100644 index 00000000000..37059cd721f --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/ApuPage.tsx @@ -0,0 +1,600 @@ +import React, { useEffect, useState } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { ComponentPositionProps } from '@instruments/common/ComponentPosition'; +import { Layer } from '@instruments/common/utils'; +import { useArinc429Var } from '@instruments/common/arinc429'; +import { GaugeComponent, GaugeMarkerComponent } from '@instruments/common/gauges'; +import { PageTitle } from './Generic/PageTitle'; + +export const ApuPage = () => { + const [apuAvail] = useSimVar('L:A32NX_OVHD_APU_START_PB_IS_AVAILABLE', 'Bool', 1000); + + return ( + <> + APU + {/* APU Avail */} + {apuAvail + && ( + AVAIL + )} + + + + + + {/* Separation Bar */} + + + + + + + + + + + + + ); +}; + +const ApuGen = ({ x, y } : ComponentPositionProps) => { + const [apuContactorClosed] = useSimVar('L:A32NX_ELEC_CONTACTOR_3XS_IS_CLOSED', 'Bool', 1000); + const [busTieContactor1Closed] = useSimVar('L:A32NX_ELEC_CONTACTOR_11XU1_IS_CLOSED', 'Bool'); + const [busTieContactor2Closed] = useSimVar('L:A32NX_ELEC_CONTACTOR_11XU2_IS_CLOSED', 'Bool'); + + const [apuGenLoad] = useSimVar('L:A32NX_ELEC_APU_GEN_1_LOAD', 'Percent', 500); + const [apuGenLoadNormalRange] = useSimVar('L:A32NX_ELEC_APU_GEN_1_LOAD_NORMAL', 'Bool', 750); + + const [apuGenVoltage] = useSimVar('L:A32NX_ELEC_APU_GEN_1_POTENTIAL', 'Volts', 500); + const [apuGenPotentialNormalRange] = useSimVar('L:A32NX_ELEC_APU_GEN_1_POTENTIAL_NORMAL', 'Bool', 750); + + const [apuGenFreq] = useSimVar('L:A32NX_ELEC_APU_GEN_1_FREQUENCY', 'Hertz', 500); + const [apuGenFreqNormalRange] = useSimVar('L:A32NX_ELEC_APU_GEN_1_FREQUENCY_NORMAL', 'Bool', 750); + + const [apuMasterPbOn] = useSimVar('L:A32NX_OVHD_APU_MASTER_SW_PB_IS_ON', 'Bool', 500); + const [apuGenPbOn] = useSimVar('A:APU GENERATOR SWITCH', 'Boolean', 750); + const [apuAvail] = useSimVar('L:A32NX_OVHD_APU_START_PB_IS_AVAILABLE', 'Bool', 1000); + + // FBW-31-06 + const inModeStandby = !apuMasterPbOn && !apuAvail; + const inModeOff = !inModeStandby && !apuGenPbOn; + const inModeOn = !inModeStandby && !inModeOff; + + return ( + <> + + {(apuContactorClosed && (busTieContactor1Closed || busTieContactor2Closed)) + && ( + + + + )} + {!inModeStandby + && } + + + APU GEN + + + {inModeOff + && OFF} + + {inModeOn + && ( + <> + + {/* FBW-31-08 */} + + {apuGenLoad.toFixed()} + + + {apuGenVoltage.toFixed()} + + + {apuGenFreq.toFixed()} + + + + % + V + HZ + + + )} + + + ); +}; + +const ApuBleed = ({ x, y } : ComponentPositionProps) => { + const [apuBleedPbOn] = useSimVar('L:A32NX_OVHD_PNEU_APU_BLEED_PB_IS_ON', 'Bool', 1000); + const [apuBleedPbOnConfirmed, setApuBleedPbOnConfirmed] = useState(false); + const [apuBleedOpen] = useSimVar('L:A32NX_APU_BLEED_AIR_VALVE_OPEN', 'Bool', 1000); + + const [apuBleedPressure] = useSimVar('L:APU_BLEED_PRESSURE', 'PSI', 1000); + const displayedBleedPressure = Math.round(apuBleedPressure / 2) * 2; // APU bleed pressure is shown in steps of two. + + const [adir1ModeSelectorKnob] = useSimVar('L:A32NX_OVHD_ADIRS_IR_1_MODE_SELECTOR_KNOB', 'Enum'); + + useEffect(() => { + if (apuBleedPbOn) { + const timeout = setTimeout(() => { + setApuBleedPbOnConfirmed(true); + }, 10_000); + return () => clearTimeout(timeout); + } + setApuBleedPbOnConfirmed(false); + + return () => {}; + }, [apuBleedPbOn]); + + return ( + <> + {/* FBW-31-08 */} + + + + + + BLEED + + + {adir1ModeSelectorKnob === 1 ? displayedBleedPressure : 'XX'} + + PSI + + + ); +}; + +// TODO: Include this in the common folder + +interface ValveProps { + x: number; + y: number; + open?: boolean; + amber?: boolean + showFlowArrow?: boolean; + entryPipeLength?: number; +} + +const Valve = ({ x, y, open, amber, showFlowArrow, entryPipeLength = 21 }: ValveProps) => { + const baseYOffset = -entryPipeLength; + + return ( + + + + + + {/* 15 in length */} + {showFlowArrow + && ( + <> + + + + )} + + ); +}; + +const NGauge = ({ x, y } : ComponentPositionProps) => { + const apuN = useArinc429Var('L:A32NX_APU_N', 100); + let apuNIndicationColor; + if (apuN.value < 102) { + apuNIndicationColor = 'Green'; + } else if (apuN.value < 107) { + apuNIndicationColor = 'Amber'; + } else { + apuNIndicationColor = 'Red'; + } + + const GAUGE_MIN = 0; + const GAUGE_MAX = 120; + const GAUGE_MARKING_MAX = GAUGE_MAX / 10; + const GAUGE_RADIUS = 64; + const GAUGE_START = 225; + const GAUGE_END = 45; + + const gaugeMarkerClassName = 'GaugeText ThickLine'; + + return ( + <> + + + + + {/* 0 */} + + {/* 50 */} + + {/* 100 */} + + {/* 102 AMBER */} + + {apuN.isNormalOperation() + && ( + + )} + + + + + + N1 + % + N2 + + + + {apuN.isNormalOperation() ? apuN.value.toFixed() : 'XX'} + + + {apuN.isNormalOperation() ? apuN.value.toFixed() : 'XX'} + + + + ); +}; + +const EgtGauge = ({ x, y } : ComponentPositionProps) => { + const apuEgt = useArinc429Var('L:A32NX_APU_EGT', 100); + const displayedEgtValue = Math.round(apuEgt.value / 5) * 5; // APU Exhaust Gas Temperature is shown in steps of five. + + const apuEgtCaution = useArinc429Var('L:A32NX_APU_EGT_CAUTION', 500); + const apuEgtWarning = useArinc429Var('L:A32NX_APU_EGT_WARNING', 500); + + const redLineShown = apuEgtCaution.isNormalOperation() && apuEgtWarning.isNormalOperation(); + + // FBW-31-05 + let egtNeedleStyle: string; + + if (apuEgt.value > apuEgtWarning.value) { + egtNeedleStyle = 'Red'; + } else if (apuEgt.value > apuEgtCaution.value) { + egtNeedleStyle = 'Amber'; + } else { + egtNeedleStyle = 'Green'; + } + + let egtNumericalStyle: string; + + if (!apuEgt.isNormalOperation()) { + egtNumericalStyle = 'AmberFill'; + } else { + egtNumericalStyle = egtNeedleStyle; + } + + const GAUGE_MIN = 0; + const GAUGE_MAX = 950; + const GAUGE_MARKING_MAX = GAUGE_MAX / 100; + + const GAUGE_RADIUS = 64; + const GAUGE_START = 220; + const GAUGE_END = 120; + + const GAUGE_MARKING_START = GAUGE_START; + + const gaugeMarkerClassName = 'GaugeText ThickLine'; + + return ( + <> + + + {/* 000 */} + + {/* 400 */} + + {/* 500 */} + + {/* 600 */} + + {/* 700 */} + + {/* 800 */} + + {/* 900 */} + + {/* AMBER BAR */} + {apuEgt.isNormalOperation() && apuEgtWarning.isNormalOperation() + && ( + + )} + + + + + {apuEgt.isNormalOperation() + && ( + + )} + + + + EGT + °C + + + + {apuEgt.isNormalOperation() ? displayedEgtValue : 'XX' } + + + + ); +}; + +const ApuMemos = ({ x, y } : ComponentPositionProps) => { + const lowFuelPressure = useArinc429Var('L:A32NX_APU_LOW_FUEL_PRESSURE_FAULT', 1000); + + const [apuFlapOpenPercentage] = useSimVar('L:A32NX_APU_FLAP_OPEN_PERCENTAGE', 'Percent', 1000); + const [isIntakeIndicationFlashing, setIsIntakeIndicationFlashing] = useState(false); + + const [apuMasterPbOn] = useSimVar('L:A32NX_OVHD_APU_MASTER_SW_PB_IS_ON', 'Bool', 1000); + + const intakeApuMismatch = apuFlapOpenPercentage !== 0 && !apuMasterPbOn; + + useEffect(() => { + if (intakeApuMismatch) { + const timeout = setTimeout(() => { + setIsIntakeIndicationFlashing(true); + }, 180000); + return () => clearTimeout(timeout); + } + if (isIntakeIndicationFlashing) { + setIsIntakeIndicationFlashing(false); + } + return () => {}; + }, [intakeApuMismatch, isIntakeIndicationFlashing]); + + return ( + <> + {/* Memos */} + + {lowFuelPressure.value + && FUEL PRESS LO} + + {apuFlapOpenPercentage === 100 + && FLAP OPEN} + {/* FBW-31-07 */} + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/BleedPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/BleedPage.tsx new file mode 100644 index 00000000000..ebf2bb94466 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/BleedPage.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { PageTitle } from '../Generic/PageTitle'; +import BleedEngine from './elements/BleedEngine'; +import BleedPack from './elements/BleedPack'; +import BleedCrossbleed from './elements/BleedCrossbleed'; +import BleedApu from './elements/BleedApu'; +import BleedHotAir from './elements/BleedHotAir'; +import BleedMixerUnit from './elements/BleedMixerUnit'; + +import '../../../index.scss'; + +export const BleedPage = () => { + const sdacDatum = true; + + return ( + <> + BLEED + + + + RAM + AIR + + {/* Hot Air */} + + + + HOT + AIR + + + + + + + {/* Packs */} + + + + {/* Crossbleed */} + + + {/* APU */} + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/config.json b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/config.json new file mode 100644 index 00000000000..20ba9a8feaf --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/config.json @@ -0,0 +1,4 @@ +{ + "index": "./BleedPage.tsx", + "isInteractive": false +} diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedApu.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedApu.tsx new file mode 100644 index 00000000000..55c52a7dff5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedApu.tsx @@ -0,0 +1,24 @@ +import React, { FC } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import Valve from './Valve'; + +const BleedApu: FC = () => { + const [apuBleedAirValveOpen] = useSimVar('L:A32NX_APU_BLEED_AIR_VALVE_OPEN', 'bool', 500); + const [apuMasterSwitchPbIsOn] = useSimVar('L:A32NX_OVHD_APU_MASTER_SW_PB_IS_ON', 'bool', 500); + + // The bleed valve and isolation valve operate "simulatneously" according to the FCOM + const apuIsolationValveOpen = apuBleedAirValveOpen; + + return ( + + + + + + APU + + + ); +}; + +export default BleedApu; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedCrossbleed.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedCrossbleed.tsx new file mode 100644 index 00000000000..c06604790e1 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedCrossbleed.tsx @@ -0,0 +1,21 @@ +import { useSimVar } from '@instruments/common/simVars'; +import React, { FC } from 'react'; +import Valve from './Valve'; + +const BleedCrossbleed: FC = () => { + const [lhCrossBleedValveOpen] = useSimVar('L:A32NX_PNEU_XBLEED_VALVE_L_OPEN', 'bool', 500); + const [centreCrossBleedValveOpen] = useSimVar('L:A32NX_PNEU_XBLEED_VALVE_C_OPEN', 'bool', 500); + const [rhCrossBleedValveOpen] = useSimVar('L:A32NX_PNEU_XBLEED_VALVE_R_OPEN', 'bool', 500); + const sdacDatum = true; + const y = 325; + + return ( + + + + + + ); +}; + +export default BleedCrossbleed; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedEngine.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedEngine.tsx new file mode 100644 index 00000000000..735f3e69109 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedEngine.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import Valve from './Valve'; + +import '../../../../index.scss'; +import BleedGauge from './BleedGauge'; + +interface BleedPageProps { + x: number, + y: number, + engine: number, + sdacDatum: boolean +} + +const BleedEngine: React.FC = ({ x, y, engine, sdacDatum }) => { + const packIndex = engine > 2 ? 2 : 1; + const packFlowValveIndex = engine > 2 ? engine - 2 : engine; + + const [engineState] = useSimVar(`L:A32NX_ENGINE_STATE:${engine}`, 'Enum', 1000); + const isEngineRunning = engineState === 1; + const [engineBleedValveOpen] = useSimVar(`L:A32NX_PNEU_ENG_${engine}_PR_VALVE_OPEN`, 'bool', 500); + const [engineHpValveOpen] = useSimVar(`L:A32NX_PNEU_ENG_${engine}_HP_VALVE_OPEN`, 'bool', 500); + const [precoolerOutletTemp] = useSimVar(`L:A32NX_PNEU_ENG_${engine}_PRECOOLER_OUTLET_TEMPERATURE`, 'celsius', 100); + const precoolerOutletTempFive = Math.round(precoolerOutletTemp / 5) * 5; + const [precoolerInletPress] = useSimVar(`L:A32NX_PNEU_ENG_${engine}_REGULATED_TRANSDUCER_PRESSURE`, 'psi', 10); + const precoolerInletPressTwo = Math.round(precoolerInletPress / 2) * 2; + const [packFlowValveOpen] = useSimVar(`L:A32NX_COND_PACK_${packIndex}_FLOW_VALVE_${packFlowValveIndex}_IS_OPEN`, 'bool', 500); + const [packFlowValveRate] = useSimVar(`L:A32NX_PNEU_PACK_${packIndex}_FLOW_VALVE_${packFlowValveIndex}_FLOW_RATE`, 'number', 100); + + // TODO: Connect these up properly, so the valves can be shown in amber once we have failure conditions. + // For now, we pretend the valves are always in the commanded state. + const shouldHpValveBeOpen = engineHpValveOpen; + const shouldBleedValveBeOpen = engineBleedValveOpen; + + // TODO Degraded accuracy indication for fuel flow and used + + return ( + <> + {engine} + + + + {/* x=75 y=525 */} + + + IP + HP + + + { /* Engine Bleed valve */} + + + + + { /* HP valve */} + + + + {/* Engine Bleed temp */} + + + PSI + °C + + {/* Precooler inlet pressure */} + 60 ? 'Amber' : 'Green'}`} + > + {!sdacDatum || precoolerInletPressTwo < 0 ? 'XX' : precoolerInletPressTwo} + + {/* Precooler outlet temperature */} + 257 ? 'Amber' : 'Green'}`} + > + {!sdacDatum ? 'XX' : precoolerOutletTempFive} + + + + + {/* Pack valve */} + + + ); +}; + +export default BleedEngine; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedGauge.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedGauge.tsx new file mode 100644 index 00000000000..f9b7041c4d9 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedGauge.tsx @@ -0,0 +1,90 @@ +import React, { FC } from 'react'; +import { GaugeComponent, GaugeMarkerComponent } from '@instruments/common/gauges'; +import Valve from './Valve'; + +interface BleedGaugeProps { + x: number, + y: number, + engine: number, + sdacDatum: boolean, + packValveOpen: boolean, + packFlowRate: number, +} + +const BleedGauge: FC = ({ x, y, engine, sdacDatum, packValveOpen, packFlowRate }) => { + const radius = 39; + const startAngle = -90; + const endAngle = 90; + const min = 0; + const max = 1; + + return ( + + + {/* Pack inlet flow */} + + + + + {sdacDatum + && ( + + )} + + + {/* Flow control valve */} + + + + + + ); +}; + +export default BleedGauge; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedHotAir.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedHotAir.tsx new file mode 100644 index 00000000000..bd7dd126259 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedHotAir.tsx @@ -0,0 +1,29 @@ +import React, { FC } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { Triangle } from '@instruments/common/Shapes'; +import Valve from './Valve'; + +interface BleedHotAirProps { + x: number, + y: number, + hotAir: number, + sdacDatum: boolean, +} + +const BleedHotAir: FC = ({ x, y, hotAir, sdacDatum }) => { + const [packValveOpen] = useSimVar(`L:A32NX_PNEU_ENG_${hotAir}_HP_VALVE_OPEN`, 'bool', 500); + const hotAirValveOpen = packValveOpen; + const xoffset = 66; + + return ( + + + + + + {hotAir} + + ); +}; + +export default BleedHotAir; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedMixerUnit.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedMixerUnit.tsx new file mode 100644 index 00000000000..064f2f2130d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedMixerUnit.tsx @@ -0,0 +1,61 @@ +import React, { FC } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { Triangle } from '@instruments/common/Shapes'; +import { GaugeMarkerComponent } from '@instruments/common/gauges'; + +interface BleedMixerUnitProps { + x: number, + y: number, + sdacDatum: boolean, +} + +const BleedMixerUnit: FC = ({ x, y, sdacDatum }) => { + // TODO Add Air supplied to cabin and cockpit simvar + const [hpValve1] = useSimVar('L:A32NX_PNEU_ENG_1_HP_VALVE_OPEN', 'bool', 500); + const [hpValve2] = useSimVar('L:A32NX_PNEU_ENG_2_HP_VALVE_OPEN', 'bool', 500); + const airSuppliedToCabinAndCockpit = hpValve1 || hpValve2; + const ramInletOpen = false; + + return ( + + + + + + + + + + + + + + + ); +}; + +export default BleedMixerUnit; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedPack.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedPack.tsx new file mode 100644 index 00000000000..d4f511ce17c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/BleedPack.tsx @@ -0,0 +1,34 @@ +import React, { FC } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; + +interface BleedPackProps { + x: number, + y: number, + pack: number, +} + +const BleedPack: FC = ({ x, y, pack }) => { + const [packFlowValveLeftOpen] = useSimVar(`L:A32NX_COND_PACK_${pack}_FLOW_VALVE_1_IS_OPEN`, 'bool', 500); + const [packFlowValveRightOpen] = useSimVar(`L:A32NX_COND_PACK_${pack}_FLOW_VALVE_2_IS_OPEN`, 'bool', 500); + // TODO: The pack should probably emit a status for this, rather than deriving it from the valve positions + const isPackOperative = packFlowValveLeftOpen || packFlowValveRightOpen; + + const [packOutletTemperature] = useSimVar(`L:A32NX_PNEU_ENG_${pack}_PRECOOLER_OUTLET_TEMPERATURE`, 'celsius', 500); + + return ( + + + + + + PACK + {pack} + + {Math.round(packOutletTemperature)} + °C + + + ); +}; + +export default BleedPack; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/Valve.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/Valve.tsx new file mode 100644 index 00000000000..aee3cd041bc --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Bleed/elements/Valve.tsx @@ -0,0 +1,21 @@ +import React, { FC } from 'react'; + +interface ValveProps { + x: number, + y: number, + radius: number, + position: 'V' |'H', + css: string, + sdacDatum: boolean +} + +const Valve: FC = ({ x, y, radius, position, css, sdacDatum }) => ( + + + {!sdacDatum ? XX : null} + {sdacDatum && position === 'V' ? : null} + {sdacDatum && position === 'H' ? : null} + +); + +export default Valve; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/BleedPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/BleedPage.tsx new file mode 100644 index 00000000000..6fce2fa5e62 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/BleedPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const BleedPage = () => ( + BLEED +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/CbPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/CbPage.tsx new file mode 100644 index 00000000000..1ec90efb46e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/CbPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const CbPage = () => ( + C/B +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/CondPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/CondPage.tsx new file mode 100644 index 00000000000..8c4d44ef02a --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/CondPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const CondPage = () => ( + COND +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/CruisePage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/CruisePage.tsx new file mode 100644 index 00000000000..484c2df2278 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/CruisePage.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { usePersistentProperty } from '@instruments/common/persistence'; +import { useSimVar } from '@instruments/common/simVars'; +import { fuelForDisplay } from '@instruments/common/FuelFunctions'; +import { PageTitle } from '../Generic/PageTitle'; +import A380XCruise from './elements/A380Cruise'; +import CruisePressure from './elements/CruisePressure'; +import CruiseCond from './elements/CruiseCond'; + +import '../../../index.scss'; + +export const CruisePage = () => { + const [unit] = usePersistentProperty('CONFIG_USING_METRIC_UNIT', '1'); + const [engine1FuelUsed] = useSimVar('L:A32NX_FUEL_USED:1', 'number', 1000); + const [engine2FuelUsed] = useSimVar('L:A32NX_FUEL_USED:2', 'number', 1000); + + const engine1FuelUsedDisplay = fuelForDisplay(engine1FuelUsed, unit, 1, 5); + const engine2FuelUsedDisplay = engine1FuelUsedDisplay; + const engine3FuelUsedDisplay = fuelForDisplay(engine2FuelUsed, unit, 1, 5); + const engine4FuelUsedDisplay = engine3FuelUsedDisplay; + + const engineTotalFuelUsedDisplay = engine1FuelUsedDisplay + engine2FuelUsedDisplay + engine3FuelUsedDisplay + engine4FuelUsedDisplay; + + const [engine1FuelFlow] = useSimVar('L:A32NX_ENGINE_FF:1', 'number', 1000); // KG/HR + const [engine2FuelFlow] = useSimVar('L:A32NX_ENGINE_FF:2', 'number', 1000); + + const engine1FuelFlowDisplay = fuelForDisplay(engine1FuelFlow, unit); + const engine2FuelFlowDisplay = engine1FuelFlowDisplay; + const engine3FuelFlowDisplay = fuelForDisplay(engine2FuelFlow, unit); + const engine4FuelFlowDisplay = engine3FuelFlowDisplay; + + // TODO Degraded accuracy indication for fuel flow and used + + return ( + <> + CRUISE + + + {/* Fuel Flow */} + FUEL + + + + + + {engine1FuelFlowDisplay} + {engine2FuelFlowDisplay} + {engine3FuelFlowDisplay} + {engine4FuelFlowDisplay} + + FF + KG/H + + {/* Fuel Used */} + + + + + + {engine1FuelUsedDisplay} + {engine2FuelUsedDisplay} + {engine3FuelUsedDisplay} + {engine4FuelUsedDisplay} + + FU + TOTAL + {engineTotalFuelUsedDisplay} + KG + + AIR + + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/config.json b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/config.json new file mode 100644 index 00000000000..b69f02ab95e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/config.json @@ -0,0 +1,4 @@ +{ + "index": "./CruisePage.tsx", + "isInteractive": false +} diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/A380Cruise.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/A380Cruise.tsx new file mode 100644 index 00000000000..f7fb7ae7a5c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/A380Cruise.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +export const A380XCruise = () => ( + + {/* Body */} + + + + + + {/* Cockpit */} + + + + {/* Triangles */} + + + + {/* Window */} + + + +); + +export default A380XCruise; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/CruiseCond.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/CruiseCond.tsx new file mode 100644 index 00000000000..e120ec26c7f --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/CruiseCond.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useSimVar } from '@instruments/common/simVars'; + +export const CruiseCond = () => { + const [cockpitCabinTemp] = useSimVar('L:A32NX_COND_CKPT_TEMP', 'celsius', 1000); + const [fwdLowerCabinTemp] = useSimVar('L:A32NX_COND_FWD_TEMP', 'celsius', 1000); + const [aftLowerCabinTemp] = useSimVar('L:A32NX_COND_AFT_TEMP', 'celsius', 1000); + const fwdUpperCabinTemp = fwdLowerCabinTemp + 2; + const aftUpperCabinTemp = aftLowerCabinTemp + 2; + const fwdCargoTemp = 12; + const aftCargoTemp = 14; + + return ( + <> + °C + {cockpitCabinTemp.toFixed(0)} + + {fwdUpperCabinTemp.toFixed(0)} + TO + {aftUpperCabinTemp.toFixed(0)} + + {fwdLowerCabinTemp.toFixed(0)} + TO + {aftLowerCabinTemp.toFixed(0)} + + {/* Cargo Temps */} + {fwdCargoTemp.toFixed(0)} + {aftCargoTemp.toFixed(0)} + + ); +}; + +export default CruiseCond; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/CruisePressure.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/CruisePressure.tsx new file mode 100644 index 00000000000..9a9d541b227 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Cruise/elements/CruisePressure.tsx @@ -0,0 +1,104 @@ +import { GaugeComponent, GaugeMarkerComponent, splitDecimals } from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; +import React, { useEffect, useState } from 'react'; + +export const CruisePressure = () => { + const [landingElevDialPosition] = useSimVar('L:XMLVAR_KNOB_OVHD_CABINPRESS_LDGELEV', 'Number', 100); + const [landingRunwayElevation] = useSimVar('L:A32NX_PRESS_AUTO_LANDING_ELEVATION', 'feet', 1000); + const autoMode = true; // TODO useSimVar('L:A32NX_OVHD_PRESS_MODE_SEL_PB_IS_AUTO', 'Bool', 1000); + const [ldgElevValue, setLdgElevValue] = useState('XX'); + const [cssLdgElevName, setCssLdgElevName] = useState('Green'); + const [landingElev] = useSimVar('L:A32NX_OVHD_PRESS_LDG_ELEV_KNOB', 'feet', 100); + const [cabinAlt] = useSimVar('L:A32NX_PRESS_CABIN_ALTITUDE', 'feet', 500); + const [cabinVs] = useSimVar('L:A32NX_PRESS_CABIN_VS', 'feet per minute', 500); + const [deltaPsi] = useSimVar('L:A32NX_PRESS_CABIN_DELTA_PRESSURE', 'psi', 1000); + + const vsx = 440; + const y = 385; + const radius = 50; + + const deltaPress = splitDecimals(deltaPsi); + + useEffect(() => { + if (landingElevDialPosition === 0) { + // On Auto + const nearestfifty = Math.round(landingRunwayElevation / 50) * 50; + setLdgElevValue(landingRunwayElevation > -5000 ? nearestfifty.toString() : 'XX'); + setCssLdgElevName(landingRunwayElevation > -5000 ? 'Green' : 'Amber'); + } else { + // On manual + const nearestfifty = Math.round(landingElev / 50) * 50; + setLdgElevValue(nearestfifty.toString()); + setCssLdgElevName('Green'); + } + }, [landingElevDialPosition, landingRunwayElevation]); + + return ( + <> + + LDG ELEVN + + {ldgElevValue} + FT + + + {/* Vertical speed gauge */} + {/* TODO */} + + + + + + + + + + + + DELTA P + + {deltaPress[0]} + + + . + + {deltaPress[1]} + PSI + + AUTO + CAB V/S + {!autoMode ? Math.round(cabinVs / 50) * 50 : Math.abs(Math.round(cabinVs / 50) * 50)} + FT/MIN + + AUTO + CAB ALT + {Math.round(cabinAlt / 50) * 50 > 0 ? Math.round(cabinAlt / 50) * 50 : 0} + FT + + {/* TODO */} + { /* + = 25) && autoMode ? '' : 'Hide'} + transform={cabinVs * 60 <= -25 ? 'translate(0, 795) scale(1, -1)' : 'scale(1, 1)'} + > + + + + */} + + ); +}; + +export default CruisePressure; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/CruisePage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/CruisePage.tsx new file mode 100644 index 00000000000..284a7e8d6cd --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/CruisePage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const CruisePage = () => ( + CRUISE +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/DoorPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/DoorPage.tsx new file mode 100644 index 00000000000..cbefc5a912e --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/DoorPage.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { PageTitle } from '../Generic/PageTitle'; +import A380XDoors from './elements/A380XDoors'; +import CabinDoor from './elements/CabinDoor'; +import Oxygen from './elements/Oxygen'; + +import '../../../index.scss'; +import CargoDoor from './elements/CargoDoor'; + +export const DoorPage = () => { + const [windowLeft] = useSimVar('L:CPT_SLIDING_WINDOW', 'number'); + const [windowRight] = useSimVar('L:FO_SLIDING_WINDOW', 'number'); + const engineRunning = true; + const sdacActive = true; + const onGround = true; + + return ( + <> + DOOR + OXYGEN + + + + + MAIN + + UPPER + + {/* Cabin Doors */} + + + + + + + + + + + + + + + + + + + + + {/* Cargo Doors */} + + + + + + + ); +}; \ No newline at end of file diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/config.json b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/config.json new file mode 100644 index 00000000000..05de791ded8 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/config.json @@ -0,0 +1,4 @@ +{ + "index": "./DoorsPage.tsx", + "isInteractive": false +} diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/A380XDoors.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/A380XDoors.tsx new file mode 100644 index 00000000000..e03c57ec29c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/A380XDoors.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +type CabinWindowProps = { + windowLeft: boolean, + windowRight: boolean, +} + +export const A380XDoors: React.FC = ({ windowLeft, windowRight }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Captain's window */} + + {/* FO's window */} + + + +); + +export default A380XDoors; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/CabinDoor.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/CabinDoor.tsx new file mode 100644 index 00000000000..45be19bb987 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/CabinDoor.tsx @@ -0,0 +1,69 @@ +import { Position, CabinDoorProps } from '@instruments/common/types'; +import React from 'react'; + +const CabinDoor: React.FC = ({ x, y, doorNumber, side, mainOrUpper, engineRunning }) => { + const doorOpen = false; + const armed = true; + const validSDAC = true; + + let slide = ''; + if (!validSDAC) { + slide = 'XX'; + } else if (armed) { + slide = 'S'; + } + + let cabinDoorMessage = ''; + let xpos = x; + if (!validSDAC || (engineRunning && doorOpen)) { + cabinDoorMessage = side === 'L' ? `${mainOrUpper} ${doorNumber}${side} ----` : `---- ${mainOrUpper} ${doorNumber}${side}`; + } + if (side === 'L') { + xpos = x - 180; + if (mainOrUpper === 'UPPER') { + xpos = x - 193; + } + } else { + xpos = x + 30; + if (mainOrUpper === 'UPPER') { + xpos = x + 33; + } + } + let doorNumberCss = 'Green'; + let doorRectCss = 'Green SW2 BackgroundFill'; + let slideCss = 'White'; + if (side === 'L') { + slideCss = 'White EndAlign'; + } + if (!validSDAC) { + doorNumberCss = 'AmberFill'; + doorRectCss = 'Hide'; + slideCss = 'AmberFill'; + if (side === 'L') { + slideCss = 'AmberFill EndAlign'; + } + } else if (engineRunning && doorOpen) { + doorNumberCss = 'BackgroundFill'; + doorRectCss = 'Amber SW2 AmberFill'; + } + + return ( + + + {!validSDAC ? 'X' : doorNumber} + {cabinDoorMessage} + {slide} + + ); +}; + +export default CabinDoor; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/CargoDoor.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/CargoDoor.tsx new file mode 100644 index 00000000000..669234a7012 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/CargoDoor.tsx @@ -0,0 +1,25 @@ +import { Position, CargoDoorProps } from '@instruments/common/types'; +import React from 'react'; + +const CargoDoor: React.FC = ({ x, y, label, width, height, engineRunning }) => { + const doorOpen = false; + const validSDAC = true; + + let cargoDoorMessage = ''; + if ( + (doorOpen && (engineRunning || label === 'AVNCS')) + || !validSDAC + ) { + cargoDoorMessage = label === 'AVNCS' ? `${label} ----` : `--${label}`; + } + + return ( + + + + {cargoDoorMessage} + + ); +}; + +export default CargoDoor; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/Oxygen.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/Oxygen.tsx new file mode 100644 index 00000000000..9a1c168fcc1 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Doors/elements/Oxygen.tsx @@ -0,0 +1,34 @@ +import { useSimVar } from '@instruments/common/simVars'; +import { SdacActive, OnGround, Position } from '@instruments/common/types'; +import React from 'react'; + +const Oxygen: React.FC = ({ x, y, active, onGround }) => { + const minCrewOxygenPressureForFlight = 1000; // TODO Find out what this is for a crew of 5 + const crewOxygenPressure = 1829; + const ckptPressureAmber = !!(crewOxygenPressure < 350 || !active || (onGround && crewOxygenPressure < minCrewOxygenPressureForFlight)); + const crewOxygenPbAuto = useSimVar('L:PUSH_OVHD_OXYGEN_CREW', 'boolean', 500); + + const cabinOxygenPressure = 1854; + const cabinPressureAmber = !!(crewOxygenPressure < 350 || !active || (onGround && crewOxygenPressure < minCrewOxygenPressureForFlight)); + const cabinOxygenPbAuto = useSimVar('L:PUSH_OVHD_OXYGEN_CREW', 'boolean', 500); + + return ( + <> + + CKPT + {!active ? 'XX' : crewOxygenPressure} + PSI + REGUL PR LO + + + + CABIN + {!active ? 'XX' : cabinOxygenPressure} + PSI + REGUL PR LO + + + ); +}; + +export default Oxygen; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecAcPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecAcPage.tsx new file mode 100644 index 00000000000..8ae54a79b41 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecAcPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const ElecAcPage = () => ( + ELEC AC +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/ElecDcPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/ElecDcPage.tsx new file mode 100644 index 00000000000..f08dfb19fd0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/ElecDcPage.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { Triangle } from '@instruments/common/Shapes'; +import { PageTitle } from '../Generic/PageTitle'; +import ElectricalNetwork from './elements/ElectricalNetwork'; + +import '../../../index.scss'; + +export const ElecDcPage = () => { + // const sdacDatum = true; + const [statInvPowered] = useSimVar('L:A32NX_ELEC_AC_STAT_INV_BUS_IS_POWERED', 'bool', 500); + + return ( + <> + ELEC DC + + + + + + {/* inverter */} + + + STAT + INV + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/config.json b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/config.json new file mode 100644 index 00000000000..331744a28e2 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/config.json @@ -0,0 +1,4 @@ +{ + "index": "./ElecDcPage.tsx", + "isInteractive": false +} diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/BusBar.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/BusBar.tsx new file mode 100644 index 00000000000..7acbd696d0d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/BusBar.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; + +interface BusBarProps { + x: number, + y: number, + network: string, +} + +const BusBar: FC = ({ x, y, network }) => { + // const sdacDatum = true; + // TODO: Add APU bus to DC electrical system + const [DCBusBar] = useSimVar(`L:A32NX_ELEC_DC_${network}_BUS_IS_POWERED`, 'bool', 500); + + let yposBB = y + 276; + let xposBB = x - 18; + + if (network === 'ESS') { + yposBB -= 103; + xposBB -= 10; + } else if (['2', 'APU'].includes(network)) { + xposBB += 20; + } + + return ( + + + DC + + {`${network}`} + + + ); +}; + +export default BusBar; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/ElectricalNetwork.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/ElectricalNetwork.tsx new file mode 100644 index 00000000000..3ccd5529eac --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/ElectricalNetwork.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { Triangle } from '@instruments/common/Shapes'; +import IndicationBox from './IndicationBox'; +import BusBar from './BusBar'; + +interface ElectricalNetworkProps { + x: number, + y: number, + network: string, + AC: number, +} + +const ElectricalNetwork: FC = ({ x, y, network, AC }) => { + // const sdacDatum = true; + // TODO: Modify ACBusBar when bus 3 and 4 are available + const [ACBusBar] = useSimVar(`L:A32NX_ELEC_AC_${AC > 2 ? AC - 2 : AC}_BUS_IS_POWERED`, 'bool', 500); + + let yposTR = y + 384; + let xposTR = x - 12; + + if (AC > 2) { + // yposTR = y + 386; + xposTR = x + 6; + } else if (AC === 1) { + yposTR = y + 329; + xposTR = x - 22; + } + + return ( + + {/* Battery */} + + {/* Busbar */} + + {/* TR to BusBar */} + + + + {/* TR */} + + + AC + {`${AC}`} + + ); +}; + +export default ElectricalNetwork; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/IndicationBox.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/IndicationBox.tsx new file mode 100644 index 00000000000..5720f766222 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/ElecDc/elements/IndicationBox.tsx @@ -0,0 +1,98 @@ +import { Triangle } from '@instruments/common/Shapes'; +import { useSimVar } from '@instruments/common/simVars'; +import React, { FC } from 'react'; + +interface IndicationBoxProps { + x: number, + y: number, + network: string, + TR: string, // TR_1 +} + +const IndicationBox: FC = ({ x, y, network, TR }) => { + // const sdacDatum = true; + const simVarString = ['1', '2'].includes(network) ? `${TR}_${network}` : `${network}_${TR}`; + const [potential] = useSimVar(`L:A32NX_ELEC_${simVarString}_POTENTIAL`, 'volts', 500); + const [current] = useSimVar(`L:A32NX_ELEC_${simVarString}_CURRENT`, 'volts', 500); + const BatteryCharging = true; + + let TRX = ['1', '2'].includes(network) ? x + 61 : x + 102; + let networkX = ['1', '2'].includes(network) ? x + 71 : x + 11; + + if (TR === 'BAT') { + TRX = ['1', '2'].includes(network) ? x + 77 : x + 118; + networkX = ['1', '2'].includes(network) ? x + 87 : x + 11; + } + + let batteryChargingStatusX = x + 56; + let batteryChargingStatusY = y + 108; + let batteryChargingArrowY = batteryChargingStatusY; + + if (network === '1') { + batteryChargingStatusX -= 11; + } else if (network === 'ESS') { + batteryChargingStatusX += 8; + } else if (network === '2') { + batteryChargingStatusX += 8; + } + + if (current < 0) { + batteryChargingStatusY -= 14; + batteryChargingArrowY = network === 'ESS' ? batteryChargingArrowY + 63 : batteryChargingArrowY + 165; + } else { + batteryChargingStatusY += 14; + batteryChargingArrowY = y + 108; + } + + return ( + <> + + + + + + + + {TR} + + + + {network} + + + + V + A + + {/* Voltage */} + + {Math.round(potential)} + + + {/* Current */} + + {Math.round(current)} + + + + + ); +}; + +export default IndicationBox; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/EngPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/EngPage.tsx new file mode 100644 index 00000000000..9e680d76385 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/EngPage.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +// import { usePersistentProperty } from '@instruments/common/persistence'; +import { PageTitle } from '../Generic/PageTitle'; +import EngineColumn from './elements/EngineColumn'; + +import '../../../index.scss'; + +export const EngPage = () => { + // const sdacDatum = true; + // const [weightUnit] = usePersistentProperty('CONFIG_USING_METRIC_UNIT', '1'); + const [engSelectorPosition] = useSimVar('L:XMLVAR_ENG_MODE_SEL', 'Enum', 1000); + const [engine1State] = useSimVar('L:A32NX_ENGINE_STATE:1', 'enum', 500); // TODO: Update with correct SimVars + const [engine2State] = useSimVar('L:A32NX_ENGINE_STATE:2', 'enum', 500); // TODO: Update with correct SimVars + const [engine3State] = useSimVar('L:A32NX_ENGINE_STATE:3', 'enum', 500); // TODO: Update with correct SimVars + const [engine4State] = useSimVar('L:A32NX_ENGINE_STATE:4', 'enum', 500); // TODO: Update with correct SimVars + const engineState = [engine1State, engine2State, engine3State, engine3State, engine4State]; + const engineRunning = engineState.some((value) => value > 0); // TODO Implement FADEC SimVars once available + + return ( + <> + ENGINE + + + + + + + {/* labels */} + N2 + % + N3 + % + FF + KG/H + + OIL + QT + °C + + PSI + + VIB + N1 + N2 + N3 + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/config.json b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/config.json new file mode 100644 index 00000000000..d9d8b80ad44 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/config.json @@ -0,0 +1,4 @@ +{ + "index": "./EngPage.tsx", + "isInteractive": false +} diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/DecimalValues.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/DecimalValues.tsx new file mode 100644 index 00000000000..8f61a2814cb --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/DecimalValues.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; + +interface DecimalValueProps { + x: number, + y: number, + value: number, + active: boolean, + shift?: number, +} + +const DecimalValue: FC = ({ x, y, value, active, shift = 0 }) => { + value = value < 0 ? 0 : value; + const shiftx = x + shift; + + return ( + <> + {!active && ( + XX + )} + {active && ( + + {value.toFixed(1).toString().split('.')[0]} + . + {value.toFixed(1).toString().split('.')[1]} + + )} + + ); +}; + +export default DecimalValue; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/EngineColumn.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/EngineColumn.tsx new file mode 100644 index 00000000000..c0dff364757 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/EngineColumn.tsx @@ -0,0 +1,89 @@ +// import { usePersistentProperty } from '@instruments/common/persistence'; +import { useSimVar } from '@instruments/common/simVars'; +import { EngineNumber, IgnitionActive, Position } from '@instruments/common/types'; +import React, { FC } from 'react'; +import DecimalValues from './DecimalValues'; +import IgnitionBorder from './IgnitionBorder'; +import NacelleTemperatureGauge from './NacelleTemperatureGauge'; +import OilPressureGauge from './OilPressureGauge'; +import OilQuantityGauge from './OilQuantityGauge'; +import StartValve from './StartValve'; + +interface EngineColumnProps { + anyEngineRunning: boolean, +} + +const EngineColumn: FC = ({ x, y, engine, ignition, anyEngineRunning }) => { + const [N2] = useSimVar(`L:A32NX_ENGINE_N2:${engine}`, 'percent', 100); // TODO: Update with correct SimVars + const [N3] = useSimVar(`L:A32NX_ENGINE_N3:${engine}`, 'percent', 100); // TODO: Update with correct SimVars + const [starterValveOpen] = useSimVar(`L:A32NX_PNEU_ENG_${engine}_STARTER_VALVE_OPEN`, 'percent', 500); // TODO: Update with correct SimVars + const starting = !!(N2 < 58.5 && ignition && starterValveOpen); // TODO Should be N3 + + const engineRunningOrIgnitionOn = ignition || anyEngineRunning; + + const [fuelFlow] = useSimVar(`L:A32NX_ENGINE_FF:${engine}`, 'number', 100); + + const [n1Vibration] = useSimVar(`TURB ENG VIBRATION:${engine}`, 'number', 100); + const n2Vibration = n1Vibration; + const n3Vibration = n1Vibration; + + const [oilQuantity] = useSimVar(`L:A32NX_ENGINE_TOTAL_OIL:${engine}`, 'number', 500); // TODO: Update with correct SimVars + const [engineOilTemperature] = useSimVar(`GENERAL ENG OIL TEMPERATURE:${engine}`, 'celsius', 100); // TODO: Update with correct SimVars + + return ( + <> + + {/* N2 */} + + 2 ? x - 96 : x + 64},${y - 10} l 26, 0`} /> + {/* N3 */} + + + 2 ? x - 96 : x + 64},${y + 28} l 26, 0`} /> + {/* Fuel Flow */} + {!engineRunningOrIgnitionOn && ( + XX + )} + {engineRunningOrIgnitionOn && ( + {(Math.ceil(fuelFlow / 10) * 10)} + )} + {/* OIL */} + + + {!engineRunningOrIgnitionOn && ( + XX + )} + {engineRunningOrIgnitionOn && ( + 177 ? 'Amber' : 'Green'} EndAlign F29`} + > + {engineOilTemperature < 0 ? 0 : Math.round(engineOilTemperature)} + + + )} + 2 ? x - 96 : x + 64},${y + 82} l 26, 0`} /> + {/* Oil Pressure */} + + {/* VIB N1 */} + + 2 ? x - 96 : x + 64},${y + 370} l 26, 0`} /> + {/* VIB N2 */} + + 2 ? x - 96 : x + 64},${y + 406} l 26, 0`} /> + {/* VIB N3 */} + + 2 ? x - 96 : x + 64},${y + 440} l 26, 0`} /> + + {/* NAC / Ignition */} + {(starting || ignition) + && } + {!starterValveOpen && !ignition + && } + + + ); +}; + +export default EngineColumn; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/IgnitionBorder.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/IgnitionBorder.tsx new file mode 100644 index 00000000000..d15d90bc140 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/IgnitionBorder.tsx @@ -0,0 +1,26 @@ +import { useSimVar } from '@instruments/common/simVars'; +import { Position, EngineNumber, IgnitionActive } from '@instruments/common/types'; +import React from 'react'; + +const IgnitionBorder: React.FC = ({ x, y, engine, ignition }) => { + const [engineState] = useSimVar(`L:A32NX_ENGINE_STATE:${engine}`, 'bool', 500); + const [N1Percent] = useSimVar(`L:A32NX_ENGINE_N1:${engine}`, 'percent', 100); + const [N1Idle] = useSimVar('L:A32NX_ENGINE_IDLE_N1', 'percent', 1000); + const showBorder = !!(N1Percent < Math.floor(N1Idle) - 1 && engineState === 2); + + return ( + <> + + {ignition && showBorder + && ( + <> + + + + )} + + + ); +}; + +export default IgnitionBorder; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/NacelleTemperatureGauge.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/NacelleTemperatureGauge.tsx new file mode 100644 index 00000000000..c742629dae2 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/NacelleTemperatureGauge.tsx @@ -0,0 +1,99 @@ +import React, { FC } from 'react'; +import { GaugeComponent, GaugeMarkerComponent } from '@instruments/common/gauges'; + +interface NacelleTemperatureGaugeProps { + x: number, + y: number, + engine: number, + active: boolean, + value: number, +} + +const NacelleTemperatureGauge: FC = ({ x, y, engine, active, value }) => { + const radius = 45; + const startAngle = -90; + const endAngle = 90; + const min = 0; + const max = 500; + + return ( + + + {/* Pack inlet flow */} + + + + {!active && ( + XX + )} + {active + && ( + <> + + + + + + ) } + + {engine === 1 && 0 } + {engine === 4 && 500 } + {engine === 2 + && ( + <> + NAC + °C + + )} + + ); +}; + +export default NacelleTemperatureGauge; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/OilPressureGauge.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/OilPressureGauge.tsx new file mode 100644 index 00000000000..c222c84d647 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/OilPressureGauge.tsx @@ -0,0 +1,102 @@ +import React, { FC } from 'react'; +import { GaugeComponent, GaugeMarkerComponent } from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; + +interface OilPressureGaugeProps { + x: number, + y: number, + engine: number, + active: boolean, +} + +const OilPressureGauge: FC = ({ x, y, engine, active }) => { + const [engineOilPressure] = useSimVar(`ENG OIL PRESSURE:${engine}`, 'psi', 100); + const radius = 45; + const startAngle = -90; + const endAngle = 90; + const min = 0; + const max = 500; + + const needleValue = engineOilPressure <= 100 ? 225 / 100 * engineOilPressure : 225 + (225 / 400 * engineOilPressure); + + return ( + + + {/* Pack inlet flow */} + + {!active && ( + XX + )} + {active + && ( + <> + + + + + + + ) } + + {active && ( + + {engineOilPressure < 0 ? 0 : Math.round(engineOilPressure)} + + + )} + + ); +}; + +export default OilPressureGauge; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/OilQuantityGauge.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/OilQuantityGauge.tsx new file mode 100644 index 00000000000..911414e04cc --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/OilQuantityGauge.tsx @@ -0,0 +1,101 @@ +import React, { FC } from 'react'; +import { GaugeComponent, GaugeMarkerComponent } from '@instruments/common/gauges'; + +interface OilQuantityGaugeProps { + x: number, + y: number, + engine: number, + active: boolean, + value: number, +} + +const OilQuantityGauge: FC = ({ x, y, engine, active, value }) => { + const radius = 53; + const startAngle = -90; + const endAngle = 90; + const min = 0; + const max = 19.3; // TODO maximum should be 18.3 but values as high as 19.0 are appearing in current model + + return ( + + + {/* Pack inlet flow */} + + {!active && ( + XX + )} + {active + && ( + <> + + + + + + + ) } + + + + ); +}; + +export default OilQuantityGauge; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/StartValve.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/StartValve.tsx new file mode 100644 index 00000000000..9f2c5dbbe75 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Engine/elements/StartValve.tsx @@ -0,0 +1,32 @@ +import { useSimVar } from '@instruments/common/simVars'; +import { Position, EngineNumber } from '@instruments/common/types'; +import Valve from '@instruments/common/Valve'; +import React from 'react'; + +const StartValve: React.FC = ({ x, y, engine }) => { + const [startValveOpen] = useSimVar(`L:A32NX_PNEU_ENG_${engine}_STARTER_VALVE_OPEN`, 'boolean', 500); + const [starterInletPressure] = useSimVar(`L:A32NX_PNEU_ENG_${engine}_STARTER_CONTAINER_PRESSURE`, 'psi', 100); + + const [N2] = useSimVar(`L:A32NX_ENGINE_N2:${engine}`, 'percent', 100); + const showIgniter = !!(N2 > 9 && N2 < 25); // TODO Use SimVars for igniter once available + + return ( + + + A + 60 ? 'Amber' : 'Green'}`}>{Math.round(starterInletPressure)} + + {startValveOpen && } + {engine === 2 + && ( + <> + IGN + PSI + + )} + + + ); +}; + +export default StartValve; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/FctlPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/FctlPage.tsx new file mode 100644 index 00000000000..3622b8892c7 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/FctlPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const FctlPage = () => ( + F/CTL +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/FuelPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/FuelPage.tsx new file mode 100644 index 00000000000..4330635b264 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/FuelPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const FuelPage = () => ( + FUEL +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Generic/Inop.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Generic/Inop.tsx new file mode 100644 index 00000000000..ca543d68ff6 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Generic/Inop.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export const Inop = () => ( + + INOP. + +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Generic/PageTitle.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Generic/PageTitle.tsx new file mode 100644 index 00000000000..bac6fea0242 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Generic/PageTitle.tsx @@ -0,0 +1,21 @@ +import React, { FunctionComponent } from 'react'; + +export const PageTitle: FunctionComponent<{ showMore: boolean, x: number, y: number }> = (props) => { + const x = props.children === 'WHEEL' ? props.x + 20 : props.x; + + return ( + <> + {props.children} + + {/* "MORE" label if showMore prop exists */} + {props.showMore ? ( + <> + ... + + MORE + + + ) : null} + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Hyd/HydPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Hyd/HydPage.tsx new file mode 100644 index 00000000000..39321524d3c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Hyd/HydPage.tsx @@ -0,0 +1,412 @@ +import { useSimVar } from '@instruments/common/simVars'; +import React from 'react'; +import { PageTitle } from '../Generic/PageTitle'; + +import '../../styles.scss'; + +const LITERS_PER_GALLON = 3.785411784; + +export const HydPage = () => { + const [greenPressure] = useSimVar('L:A32NX_HYD_GREEN_SYSTEM_1_SECTION_PRESSURE', 'psi', 1000); + const [yellowPressure] = useSimVar('L:A32NX_HYD_YELLOW_SYSTEM_1_SECTION_PRESSURE', 'psi', 1000); + + const [engine1State] = useSimVar('L:A32NX_ENGINE_STATE:1', 'Enum', 1000); + const [engine2State] = useSimVar('L:A32NX_ENGINE_STATE:2', 'Enum', 1000); + const [engine3State] = useSimVar('L:A32NX_ENGINE_STATE:3', 'Enum', 1000); + const [engine4State] = useSimVar('L:A32NX_ENGINE_STATE:4', 'Enum', 1000); + + const [greenReservoirLevel] = useSimVar('L:A32NX_HYD_GREEN_RESERVOIR_LEVEL', 'gallon', 1000); + const [greenReservoirLevelIsLow] = useSimVar('L:A32NX_HYD_GREEN_RESERVOIR_LEVEL_IS_LOW', 'boolean', 1000); + const [greenReservoirAirPressureIsLow] = useSimVar('L:A32NX_HYD_GREEN_RESERVOIR_AIR_PRESSURE_IS_LOW', 'boolean', 1000); + + let greenReservoirFlags = ReservoirFailFlags.None; + if (greenReservoirAirPressureIsLow) greenReservoirFlags |= ReservoirFailFlags.AirPressureLow; + if (greenReservoirLevelIsLow) greenReservoirFlags |= ReservoirFailFlags.LevelLow; + + const [yellowReservoirLevel] = useSimVar('L:A32NX_HYD_YELLOW_RESERVOIR_LEVEL', 'gallon', 1000); + const [yellowReservoirLevelIsLow] = useSimVar('L:A32NX_HYD_YELLOW_RESERVOIR_LEVEL_IS_LOW', 'boolean', 1000); + const [yellowReservoirAirPressureIsLow] = useSimVar('L:A32NX_HYD_YELLOW_RESERVOIR_AIR_PRESSURE_IS_LOW', 'boolean', 1000); + + let yellowReservoirFlags = ReservoirFailFlags.None; + if (yellowReservoirAirPressureIsLow) yellowReservoirFlags |= ReservoirFailFlags.AirPressureLow; + if (yellowReservoirLevelIsLow) yellowReservoirFlags |= ReservoirFailFlags.LevelLow; + + const [engineDrivenPump1aState] = useEngineDrivenPumpState('1A'); + const [engineDrivenPump1bState] = useEngineDrivenPumpState('1B'); + const [engineDrivenPump2aState] = useEngineDrivenPumpState('2A'); + const [engineDrivenPump2bState] = useEngineDrivenPumpState('2B'); + const [engineDrivenPump3aState] = useEngineDrivenPumpState('3A'); + const [engineDrivenPump3bState] = useEngineDrivenPumpState('3B'); + const [engineDrivenPump4aState] = useEngineDrivenPumpState('4A'); + const [engineDrivenPump4bState] = useEngineDrivenPumpState('4B'); + + const [engine1PumpAbDisconnect] = useSimVar('L:A32NX_HYD_ENG_1AB_PUMP_DISC', 'boolean', 1000); + const [engine2PumpAbDisconnect] = useSimVar('L:A32NX_HYD_ENG_2AB_PUMP_DISC', 'boolean', 1000); + const [engine3PumpAbDisconnect] = useSimVar('L:A32NX_HYD_ENG_3AB_PUMP_DISC', 'boolean', 1000); + const [engine4PumpAbDisconnect] = useSimVar('L:A32NX_HYD_ENG_4AB_PUMP_DISC', 'boolean', 1000); + + const [isFireValve1Open] = useFireValveOpenState(1); + const [isFireValve2Open] = useFireValveOpenState(2); + const [isFireValve3Open] = useFireValveOpenState(3); + const [isFireValve4Open] = useFireValveOpenState(4); + + const line1Center = 77; + const line2Center = 230; + const line3Center = 534; + const line4Center = 686; + + return ( + + HYD + + + + + + + 2900 ? 'Green' : 'Amber'} /> + 2900 ? 'Green' : 'Amber'} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2900 ? 'Green' : 'Amber'} /> + 2900 ? 'Green' : 'Amber'} /> + + + + + + + + + + + + + + + + + + + + + + ); +}; + +type ElecPumpGroupProps = { + x: number; + y: number; +} +const ElecPumps = ({ x, y }: ElecPumpGroupProps) => { + const [electricPumpStateGreenA] = useElectricPumpState('G', 'A'); + const [electricPumpStateGreenB] = useElectricPumpState('G', 'B'); + const [electricPumpStateYellowA] = useElectricPumpState('Y', 'A'); + const [electricPumpStateYellowB] = useElectricPumpState('Y', 'B'); + + return ( + + + + ELEC + PMPS + + + + ); +}; + +type SystemLabelProps = { + x: number, + y: number, + label: string, + pressure: number +} +const SystemLabel = ({ x, y, label, pressure }: SystemLabelProps) => { + const width = 226; + const height = 36; + + return ( + + 2900 ? 'Green' : 'Amber'} /> + 2900 ? 'Green' : 'Amber'} /> + 2900 ? 'White' : 'Amber'}`}>{label} + 2900 ? 'Green' : 'Amber'} F28`} + > + {(Math.round(pressure / 100) * 100).toFixed(0)} + + PSI + + ); +}; + +function useEngineDrivenPumpState(label: string): [EnginePumpState] { + const [engineDrivenPumpLowPressure] = useSimVar(`L:A32NX_HYD_EDPUMP_${label}_LOW_PRESS`, 'boolean', 1000); + const [engineDrivenPumpPbIsAuto] = useSimVar(`L:A32NX_OVHD_HYD_ENG_${label}_PUMP_PB_IS_AUTO`, 'boolean', 1000); + + if (!engineDrivenPumpPbIsAuto) { + if (!engineDrivenPumpLowPressure) { + return [EnginePumpState.AbnormallyPressurized]; + } + return [EnginePumpState.Depressurized]; + } + + if (engineDrivenPumpLowPressure) { + return [EnginePumpState.LowPressure]; + } + + return [EnginePumpState.Normal]; +} + +function useFireValveOpenState(engineNumber: 1 | 2 | 3 | 4): [boolean] { + // In the hydraulics simulation, there's one fire valve per engine pump, i.e 2 per engine. But there's only one symbol on the ECAM. + // So we show the ECAM symbol closed only if the fire valves on both pumps are closed + + const [isFireValveAOpen] = useSimVar(`L:A32NX_HYD_${engineNumber <= 2 ? 'GREEN' : 'YELLOW'}_PUMP_${1 + 2 * ((engineNumber - 1) % 2)}_FIRE_VALVE_OPENED`, 'boolean', 1000); // 1, 3, 1, 3 + const [isFireValveBOpen] = useSimVar(`L:A32NX_HYD_${engineNumber <= 2 ? 'GREEN' : 'YELLOW'}_PUMP_${2 + 2 * ((engineNumber - 1) % 2)}_FIRE_VALVE_OPENED`, 'boolean', 1000); // 2, 4, 2, 4 + + return [isFireValveAOpen || isFireValveBOpen]; +} + +enum EnginePumpState { + Normal, + Depressurized, + AbnormallyPressurized, + LowPressure, +} + +type EnginePumpProps = { + x: number; + y: number; + state: EnginePumpState, + isDisconnected?: boolean, + label: string; + isMirrored?: boolean; +}; + +const EnginePump = ({ x, y, state, isDisconnected = false, label, isMirrored }: EnginePumpProps) => { + const size = 39; + + return ( + + + {(state === EnginePumpState.Normal || state === EnginePumpState.AbnormallyPressurized) + && } + {state === EnginePumpState.Depressurized + && } + {state === EnginePumpState.LowPressure && LO} + {label} + {isDisconnected && DISC} + + ); +}; + +type FireShutoffValveProps = { + x: number; + y: number; + isOpen?: boolean; +}; + +const FireShutoffValve = ({ x, y, isOpen }: FireShutoffValveProps) => { + const radius = 17; + + return ( + <> + + {isOpen ? ( + + ) : ( + + )} + + ); +}; + +function useElectricPumpState(side: ('G' | 'Y'), aOrB: ('A' | 'B')): [ElecPumpState] { + const [isElecPumpActive] = useSimVar(`L:A32NX_HYD_${side}${aOrB}_EPUMP_ACTIVE`, 'boolean', 1000); + const [offPbIsAuto] = useSimVar(`L:A32NX_OVHD_HYD_EPUMP${side[0]}${aOrB}_OFF_PB_IS_AUTO`, 'boolean', 1000); + const [offPbHasFault] = useSimVar(`L:A32NX_OVHD_HYD_EPUMP${side[0]}${aOrB}_OFF_PB_HAS_FAULT`, 'boolean', 1000); + // TODO: Use the ON pb + + if (isElecPumpActive) { + if (offPbHasFault) { + return [ElecPumpState.FailOn]; + } + + return [ElecPumpState.On]; + } + + if (offPbHasFault || !offPbIsAuto) { + return [ElecPumpState.FailOff]; + } + + return [ElecPumpState.Auto]; +} + +enum ElecPumpState { + On = 'GreenFill Green', + Auto = 'White', + FailOn = 'Amber AmberFill', + FailOff = 'Amber', +} + +type ElecPumpProps = { + x: number; + y: number; + state: ElecPumpState; + label: 'A' | 'B'; + isOverheat?: boolean; + isMirrored: boolean; +}; +const ElecPump = ({ x, y, state, isOverheat = false, isMirrored, label }: ElecPumpProps) => { + const isFailed = state === ElecPumpState.FailOff || state === ElecPumpState.FailOn; + + if (isMirrored) { + return ( + + + + {label} + + {isOverheat && OVHT} + + ); + } + + return ( + + + {label} + + + {isOverheat && OVHT} + + ); +}; + +type ReservoirProps = { + x: number; + y: number; + isMirrored?: boolean; + levelInLitres: number; + normalFillingRange?: number; + failFlags?: ReservoirFailFlags; +}; +const Reservoir = ({ x, y, levelInLitres, normalFillingRange, isMirrored = false, failFlags = ReservoirFailFlags.None }: ReservoirProps) => { + const height = 160; + const width = 18; + const fallbackFillingRange = 40; + + // TODO: Figure out + const reservoirCapacityInLiters = 50; + const litersToPixels = (liters: number) => liters * height / reservoirCapacityInLiters; + + if (!Number.isFinite(normalFillingRange)) { + failFlags |= ReservoirFailFlags.FillingRangeFail; + } else if (failFlags & ReservoirFailFlags.FillingRangeFail) { + normalFillingRange = undefined; + } + + return ( + + + + + + {(failFlags & ReservoirFailFlags.FillingRangeFail) + && } + + + + ); +}; + +enum ReservoirFailFlags { + None = 0, + AirPressureLow = 1, + TemperatureHigh = 2, + Overheat = 4, + LevelLow = 8, + FillingRangeFail = 16, +} +type ReservoirFailIndicationsProps = { + x: number; + y: number; + flags?: ReservoirFailFlags +} +const ReservoirFailIndications = ({ x, y, flags = ReservoirFailFlags.None }: ReservoirFailIndicationsProps) => ( + + {flags & ReservoirFailFlags.AirPressureLow && AIR} + {flags & ReservoirFailFlags.AirPressureLow && PRESS} + {flags & ReservoirFailFlags.AirPressureLow && LOW} + {flags & ReservoirFailFlags.TemperatureHigh && TEMP HI} + {flags & ReservoirFailFlags.Overheat && OVHT} + +); + +type EngineGraphicProps = { + x: number; + y: number; + label: number; + isRunning: boolean; +}; +const EngineGraphic = ({ x, y, label, isRunning }: EngineGraphicProps) => ( + + + + {label} + + +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/PressPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/PressPage.tsx new file mode 100644 index 00000000000..6ec480f03b5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/PressPage.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { PageTitle } from '../Generic/PageTitle'; +import LandingElevation from './elements/LandingElevation'; +import DeltaP from './elements/DeltaP'; +import A380XBleed from './elements/A380Press'; +import CabAlt from './elements/CabAlt'; +import CabinVerticalSpeed from './elements/CabinVerticalSpeed'; +import Packs from './elements/Packs'; + +import '../../../index.scss'; +import OutflowValve from './elements/OutflowValve'; +import ExtractValve from './elements/ExtractValve'; + +export const PressPage = () => ( + <> + CAB PRESS + + {/* Landing Elevation */} + + + {/* Delta Pression, Cab Alt and V/S gauges */} + + + + + + + + + + + {/* Outflow valves */} + AUTO + CTL + + + + + + + + + + AVNCS + EXTRACT + OVBD + + + CAB AIR + EXTRACT + + + {/* Packs */} + + + + +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/config.json b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/config.json new file mode 100644 index 00000000000..22a2bee56a0 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/config.json @@ -0,0 +1,4 @@ +{ + "index": "./PressPage.tsx", + "isInteractive": false +} diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/A380Press.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/A380Press.tsx new file mode 100644 index 00000000000..090f5335f2d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/A380Press.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +export const A380XBleed = () => ( + + {/* + */} + + + + + + + + + + +); + +export default A380XBleed; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/CabAlt.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/CabAlt.tsx new file mode 100644 index 00000000000..e410c13e8bc --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/CabAlt.tsx @@ -0,0 +1,136 @@ +import { GaugeComponent, GaugeMarkerComponent, ThrottlePositionDonutComponent } from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; +import { Position } from '@instruments/common/types'; +import React from 'react'; + +export const CabAlt: React.FC = ({ x, y }) => { + const [cabinAlt] = useSimVar('L:A32NX_PRESS_CABIN_ALTITUDE', 'feet', 500); + const cabAlt50 = Math.round(cabinAlt / 50) * 50; + + const [cabManMode] = useSimVar('L:A32NX_CAB_PRESS_MODE_MAN', 'bool', 500); + + const radius = 87; + const startAngle = 212; + const endAngle = 89; + const maxValue = 12.5; + + return ( + + {!cabManMode ? 'AUTO' : 'MAN'} + CAB ALT + FT + = 9550 ? 'Red' : 'Green'}`} x={x + 108} y={y + 66}> + {cabAlt50} + + + + 17 + + + + + + + = 8.5 ? 'Amber' : ''}`} + indicator + multiplierOuter={1.01} + /> + + + + ); +}; + +export default CabAlt; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/CabinVerticalSpeed.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/CabinVerticalSpeed.tsx new file mode 100644 index 00000000000..cdc006c592c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/CabinVerticalSpeed.tsx @@ -0,0 +1,125 @@ +import { GaugeComponent, GaugeMarkerComponent, ThrottlePositionDonutComponent } from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; +import { Position } from '@instruments/common/types'; +import React from 'react'; + +const CabinVerticalSpeed: React.FC = ({ x, y }) => { + const [cabinVs] = useSimVar('L:A32NX_PRESS_CABIN_VS', 'feet per minute', 500); + const cabinAltManMode = false; + + const radius = 88; + const min = -2; + const max = 2; + const startAngle = 180; + const endAngle = 0; + + return ( + <> + + {!cabinAltManMode ? 'AUTO' : 'MAN'} + V/S + FT/MIN + 1800 ? 'GreenTextPulse' : 'Green'}`} x={x + 128} y={y + 13}>{Math.round(cabinVs / 50) * 50} + + + + + + + + 1800 ? 'GreenIndicatorPulse' : ''}`} + indicator + multiplierOuter={1.01} + /> + + + + + ); +}; + +export default CabinVerticalSpeed; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/DeltaP.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/DeltaP.tsx new file mode 100644 index 00000000000..ef8198b820c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/DeltaP.tsx @@ -0,0 +1,107 @@ +import { GaugeComponent, GaugeMarkerComponent, splitDecimals } from '@instruments/common/gauges'; +import { useSimVar } from '@instruments/common/simVars'; +import { Position } from '@instruments/common/types'; +import React from 'react'; + +export const DeltaP: React.FC = ({ x, y }) => { + const [deltaPsi] = useSimVar('L:A32NX_PRESS_CABIN_DELTA_PRESSURE', 'psi', 500); + const deltaPress = splitDecimals(deltaPsi); + + const radius = 86; + const startAngle = 205; + const endAngle = 55; + + return ( + + DELTA P + PSI + = 8.5 ? 'Amber' : 'Green'}`} x={x + 9} y={y + 48}> + {deltaPress[0]} + + = 8.5 ? 'Amber' : 'Green'}`} x={x + 29} y={y + 48}>. + = 8.5 ? 'Amber' : 'Green'}`} x={x + 56} y={y + 48}>{deltaPress[1]} + + + + + + + + + + + = 8.5 ? 'Amber' : ''}`} + indicator + multiplierOuter={1.01} + /> + + + ); +}; + +export default DeltaP; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/ExtractValve.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/ExtractValve.tsx new file mode 100644 index 00000000000..87e1bf5249f --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/ExtractValve.tsx @@ -0,0 +1,36 @@ +import { GaugeMarkerComponent } from '@instruments/common/gauges'; +import React from 'react'; + +interface ExtractValveProps { + x: number, + y: number, + value: number, + min: number, + max: number, + radius: number, + css?: string, + circleCss?: string, + startAngle?: number, + endAngle?: number, +} + +const ExtractValve: React.FC = ({ x, y, value, min, max, radius, css = 'Green Line', circleCss = 'Green SW3 BackgroundFill', startAngle = 90, endAngle = 180 }) => ( + <> + + + +); + +export default ExtractValve; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/LandingElevation.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/LandingElevation.tsx new file mode 100644 index 00000000000..21b2b1ff682 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/LandingElevation.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { Position } from '@instruments/common/types'; + + +const LandingElevation: React.FC = ({ x, y }) => { + const [landingElev] = useSimVar('L:A32NX_OVHD_PRESS_LDG_ELEV_KNOB', 'feet', 100); + + const ldgElevValue = Math.round(landingElev / 50) * 50; + + return ( + <> + + LDG ELEVN + + {ldgElevValue} + FT + + + ); +}; + +export default LandingElevation; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/OutflowValve.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/OutflowValve.tsx new file mode 100644 index 00000000000..5bfe2bfceae --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/OutflowValve.tsx @@ -0,0 +1,99 @@ +import React, { memo } from 'react'; +import { useSimVar } from '@instruments/common/simVars'; +import { EngineNumber, Position } from '@instruments/common/types'; +import { GaugeComponent, GaugeMarkerComponent } from '@instruments/common/gauges'; + +const OutflowValve: React.FC = memo(({ x, y, engine }) => { + const ofradius = 52; + + const [flightPhase] = useSimVar('L:A32NX_FWC_FLIGHT_PHASE', 'enum', 1000); + const [outflowValueOpenPercentage] = useSimVar('L:A32NX_PRESS_OUTFLOW_VALVE_OPEN_PERCENTAGE', 'percent', 500); + + return ( + <> + {engine} + + + = 5 && flightPhase <= 7 && outflowValueOpenPercentage > 95 ? 'Amber Line' : 'Green Line'} + indicator + multiplierOuter={1} + /> + + + + + + + + + ); +}); + +export default OutflowValve; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/Packs.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/Packs.tsx new file mode 100644 index 00000000000..198951b873c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/Press/elements/Packs.tsx @@ -0,0 +1,20 @@ +import { Triangle } from '@instruments/common/Shapes'; +import { useSimVar } from '@instruments/common/simVars'; +import { PackNumber, Position } from '@instruments/common/types'; +import React from 'react'; + +const Packs: React.FC = ({ pack, x, y }) => { + const [packOpen] = useSimVar(`L:A32NX_COND_PACK_FLOW_VALVE_${pack}_IS_OPEN`, 'bool', 500); + const triangleColour = !packOpen ? 'Amber' : 'Green'; + const packWordColour = !packOpen ? 'AmberFill' : 'WhiteFill'; + + return ( + <> + + PACK + {pack} + + ); +}; + +export default Packs; diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/StatusPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/StatusPage.tsx new file mode 100644 index 00000000000..83b8edd4861 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/StatusPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const StatusPage = () => ( + STATUS +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/Pages/WheelPage.tsx b/fbw-a380x/src/systems/instruments/src/SD/Pages/WheelPage.tsx new file mode 100644 index 00000000000..6362cfc05e5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/Pages/WheelPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PageTitle } from './Generic/PageTitle'; + +export const WheelPage = () => ( + WHEEL +); diff --git a/fbw-a380x/src/systems/instruments/src/SD/StatusArea.tsx b/fbw-a380x/src/systems/instruments/src/SD/StatusArea.tsx new file mode 100644 index 00000000000..57f2da611e5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/StatusArea.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import useMouse from '@react-hook/mouse-position'; +import { useSimVar } from '@instruments/common/simVars'; +import { useArinc429Var } from '@instruments/common/arinc429'; +import { NXUnits } from '@shared/NXUnits'; +import { Button } from '../MFD/Components/Button'; +import { Cursor } from '../MFD/MultiFunctionDisplay'; + +export const StatusArea = () => { + const ref = useRef(null); + + const mouse = useMouse(ref, { + fps: 165, + enterDelay: 100, + leaveDelay: 100, + }); + + // TODO REMOVE TEMP CODE JUST TO TRIGGER SD PAGES + const [, setEcamPage] = useSimVar('L:A380X_SD_CURRENT_PAGE_INDEX', 'number'); + const [ecam, setEcam] = useState(0); + + useEffect(() => { + if (ecam === 13) { + setEcam(0); + setEcamPage(0); + } else { + setEcamPage(ecam); + } + }, [ecam]); + // END TEMP CODE + + const [airDataSwitchingKnob] = useSimVar('L:A32NX_AIR_DATA_SWITCHING_KNOB', 'Enum'); + + const getStatusAirDataReferenceSource = () => { + const ADIRS_3_TO_CAPTAIN = 0; + + return airDataSwitchingKnob === ADIRS_3_TO_CAPTAIN ? 3 : 1; + }; + + const [gLoad] = useSimVar('G FORCE', 'GFORCE'); + const [gLoadIsAbnormal, setGLoadIsAbnormal] = useState(false); + + const getValuePrefix = (value: number) => (value >= 0 ? '+' : ''); + + useEffect(() => { + if (gLoad < 0.7 || gLoad > 1.4) { + const timeout = setTimeout(() => { + setGLoadIsAbnormal(true); + }, 2_000); + return () => clearTimeout(timeout); + } + setGLoadIsAbnormal(false); + + return () => {}; + }, [gLoad]); + + const airDataReferenceSource = getStatusAirDataReferenceSource(); + const sat = useArinc429Var(`L:A32NX_ADIRS_ADR_${airDataReferenceSource}_STATIC_AIR_TEMPERATURE`); + const tat = useArinc429Var(`L:A32NX_ADIRS_ADR_${airDataReferenceSource}_TOTAL_AIR_TEMPERATURE`); + + const [cg] = useSimVar('CG PERCENT', 'percent'); + + const userWeightUnit = NXUnits.userWeightUnit(); + + // TODO: Currently this value will always be displayed but it should have more underlying logic tied to it as it relates to SAT + const isa = useArinc429Var(`L:A32NX_ADIRS_ADR_${airDataReferenceSource}_INTERNATIONAL_STANDARD_ATMOSPHERE_DELTA`); + + const [emptyWeight] = useSimVar('EMPTY WEIGHT', 'kg'); + const [payloadCount] = useSimVar('PAYLOAD STATION COUNT', 'number'); + + const getPayloadWeight = () => { + let payloadWeight = 0; + + for (let i = 1; i <= payloadCount; i++) { + payloadWeight += SimVar.GetSimVarValue(`PAYLOAD STATION WEIGHT:${i}`, 'kg'); + } + + return payloadWeight; + }; + + const [fuelWeight] = useSimVar('FUEL TOTAL QUANTITY WEIGHT', 'kg'); + const gw = Math.round(NXUnits.kgToUser(emptyWeight + fuelWeight + getPayloadWeight())); + + const [seconds] = useSimVar('E:ZULU TIME', 'seconds'); + + const getCurrentHHMMSS = () => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secondsLeft = (seconds - (hours * 3600) - (minutes * 60)).toFixed(0); + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}.${secondsLeft.toString().padStart(2, '0')}`; + }; + + return ( + + {/* Upper status */} + + {/* Frame */} + + + + + + {/* */} + + {/* Temps */} + TAT + SAT + ISA + + {tat.isNormalOperation() ? getValuePrefix(tat.value) + tat.value.toFixed(0) : 'XX'} + + + {sat.isNormalOperation() ? getValuePrefix(sat.value) + sat.value.toFixed(0) : 'XX'} + + + {isa.isNormalOperation() ? getValuePrefix(isa.value) + isa.value.toFixed(0) : 'XX'} + + °C + °C + °C + + {/* G Load Indication */} + {gLoadIsAbnormal && ( + <> + G LOAD + + {getValuePrefix(gLoad)} + {gLoad} + + + )} + + {/* Clock */} + + {getCurrentHHMMSS().substring(0, 5)} + {getCurrentHHMMSS().substring(5)} + + GPS + + {/* Weights / Fuel */} + GW + GWCG + FOB + + {Math.round(gw)} + {Number.parseFloat(cg).toFixed(1)} + {Math.round(fuelWeight)} + + {userWeightUnit} + % + {userWeightUnit} + + + + + {/* Lower status */} + + + + + + {/* ATC thing */} + ATC DATALINK COM + NOT AVAIL + + {/* Recall */} + {/* TODO REMOVE onClick event when ECAM implememented */} + + {mouse.isOver && } + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/SD/SystemDisplay.tsx b/fbw-a380x/src/systems/instruments/src/SD/SystemDisplay.tsx new file mode 100644 index 00000000000..233f88efc26 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/SystemDisplay.tsx @@ -0,0 +1,65 @@ +import React, { useEffect } from 'react'; +// import { useInteractionEvent } from '@instruments/common/hooks'; +import { useSimVar } from '@instruments/common/simVars'; +import { CdsDisplayUnit, DisplayUnitID } from '@instruments/common/CdsDisplayUnit'; + +// import { getSimVar } from '../util'; +import { EngPage } from './Pages/Engine/EngPage'; +import { BleedPage } from './Pages/Bleed/BleedPage'; +import { HydPage } from './Pages/Hyd/HydPage'; +import { PressPage } from './Pages/Press/PressPage'; +import { ElecAcPage } from './Pages/ElecAcPage'; +import { FuelPage } from './Pages/FuelPage'; +import { CbPage } from './Pages/CbPage'; +import { ApuPage } from './Pages/ApuPage'; +import { CondPage } from './Pages/CondPage'; +import { DoorPage } from './Pages/Doors/DoorPage'; +import { ElecDcPage } from './Pages/ElecDc/ElecDcPage'; +import { WheelPage } from './Pages/WheelPage'; +import { FctlPage } from './Pages/FctlPage'; +// import { VideoPage } from './Pages/VideoPage'; +import { CruisePage } from './Pages/Cruise/CruisePage'; +import { StatusPage } from './Pages/StatusPage'; + +import { StatusArea } from './StatusArea'; + +import '../index.scss'; + +export const SystemDisplay = () => { + const [theCurrentPage] = useSimVar('L:A380X_ECAM_CP_SELECTED_PAGE', 'number', 500); + // const [currentPage, setCurrentPage] = useState(0); + // useInteractionEvent('A380X_SD_PAGE_CHANGED', () => setCurrentPage(getSimVar('L:A380X_SD_CURRENT_PAGE_INDEX', 'number'))); + + useEffect(() => { + console.log(`Changing current page to ${theCurrentPage}`); + // setCurrentPage(getSimVar('L:A380X_SD_CURRENT_PAGE_INDEX', 'number')); + }, [theCurrentPage]); + + const PAGES = { + 0: , + 1: , + 2: , + 3: , + 4: , + 5: , + 6: , + 7: , + 8: , + 9: , + 10: , + 11: , + 12: , + 13: , // TODO video page + 14: , + 15: , + }; + + return ( + + + {PAGES[theCurrentPage]} + + + + ); +}; diff --git a/fbw-a380x/src/systems/instruments/src/SD/config.json b/fbw-a380x/src/systems/instruments/src/SD/config.json new file mode 100644 index 00000000000..db0f8331775 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/config.json @@ -0,0 +1,10 @@ +{ + "index": "./index.tsx", + "isInteractive": true, + "name": "SD", + "dimensions": { + "width": 768, + "height": 1024 + } +} + diff --git a/fbw-a380x/src/systems/instruments/src/SD/index.tsx b/fbw-a380x/src/systems/instruments/src/SD/index.tsx new file mode 100644 index 00000000000..9ec07e531a5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/index.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { getRootElement } from '@instruments/common/defaults.js'; +import { SystemDisplay } from './SystemDisplay'; +import { render } from '../Common'; +import { renderTarget } from '../util'; + +if (renderTarget) { + render(); +} + +getRootElement().addEventListener('unload', () => { + ReactDOM.unmountComponentAtNode(renderTarget ?? document.body); +}); diff --git a/fbw-a380x/src/systems/instruments/src/SD/styles.scss b/fbw-a380x/src/systems/instruments/src/SD/styles.scss new file mode 100644 index 00000000000..c6f1f2d1a76 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/SD/styles.scss @@ -0,0 +1,106 @@ +@import "../Common/definitions"; + +.hyd { + text { + fill: $display-white; + } + + line, path, rect { + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + } + + image { + opacity: 0.5; + overflow: hidden; + } + + .hyd-engine-pump { + .hyd-engine-pump-label { + stroke: white; + stroke-width: 2px; + } + + .hyd-engine-pump-disc { + stroke-width: 1px; + } + + text.Amber { + stroke: $display-amber; + } + + rect { + stroke-width: 3px; + } + } + + .hyd-system-label { + font-size: 22px; + + text.Amber { + stroke: $display-amber; + } + + text.Green { + stroke: $display-green; + } + + text.Cyan { + stroke: $display-cyan; + } + + text.White { + stroke: $display-white; + } + } + + .hyd-reservoir { + .hyd-reservoir-low { + stroke-width: 1px; + fill: $display-amber; + stroke: $display-background; + } + + .hyd-reservoir-normal { + stroke-width: 1px; + fill: $display-green; + stroke: $display-background; + } + + .hyd-reservoir-normal-fail { + stroke-width: 1px; + fill: $display-white; + stroke: $display-background; + } + + .hyd-reservoir-normal-fail-x { + stroke-width: 2px; + } + } + + .hyd-elec-pumps { + text.White { + stroke: $display-white; + } + + text.Amber { + stroke: $display-amber; + } + + .hyd-elec-pump { + path { + stroke-width: 2px + } + + .GreenFill { + fill: $display-green + } + + .AmberFill { + fill: $display-amber; + } + } + } + +} \ No newline at end of file diff --git a/fbw-a380x/src/systems/instruments/src/index.scss b/fbw-a380x/src/systems/instruments/src/index.scss new file mode 100644 index 00000000000..3cdc8bae2e3 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/index.scss @@ -0,0 +1,352 @@ +@font-face { + font-family: 'Ecam'; + src: url('/Fonts/ECAMFontRegular.ttf'); +} + +@import "Common/definitions"; + +#main-container { + width: 100%; + height: 100%; + + display: flex; + flex-direction: row; + align-items: center; + column-gap: 2em; +} + +#ecam-cp { + width: 60em; + height: 20em; + + background-color: gray; +} + +#displays-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + row-gap: 2em; +} + +.main-svg { + width: 100%; + background-color: #0a0a0a; + transform: translateY(10px); +} + +.main-svg text { + font-family: Ecam, monospace; +} + +#safety-check { + font-size: 24px; + text-anchor: middle; + + fill: $display-green +} + +.NoFill { + fill: none; +} + +.Green { + stroke: $display-green; +} +text.Green, tspan.Green, .Green.Fill { + stroke: none; + fill: $display-green; +} + +.Cyan { + stroke: $display-cyan; +} +text.Cyan, tspan.Cyan, .Cyan.Fill { + stroke: none; + fill: $display-cyan; +} + +.White { + stroke: $display-white; +} + +.WhiteFill { + fill: $display-white; +} + +text.White, tspan.White, .White.Fill { + stroke: none; + fill: $display-white; +} + +.LightGrey { + stroke: $display-light-grey; +} +text.LightGrey, tspan.LightGrey, .LightGreyFill { + fill: $display-light-grey; +} + +.LightGreyBox { + stroke: none; + fill: $display-light-grey; + opacity: 0.2 +} + +.Grey { + stroke: $display-grey; +} +text.Grey, tspan.Grey, .GreyFill { + fill: $display-grey; +} + +.DarkGrey { + stroke: $display-dark-grey; +} +text.DarkGrey, tspan.DarkGrey, .DarkGreyFill { + fill: $display-dark-grey; +} + +.Amber { + stroke: $display-amber; +} +text.Amber, tspan.Amber, .Amber.Fill { + stroke: none; + fill: $display-amber; +} + +.Magenta { + stroke: $display-magenta; +} + +.Red { + stroke: $display-red; +} + +text.Red, tspan.Red, .Red.Fill { + stroke: none; + fill: $display-red; +} + +.Transparent.Fill { + fill: transparent; +} + +.BackgroundFill { + fill: $display-background !important; +} + +.Background { + stroke: $display-background; +} + +.XSmall, .F19 { + font-size: 19px !important; +} + +.F20 { + font-size: 20px !important; +} + +.Small, .F21 { + font-size: 21px !important; +} + +.Medium, .F22 { + font-size: 22px !important; +} + +.Large, .F23 { + font-size: 23px !important; +} + +.VLarge, .F24 { + font-size: 24px !important; +} + +.F25 { + font-size: 25px !important; +} + +.F26 { + font-size: 26px !important; +} + +.XLarge, .F27 { + font-size: 27px !important; +} + +.F28 { + font-size: 28px !important; +} + +.F29 { + font-size: 29px !important; +} + +.Huge, .F30 { + font-size: 30px !important; +} + +.F32 { + font-size: 32px !important; +} + +.F34 { + font-size: 34px !important; +} + +.F35 { + font-size: 35px !important; +} + +.F36 { + font-size: 36px !important; +} + +.LS1 { + letter-spacing: 1px !important; +} + +.LS2 { + letter-spacing: 2px !important; +} + +.LS-1 { + letter-spacing: -1px !important; +} + +.LS-8 { + letter-spacing: -8px !important; +} + +.MiddleAlign { + text-anchor: middle; + dominant-baseline: middle; +} + +.EndAlign, .End { + text-anchor: end !important; +} + +.ecam-page-text { + fill: $display-white; + font-size: 21px; +} + +.EcamPageTitle { + fill: $display-white; + font-size: 34px; + text-decoration: underline; +} + +.LineRound { + stroke-linecap: round; +} + +.EcamPageMore { + fill: $display-grey; + font-size: 21px; +} + +.ecam-thiccer-line { + stroke: $display-white; + stroke-width: 5; +} + +.ecam-thicc-line { + stroke: $display-white; + stroke-width: 4; +} + +.ecam-thin-line { + stroke: $display-white; + stroke-width: 2; +} + +.body-line { + stroke: $display-grey; + stroke-width: 3; +} + +.Line, .SW2 { + stroke-width: 2px; +} + +.ThickLine, .SW3 { + stroke-width: 3px; +} + +.SW4 { + stroke-width: 4px !important; +} + +.VThickLine, .SW6 { + stroke-width: 6px; +} + +.ThickRedLine { + stroke: $display-red !important; + stroke-width: 8 !important; + fill: $display-red; +} + +.SW6RedLine { + stroke: $display-red !important; + stroke-width: 6 !important; + fill: $display-red; +} + +.ThickAmberLine { + stroke: $display-amber !important; + stroke-width: 8 !important; + fill: $display-amber; +} + +.FillPulse { + animation: fill-pulse 1s step-end infinite; + + @keyframes fill-pulse { + 0%, 100% { + fill: $display-green; + } + 50% { + fill: hsl(120, 100%, 20%); + } + } +} + +.LinePulse { + animation: line-pulse 1s step-end infinite; + + @keyframes line-pulse { + 0%, 100% { + stroke: $display-green; + } + 50% { + stroke: hsl(120, 100%, 20%); + } + } +} + +.Box { + stroke: $display-light-grey; + fill: none; + stroke-width: 2.5px; +} + +.StrokeRound { + stroke-linecap:round; +} + +.Mitre10 { + stroke-miterlimit:10; +} + +.Show { + display: block !important; +} + +.Hide { + display: none !important; +} diff --git a/fbw-a380x/src/systems/instruments/src/util.js b/fbw-a380x/src/systems/instruments/src/util.js new file mode 100644 index 00000000000..6d746e9288c --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/util.js @@ -0,0 +1,86 @@ +/* global SimVar */ + +import { useEffect, useRef } from 'react'; + +export const renderTarget = document.getElementById('MSFS_REACT_MOUNT'); +export const customElement = renderTarget.parentElement; + +// @param {() => void} handler +export function useInteractionEvent(event, handler) { + // Logic based on https://usehooks.com/useEventListener/ + const savedHandler = useRef(handler); + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + const wrappedHandler = (e) => { + if (event === '*') { + savedHandler.current(e.detail); + } else { + savedHandler.current(); + } + }; + customElement.addEventListener(event, wrappedHandler); + return () => { + customElement.removeEventListener(event, wrappedHandler); + }; + }, [event]); +} + +// @param {(deltaTime: number) => void} handler +export function useUpdate(handler) { + // Logic based on https://usehooks.com/useEventListener/ + const savedHandler = useRef(handler); + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + const wrappedHandler = (event) => { + savedHandler.current(event.detail); + }; + customElement.addEventListener('update', wrappedHandler); + return () => { + customElement.removeEventListener('update', wrappedHandler); + }; + }); +} + +const SIMVAR_TYPES = { + '__proto__': null, + 'GPS POSITION LAT': 'degrees latitude', + 'L:APU_GEN_ONLINE': 'Bool', + 'EXTERNAL POWER AVAILABLE:1': 'Bool', + 'EXTERNAL POWER ON': 'Bool', + 'L:A32NX_COLD_AND_DARK_SPAWN': 'Bool', +}; + +const SIMVAR_CACHE = new Map(); +customElement.addEventListener('update', () => { + SIMVAR_CACHE.clear(); +}); + +export function getSimVar(name, type) { + if (!SIMVAR_CACHE.has(name)) { + SIMVAR_CACHE.set(name, SimVar.GetSimVarValue(name, type || SIMVAR_TYPES[name])); + } + return SIMVAR_CACHE.get(name); +} + +export function setSimVar(name, value, type = SIMVAR_TYPES[name]) { + SIMVAR_CACHE.delete(name); + return SimVar.SetSimVarValue(name, type, value); +} + +export const createDeltaTimeCalculator = (startTime = Date.now()) => { + let lastTime = startTime; + + return () => { + const nowTime = Date.now(); + const deltaTime = nowTime - lastTime; + lastTime = nowTime; + + return deltaTime; + }; +}; diff --git a/fbw-a380x/src/systems/instruments/template/rollup.js b/fbw-a380x/src/systems/instruments/template/rollup.js new file mode 100644 index 00000000000..9875f4eef72 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/template/rollup.js @@ -0,0 +1,37 @@ +'use strict'; + +const fs = require('fs'); + +// The bundle code contains `$`, which is a special character +// in JS replace and replaceAll, so we can't use those. +function replaceButSad(s, search, replace) { + return s.split(search).join(replace); +} + +const TEMPLATE_HTML = fs.readFileSync(`${__dirname}/template.html`, 'utf8'); +const TEMPLATE_JS = fs.readFileSync(`${__dirname}/template.js`, 'utf8'); + +module.exports = ({ name, config, outputDir, getCssBundle }) => ({ + name: 'template', + writeBundle(_config, bundle) { + const { code: jsCode } = bundle[`${name}-gen.js`]; + const cssCode = getCssBundle(); + + const process = (s) => { + let tmp = s; + tmp = replaceButSad(tmp, 'INSTRUMENT_NAME_LOWER', name.toLowerCase()); + tmp = replaceButSad(tmp, 'INSTRUMENT_NAME', name); + tmp = replaceButSad(tmp, 'INSTRUMENT_BUNDLE', jsCode); + tmp = replaceButSad(tmp, 'INSTRUMENT_STYLE', cssCode); + tmp = replaceButSad(tmp, 'INSTRUMENT_IS_INTERACTIVE', config.isInteractive || false); + return tmp; + }; + + const templateHtml = process(TEMPLATE_HTML); + const templateJs = process(TEMPLATE_JS); + + fs.mkdirSync(`${outputDir}/${name}`, { recursive: true }); + fs.writeFileSync(`${outputDir}/${name}/template.html`, templateHtml); + fs.writeFileSync(`${outputDir}/${name}/template.js`, templateJs); + }, +}); diff --git a/fbw-a380x/src/systems/instruments/template/template.html b/fbw-a380x/src/systems/instruments/template/template.html new file mode 100644 index 00000000000..904dfd76888 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/template/template.html @@ -0,0 +1,21 @@ + + + + + diff --git a/fbw-a380x/src/systems/instruments/template/template.js b/fbw-a380x/src/systems/instruments/template/template.js new file mode 100644 index 00000000000..bc7a011239d --- /dev/null +++ b/fbw-a380x/src/systems/instruments/template/template.js @@ -0,0 +1,53 @@ +'use strict'; + +/* global BaseInstrument */ +/* global registerInstrument */ + +// eslint-disable-next-line camelcase +class A32NX_INSTRUMENT_NAME_Logic extends BaseInstrument { + get templateID() { + return 'A32NX_INSTRUMENT_NAME_TEMPLATE'; + } + + get isInteractive() { + // eslint-disable-next-line + return INSTRUMENT_IS_INTERACTIVE; + } + + get IsGlassCockpit() { + return true; + } + + connectedCallback() { + super.connectedCallback(); + + // This is big hack, see `template.html`. + { + const code = document.getElementById('A32NX_BUNDLED_STYLE').innerHTML; + const style = document.createElement('style'); + style.innerHTML = code; + document.head.appendChild(style); + } + { + const code = document.getElementById('A32NX_BUNDLED_LOGIC').innerHTML; + const script = document.createElement('script'); + script.innerHTML = code; + document.body.appendChild(script); + } + } + + Update() { + super.Update(); + if (this.CanUpdate()) { + this.dispatchEvent(new CustomEvent('update', { detail: this.deltaTime })); + } + } + + onInteractionEvent(event) { + const eventName = String(event); + this.dispatchEvent(new CustomEvent(eventName)); + this.dispatchEvent(new CustomEvent('*', { detail: eventName })); + } +} + +registerInstrument('a32nx-INSTRUMENT_NAME_LOWER-element', A32NX_INSTRUMENT_NAME_Logic); diff --git a/fbw-a380x/src/systems/instruments/tsconfig.json b/fbw-a380x/src/systems/instruments/tsconfig.json new file mode 100644 index 00000000000..449551702d5 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "downlevelIteration": true, + "module": "esnext", + "esModuleInterop": true, + "jsx": "react", + "moduleResolution": "node", + "skipLibCheck": true, + "strictNullChecks": true, + }, + "include": [ + "src/**/*", + "../../typings/**/*.d.ts" + ] +} diff --git a/fbw-a380x/src/systems/shared/src/Coherent.d.ts b/fbw-a380x/src/systems/shared/src/Coherent.d.ts new file mode 100644 index 00000000000..15c477e36c7 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/Coherent.d.ts @@ -0,0 +1,23 @@ +declare global { + namespace Coherent { + /** + * Trigger an event. This function will trigger any C++ handler registered for + * this event with `Coherent::UI::View::RegisterForEvent`. + * @param name name of the event. + * @param args any extra arguments to be passed to the event handlers. + */ + function trigger(name: string, ...args: any[]): void; + + /** + * Add a handler for an event. + * @param name The event name. + * @param callback Function to be called when the event is triggered. + * @param context This binding for executing the handler, defaults to the Emitter. + * @return An object with a clear function to remove the handler for the event. + */ + function on(name: string, callback: (event: any, ...args: any) => void): { clear: () => void }; + function on(name: string, callback: (event: any, ...args: any) => void, context: any): { clear: () => void }; + } +} + +export {}; diff --git a/fbw-a380x/src/systems/shared/src/Constants.ts b/fbw-a380x/src/systems/shared/src/Constants.ts new file mode 100644 index 00000000000..10bfef17c4c --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/Constants.ts @@ -0,0 +1,4 @@ +export const enum Constants { + G = 9.81, + EARTH_RADIUS_NM = 3440.1, +} diff --git a/fbw-a380x/src/systems/shared/src/FlowEventSync.ts b/fbw-a380x/src/systems/shared/src/FlowEventSync.ts new file mode 100644 index 00000000000..011c46b3484 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/FlowEventSync.ts @@ -0,0 +1,90 @@ +import stringify from 'safe-stable-stringify'; + +export class FlowEventSync { + static EB_LISTENER_KEY = 'EB_EVENTS'; + + private evtNum: number; + + private dataPackageQueue: any[]; + + private topic: string; + + private isRunning: boolean; + + private recvEventCb: (topic: string, data: any) => void; + + constructor(recvEventCb?: (topic: string, data: any) => void, topic?: string) { + this.evtNum = 0; + this.topic = topic; + this.dataPackageQueue = []; + this.isRunning = true; + this.recvEventCb = recvEventCb; + if (topic) { + Coherent.on('OnInteractionEvent', this.processEventsReceived.bind(this)); + } + + const sendFn = () => { + if (this.dataPackageQueue.length > 0) { + const syncDataPackage = { data: this.dataPackageQueue }; + if (!window.ACE_ENGINE_HANDLE) { + LaunchFlowEvent('ON_MOUSERECT_HTMLEVENT', FlowEventSync.EB_LISTENER_KEY, stringify(syncDataPackage)); + } + this.dataPackageQueue.length = 0; + } + if (this.isRunning) { + requestAnimationFrame(sendFn); + } + }; + requestAnimationFrame(sendFn); + } + + public stop() { + this.isRunning = false; + } + + /** + * Processes events received from onInteractionEvent and executes the configured callback. + * @param target always empty + * @param args [0] the eventlistener key [1] SyncDataPackage + */ + processEventsReceived(_target, args) { + // identify if its a flowsyncevent + if (args.length === 0 || args[0] !== FlowEventSync.EB_LISTENER_KEY) { + return; + } + const syncDataPackage = JSON.parse(args[1]); + syncDataPackage.data.forEach((data) => { + if (data.topic === this.topic) { + try { + this.recvEventCb(data.topic, data.data !== undefined ? JSON.parse(data.data) : undefined); + } catch (e) { + console.error(e); + if (e instanceof Error) { + console.error(e.stack); + } + } + } + }); + } + + /** + * Sends an event via flow events. + * @param topic The topic to send data on. + * @param data The data to send. + */ + sendEvent(topic: string, data: any) { + const dataObj = stringify(data); + + const dataPackage = { + evtNum: this.evtNum++, + topic, + data: dataObj, + }; + + this.dataPackageQueue.push(dataPackage); + } + + receiveEvent() { + // noop + } +} diff --git a/fbw-a380x/src/systems/shared/src/FmMessages.ts b/fbw-a380x/src/systems/shared/src/FmMessages.ts new file mode 100644 index 00000000000..273275bf073 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/FmMessages.ts @@ -0,0 +1,183 @@ +export type FMMessageColor = 'White' | 'Amber' + +export interface FMMessage { + /** + * Unique ID for this message type + */ + id: number, + + /** + * Text on the MCDU scratchpad + */ + text?: string, + + /** + * ND message flag, if applicable + */ + ndFlag?: NdFmMessageFlag, + + /** + * ND priority, if applicable + */ + ndPriority?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9, + + /** + * EFIS display text, if different than MCDU scratchpad text + */ + efisText?: string, + + /** + * Display color for both MCDU and EFIS + */ + color: FMMessageColor, + + /** + * Can the message be cleared by the MCDU CLR key? + */ + clearable?: boolean, +} + +/** See a320-coherent-triggers.md */ +export const FMMessageTriggers = { + SEND_TO_MCDU: 'A32NX_FMGC_SEND_MESSAGE_TO_MCDU', + + RECALL_FROM_MCDU_WITH_ID: 'A32NX_FMGC_RECALL_MESSAGE_FROM_MCDU_WITH_ID', + + POP_FROM_STACK: 'A32NX_FMGC_POP_MESSAGE', +}; + +/* eslint-disable no-multi-spaces */ +export enum NdFmMessageFlag { + None = 0, + SelectTrueRef = 1 << 0, + CheckNorthRef = 1 << 1, + NavAccuracyDowngrade = 1 << 2, + NavAccuracyUpgradeNoGps = 1 << 3, + SpecifiedVorDmeUnavailble = 1 << 4, + NavAccuracyUpgradeGps = 1 << 5, + GpsPrimary = 1 << 6, + MapPartlyDisplayed = 1 << 7, + SetOffsideRangeMode = 1 << 8, + OffsideFmControl = 1 << 9, + OffsideFmWxrControl = 1 << 10, + OffsideWxrControl = 1 << 11, + GpsPrimaryLost = 1 << 12, + RtaMissed = 1 << 13, + BackupNav = 1 << 14, +} +/* eslint-enable no-multi-spaces */ + +export const FMMessageTypes: Readonly> = { + SelectTrueRef: { + id: 1, + ndFlag: NdFmMessageFlag.SelectTrueRef, + text: 'SELECT TRUE REF', + color: 'Amber', + ndPriority: 1, + clearable: true, + }, + CheckNorthRef: { + id: 2, + ndFlag: NdFmMessageFlag.CheckNorthRef, + text: 'CHECK NORTH REF', + color: 'Amber', + ndPriority: 1, + clearable: true, + }, + NavAccuracyDowngrade: { + id: 3, + ndFlag: NdFmMessageFlag.NavAccuracyDowngrade, + text: 'NAV ACCUR DOWNGRAD', + color: 'Amber', + ndPriority: 1, + clearable: true, + }, + NavAccuracyUpgradeNoGps: { + id: 4, + ndFlag: NdFmMessageFlag.NavAccuracyUpgradeNoGps, + text: 'NAV ACCUR UPGRAD', + color: 'Amber', + ndPriority: 1, + clearable: true, + }, + SpecifiedVorDmeUnavailble: { + id: 5, + ndFlag: NdFmMessageFlag.SpecifiedVorDmeUnavailble, + text: 'SPECIF VOR/D UNAVAIL', + color: 'Amber', + ndPriority: 1, + clearable: true, + }, + NavAccuracyUpgradeGps: { + id: 6, + ndFlag: NdFmMessageFlag.NavAccuracyUpgradeGps, + text: 'NAV ACCUR UPGRAD', + color: 'White', + ndPriority: 1, + clearable: true, + }, + GpsPrimary: { + id: 7, + ndFlag: NdFmMessageFlag.GpsPrimary, + text: 'GPS PRIMARY', + color: 'White', + ndPriority: 1, + clearable: true, + }, + MapPartlyDisplayed: { + id: 8, + ndFlag: NdFmMessageFlag.MapPartlyDisplayed, + efisText: 'MAP PARTLY DISPLAYED', + color: 'Amber', + ndPriority: 2, + }, + SetOffsideRangeMode: { + id: 9, + ndFlag: NdFmMessageFlag.SetOffsideRangeMode, + text: 'SET OFFSIDE RNG/MODE', + color: 'Amber', + ndPriority: 3, + }, + OffsideFmControl: { + id: 10, + ndFlag: NdFmMessageFlag.OffsideFmControl, + text: 'OFFSIDE FM CONTROL', + color: 'Amber', + ndPriority: 4, + }, + OffsideFmWxrControl: { + id: 11, + ndFlag: NdFmMessageFlag.OffsideFmWxrControl, + text: 'OFFSIDE FM/WXR CONTROL', + color: 'Amber', + ndPriority: 5, + }, + OffsideWxrControl: { + id: 12, + ndFlag: NdFmMessageFlag.OffsideWxrControl, + text: 'OFFSIDE WXR CONTROL', + color: 'Amber', + ndPriority: 6, + }, + GpsPrimaryLost: { + id: 13, + ndFlag: NdFmMessageFlag.GpsPrimaryLost, + text: 'GPS PRIMARY LOST', + color: 'Amber', + ndPriority: 7, + }, + RtaMissed: { + id: 14, + ndFlag: NdFmMessageFlag.RtaMissed, + text: 'RTA MISSED', + color: 'Amber', + ndPriority: 8, + }, + BackupNav: { + id: 15, + ndFlag: NdFmMessageFlag.BackupNav, + text: 'BACK UP NAV', + color: 'Amber', + ndPriority: 9, + }, +}; diff --git a/fbw-a380x/src/systems/shared/src/MagVar.ts b/fbw-a380x/src/systems/shared/src/MagVar.ts new file mode 100644 index 00000000000..bcc34ebf018 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/MagVar.ts @@ -0,0 +1,19 @@ +import { Coordinates } from 'msfs-geo'; + +export class MagVar { + static getMagVar(location: Coordinates): Degrees { + if ('Facilities' in window) { + return Facilities.getMagVar(location.lat, location.long); + } + + return 0; + } + + static magneticToTrue(magneticHeading: Degrees, amgVar?: Degrees): Degrees { + return (720 + magneticHeading + (amgVar || SimVar.GetSimVarValue('MAGVAR', 'degree'))) % 360; + } + + static trueToMagnetic(trueHeading: DegreesTrue, magVar?: Degrees): Degrees { + return (720 + trueHeading - (magVar || SimVar.GetSimVarValue('MAGNAR', 'degree'))) % 360; + } +} diff --git a/fbw-a380x/src/systems/shared/src/MathUtils.spec.ts b/fbw-a380x/src/systems/shared/src/MathUtils.spec.ts new file mode 100644 index 00000000000..d188ec17952 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/MathUtils.spec.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { MathUtils } from './MathUtils'; + +describe('MathUtils.clamp', () => { + it('correctly clamps', () => { + expect(MathUtils.clamp(0, -1, 1)).toBe(0); + expect(MathUtils.clamp(-1.0, -1, 1)).toBe(-1.0); + expect(MathUtils.clamp(1.0, -1, 1)).toBe(1); + expect(MathUtils.clamp(-1.001, -1, 1)).toBe(-1); + expect(MathUtils.clamp(0.11, 0.1, 0.2)).toBe(0.11); + expect(MathUtils.clamp(0.21, 0.1, 0.2)).toBe(0.2); + }); +}); + +describe('MathUtils.round', () => { + it('correctly rounds', () => { + expect(MathUtils.round(1.005, 2)).toBe(1.01); + expect(MathUtils.round(1.005, 3)).toBe(1.005); + expect(MathUtils.round(1.004, 2)).toBe(1); + expect(MathUtils.round(1.5, 0)).toBe(2); + expect(MathUtils.round(1.05, 1)).toBe(1.1); + expect(MathUtils.round(1.05, 0)).toBe(1); + }); +}); + +describe('MathUtils.angleAdd', () => { + it('correctly adds two angles', () => { + expect(MathUtils.angleAdd(270, 90)).toBeCloseTo(360, 4); + expect(MathUtils.angleAdd(270, 90.1)).toBeCloseTo(0.1, 4); + expect(MathUtils.angleAdd(270, -90)).toBeCloseTo(180, 4); + expect(MathUtils.angleAdd(90, -90)).toBeCloseTo(0, 4); + expect(MathUtils.angleAdd(90, -90)).toBeCloseTo(0, 4); + expect(MathUtils.angleAdd(90, -89.9)).toBeCloseTo(0.1, 4); + expect(MathUtils.angleAdd(-90, -89.9)).toBeCloseTo(180.1, 4); + expect(MathUtils.angleAdd(-90, -90.1)).toBeCloseTo(179.9, 4); + expect(MathUtils.angleAdd(359, -359)).toBeCloseTo(0, 4); + expect(MathUtils.angleAdd(180, 179.9)).toBeCloseTo(359.9, 4); + expect(MathUtils.angleAdd(180, 180.1)).toBeCloseTo(0.1, 4); + expect(MathUtils.angleAdd(-180, 180.1)).toBeCloseTo(0.1, 4); + expect(MathUtils.angleAdd(-180, -180.1)).toBeCloseTo(359.9, 4); + }); +}); diff --git a/fbw-a380x/src/systems/shared/src/MathUtils.ts b/fbw-a380x/src/systems/shared/src/MathUtils.ts new file mode 100644 index 00000000000..2eb739ba4de --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/MathUtils.ts @@ -0,0 +1,432 @@ +// do not use @fmgc shortcut - breaks units tests with jest +// noinspection ES6PreferShortImport +import { TurnDirection } from '../../fmgc/src/types/fstypes/FSEnums'; + +export class MathUtils { + static DEGREES_TO_RADIANS = Math.PI / 180; + + static RADIANS_TO_DEGREES = 180 / Math.PI; + + static DIV_FEET_TO_NAUTICAL_MILES = 6076.12; + + static DIV_METRES_TO_NAUTICAL_MILES = 1852; + + private static optiPow10 = []; + + public static fastToFixed(val: number, fraction: number): string { + if (fraction <= 0) { + return Math.round(val).toString(); + } + + let coefficient = MathUtils.optiPow10[fraction]; + if (!coefficient || Number.isNaN(coefficient)) { + coefficient = 10 ** fraction; + MathUtils.optiPow10[fraction] = coefficient; + } + + return (Math.round(val * coefficient) / coefficient).toString(); + } + + public static fastToFixedNum(val: number, fraction: number): number { + if (fraction <= 0) { + return Math.round(val); + } + + let coefficient = MathUtils.optiPow10[fraction]; + if (!coefficient || Number.isNaN(coefficient)) { + coefficient = 10 ** fraction; + MathUtils.optiPow10[fraction] = coefficient; + } + + return (Math.round(val * coefficient) / coefficient); + } + + /** + * Adds two angles with wrap around to result in 0-360° + * @param a - positive or negative angle + * @param b - positive or negative angle + */ + public static angleAdd(a: number, b: number): number { + let r = a + b; + while (r > 360) { + r -= 360; + } + while (r < 0) { + r += 360; + } + return r; + } + + public static diffAngle(a: number, b: number, direction?: TurnDirection): number { + let diff = b - a; + while (diff > 180) { + diff -= 360; + } + while (diff <= -180) { + diff += 360; + } + if (diff < 0 && direction === TurnDirection.Right) { + diff += 360; + } + if (diff > 0 && direction === TurnDirection.Left) { + diff -= 360; + } + return diff; + } + + public static clampAngle(angle: number): Degrees { + let ret = angle % 360; + + if (ret < 0) { + ret += 360; + } + + return ret; + } + + public static adjustAngleForTurnDirection(angle: Degrees, turnDirection: TurnDirection) { + let ret = angle; + + if (angle < 0 && turnDirection === TurnDirection.Right) { + ret += 360; + } + if (angle > 0 && turnDirection === TurnDirection.Left) { + ret -= 360; + } + + return ret; + } + + /** + * Calculates the inner angle of the small triangle formed by two intersecting lines + * + * This effectively returns the angle XYZ in the figure shown below: + * + * ``` + * * Y + * |\ + * | \ + * | \ + * | \ + * | \ + * | \ + * | \ + * * X * Z + * ``` + * + * @param xyAngle {number} bearing of line XY + * @param zyAngle {number} bearing of line ZY + */ + public static smallCrossingAngle(xyAngle: number, zyAngle: number): number { + // Rotate frame of reference to 0deg + let correctedXyBearing = xyAngle - zyAngle; + if (correctedXyBearing < 0) { + correctedXyBearing = 360 + correctedXyBearing; + } + + let xyzAngle = 180 - correctedXyBearing; + if (xyzAngle < 0) { + // correctedXyBearing was > 180 + + xyzAngle = 360 + xyzAngle; + } + + return xyzAngle; + } + + public static mod(x: number, n: number): number { + return x - Math.floor(x / n) * n; + } + + public static highestPower2(n: number): number { + let res = 0; + for (let i = n; i >= 1; i--) { + if ((i & (i - 1)) === 0) { + res = i; + break; + } + } + return res; + } + + public static unpackPowers(n: number): number[] { + const res: number[] = []; + + let x = n; + while (x > 0) { + const pow = MathUtils.highestPower2(x); + res.push(pow); + x -= pow; + } + + return res; + } + + public static packPowers(ns: number[]): number { + if (ns.some((it) => it === 0 || (it & it - 1) !== 0)) { + throw new Error('Cannot pack number which is not a power of 2 or is equal to zero.'); + } + + return ns.reduce((acc, v) => acc + v); + } + + /** + * Convert degrees Celsius into Kelvin + * @param celsius degrees Celsius + * @returns degrees Kelvin + */ + public static convertCtoK(celsius: number): number { + return celsius + 273.15; + } + + /** + * Convert Mach to True Air Speed + * @param mach Mach + * @param oat Kelvin + * @returns True Air Speed + */ + public static convertMachToKTas(mach: number, oat: number): number { + return mach * 661.4786 * Math.sqrt(oat / 288.15); + } + + /** + * Convert TAS to Mach + * @param tas TAS + * @param oat Kelvin + * @returns True Air Speed + */ + public static convertKTASToMach(tas: number, oat: number): number { + return tas / 661.4786 / Math.sqrt(oat / 288.15); + } + + /** + * Convert TAS to Calibrated Air Speed + * @param tas velocity true air speed + * @param oat current temperature Kelvin + * @param pressure current pressure hpa + * @returns Calibrated Air Speed + */ + public static convertTasToKCas(tas: number, oat: number, pressure: number): number { + return 1479.1 * Math.sqrt((pressure / 1013 * ((1 + 1 / (oat / 288.15) * (tas / 1479.1) ** 2) ** 3.5 - 1) + 1) ** (1 / 3.5) - 1); + } + + /** + * Convert KCAS to KTAS + * @param kcas velocity true air speed + * @param oat current temperature Kelvin + * @param pressure current pressure hpa + * @returns True Air Speed + */ + public static convertKCasToKTAS(kcas, oat, pressure): number { + return 1479.1 * Math.sqrt(oat / 288.15 * ((1 / (pressure / 1013) * ((1 + 0.2 * (kcas / 661.4786) ** 2) ** 3.5 - 1) + 1) ** (1 / 3.5) - 1)); + } + + /** + * Convert Mach to Calibrated Air Speed + * @param mach Mach + * @param oat Kelvin + * @param pressure current pressure hpa + * @returns Calibrated Air Speed + */ + public static convertMachToKCas(mach: number, oat: number, pressure: number): number { + return MathUtils.convertTasToKCas(MathUtils.convertMachToKTas(mach, oat), oat, pressure); + } + + /** + * Gets the horizontal distance between 2 points, given in lat/lon + * @param pos0Lat {number} Position 0 lat + * @param pos0Lon {number} Position 0 lon + * @param pos1Lat {number} Position 1 lat + * @param pos1Lon {number} Position 1 lon + * @return {number} distance in nautical miles + */ + public static computeGreatCircleDistance(pos0Lat: number, pos0Lon: number, pos1Lat: number, pos1Lon: number): number { + const lat0 = pos0Lat * MathUtils.DEGREES_TO_RADIANS; + const lon0 = pos0Lon * MathUtils.DEGREES_TO_RADIANS; + const lat1 = pos1Lat * MathUtils.DEGREES_TO_RADIANS; + const lon1 = pos1Lon * MathUtils.DEGREES_TO_RADIANS; + const dlon = lon1 - lon0; + const cosLat0 = Math.cos(lat0); + const cosLat1 = Math.cos(lat1); + const a1 = Math.sin((lat1 - lat0) / 2); + const a2 = Math.sin(dlon / 2); + return Math.asin(Math.sqrt(a1 * a1 + cosLat0 * cosLat1 * a2 * a2)) * 6880.126; + } + + /** + * Gets the heading between 2 points, given in lat/lon + * @param pos0Lat {number} Position 0 lat + * @param pos0Lon {number} Position 0 lon + * @param pos1Lat {number} Position 1 lat + * @param pos1Lon {number} Position 1 lon + * @return {number} distance in nautical miles + */ + static computeGreatCircleHeading(pos0Lat: number, pos0Lon: number, pos1Lat: number, pos1Lon: number): number { + const lat0 = pos0Lat * MathUtils.DEGREES_TO_RADIANS; + const lon0 = pos0Lon * MathUtils.DEGREES_TO_RADIANS; + const lat1 = pos1Lat * MathUtils.DEGREES_TO_RADIANS; + const lon1 = pos1Lon * MathUtils.DEGREES_TO_RADIANS; + const dlon = lon1 - lon0; + const cosLat1 = Math.cos(lat1); + let x = Math.sin(lat1 - lat0); + const sinLon2 = Math.sin(dlon / 2.0); + x += sinLon2 * sinLon2 * 2.0 * Math.sin(lat0) * cosLat1; + let heading = Math.atan2(cosLat1 * Math.sin(dlon), x); + if (heading < 0) { + heading += 2 * Math.PI; + } + return heading * MathUtils.RADIANS_TO_DEGREES; + } + + /** + * Gets the distance between 2 points, given in lat/lon/alt above sea level + * @param pos0Lat {number} Position 0 lat + * @param pos0Lon {number} Position 0 lon + * @param pos0alt {number} Position 0 alt (feet) + * @param pos1Lat {number} Position 1 lat + * @param pos1Lon {number} Position 1 lon + * @param pos1alt {number} Position 1 alt (feet) + * @return {number} distance in nautical miles + */ + public static computeDistance3D(pos0Lat: number, pos0Lon: number, pos0alt: number, pos1Lat: number, pos1Lon: number, pos1alt: number): number { + const earthRadius = 3440.065; // earth radius in nautcal miles + const deg2rad = Math.PI / 180; + + const radius1 = pos0alt / 6076 + earthRadius; + const radius2 = pos1alt / 6076 + earthRadius; + + const x1 = radius1 * Math.sin(deg2rad * (pos0Lat + 90)) * Math.cos(deg2rad * (pos0Lon + 180)); + const y1 = radius1 * Math.sin(deg2rad * (pos0Lat + 90)) * Math.sin(deg2rad * (pos0Lon + 180)); + const z1 = radius1 * Math.cos(deg2rad * (pos0Lat + 90)); + + const x2 = radius2 * Math.sin(deg2rad * (pos1Lat + 90)) * Math.cos(deg2rad * (pos1Lon + 180)); + const y2 = radius2 * Math.sin(deg2rad * (pos1Lat + 90)) * Math.sin(deg2rad * (pos1Lon + 180)); + const z2 = radius2 * Math.cos(deg2rad * (pos1Lat + 90)); + + return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2); + } + + /** + * Check if point is inside a given ellipse + * + * @param {number} xPos x value of point + * @param {number} yPos y value of point + * @param {number} xLimPos +ve xLimit of ellipse + * @param {number} xLimNeg -ve xLimit of ellipse + * @param {number} yLimPos +ve yLimit of ellipse + * @param {number} yLimNeg -ve yLimit of ellipse + * @return {boolean} Whether the point is in the ellipse + * + */ + public static pointInEllipse(xPos: number, yPos: number, xLimPos: number, yLimPos: number, xLimNeg: number = xLimPos, yLimNeg: number = yLimPos): boolean { + return (xPos ** 2 / ((xPos >= 0) ? xLimPos : xLimNeg) ** 2 + yPos ** 2 / ((yPos >= 0) ? yLimPos : yLimNeg) ** 2) <= 1; + } + + /** + * Performs the even-odd-rule Algorithm (a raycasting algorithm) to find out whether a point is in a given polygon. + * This runs in O(n) where n is the number of edges of the polygon. + * + * @param {Array} polygon an array representation of the polygon where polygon[i][0] is the x Value of the i-th point and polygon[i][1] is the y Value. + * @param {number} xPos x value of point + * @param {number} yPos y value of point + * @return {boolean} Whether the point is in the polygon (not on the edge, just turn < into <= and > into >= for that) + */ + public static pointInPolygon(xPos: number, yPos: number, polygon: [number, number][]): boolean { + // A point is in a polygon if a line from the point to infinity crosses the polygon an odd number of times + let odd = false; + // For each edge (In this case for each point of the polygon and the previous one) + for (let i = 0, j = polygon.length - 1; i < polygon.length; i++) { + // If a line from the point into infinity crosses this edge + if (((polygon[i][1] > yPos) !== (polygon[j][1] > yPos)) // One point needs to be above, one below our y coordinate + // ...and the edge doesn't cross our Y corrdinate before our x coordinate (but between our x coordinate and infinity) + && (xPos < ((polygon[j][0] - polygon[i][0]) * (yPos - polygon[i][1]) / (polygon[j][1] - polygon[i][1]) + polygon[i][0]))) { + // Invert odd + odd = !odd; + } + j = i; + } + // If the number of crossings was odd, the point is in the polygon + return odd; + } + + /** + * Line intercept math by Paul Bourke http://paulbourke.net/geometry/pointlineplane/ + * Determine the intersection point of two line segments + * Return null if the lines don't intersect + * + * @param {number} x1 line0 x origin + * @param {number} y1 line0 y origin + * @param {number} x2 line0 x end + * @param {number} y2 line0 y end + * @param {number} x3 line1 x origin + * @param {number} y3 line1 y origin + * @param {number} x4 line1 x end + * @param {number} y4 line1 y end + * + * @return {[number, number] | null} [x,y] of intercept, null if no intercept. + */ + public static intersect(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): [number, number] | null { + // Check if none of the lines are of length 0 + if ((x1 === x2 && y1 === y2) || (x3 === x4 && y3 === y4)) { + return null; + } + + const denominator = ((y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)); + + // Lines are parallel + if (denominator === 0) { + return null; + } + + const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator; + const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator; + + // is the intersection along the segments + if (ua < 0 || ua > 1 || ub < 0 || ub > 1) { + return null; + } + + // Return a object with the x and y coordinates of the intersection + const x = x1 + ua * (x2 - x1); + const y = y1 + ua * (y2 - y1); + + return [x, y]; + } + + // Find intersect with polygon + public static intersectWithPolygon(x1: number, y1: number, x2: number, y2: number, polygon: [number, number][]): [number, number] | null { + let ret: [number, number] | null = null; + polygon.forEach((xy, index, polygon) => { + if (ret) return; + if (index + 1 >= polygon.length) { + return; + } + const x3 = xy[0]; + const y3 = xy[1]; + const x4 = polygon[index + 1][0]; + const y4 = polygon[index + 1][1]; + ret = MathUtils.intersect(x1, y1, x2, y2, x3, y3, x4, y4); + }); + return ret; + } + + /** + * Returns the given value if the value is >=lower or <= upper. Otherwise returns the boundary value. + * @param value the value to be clamped + * @param lower lowest boundary value + * @param upper highest boundary value + */ + public static clamp(value, lower, upper) { + return Math.min(Math.max(value, lower), upper); + } + + /** + * Returns a value rounded to the given number of decimal precission. + * @param value + * @param decimalPrecision + */ + public static round(value: number, decimalPrecision: number) { + const shift = 10 ** decimalPrecision; + return Math.round((value + Number.EPSILON) * shift) / shift; + } +} diff --git a/fbw-a380x/src/systems/shared/src/NXUnits.ts b/fbw-a380x/src/systems/shared/src/NXUnits.ts new file mode 100644 index 00000000000..b3718df2fd5 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/NXUnits.ts @@ -0,0 +1,29 @@ +/** + * Unit conversion utilities + */ +import { NXDataStore } from '@shared/persistence'; + +export class NXUnits { + private static _metricWeight: boolean; + + static get metricWeight() { + if (NXUnits._metricWeight === undefined) { + NXDataStore.getAndSubscribe('CONFIG_USING_METRIC_UNIT', (_, value: string) => { + NXUnits._metricWeight = value === '1'; + }, '1'); + } + return NXUnits._metricWeight; + } + + static userToKg(value: number) { + return NXUnits.metricWeight ? value : value / 2.204625; + } + + static kgToUser(value: number) { + return NXUnits.metricWeight ? value : value * 2.204625; + } + + static userWeightUnit() { + return NXUnits.metricWeight ? 'KG' : 'LBS'; // EIS uses S suffix on LB + } +} diff --git a/fbw-a380x/src/systems/shared/src/NavigationDisplay.ts b/fbw-a380x/src/systems/shared/src/NavigationDisplay.ts new file mode 100644 index 00000000000..82d3586e1f5 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/NavigationDisplay.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { Coordinates } from '@fmgc/flightplanning/data/geo'; + +export type RangeSetting = 10 | 20 | 40 | 80 | 160 | 320; +export const rangeSettings: RangeSetting[] = [10, 20, 40, 80, 160, 320]; + +export enum Mode { + ROSE_ILS, + ROSE_VOR, + ROSE_NAV, + ARC, + PLAN, +} + +export type EfisSide = 'L' | 'R' + +export enum EfisOption { + None = 0, + Constraints = 1, + VorDmes = 2, + Waypoints = 3, + Ndbs = 4, + Airports = 5, +} + +export enum NdSymbolTypeFlags { + Vor = 1 << 0, + VorDme = 1 << 1, + Ndb = 1 << 2, + Waypoint = 1 << 3, + Airport = 1 << 4, + Runway = 1 << 5, + Tuned = 1 << 6, + ActiveLegTermination = 1 << 7, + EfisOption = 1 << 8, + Dme = 1 << 9, + ConstraintMet = 1 << 10, + ConstraintMissed = 1 << 11, + ConstraintUnknown = 1 << 12, + SpeedChange = 1 << 13, + FixInfo = 1 << 14, + FlightPlan = 1 << 15, + PwpDecel = 1 << 16, + PwpTopOfDescent = 1 << 17, + PwpCdaFlap1 = 1 << 18, + PwpCdaFlap2 = 1 << 19, + FlightPlanVectorLine = 1 << 20, + FlightPlanVectorArc = 1 << 21, + FlightPlanVectorDebugPoint = 1 << 22, + ActiveFlightPlanVector = 1 << 23, + CourseReversalLeft = 1 << 24, + CourseReversalRight = 1 << 25, + MissedApproach = 1 << 26, +} + +export interface NdSymbol { + databaseId: string, + ident: string, + location: Coordinates, + direction?: number, // true + length?: number, // nautical miles + lineEnd?: Coordinates, + arcRadius?: number, + arcSweepAngle?: Degrees, + arcEnd?: Coordinates, + type: NdSymbolTypeFlags, + constraints?: string[], + radials?: number[], + radii?: number[], +} + +/** + * Possible flight plan vector groups to be transmitted to the ND. + * + * **NOTE:** this does not necessarily represent the current function of a transmitted flight plan. + */ +export enum EfisVectorsGroup { + /** + * Solid green line + */ + ACTIVE, + + /** + * Dashed green line + */ + DASHED, + + /** + * Dashed green line + */ + OFFSET, + + /** + * Dashed yellow line + */ + TEMPORARY, + + /** + * Dimmed white line + */ + SECONDARY, + + /** + * Dashed dimmed white line + */ + SECONDARY_DASHED, + + /** + * Solid cyan line + */ + MISSED, + + /** + * Dashed cyan line + */ + ALTERNATE, + + /** + * Continuous yellow line + */ + ACTIVE_EOSID, +} + +export interface NdTraffic { + alive?: boolean; + ID: string; + lat: number; + lon: number; + relativeAlt: number; + bitfield: number; + vertSpeed?: number; + intrusionLevel?: number; + posX?: number; + posY?: number; + // debug + seen?: number; + hidden?: boolean; + raTau?: number; + taTau?: number; + vTau?: number; + closureRate?: number; + closureAccel?: number; +} diff --git a/fbw-a380x/src/systems/shared/src/UpdateThrottler.ts b/fbw-a380x/src/systems/shared/src/UpdateThrottler.ts new file mode 100644 index 00000000000..d2bb9ff3423 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/UpdateThrottler.ts @@ -0,0 +1,50 @@ +/** + * Utility class to throttle instrument updates + */ +export class UpdateThrottler { + private intervalMs: number; + + private currentTime: number; + + private lastUpdateTime: number; + + private refreshOffset: number; + + private refreshNumber: number; + + /** + * @param {number} intervalMs Interval between updates, in milliseconds + */ + constructor(intervalMs) { + this.intervalMs = intervalMs; + this.currentTime = 0; + this.lastUpdateTime = 0; + + // Take a random offset to space out updates from different instruments among different + // frames as much as possible. + this.refreshOffset = Math.floor(Math.random() * intervalMs); + this.refreshNumber = 0; + } + + /** + * Checks whether the instrument should be updated in the current frame according to the + * configured update interval. + * + * @param {number} deltaTime + * @param {boolean} [forceUpdate = false] - True if you want to force an update during this frame. + * @returns -1 if the instrument should not update, or the time elapsed since the last + * update in milliseconds + */ + canUpdate(deltaTime, forceUpdate = false) { + this.currentTime += deltaTime; + const number = Math.floor((this.currentTime + this.refreshOffset) / this.intervalMs); + const update = number > this.refreshNumber; + this.refreshNumber = number; + if (update || forceUpdate) { + const accumulatedDelta = this.currentTime - this.lastUpdateTime; + this.lastUpdateTime = this.currentTime; + return accumulatedDelta; + } + return -1; + } +} diff --git a/fbw-a380x/src/systems/shared/src/arinc429.ts b/fbw-a380x/src/systems/shared/src/arinc429.ts new file mode 100644 index 00000000000..91f3b1fcde8 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/arinc429.ts @@ -0,0 +1,148 @@ +export enum Arinc429SignStatusMatrix { + FailureWarning = 0b00, + NoComputedData = 0b01, + FunctionalTest = 0b10, + NormalOperation = 0b11, +} + +export interface Arinc429WordData { + ssm: Arinc429SignStatusMatrix, + + value: number, + + isFailureWarning(): boolean, + + isNoComputedData(): boolean, + + isFunctionalTest(): boolean, + + isNormalOperation(): boolean, +} + +export class Arinc429Word implements Arinc429WordData { + static u32View = new Uint32Array(1); + + static f32View = new Float32Array(Arinc429Word.u32View.buffer); + + ssm: Arinc429SignStatusMatrix; + + value: number; + + constructor(word: number) { + Arinc429Word.u32View[0] = (word & 0xffffffff) >>> 0; + this.ssm = (Math.trunc(word / 2 ** 32) & 0b11) as Arinc429SignStatusMatrix; + this.value = Arinc429Word.f32View[0]; + } + + static empty(): Arinc429Word { + return new Arinc429Word(0); + } + + static fromSimVarValue(name: string): Arinc429Word { + return new Arinc429Word(SimVar.GetSimVarValue(name, 'number')); + } + + static async toSimVarValue(name: string, value: number, ssm: Arinc429SignStatusMatrix) { + Arinc429Word.f32View[0] = value; + const simVal = Arinc429Word.u32View[0] + Math.trunc(ssm) * 2 ** 32; + return SimVar.SetSimVarValue(name, 'string', simVal.toString()); + } + + isFailureWarning() { + return this.ssm === Arinc429SignStatusMatrix.FailureWarning; + } + + isNoComputedData() { + return this.ssm === Arinc429SignStatusMatrix.NoComputedData; + } + + isFunctionalTest() { + return this.ssm === Arinc429SignStatusMatrix.FunctionalTest; + } + + isNormalOperation() { + return this.ssm === Arinc429SignStatusMatrix.NormalOperation; + } + + /** + * Returns the value when normal operation, the supplied default value otherwise. + */ + valueOr(defaultValue: number | undefined | null) { + return this.isNormalOperation() ? this.value : defaultValue; + } + + getBitValue(bit: number): boolean { + return ((this.value >> (bit - 1)) & 1) !== 0; + } + + getBitValueOr(bit: number, defaultValue: boolean | undefined | null): boolean { + return this.isNormalOperation() ? ((this.value >> (bit - 1)) & 1) !== 0 : defaultValue; + } + + setBitValue(bit: number, value: boolean): void { + if (value) { + this.value |= 1 << (bit - 1); + } else { + this.value &= ~(1 << (bit - 1)); + } + } +} + +export class Arinc429Register implements Arinc429WordData { + u32View = new Uint32Array(1); + + f32View = new Float32Array(this.u32View.buffer); + + ssm: Arinc429SignStatusMatrix; + + value: number; + + static empty() { + return new Arinc429Register(); + } + + private constructor() { + this.set(0); + } + + set(word: number) { + this.u32View[0] = (word & 0xffffffff) >>> 0; + this.ssm = (Math.trunc(word / 2 ** 32) & 0b11) as Arinc429SignStatusMatrix; + this.value = this.f32View[0]; + } + + setFromSimVar(name: string): void { + this.set(SimVar.GetSimVarValue(name, 'number')); + } + + isFailureWarning() { + return this.ssm === Arinc429SignStatusMatrix.FailureWarning; + } + + isNoComputedData() { + return this.ssm === Arinc429SignStatusMatrix.NoComputedData; + } + + isFunctionalTest() { + return this.ssm === Arinc429SignStatusMatrix.FunctionalTest; + } + + isNormalOperation() { + return this.ssm === Arinc429SignStatusMatrix.NormalOperation; + } + + /** + * Returns the value when normal operation, the supplied default value otherwise. + */ + valueOr(defaultValue: number | undefined | null) { + return this.isNormalOperation() ? this.value : defaultValue; + } + + bitValue(bit: number): boolean { + return ((this.value >> (bit - 1)) & 1) !== 0; + } + + bitValueOr(bit: number, defaultValue: boolean | undefined | null): boolean { + return this.isNormalOperation() ? ((this.value >> (bit - 1)) & 1) !== 0 : defaultValue; + } +} diff --git a/fbw-a380x/src/systems/shared/src/ata.ts b/fbw-a380x/src/systems/shared/src/ata.ts new file mode 100644 index 00000000000..80f2aa26563 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/ata.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2022 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +/* eslint-disable max-len */ +export const AtaChaptersTitle = { + 0: 'General', + 1: 'Maintenance Policy', + 2: 'Operations', + 3: 'Support', + 4: 'Airworthiness Limitations', + 5: 'Time Limits/Maintenance Checks', + 6: 'Dimensions And Areas', + 7: 'Lifting And Shoring', + 8: 'Leveling And Weighing', + 9: 'Towing And Taxiing', + 10: 'Parking, Mooring, Storage And Return To Service', + 11: 'Placards And Markings', + 12: 'Servicing', + 13: 'Hardware And General Tools', + 15: 'Aircrew Information', + 16: 'Change Of Role', + 18: 'Vibration And Noise Analysis (Helicopter Only)', + 20: 'Standard Practices- Airframe', + 21: 'Air Conditioning', + 22: 'Auto Flight', + 23: 'Communication', + 24: 'Electrical Power', + 25: 'Equipment /Furnishings', + 26: 'Fire Protection', + 27: 'Flight Controls', + 28: 'Fuel', + 29: 'Hydraulic Power', + 30: 'Ice And Rain Protection', + 31: 'Indicating / Recording System', + 32: 'Landing Gear', + 33: 'Lights', + 34: 'Navigation', + 35: 'Oxygen', + 36: 'Pneumatic', + 37: 'Vacuum', + 38: 'Water / Waste', + 39: 'Electrical - Electronic Panels And Multipurpose Components', + 40: 'Multisystem', + 41: 'Water Ballast', + 42: 'Integrated Modular Avionics', + 44: 'Cabin Systems', + 45: 'Onboard Maintenance Systems (Oms)', + 46: 'Information Systems', + 47: 'Inert Gas System', + 48: 'In Flight Fuel Dispensing', + 49: 'Airborne Auxiliary Power', + 50: 'Cargo And Accessory Compartments', + 51: 'Standard Practices And Structures - General', + 52: 'Doors', + 53: 'Fuselage', + 54: 'Nacelles/Pylons', + 55: 'Stabilizers', + 56: 'Windows', + 57: 'Wings', + 60: 'Standard Practices - Prop./Rotor', + 61: 'Propellers/ Propulsors', + 62: 'Main Rotor(S)', + 63: 'Main Rotor Drive(S)', + 64: 'Tail Rotor', + 65: 'Tail Rotor Drive', + 66: 'Folding Blades/Pylon', + 67: 'Rotors Flight Control', + 71: 'Power Plant', + 72: 'Engine', + 73: 'Engine - Fuel And Control', + 74: 'Ignition', + 75: 'Bleed Air', + 76: 'Engine Controls', + 77: 'Engine Indicating', + 78: 'Exhaust', + 79: 'Oil', + 80: 'Starting', + 81: 'Turbines (Reciprocating Engines)', + 82: 'Water Injection', + 83: 'Accessory Gear Box (Engine Driven)', + 84: 'Propulsion Augmentation', + 91: 'Charts', + 92: 'Electrical Power Multiplexing', + 93: 'Surveillance', + 94: 'Weapon System', + 95: 'Crew Escape And Safety', + 96: 'Missiles, Drones And Telemetry', + 97: 'Wiring Reporting', + 98: 'Meteorological And Atmospheric Research', + 99: 'Electronic Warfare System', + 115: 'Flight Simulator Systems', + 116: 'Flight Simulator Cuing System', +}; + +export const AtaChaptersDescription = Object.freeze({ + 24: 'All things related to the electrical system. The electrical system supplies power from the engines, APU, batteries, or emergency generator to all cockpit instruments.', + 29: 'The hydraulic system connects to the flight controls, flaps and landing gear to provide pressure to these surfaces. Failing these can cause loss of control over some flight surfaces.', + 31: 'The cockpit displays give critical flight information to the pilots. In a failure where displays are lost, the pilots must deal with a lack of flight data given to them.', + 32: 'The landing gear components are responsible for supporting and steering the aircraft on the ground, and make it possible to retract and store the landing gear in flight. Includes the functioning and maintenance aspects of the landing gear doors.', + 34: 'The navigation systems provide data about the position, speed, heading, and altitude of the aircraft. Failures in a system such as the ADIRS can cause a loss of data sent to instrumentation.', +}); + +export type AtaChapterNumber = keyof typeof AtaChaptersTitle; diff --git a/fbw-a380x/src/systems/shared/src/autopilot.ts b/fbw-a380x/src/systems/shared/src/autopilot.ts new file mode 100644 index 00000000000..dd9c5f7e65c --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/autopilot.ts @@ -0,0 +1,67 @@ +import { ControlLaw } from '@fmgc/guidance/ControlLaws'; + +enum LateralMode { + NONE = 0, + HDG = 10, + TRACK = 11, + NAV = 20, + LOC_CPT = 30, + LOC_TRACK = 31, + LAND = 32, + FLARE = 33, + ROLL_OUT = 34, + RWY = 40, + RWY_TRACK = 41, + GA_TRACK = 50, +} + +enum ArmedLateralMode { + NAV = 0, + LOC = 1, +} + +enum VerticalMode { + NONE = 0, + ALT = 10, + ALT_CPT = 11, + OP_CLB = 12, + OP_DES = 13, + VS = 14, + FPA = 15, + ALT_CST = 20, + ALT_CST_CPT = 21, + CLB = 22, + DES = 23, + FINAL = 24, + GS_CPT = 30, + GS_TRACK = 31, + LAND = 32, + FLARE = 33, + ROLL_OUT = 34, + SRS = 40, + SRS_GA = 41, + TCAS = 50, +} + +enum ArmedVerticalMode { + ALT = 0, + ALT_CST = 1, + CLB = 2, + DES = 3, + GS = 4, + FINAL = 5, + TCAS = 6, +} + +function isArmed(bitmask, armedBit: ArmedVerticalMode | ArmedLateralMode): boolean { + return ((bitmask >> armedBit) & 1) === 1; +} + +export { + ControlLaw, + LateralMode, + ArmedLateralMode, + VerticalMode, + ArmedVerticalMode, + isArmed, +}; diff --git a/fbw-a380x/src/systems/shared/src/electrical.ts b/fbw-a380x/src/systems/shared/src/electrical.ts new file mode 100644 index 00000000000..6c19a9fa9d6 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/electrical.ts @@ -0,0 +1,5 @@ +export enum DcElectricalBus { + Dc1 = 'DC_1', + Dc2 = 'DC_2', + DcEss = 'DC_ESS', +} diff --git a/fbw-a380x/src/systems/shared/src/flightphase.ts b/fbw-a380x/src/systems/shared/src/flightphase.ts new file mode 100644 index 00000000000..e4b4a6082e8 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/flightphase.ts @@ -0,0 +1,52 @@ +import { VerticalMode } from '@shared/autopilot'; + +export enum FmgcFlightPhase { + Preflight, + Takeoff, + Climb, + Cruise, + Descent, + Approach, + GoAround, + Done, +} + +export function isReady(): boolean { + return SimVar.GetSimVarValue('L:A32NX_IS_READY', 'number') === 1; +} + +export function isSlewActive(): boolean { + return SimVar.GetSimVarValue('IS SLEW ACTIVE', 'bool'); +} + +export function isOnGround(): boolean { + return SimVar.GetSimVarValue('L:A32NX_LGCIU_1_NOSE_GEAR_COMPRESSED', 'bool') + || SimVar.GetSimVarValue('L:A32NX_LGCIU_2_NOSE_GEAR_COMPRESSED', 'bool'); +} + +function isEngineOn(index: number): boolean { + return SimVar.GetSimVarValue(`L:A32NX_ENGINE_N2:${index}`, 'number') > 20; +} + +function isEngineOnTakeOffThrust(index: number): boolean { + return SimVar.GetSimVarValue(`L:A32NX_ENGINE_N1:${index}`, 'number') >= 70; +} + +export function isAnEngineOn(): boolean { + return isEngineOn(1) || isEngineOn(2); +} + +export function isAllEngineOn(): boolean { + return isEngineOn(1) && isEngineOn(2); +} + +export function getAutopilotVerticalMode(): VerticalMode { + return SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Enum'); +} + +export function conditionTakeOff(): boolean { + return ( + (getAutopilotVerticalMode() === VerticalMode.SRS && isEngineOnTakeOffThrust(1) && isEngineOnTakeOffThrust(2)) + || Math.abs(Simplane.getGroundSpeed()) > 90 + ); +} diff --git a/fbw-a380x/src/systems/shared/src/flightplan.ts b/fbw-a380x/src/systems/shared/src/flightplan.ts new file mode 100644 index 00000000000..d80032f95e9 --- /dev/null +++ b/fbw-a380x/src/systems/shared/src/flightplan.ts @@ -0,0 +1,39 @@ +export type ApproachNameComponents = { + // the approach type, e.g. ILS or RNAV + type: string, + + // the runway + runway: string, + + // alphanumeric designator when multiple approaches of the same type exist for the same runway + designator: string | undefined, +}; + +export const parseApproachName = (name: string): ApproachNameComponents | undefined => { + // L(eft), C(entre), R(ight), T(true North) are the possible runway designators (ARINC424) + // If there are multiple procedures for the same type of approach, an alphanumeric suffix is added to their names (last subpattern) + // We are a little more lenient than ARINC424 in an effort to match non-perfect navdata, so we allow dashes, spaces, or nothing before the suffix + const match = name.trim().match(/^(ILS|LOC|RNAV|NDB|VOR|GPS) (RW)?([0-9]{1,2}[LCRT]?)([\s-]*([A-Z0-9]))?$/); + if (!match) { + return undefined; + } + return { + type: match[1], + runway: match[3], + designator: match[5], + }; +}; + +/** + * + * @param name approach name from the nav database + * @returns max 9 digit name in the format