diff --git a/fbw-a32nx/.env b/fbw-a32nx/.env index 667ddf3be9e..8f9a1b1c47f 100644 --- a/fbw-a32nx/.env +++ b/fbw-a32nx/.env @@ -1,7 +1,7 @@ VITE_BUILD=false NODE_ENV=production -CLIENT_SECRET="" CLIENT_ID="" +CLIENT_SECRET="" SENTRY_DSN="" AIRCRAFT_PROJECT_PREFIX="a32nx" AIRCRAFT_VARIANT="a320-251n" diff --git a/fbw-a32nx/mach.config.js b/fbw-a32nx/mach.config.js index 9f564c5a5bb..479d2770dbb 100644 --- a/fbw-a32nx/mach.config.js +++ b/fbw-a32nx/mach.config.js @@ -34,6 +34,7 @@ module.exports = { msfsAvionicsInstrument('ND'), msfsAvionicsInstrument('EWD'), msfsAvionicsInstrument('Clock'), + msfsAvionicsInstrument('OANC'), reactInstrument('SD'), reactInstrument('DCDU'), diff --git a/fbw-a32nx/src/systems/instruments/src/ND/config.json b/fbw-a32nx/src/systems/instruments/src/ND/config.json index c99f13d05c9..b33e45922be 100644 --- a/fbw-a32nx/src/systems/instruments/src/ND/config.json +++ b/fbw-a32nx/src/systems/instruments/src/ND/config.json @@ -2,6 +2,7 @@ "index": "./instrument.tsx", "isInteractive": false, "extraDeps": [ - "fbw-common/src/systems/instruments/src/ND" + "fbw-common/src/systems/instruments/src/ND", + "fbw-common/src/systems/instruments/src/OANC" ] } diff --git a/fbw-a32nx/src/systems/instruments/src/ND/tsconfig.json b/fbw-a32nx/src/systems/instruments/src/ND/tsconfig.json index a66548247a2..2874005b62d 100644 --- a/fbw-a32nx/src/systems/instruments/src/ND/tsconfig.json +++ b/fbw-a32nx/src/systems/instruments/src/ND/tsconfig.json @@ -28,7 +28,9 @@ "@tcas/*": ["./tcas/src/*"], "@typings/*": ["../../../fbw-common/src/typings/*"], "@flybywiresim/fbw-sdk": ["../../../fbw-common/src/systems/index-no-react.ts"], - "@flybywiresim/navigation-display": ["../../../fbw-common/src/systems/instruments/src/ND/index.ts"] + "@flybywiresim/navigation-display": ["../../../fbw-common/src/systems/instruments/src/ND/index.ts"], + "@flybywiresim/oanc": ["../../../fbw-common/src/systems/instruments/src/OANC/index.ts"], + "@flybywiresim/msfs-avionics-common": ["../../../fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/index.ts"] } } } diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/.eslintrc.js b/fbw-a32nx/src/systems/instruments/src/OANC/.eslintrc.js new file mode 100644 index 00000000000..30b48c5735d --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/.eslintrc.js @@ -0,0 +1,11 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +'use strict'; + +module.exports = { + extends: '../../../../../../.eslintrc.js', + + // overrides airbnb, use sparingly + rules: { 'react/react-in-jsx-scope': 'off', 'react/no-unknown-property': 'off', 'react/style-prop-object': 'off' }, +}; diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/ContextMenu.scss b/fbw-a32nx/src/systems/instruments/src/OANC/Components/ContextMenu.scss new file mode 100644 index 00000000000..2b31d71ab6b --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/ContextMenu.scss @@ -0,0 +1,42 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +@import "../../Common/definitions"; + +.oanc-context-menu { + width: 180px; + + display: flex; + flex-direction: column; + + position: absolute; + + margin: 7px 0 7px 0; + + background-color: rgb(128, 128, 128); + border: solid 2px white; +} + +.oanc-context-menu-item { + width: 100%; + height: 28px; + + color: rgb(255, 255, 255); + font-size: 15px; + + display: flex; + flex-direction: column; + justify-content: center; + + padding-left: 3px; + + border: solid 3px transparent; +} + +.oanc-context-menu-item-disabled { + color: rgb(255, 255, 255, 0.7); +} + +.oanc-context-menu-item:not(.oanc-context-menu-item-disabled):hover { + border: solid 3px $display-cyan; +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/ContextMenu.tsx b/fbw-a32nx/src/systems/instruments/src/OANC/Components/ContextMenu.tsx new file mode 100644 index 00000000000..a2bdc266bb1 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/ContextMenu.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { + FSComponent, + DisplayComponent, + VNode, + MapSubject, + Subscribable, + Subscription, + SubscribableUtils, +} from '@microsoft/msfs-sdk'; + +import './ContextMenu.scss'; +import { ContextMenuItemData } from '@flybywiresim/oanc'; + +export interface ContextMenuProps { + isVisible: Subscribable; + + x: Subscribable; + + y: Subscribable; + + items: ContextMenuItemData[]; + + closeMenu: () => void; +} + +export class ContextMenu extends DisplayComponent { + private readonly subscriptions: Subscription[] = []; + + private readonly style = MapSubject.create(); + + onAfterRender() { + this.subscriptions.push( + this.props.isVisible.sub((it) => this.style.setValue('visibility', it ? 'visible' : 'hidden'), true), + this.props.x.sub((it) => this.style.setValue('left', `${it.toFixed(0)}px`), true), + this.props.y.sub((it) => this.style.setValue('top', `${it.toFixed(0)}px`), true), + ); + } + + private readonly handleItemPressed = (item: ContextMenuItemData) => { + item.onPressed?.(); + + this.props.closeMenu(); + }; + + render(): VNode | null { + return ( +
+ {this.props.items.map((it) => ( + this.handleItemPressed(it)} + /> + ))} +
+ ); + } +} + +export interface ContextMenuItemProps { + name: string; + + disabled: Subscribable; + + onPressed: () => void; +} + +export class ContextMenuItem extends DisplayComponent { + private readonly root = FSComponent.createRef(); + + onAfterRender() { + this.root.instance.addEventListener('click', this.handlePressed); + } + + private handlePressed = () => this.props.onPressed(); + + render(): VNode | null { + return ( + + {this.props.name} + + ); + } +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/ControlPanel.scss b/fbw-a32nx/src/systems/instruments/src/OANC/Components/ControlPanel.scss new file mode 100644 index 00000000000..0793b0b8418 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/ControlPanel.scss @@ -0,0 +1,277 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +@import "../../Common/definitions"; + +$display-mfd-dark-grey: rgb(128, 128, 128); +$display-dropdown-handle: rgb(160, 160, 160); + +.oanc-control-panel-container { + width: 768px; + + display: flex; + + position: absolute; + right: 0; + bottom: 0; + + z-index: 9999; +} + +.oanc-control-panel-tabs { + width: 123px; + height: 130px; + + display: flex; + flex-direction: column; + + margin-top: auto; +} + +.oanc-control-panel { + width: 645px; + height: 130px; + + display: flex; + flex-direction: row; + align-items: center; + + padding: 5px; + + background-color: rgb(128, 128, 128); + border: solid 3px white; + border-left: none; + border-bottom-width: 2px; + border-right-width: 2px; + + button { + font-family: "Ecam", monospace; + font-size: 17px; + + color: white; + background-color: rgb(128, 128, 128); + + border: outset 2px white; + + &:not(:last-child) { + margin-bottom: 8px; + } + + &:hover { + border-style: solid; + border-color: $display-cyan; + } + } +} + +.oanc-control-panel-tabs-dummy { + width: 123px; + height: calc(100% / 4 + 2px); + + border-right: 3px solid rgb(255, 255, 255); +} + +.oanc-control-panel-tab-button { + width: 123px; + height: calc(100% / 4); + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + font-size: 18px; + color: white; + + border-top: 2px solid rgb(100, 100, 100); + border-right: 3px solid rgb(255, 255, 255); + border-bottom: 2px solid rgb(100, 100, 100); + border-left: 2px solid rgb(100, 100, 100); + + background-color: rgb(20, 20, 20); +} + +.oanc-control-panel-tab-button:not(.oanc-control-panel-tab-button-selected):hover { + border-top: 2px solid $display-cyan !important; + border-right: 3px solid $display-cyan !important; + border-bottom: 2px solid $display-cyan !important; + border-left: 2px solid $display-cyan !important; + + background-color: rgb(128, 128, 128); +} + +.oanc-control-panel-tab-button:nth-child(3) { + border-top: none; + border-bottom: none; +} + +.oanc-control-panel-tabs[data-active-tab-index="2"] .oanc-control-panel-tab-button:nth-child(2) { + border-bottom: none; + padding-bottom: 1px; +} + +.oanc-control-panel-tabs[data-active-tab-index="2"] .oanc-control-panel-tab-button:nth-child(4) { + border-top: none; + padding-top: 2px; +} + +.oanc-control-panel-tab-button.oanc-control-panel-tab-button-selected { + border-top: 3px solid white; + border-left: 3px solid white; + border-bottom: 2px solid white; + border-right: none; + padding-right: 2px; + + background-color: rgb(128, 128, 128); + color: $display-cyan; +} + +.oanc-control-panel form { + width: 200px; +} + +.oanc-control-panel .mfd-radio-button { + font-size: 19px; + + margin-left: 22px; +} + +.oanc-control-panel .mfd-radio-button:not(:last-child) { + margin-bottom: 5px; +} + +.oanc-control-panel .mfd-radio-button input[type="radio"] { + margin-right: 1em; +} + +.oanc-control-panel .mfd-dropdown-container { + .mfd-input-field-text-input { + font-size: 17px; + } + + .mfd-dropdown-menu-element { + font-size: 17px !important; + + padding: 2px 6px; + } + + .mfd-dropdown-menu { + max-height: 87px; + + &::-webkit-scrollbar-track { + background: $display-mfd-dark-grey; + border-left: solid 1px $display-dropdown-handle; + padding-left: 1px; + padding-right: 1px; + } + + &::-webkit-scrollbar-thumb { + background: $display-dropdown-handle; + } + } +} + +.oanc-control-panel .oanc-control-panel-arpt-sel-left-dropdowns { + display: flex; + flex-direction: row; + + margin-top: 2px; +} + +.oanc-control-panel .oanc-control-panel-arpt-sel-left-letter-dropdown { + width: 60px; + height: 30px; + + margin-left: 26px; + margin-right: 24px; + margin-bottom: 5px; + + background-color: magenta; + + .mfd-dropdown-inner { + height: 30px; + + .mfd-input-field-container { + height: 27px; + } + } +} + +.oanc-control-panel .oanc-control-panel-arpt-sel-left-airport-dropdown { + width: 150px; + height: 30px; + + margin-bottom: 3px; + + background-color: magenta; + + .mfd-dropdown-inner { + height: 30px; + + .mfd-input-field-container { + height: 27px; + } + } +} + +.oanc-control-panel .oanc-control-panel-arpt-sel-center { + flex-grow: 1; + + display: flex; + flex-direction: column; + align-items: center; + + .oanc-control-panel-arpt-sel-center-info { + color: $display-green; + + white-space: pre; + font-size: 17px; + } + + .oanc-control-panel-arpt-sel-center-info:not(:last-child) { + margin-bottom: 6px; + } + + button { + width: 150px; + height: 35px; + } +} + +.oanc-control-panel.oanc-control-panel-tmpy { + .oanc-control-panel-arpt-sel-center-info { + color: $display-yellow; + } +} + +.oanc-control-panel .oanc-control-panel-arpt-sel-right { + height: 100%; + + display: flex; + flex-direction: column; + justify-content: space-between; + + margin-left: auto; + + padding: 4px 0 4px 9px; + border-left: 2px solid $display-white; + + button { + width: 100px; + height: 27px; + } +} + +.oanc-control-panel .oanc-control-panel-arpt-sel-close { + width: 32px; + height: 100%; + + display: flex; + flex-direction: column; + align-items: flex-end; + + button { + width: 27px; + height: 27px; + } +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/ControlPanel.tsx b/fbw-a32nx/src/systems/instruments/src/OANC/Components/ControlPanel.tsx new file mode 100644 index 00000000000..3a9352dfb69 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/ControlPanel.tsx @@ -0,0 +1,355 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { + ArraySubject, + ComponentProps, + DisplayComponent, + FSComponent, + MappedSubscribable, + MapSubject, + Subject, + Subscribable, + Subscription, + VNode, +} from '@microsoft/msfs-sdk'; + +import { AmdbAirportSearchResult } from '@flybywiresim/fbw-sdk'; +import { + ControlPanelAirportSearchMode, + ControlPanelStore, + ControlPanelUtils, + NavigraphAmdbClient, +} from '@flybywiresim/oanc'; +import { RadioButtonGroup } from './RadioButtonGroup'; +import { DropdownMenu } from './DropdownMenu'; + +import './ControlPanel.scss'; + +export interface ControlPanelProps extends ComponentProps { + amdbClient: NavigraphAmdbClient; + + isVisible: Subscribable; + + onSelectAirport: (airportIcao: string) => void; + + closePanel: () => void; + + onZoomIn: () => void; + + onZoomOut: () => void; +} + +export class ControlPanel extends DisplayComponent { + private readonly airportSearchAirportDropdownRef = FSComponent.createRef(); + + private readonly displayAirportButtonRef = FSComponent.createRef(); + + private readonly buttonRefs = [ + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + ]; + + private readonly closePanelButtonRef = FSComponent.createRef(); + + private readonly zoomInButtonRef = FSComponent.createRef(); + + private readonly zoomOutButtonRef = FSComponent.createRef(); + + private readonly store = new ControlPanelStore(); + + private readonly subscriptions: (Subscription | MappedSubscribable)[] = []; + + private readonly style = MapSubject.create(); + + private readonly activeTabIndex = Subject.create<1 | 2 | 3>(2); + + onAfterRender() { + this.displayAirportButtonRef.instance.addEventListener('click', () => this.handleDisplayAirport()); + + this.buttonRefs[0].instance.addEventListener('click', () => this.handleSelectAirport('NZQN')); + this.buttonRefs[1].instance.addEventListener('click', () => this.handleSelectAirport('LFPG')); + this.buttonRefs[2].instance.addEventListener('click', () => this.handleSelectAirport('CYUL')); + + this.closePanelButtonRef.instance.addEventListener('click', () => this.props.closePanel()); + + this.zoomInButtonRef.instance.addEventListener('click', () => this.props.onZoomIn()); + this.zoomOutButtonRef.instance.addEventListener('click', () => this.props.onZoomOut()); + + this.subscriptions.push( + this.props.isVisible.sub((it) => this.style.setValue('visibility', it ? 'visible' : 'hidden'), true), + ); + + this.props.amdbClient.searchForAirports('').then((airports) => { + this.store.airports.set(airports); + }); + + this.subscriptions.push(this.store.airports.sub(() => this.sortAirports(this.store.airportSearchMode.get()))); + + this.subscriptions.push(this.store.airportSearchMode.sub((mode) => this.sortAirports(mode))); + + this.subscriptions.push( + this.store.airportSearchMode.sub(() => this.updateAirportSearchData(), true), + this.store.sortedAirports.sub(() => this.updateAirportSearchData(), true), + ); + } + + public updateAirportSearchData() { + const searchMode = this.store.airportSearchMode.get(); + const sortedAirports = this.store.sortedAirports.getArray(); + + const prop = ControlPanelUtils.getSearchModeProp(searchMode); + + this.store.airportSearchData.set(sortedAirports.map((it) => (it[prop] as string).toUpperCase())); + } + + public setSelectedAirport(airport: AmdbAirportSearchResult) { + this.store.selectedAirport.set(airport); + this.store.airportSearchSelectedAirportIndex.set( + this.store.sortedAirports.getArray().findIndex((it) => it.idarpt === airport.idarpt), + ); + } + + private sortAirports(mode: ControlPanelAirportSearchMode) { + const array = this.store.airports.getArray().slice(); + + const prop = ControlPanelUtils.getSearchModeProp(mode); + + array.sort((a, b) => { + if (a[prop] < b[prop]) { + return -1; + } + if (a[prop] > b[prop]) { + return 1; + } + return 0; + }); + + this.store.sortedAirports.set(array.filter((it) => it[prop] !== null)); + } + + private handleSelectSearchLetter = (letterIndex: number, letter: string) => { + this.store.airportSearchSelectedSearchLetterIndex.set(letterIndex); + + const firstAirportIndex = this.store.airportSearchData.getArray().findIndex((it) => it.startsWith(letter)); + + if (firstAirportIndex === -1) { + return; + } + + this.airportSearchAirportDropdownRef.instance.scrollToValue(firstAirportIndex); + }; + + private handleSelectAirport = (icao: string, indexInSearchData?: number) => { + const airport = this.store.airports.getArray().find((it) => it.idarpt === icao); + + if (!airport) { + throw new Error(''); + } + + const firstLetter = airport[ControlPanelUtils.getSearchModeProp(this.store.airportSearchMode.get())][0]; + this.store.airportSearchSelectedSearchLetterIndex.set( + ControlPanelUtils.LETTERS.findIndex((it) => it === firstLetter), + ); + + const airportIndexInSearchData = + indexInSearchData ?? this.store.sortedAirports.getArray().findIndex((it) => it.idarpt === icao); + + this.store.airportSearchSelectedAirportIndex.set(airportIndexInSearchData); + this.store.selectedAirport.set(airport); + this.store.isAirportSelectionPending.set(true); + }; + + private handleSelectSearchMode = (newSearchMode: ControlPanelAirportSearchMode) => { + const selectedAirport = this.store.selectedAirport.get(); + + this.store.airportSearchMode.set(newSearchMode); + + if (selectedAirport !== null) { + const prop = ControlPanelUtils.getSearchModeProp(newSearchMode); + + const firstLetter = selectedAirport[prop][0]; + const airportIndexInSearchData = this.store.sortedAirports + .getArray() + .findIndex((it) => it.idarpt === selectedAirport.idarpt); + + this.store.airportSearchSelectedSearchLetterIndex.set( + ControlPanelUtils.LETTERS.findIndex((it) => it === firstLetter), + ); + this.store.airportSearchSelectedAirportIndex.set(airportIndexInSearchData); + } + }; + + private handleDisplayAirport = () => { + if (!this.store.selectedAirport.get()) { + throw new Error(''); + } + + this.props.onSelectAirport(this.store.selectedAirport.get().idarpt); + this.store.loadedAirport.set(this.store.selectedAirport.get()); + this.store.isAirportSelectionPending.set(false); // TODO should be done when airport is fully loaded + }; + + render(): VNode | null { + return ( +
+
+
+ it === 1)} + onSelected={() => this.activeTabIndex.set(1)} + /> + it === 2)} + onSelected={() => this.activeTabIndex.set(2)} + /> + it === 3)} + onSelected={() => this.activeTabIndex.set(3)} + /> +
+ +
+
+
+
+ + this.handleSelectSearchLetter(newSelectedIndex, ControlPanelUtils.LETTERS[newSelectedIndex]) + } + freeTextAllowed={false} + numberOfDigitsForInputField={1} + idPrefix="oanc-search-letter" + /> +
+ +
+ { + this.handleSelectAirport(this.store.sortedAirports.get(newSelectedIndex).idarpt, newSelectedIndex); + }} + freeTextAllowed={false} + numberOfDigitsForInputField={10} + alignLabels={this.store.airportSearchMode.map((it) => + it === ControlPanelAirportSearchMode.City ? 'flex-start' : 'center', + )} + idPrefix="oanc-search-airport" + /> +
+
+ + { + switch (newSelectedIndex) { + case 0: + this.handleSelectSearchMode(ControlPanelAirportSearchMode.Icao); + break; + case 1: + this.handleSelectSearchMode(ControlPanelAirportSearchMode.Iata); + break; + default: + this.handleSelectSearchMode(ControlPanelAirportSearchMode.City); + break; + } + }} + idPrefix="oanc-search" + /> +
+ +
+ + {this.store.selectedAirport.map((it) => it?.name?.substring(0, 18).toUpperCase() ?? '')} + + + {this.store.selectedAirport.map((it) => { + if (!it) { + return ''; + } + + return `${it.idarpt} ${it.iata}`; + })} + + + {this.store.selectedAirport.map((it) => { + if (!it) { + return ''; + } + + return `${ControlPanelUtils.LAT_FORMATTER(it.coordinates.lat)}/${ControlPanelUtils.LONG_FORMATTER(it.coordinates.lon)}`; + })} + + + +
+ +
+ + + +
+ +
+ + + +
+
+
+ ); + } +} + +interface ControlPanelTabButtonProps { + text: string; + + isSelected: Subscribable; + + onSelected: () => void; +} + +class ControlPanelTabButton extends DisplayComponent { + private readonly root = FSComponent.createRef(); + + onAfterRender() { + this.root.instance.addEventListener('click', this.props.onSelected); + } + + render(): VNode | null { + return ( +
+ {this.props.text} +
+ ); + } +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/DataEntryFormats.tsx b/fbw-a32nx/src/systems/instruments/src/OANC/Components/DataEntryFormats.tsx new file mode 100644 index 00000000000..f95b132f8ba --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/DataEntryFormats.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { Subscribable } from '@microsoft/msfs-sdk'; + +type FieldFormatTuple = [value: string, unitLeading: string, unitTrailing: string]; +export interface DataEntryFormat { + placeholder: string; + maxDigits: number; + format(value: T): FieldFormatTuple; + parse(input: string): Promise; + /** + * If modified or notify()ed, triggers format() in the input field (i.e. when dependencies to value have changed) + */ + reFormatTrigger?: Subscribable; +} + +export class DropdownFieldFormat implements DataEntryFormat { + public placeholder = ''; + + public maxDigits = 6; + + constructor(numDigits: number) { + this.maxDigits = numDigits; + this.placeholder = '-'.repeat(numDigits); + } + + public format(value: string) { + if (!value) { + return [this.placeholder, null, null] as FieldFormatTuple; + } + return [value, null, null] as FieldFormatTuple; + } + + public async parse(input: string) { + return input; + } +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/DropdownMenu.scss b/fbw-a32nx/src/systems/instruments/src/OANC/Components/DropdownMenu.scss new file mode 100644 index 00000000000..f6cdba7890b --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/DropdownMenu.scss @@ -0,0 +1,93 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +@import "../../Common/definitions"; + +$display-mfd-dark-grey: rgb(128, 128, 128); +$display-light-grey: gray; + +.mfd-dropdown-container { + position: relative; +} + +.mfd-dropdown-outer { + display: flex; + flex-direction: row; + justify-content: space-between; + background-color: $display-mfd-dark-grey; + border: 2px outset $display-light-grey; +} + +.mfd-dropdown-outer.inactive { + background-color: $display-background; + border: 2px outset transparent !important; +} + +.mfd-dropdown-outer:hover { + border-color: $display-cyan; +} + +.mfd-dropdown-inner { + background-color: $display-background; + display: flex; + flex: 1; + justify-content: center; + align-items: center; + height: 40px; +} + +.mfd-dropdown-arrow { + display: flex; + justify-content: center; + align-items: center; + width: 25px; +} + +.mfd-dropdown-arrow.inactive { + visibility: hidden; +} + +.mfd-dropdown-menu::-webkit-scrollbar { + width: 20px; +} + +.mfd-dropdown-menu::-webkit-scrollbar-track { + background: $display-mfd-dark-grey; +} + +.mfd-dropdown-menu::-webkit-scrollbar-thumb { + background: $display-light-grey; +} + +.mfd-dropdown-menu { + background-color: $display-mfd-dark-grey; + min-width: 100%; + max-width: 200%; + position: absolute; + z-index: 60; + overflow: auto; + max-height: 100px; + display: none; + overflow-x: hidden; + border: 2px outset $display-light-grey; +} + +.mfd-dropdown-menu-element { + color: $display-white; + font-size: 20px; + padding: 12px 16px; + display: block; + border: 3px solid transparent; + white-space: nowrap; + overflow: hidden; + text-align: left; + padding: 5px 16px; +} + +.mfd-dropdown-menu-element:hover { + border: 3px solid $display-cyan; +} + +.mfd-dropdown-menu-element.disabled { + color: $display-grey; +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/DropdownMenu.tsx b/fbw-a32nx/src/systems/instruments/src/OANC/Components/DropdownMenu.tsx new file mode 100644 index 00000000000..9cb15e12310 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/DropdownMenu.tsx @@ -0,0 +1,302 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { + ArraySubject, + ComponentProps, + DisplayComponent, + FSComponent, + Subject, + Subscribable, + SubscribableArray, + SubscribableUtils, + Subscription, + VNode, +} from '@microsoft/msfs-sdk'; + +import { InputField } from './InputField'; +import { DropdownFieldFormat } from './DataEntryFormats'; + +import './DropdownMenu.scss'; + +interface DropdownMenuProps extends ComponentProps { + values: SubscribableArray; + selectedIndex: Subject; + freeTextAllowed: boolean; + idPrefix: string; + /** + * + * If defined, this component does not update the selectedIndex prop, but rather calls this method. + */ + onModified?: (newSelectedIndex: number, freeTextEntry: string) => void; + inactive?: Subscribable; + containerStyle?: string; + alignLabels?: 'flex-start' | 'center' | 'flex-end' | Subscribable<'flex-start' | 'center' | 'flex-end'>; + numberOfDigitsForInputField?: number; + tmpyActive?: Subscribable; +} + +/* + * Dropdown menu with optional free text entry (with black background, and cyan font color) + */ +export class DropdownMenu extends DisplayComponent { + // Make sure to collect all subscriptions here, otherwise page navigation doesn't work. + private subs = [] as Subscription[]; + + private topRef = FSComponent.createRef(); + + private dropdownSelectorRef = FSComponent.createRef(); + + private dropdownInnerRef = FSComponent.createRef(); + + private dropdownArrowRef = FSComponent.createRef(); + + // private dropdownSelectorLabelRef = FSComponent.createRef(); + + private dropdownMenuRef = FSComponent.createRef(); + + private dropdownIsOpened = Subject.create(false); + + private inputFieldRef = FSComponent.createRef>(); + + private inputFieldValue = Subject.create(''); + + private freeTextEntered = false; + + private renderedDropdownOptions = ArraySubject.create(); + + private renderedDropdownOptionsIndices: number[] = []; + + private onDropdownOpenedCallback: () => void | undefined; + + private alignTextSub: Subscribable<'flex-start' | 'center' | 'flex-end'> = SubscribableUtils.toSubscribable( + this.props.alignLabels, + true, + ); + + clickHandler(i: number, thisArg: DropdownMenu) { + if (this.props.inactive.get() === false) { + this.freeTextEntered = false; + if (thisArg.props.onModified) { + thisArg.props.onModified(this.renderedDropdownOptionsIndices[i], ''); + } else { + thisArg.props.selectedIndex.set(this.renderedDropdownOptionsIndices[i]); + } + thisArg.dropdownIsOpened.set(false); + this.filterList(''); + } + } + + private onFieldSubmit(text: string) { + if (this.props.freeTextAllowed && this.props.onModified && this.props.inactive.get() === false) { + // selected index of -1 marks free text entry + this.props.onModified(-1, text); + + this.dropdownMenuRef.instance.style.display = 'none'; + this.freeTextEntered = true; + this.setInputFieldValue(text); + this.dropdownIsOpened.set(false); + this.freeTextEntered = false; + } + this.filterList(''); + } + + private filterList(text: string) { + const arr = this.props.values.getArray(); + this.renderedDropdownOptionsIndices = arr.map((val, idx) => idx).filter((val, idx) => arr[idx].startsWith(text)); + this.renderedDropdownOptions.set(arr.filter((val) => val.startsWith(text))); + } + + private onFieldChanged(text: string) { + this.freeTextEntered = true; + + // Filter dropdown options based on input + this.filterList(text); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + if (this.props.inactive === undefined) { + this.props.inactive = Subject.create(false); + } + if (this.props.tmpyActive === undefined) { + this.props.tmpyActive = Subject.create(false); + } + + this.subs.push( + this.renderedDropdownOptions.sub((index, type, item, array) => { + // Remove click handlers + array.forEach((val, i) => { + if (document.getElementById(`${this.props.idPrefix}_${i}`)) { + document + .getElementById(`${this.props.idPrefix}_${i}`) + .removeEventListener('click', () => this.clickHandler(i, this)); + } + }); + + // Re-draw all options + while (this.dropdownMenuRef.instance.firstChild) { + this.dropdownMenuRef.instance.removeChild(this.dropdownMenuRef.instance.firstChild); + } + array.forEach((el, idx) => { + const n: VNode = ( + `text-align: ${it};`)} + > + {el} + + ); + FSComponent.render(n, this.dropdownMenuRef.instance); + }, this); + // + + // Add click handlers + array.forEach((val, i) => { + document + .getElementById(`${this.props.idPrefix}_${i}`) + .addEventListener('click', () => this.clickHandler(i, this)); + }); + }), + ); + + this.subs.push( + this.props.values.sub((index, type, item, array) => { + const selectedIndex = this.props.selectedIndex.get(); + const value = array[selectedIndex]; + + if (selectedIndex !== undefined && selectedIndex !== null && value !== null && value !== undefined) { + this.setInputFieldValue(value); + } else { + this.setInputFieldValue(''); + } + + this.renderedDropdownOptionsIndices = array.map((val, idx) => idx); + this.renderedDropdownOptions.set(array); + }, true), + ); + + this.subs.push( + this.props.selectedIndex.sub((value) => { + if (this.props.values.get(value)) { + this.setInputFieldValue(this.props.values.get(value)); + } + }), + ); + + this.dropdownSelectorRef.instance.addEventListener('click', () => { + this.dropdownIsOpened.set(!this.dropdownIsOpened.get()); + }); + + // Close dropdown menu if clicked outside + document.getElementById('OANC_CONTENT').addEventListener('click', (e) => { + if (!this.topRef.getOrDefault().contains(e.target as Node) && this.dropdownIsOpened.get() === true) { + this.dropdownIsOpened.set(false); + } + }); + + this.subs.push( + this.dropdownIsOpened.sub((val) => { + this.dropdownMenuRef.instance.style.display = val ? 'block' : 'none'; + + this.onDropdownOpenedCallback?.(); + this.onDropdownOpenedCallback = undefined; + + if (!this.freeTextEntered) { + if (val === true) { + this.inputFieldRef.instance.textInputRef.instance.focus(); + this.inputFieldRef.instance.onFocus(); + } else { + this.inputFieldRef.instance.textInputRef.instance.blur(); + this.inputFieldRef.instance.onBlur(false); + } + } + }), + ); + + this.subs.push( + this.props.inactive.sub((val) => { + if (val === true) { + this.dropdownSelectorRef.getOrDefault().classList.add('inactive'); + this.dropdownArrowRef.getOrDefault().classList.add('inactive'); + } else { + this.dropdownSelectorRef.getOrDefault().classList.remove('inactive'); + this.dropdownArrowRef.getOrDefault().classList.remove('inactive'); + } + }, true), + ); + + // TODO add KCCU events + } + + /** + * Scrolls the dropdown list to the given index + * + * @param index the index of the value to scroll to + */ + public scrollToValue(index: number): void { + this.onDropdownOpenedCallback = () => { + const element = Array.from(this.dropdownMenuRef.instance.children).find( + (it) => it.id === `${this.props.idPrefix}_${index}`, + ); + + this.dropdownMenuRef.instance.scrollTop = (element as unknown as { offsetTop: number }).offsetTop; + }; + } + + public destroy(): void { + // Destroy all subscriptions to remove all references to this instance. + this.subs.forEach((x) => x.destroy()); + + super.destroy(); + } + + private setInputFieldValue(value: string) { + if (this.props.numberOfDigitsForInputField !== undefined) { + this.inputFieldValue.set(value.substring(0, this.props.numberOfDigitsForInputField).trim()); + } else { + this.inputFieldValue.set(value); + } + } + + render(): VNode { + return ( +
+
+
`justify-content: ${it};`)} + > + + ref={this.inputFieldRef} + dataEntryFormat={new DropdownFieldFormat(this.props.numberOfDigitsForInputField ?? 6)} + value={this.inputFieldValue} + containerStyle="border: 2px inset transparent" + alignText={this.props.alignLabels} + canOverflow={this.props.freeTextAllowed} + onModified={(text) => this.onFieldSubmit(text)} + onInput={(text) => this.onFieldChanged(text)} + inactive={this.props.inactive} + handleFocusBlurExternally + tmpyActive={this.props.tmpyActive} + enteredByPilot={Subject.create(false)} + /> +
+
+ + + +
+
+
+
+ ); + } +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/InputField.scss b/fbw-a32nx/src/systems/instruments/src/OANC/Components/InputField.scss new file mode 100644 index 00000000000..9bb4ca2a16b --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/InputField.scss @@ -0,0 +1,120 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +@import "../../Common/definitions"; + +$display-mfd-dark-grey: magenta; +$display-light-grey: magenta; + +.mfd-input-field-root { + display: flex; + flex-direction: row; + justify-items: flex-start; + align-items: baseline; +} + +.mfd-input-field-container { + display: flex; + flex-direction: row; + justify-items: center; + align-items: baseline; + padding: 4px 2px; + background-color: $display-background; + border: 2px inset $display-light-grey; + overflow: visible; + height: 37px; +} + +.mfd-input-field-container.inactive { + border: 2px inset transparent !important; + background-color: $display-background !important; +} + +.mfd-input-field-container.disabled { + background-color: $display-mfd-dark-grey; +} + +.mfd-input-field-container:hover { + border: 2px inset $display-cyan; +} + +.mfd-input-field-text-input { + background-color: $display-background; + border: none; + font-family: "Ecam", monospace; + font-size: 27px; + line-height: 27px; + color: $display-cyan; + padding: 0px; +} + +.mfd-input-field-text-input.inactive { + background-color: $display-background !important; + color: $display-green !important; +} + +.mfd-input-field-text-input.tmpy { + color: $display-yellow; +} + +.mfd-input-field-text-input.validating { + color: $display-light-grey; +} + +.mfd-input-field-text-input.disabled { + background-color: $display-mfd-dark-grey; + color: $display-light-grey; +} + +.mfd-input-field-text-input.mandatory { + color: $display-amber; +} + +.mfd-input-field-text-input.valueSelected { + background-color: $display-cyan; + color: $display-background; +} + +.mfd-input-field-text-input.tmpy.valueSelected { + background-color: $display-yellow; + color: $display-background; +} + +.mfd-input-field-text-input.editing { + font-size: 27px !important; +} + +.mfd-input-field-text-input.computedByFms { + font-size: 22px; +} + +.mfd-input-field-caret { + animation: blinking 1s step-start infinite; + height: 27px; + width: 18px; + background-color: $display-cyan; + color: $display-background; + font-family: "Ecam", monospace; + font-size: 27px; + text-align: center; + vertical-align: center; +} + +@keyframes blinking { + 50% { + background-color: $display-background; + color: $display-cyan; + } +} + +.mfd-input-field-unit { + align-self: center; +} + +.mfd-input-field-text-input-container { + display: flex; + flex: 1; + flex-direction: row; + align-self: center; + align-items: center; +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/InputField.tsx b/fbw-a32nx/src/systems/instruments/src/OANC/Components/InputField.tsx new file mode 100644 index 00000000000..1e0eeee5c62 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/InputField.tsx @@ -0,0 +1,501 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { + ComponentProps, + DisplayComponent, + FSComponent, + Subject, + Subscribable, + SubscribableUtils, + Subscription, + VNode, +} from '@microsoft/msfs-sdk'; + +import { DataEntryFormat } from './DataEntryFormats'; + +import './InputField.scss'; + +// eslint-disable-next-line max-len +export const emptyMandatoryCharacter = (selected: boolean) => + ``; + +interface InputFieldProps extends ComponentProps { + dataEntryFormat: DataEntryFormat; + mandatory?: Subscribable; // Renders empty values with orange rectangles + inactive?: Subscribable; // If inactive, will be rendered as static value (green text) + disabled?: Subscribable; // Whether value can be set (if disabled, rendered as input field but greyed out) + canBeCleared?: Subscribable; + enteredByPilot?: Subscribable; // Value will be displayed in smaller font, if not entered by pilot (i.e. computed) + canOverflow?: boolean; + value: Subject | Subscribable; + /** + * If defined, this component does not update the value prop, but rather calls this method. + */ + onModified?: (newValue: T) => void; + onInput?: (newValue: string) => void; // Called for every character that is being typed + /** + * Function which modifies data within flight plan. Called during validation phase, after data entry format has been checked + * @param newValue to be validated + * @returns whether validation was successful. If nothing is returned, success is assumed + */ + dataHandlerDuringValidation?: (newValue: T) => Promise; + handleFocusBlurExternally?: boolean; + containerStyle?: string; + alignText?: ('flex-start' | 'center' | 'flex-end') | Subscribable<'flex-start' | 'center' | 'flex-end'>; + tmpyActive?: Subscribable; +} + +/** + * Input field for text or numbers + */ +export class InputField extends DisplayComponent> { + // Make sure to collect all subscriptions here, otherwise page navigation doesn't work. + private subs = [] as Subscription[]; + + public topRef = FSComponent.createRef(); + + public containerRef = FSComponent.createRef(); + + private spanningDivRef = FSComponent.createRef(); + + public textInputRef = FSComponent.createRef(); + + private caretRef = FSComponent.createRef(); + + private leadingUnit = Subject.create(undefined); + + private trailingUnit = Subject.create(undefined); + + private leadingUnitRef = FSComponent.createRef(); + + private trailingUnitRef = FSComponent.createRef(); + + private modifiedFieldValue = Subject.create(null); + + private isFocused = Subject.create(false); + + private isValidating = Subject.create(false); + + private alignTextSub: Subscribable<'flex-start' | 'center' | 'flex-end'> = SubscribableUtils.toSubscribable( + this.props.alignText, + true, + ); + + private onNewValue() { + // Don't update if field is being edited + if (this.isFocused.get() === true || this.isValidating.get() === true) { + return; + } + + // Reset modifiedFieldValue + if (this.modifiedFieldValue.get() !== null) { + this.modifiedFieldValue.set(null); + } + if (this.props.value.get()) { + if (this.props.canOverflow === true) { + // If item was overflowed, check whether overflow is still needed + this.overflow(!(this.props.value.get().toString().length <= this.props.dataEntryFormat.maxDigits)); + } + + if (this.props.mandatory.get() === true) { + this.textInputRef.getOrDefault().classList.remove('mandatory'); + } + } + this.updateDisplayElement(); + } + + private updateDisplayElement() { + // If input was not modified, render props' value + if (this.modifiedFieldValue.get() === null) { + if (!this.props.value.get()) { + this.populatePlaceholders(); + } else { + const [formatted, leadingUnit, trailingUnit] = this.props.dataEntryFormat.format(this.props.value.get()); + this.textInputRef.getOrDefault().innerText = formatted; + this.leadingUnit.set(leadingUnit); + this.trailingUnit.set(trailingUnit); + } + } else { + // Else, render modifiedFieldValue + const numDigits = this.props.dataEntryFormat.maxDigits; + if ( + this.modifiedFieldValue.get().length < numDigits || + this.isFocused.get() === false || + this.props.canOverflow === true + ) { + this.textInputRef.getOrDefault().innerText = this.modifiedFieldValue.get(); + this.caretRef.getOrDefault().innerText = ''; + } else { + this.textInputRef.getOrDefault().innerText = this.modifiedFieldValue.get().slice(0, numDigits - 1); + this.caretRef.getOrDefault().innerText = this.modifiedFieldValue.get().slice(numDigits - 1, numDigits); + } + } + } + + // Called when the input field changes + private onInput() { + if ( + this.props.canOverflow === true && + this.modifiedFieldValue.get().length === this.props.dataEntryFormat.maxDigits + ) { + this.overflow(true); + } + + if (this.props.onInput) { + this.props.onInput(this.modifiedFieldValue.get()); + } + } + + public overflow(overflow: boolean) { + if (overflow === true) { + this.topRef.instance.style.position = 'relative'; + this.topRef.instance.style.top = '0px'; + this.containerRef.instance.style.position = 'absolute'; + this.containerRef.instance.style.top = '-18px'; + this.containerRef.instance.style.zIndex = '5'; + const remainingWidth = 768 - 50 - this.containerRef.instance.getBoundingClientRect().left; + this.containerRef.instance.style.width = `${remainingWidth}px`; // TODO extend to right edge + this.containerRef.instance.style.border = '1px solid grey'; + } else { + this.topRef.instance.style.position = null; + this.topRef.instance.style.top = null; + this.containerRef.instance.style.position = null; + this.containerRef.instance.style.top = null; + this.containerRef.instance.style.zIndex = null; + this.containerRef.instance.style.width = null; + this.containerRef.instance.style.border = null; + + if (this.props.containerStyle) { + this.containerRef.instance.setAttribute('style', this.props.containerStyle); + } + } + } + + private onKeyDown(ev: KeyboardEvent) { + if (ev.keyCode === KeyCode.KEY_BACK_SPACE) { + if (this.modifiedFieldValue.get() === null && this.props.canBeCleared.get() === true) { + this.modifiedFieldValue.set(''); + } else if (this.modifiedFieldValue.get().length === 0) { + // Do nothing + } else { + this.modifiedFieldValue.set(this.modifiedFieldValue.get().slice(0, -1)); + } + + this.onInput(); + } + } + + private onKeyPress(ev: KeyboardEvent) { + // Un-select the text + this.textInputRef.getOrDefault().classList.remove('valueSelected'); + // ev.key is undefined, so we have to use the deprecated keyCode here + const key = String.fromCharCode(ev.keyCode).toUpperCase(); + + if (ev.keyCode !== KeyCode.KEY_ENTER) { + if (this.modifiedFieldValue.get() === null) { + this.modifiedFieldValue.set(''); + this.spanningDivRef.getOrDefault().style.justifyContent = 'flex-start'; + } + + if ( + this.modifiedFieldValue.get()?.length < this.props.dataEntryFormat.maxDigits || + this.props.canOverflow === true + ) { + this.modifiedFieldValue.set(`${this.modifiedFieldValue.get()}${key}`); + this.caretRef.getOrDefault().style.display = 'inline'; + } + + this.onInput(); + } else { + // Enter was pressed + ev.preventDefault(); + + if (this.props.handleFocusBlurExternally === true) { + this.onBlur(true); + } else { + this.textInputRef.getOrDefault().blur(); + } + } + } + + public onFocus() { + if ( + this.isFocused.get() === false && + this.isValidating.get() === false && + this.props.disabled.get() === false && + this.props.inactive.get() === false + ) { + this.isFocused.set(true); + Coherent.trigger('FOCUS_INPUT_FIELD'); + this.textInputRef.getOrDefault().classList.add('valueSelected'); + this.textInputRef.getOrDefault().classList.add('editing'); + if (this.props.mandatory.get() === true) { + this.textInputRef.getOrDefault().classList.remove('mandatory'); + } + this.modifiedFieldValue.set(null); + this.spanningDivRef.getOrDefault().style.justifyContent = this.alignTextSub.get(); + this.updateDisplayElement(); + } + } + + public async onBlur(validateAndUpdate: boolean = true) { + if (this.props.disabled.get() === false && this.props.inactive.get() === false && this.isFocused.get() === true) { + this.isFocused.set(false); + Coherent.trigger('UNFOCUS_INPUT_FIELD'); + this.textInputRef.getOrDefault().classList.remove('valueSelected'); + this.caretRef.getOrDefault().style.display = 'none'; + this.updateDisplayElement(); + + if (validateAndUpdate) { + if (this.modifiedFieldValue.get() === null && this.props.value.get() !== null) { + console.log('Enter pressed after no modification'); + // Enter is pressed after no modification + const [formatted] = this.props.dataEntryFormat.format(this.props.value.get()); + await this.validateAndUpdate(formatted); + } else { + await this.validateAndUpdate(this.modifiedFieldValue.get()); + } + } + + // Restore mandatory class for correct coloring of dot (e.g. non-placeholders) + if (!this.props.value.get() && this.props.mandatory.get() === true) { + this.textInputRef.getOrDefault().classList.add('mandatory'); + } + + this.spanningDivRef.getOrDefault().style.justifyContent = this.alignTextSub.get(); + this.textInputRef.getOrDefault().classList.remove('editing'); + } + } + + private populatePlaceholders() { + const [formatted, unitLeading, unitTrailing] = this.props.dataEntryFormat.format(null); + this.leadingUnit.set(unitLeading); + this.trailingUnit.set(unitTrailing); + + if ( + this.props.mandatory.get() === true && + this.props.inactive.get() === false && + this.props.disabled.get() === false + ) { + this.textInputRef.getOrDefault().innerHTML = formatted.replace( + /-/gi, + emptyMandatoryCharacter(this.isFocused.get()), + ); + } else { + this.textInputRef.getOrDefault().innerText = formatted; + } + } + + private async validateAndUpdate(input: string) { + this.isValidating.set(true); + + const newValue = await this.props.dataEntryFormat.parse(input); + + let updateWasSuccessful = true; + const artificialWaitingTime = new Promise((resolve) => setTimeout(resolve, 500)); + if (this.props.dataHandlerDuringValidation) { + try { + const realWaitingTime = this.props.dataHandlerDuringValidation(newValue); + const [validation] = await Promise.all([realWaitingTime, artificialWaitingTime]); + + if (validation === false) { + updateWasSuccessful = false; + } + } catch { + updateWasSuccessful = false; + await artificialWaitingTime; + } + } else { + await artificialWaitingTime; + } + + if (updateWasSuccessful) { + if (this.props.onModified) { + this.props.onModified(newValue); + } else if (this.props.value instanceof Subject) { + this.props.value.set(newValue); + } else { + console.error('InputField: this.props.value not of type Subject, and no onModified handler was defined'); + } + } + + this.modifiedFieldValue.set(null); + this.isValidating.set(false); + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + // Optional props + if (this.props.mandatory === undefined) { + this.props.mandatory = Subject.create(false); + } + if (this.props.inactive === undefined) { + this.props.inactive = Subject.create(false); + } + if (this.props.disabled === undefined) { + this.props.disabled = Subject.create(false); + } + if (this.props.canBeCleared === undefined) { + this.props.canBeCleared = Subject.create(true); + } + if (this.props.enteredByPilot === undefined) { + this.props.enteredByPilot = Subject.create(true); + } + if (this.props.alignText === undefined) { + this.props.alignText = 'flex-end'; + } + if (this.props.handleFocusBlurExternally === undefined) { + this.props.handleFocusBlurExternally = false; + } + if (this.props.canOverflow === undefined) { + this.props.canOverflow = false; + } + if (this.props.tmpyActive === undefined) { + this.props.tmpyActive = Subject.create(false); + } + + // Aspect ratio for font: 2:3 WxH + this.spanningDivRef.instance.style.minWidth = `${Math.round((this.props.dataEntryFormat.maxDigits * (27.0 * 0.629)) / 1.5)}px`; + + // Hide caret + this.caretRef.instance.style.display = 'none'; + this.caretRef.instance.innerText = ''; + + this.subs.push(this.props.value.sub(() => this.onNewValue(), true)); + this.subs.push(this.modifiedFieldValue.sub(() => this.updateDisplayElement())); + this.subs.push( + this.isValidating.sub((val) => { + if (val === true) { + this.textInputRef.getOrDefault().classList.add('validating'); + } else { + this.textInputRef.getOrDefault().classList.remove('validating'); + } + }), + ); + + this.subs.push( + this.props.mandatory.sub((val) => { + if (val === true && !this.props.value.get()) { + this.textInputRef.getOrDefault().classList.add('mandatory'); + } else { + this.textInputRef.getOrDefault().classList.remove('mandatory'); + } + this.updateDisplayElement(); + }, true), + ); + + this.subs.push( + this.props.inactive.sub((val) => { + if (val === true) { + this.containerRef.getOrDefault().classList.add('inactive'); + this.textInputRef.getOrDefault().classList.add('inactive'); + + this.textInputRef.getOrDefault().tabIndex = null; + } else { + this.containerRef.getOrDefault().classList.remove('inactive'); + this.textInputRef.getOrDefault().classList.remove('inactive'); + + if (this.props.disabled.get() === false) { + this.textInputRef.getOrDefault().tabIndex = -1; + } + } + this.updateDisplayElement(); + }, true), + ); + + this.subs.push( + this.props.disabled.sub((val) => { + if (this.props.inactive.get() !== true) { + if (val === true) { + this.textInputRef.getOrDefault().tabIndex = null; + this.containerRef.getOrDefault().classList.add('disabled'); + this.textInputRef.getOrDefault().classList.add('disabled'); + + if (this.props.mandatory.get() === true && !this.props.value.get()) { + this.textInputRef.getOrDefault().classList.remove('mandatory'); + } + } else { + this.textInputRef.getOrDefault().tabIndex = -1; + this.containerRef.getOrDefault().classList.remove('disabled'); + this.textInputRef.getOrDefault().classList.remove('disabled'); + + if (this.props.mandatory.get() === true && !this.props.value.get()) { + this.textInputRef.getOrDefault().classList.add('mandatory'); + } + } + } + this.updateDisplayElement(); + }, true), + ); + + this.subs.push( + this.props.enteredByPilot.sub((val) => { + if (val === false) { + this.textInputRef.getOrDefault().classList.add('computedByFms'); + } else { + this.textInputRef.getOrDefault().classList.remove('computedByFms'); + } + }, true), + ); + + this.subs.push( + this.props.tmpyActive.sub((v) => { + if (v === true) { + this.textInputRef.instance.classList.add('tmpy'); + } else { + this.textInputRef.instance.classList.remove('tmpy'); + } + }, true), + ); + + if (this.props.dataEntryFormat.reFormatTrigger) { + this.subs.push(this.props.dataEntryFormat.reFormatTrigger.sub(() => this.updateDisplayElement())); + } + + this.textInputRef.instance.addEventListener('keypress', (ev) => this.onKeyPress(ev)); + this.textInputRef.instance.addEventListener('keydown', (ev) => this.onKeyDown(ev)); + + if (!this.props.handleFocusBlurExternally) { + this.textInputRef.instance.addEventListener('focus', () => this.onFocus()); + this.textInputRef.instance.addEventListener('blur', () => { + this.onBlur(); + }); + this.spanningDivRef.instance.addEventListener('click', () => { + this.textInputRef.instance.focus(); + }); + this.leadingUnitRef.instance.addEventListener('click', () => { + this.textInputRef.instance.focus(); + }); + this.trailingUnitRef.instance.addEventListener('click', () => { + this.textInputRef.instance.focus(); + }); + } + } + + render(): VNode { + return ( +
+
+ + {this.leadingUnit} + +
`justify-content: ${it};`)} + > + + . + + +
+ + {this.trailingUnit} + +
+
+ ); + } +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/RadioButtonGroup.scss b/fbw-a32nx/src/systems/instruments/src/OANC/Components/RadioButtonGroup.scss new file mode 100644 index 00000000000..a878ea370a9 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/RadioButtonGroup.scss @@ -0,0 +1,79 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +@import "../../Common/definitions"; + +.mfd-radio-button { + -webkit-appearance: none; /* WebKit */ + -moz-appearance: none; /* Mozilla */ + -o-appearance: none; /* Opera */ + -ms-appearance: none; /* Internet Explorer */ + appearance: none; /* CSS3 */ + font-size: 26px; + display: flex; + align-items: center; + border: 0.15em solid transparent; +} + +.mfd-radio-button + .mfd-radio-button { + margin-top: 3px; +} + +.mfd-radio-button input[type="radio"] { + font-size: inherit; + + background-color: $display-background; + width: 1.15em; + //width: 30px; + height: 1.15em; + //height: 30px; + border: 0.07em inset lightgray; + //border: 2px inset lightgray; + border-radius: 50%; + margin-right: 0.57em; + //margin-right: 15px; + display: grid; + place-content: center; +} + +.mfd-radio-button input[type="radio"]::after { + display: none; +} + + +.mfd-radio-button input[type="radio"]:checked { + background: $display-cyan; + box-shadow: inset 0 0 0 0.15em black; + //box-shadow: inset 0 0 0px 4px black; +} + +.mfd-radio-button.tmpy input[type="radio"]:checked { + background: $display-yellow; +} + +.mfd-radio-button input[type="radio"]:disabled { + background: $display-grey !important; +} + + +.mfd-radio-button input[type="radio"]:checked { + background: $display-cyan; + box-shadow: inset 0 0 0 0.15em black; + //box-shadow: inset 0 0 0px 4px black; +} + +.mfd-radio-button input[type="radio"]:checked ~ * { + color: $display-cyan; +} + +.mfd-radio-button.tmpy input[type="radio"]:checked ~ * { + color: $display-yellow; +} + +.mfd-radio-button input[type="radio"]:disabled ~ * { + color: $display-grey !important; +} + +.mfd-radio-button:hover { + border-color: $display-cyan; +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/Components/RadioButtonGroup.tsx b/fbw-a32nx/src/systems/instruments/src/OANC/Components/RadioButtonGroup.tsx new file mode 100644 index 00000000000..99f6a5047e8 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/Components/RadioButtonGroup.tsx @@ -0,0 +1,111 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { + ComponentProps, + DisplayComponent, + FSComponent, + Subject, + Subscribable, + Subscription, + VNode, +} from '@microsoft/msfs-sdk'; + +import './RadioButtonGroup.scss'; + +interface RadioButtonGroupProps extends ComponentProps { + values: string[]; + valuesDisabled?: Subscribable; + selectedIndex: Subject; + idPrefix: string; + onModified?: (newSelectedIndex: number) => void; + additionalVerticalSpacing?: number; + tmpyActive?: Subscribable; +} + +export class RadioButtonGroup extends DisplayComponent { + // Make sure to collect all subscriptions here, otherwise page navigation doesn't work. + private subs = [] as Subscription[]; + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + if (this.props.tmpyActive === undefined) { + this.props.tmpyActive = Subject.create(false); + } + if (this.props.valuesDisabled === undefined) { + this.props.valuesDisabled = Subject.create(this.props.values.map(() => false)); + } + + for (let i = 0; i < this.props.values.length; i++) { + document.getElementById(`${this.props.idPrefix}_${i}`).addEventListener('change', () => { + if (this.props.onModified) { + this.props.onModified(i); + } else { + this.props.selectedIndex.set(i); + } + }); + } + + this.subs.push( + this.props.selectedIndex.sub((val) => { + for (let i = 0; i < this.props.values.length; i++) { + if (i === val) { + document.getElementById(`${this.props.idPrefix}_${i}`).setAttribute('checked', 'checked'); + } else { + document.getElementById(`${this.props.idPrefix}_${i}`).removeAttribute('checked'); + } + } + }, true), + ); + + this.subs.push( + this.props.valuesDisabled.sub((val) => { + for (let i = 0; i < this.props.values.length; i++) { + if (val[i] === true) { + document.getElementById(`${this.props.idPrefix}_${i}`).setAttribute('disabled', 'disabled'); + } else { + document.getElementById(`${this.props.idPrefix}_${i}`).removeAttribute('disabled'); + } + } + }, true), + ); + + this.subs.push( + this.props.tmpyActive.sub((v) => { + this.props.values.forEach((val, idx) => { + if (v === true) { + document.getElementById(`${this.props.idPrefix}_label_${idx}`).classList.add('tmpy'); + } else { + document.getElementById(`${this.props.idPrefix}_label_${idx}`).classList.remove('tmpy'); + } + }); + }, true), + ); + } + + public destroy(): void { + // Destroy all subscriptions to remove all references to this instance. + this.subs.forEach((x) => x.destroy()); + + super.destroy(); + } + + render(): VNode { + return ( +
+ {this.props.values.map((el, idx) => ( + + ))} +
+ ); + } +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/config.json b/fbw-a32nx/src/systems/instruments/src/OANC/config.json new file mode 100644 index 00000000000..4e7d8c4b38a --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/config.json @@ -0,0 +1,8 @@ +{ + "index": "./instrument.tsx", + "isInteractive": true, + "extraDeps": [ + "fbw-common/src/systems/instruments/src/ND", + "fbw-common/src/systems/instruments/src/OANC" + ] +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/instrument.tsx b/fbw-a32nx/src/systems/instruments/src/OANC/instrument.tsx new file mode 100644 index 00000000000..47474249512 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/instrument.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { + EventBus, + FSComponent, + HEventPublisher, + InstrumentBackplane, + Subject, + SubscribableMapFunctions, + Wait, +} from '@microsoft/msfs-sdk'; +import { + A320EfisZoomRangeValue, + ContextMenuItemData, + OANC_RENDER_HEIGHT, + OANC_RENDER_WIDTH, + Oanc, + ZOOM_TRANSITION_TIME_MS, + a320EfisZoomRangeSettings, +} from '@flybywiresim/oanc'; +import { EfisSide } from '@flybywiresim/fbw-sdk'; +import { ContextMenu } from './Components/ContextMenu'; +import { getDisplayIndex } from '../MsfsAvionicsCommon/displayUnit'; +import { ControlPanel } from './Components/ControlPanel'; +import { FcuBusPublisher } from '../MsfsAvionicsCommon/providers/FcuBusPublisher'; + +import './styles.scss'; + +class A32NX_OANC extends BaseInstrument { + private readonly efisSide: EfisSide; + + private bus: EventBus; + + private readonly backplane = new InstrumentBackplane(); + + private readonly fcuBusPublisher: FcuBusPublisher; + + private readonly hEventPublisher: HEventPublisher; + + /** + * "mainmenu" = 0 + * "loading" = 1 + * "briefing" = 2 + * "ingame" = 3 + */ + private gameState = 0; + + private oancRef = FSComponent.createRef>(); + + public readonly controlPanelRef = FSComponent.createRef(); + + private readonly contextMenuItems: ContextMenuItemData[] = [ + { name: 'ADD CROSS', disabled: true }, + { name: 'ADD FLAG', disabled: true }, + { + name: 'MENU', + onPressed: () => { + this.controlPanelVisible.set(!this.controlPanelVisible.get()); + this.contextMenuVisible.set(false); + }, + }, + { name: 'ERASE ALL CROSSES', disabled: true }, + { name: 'ERASE ALL FLAGS', disabled: true }, + { + name: 'CENTER ON ACFT', + disabled: + this.oancRef.getOrDefault() !== null + ? this.oancRef.instance.aircraftWithinAirport.map(SubscribableMapFunctions.not()) + : true, + onPressed: async () => { + if (this.oancRef.getOrDefault() !== null) { + await this.oancRef.instance.enablePanningTransitions(); + this.oancRef.instance.panOffsetX.set(0); + this.oancRef.instance.panOffsetY.set(0); + await Wait.awaitDelay(ZOOM_TRANSITION_TIME_MS); + await this.oancRef.instance.disablePanningTransitions(); + } + }, + }, + ]; + + private readonly contextMenuVisible = Subject.create(false); + + private readonly contextMenuX = Subject.create(0); + + private readonly contextMenuY = Subject.create(0); + + private readonly controlPanelVisible = Subject.create(false); + + private readonly waitScreenRef = FSComponent.createRef(); + + constructor() { + super(); + this.efisSide = getDisplayIndex() === 1 ? 'L' : 'R'; + this.bus = new EventBus(); + this.fcuBusPublisher = new FcuBusPublisher(this.bus, 'L'); + this.hEventPublisher = new HEventPublisher(this.bus); + } + + get templateID(): string { + return 'A32NX_OANC'; + } + + get isInteractive(): boolean { + return true; + } + + public onInteractionEvent(args: string[]): void { + this.hEventPublisher.dispatchHEvent(args[0]); + } + + public connectedCallback(): void { + super.connectedCallback(); + + this.backplane.addPublisher('fcu', this.fcuBusPublisher); + this.backplane.addPublisher('hEvent', this.hEventPublisher); + + this.backplane.init(); + + FSComponent.render( +
+
+ PLEASE WAIT +
+ + this.oancRef.instance.loadAirportMap(icao)} + closePanel={() => this.controlPanelVisible.set(false)} + onZoomIn={() => this.oancRef.instance.handleZoomIn()} + onZoomOut={() => this.oancRef.instance.handleZoomOut()} + /> + this.contextMenuVisible.set(false)} + /> +
, + document.getElementById('OANC_CONTENT'), + ); + + // Remove "instrument didn't load" text + document.getElementById('OANC_CONTENT').querySelector(':scope > h1').remove(); + } + + public Update(): void { + super.Update(); + + if (this.gameState !== 3) { + const gamestate = this.getGameState(); + if (gamestate === 3) { + this.backplane.onUpdate(); + } + this.gameState = gamestate; + } else { + this.backplane.onUpdate(); + } + + if (this.oancRef.getOrDefault()) { + this.oancRef.instance.Update(); + } + } +} + +registerInstrument('a32nx-oanc', A32NX_OANC); diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/styles.scss b/fbw-a32nx/src/systems/instruments/src/OANC/styles.scss new file mode 100644 index 00000000000..b45fb8c1e95 --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/styles.scss @@ -0,0 +1,408 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +@import "../MsfsAvionicsCommon/definitions"; + +@font-face { + font-family: "Ecam"; + /* noinspection CssUnknownTarget */ + src: url("/Fonts/fbw-a32nx/ECAMFontRegular.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} + +.oanc-container { + width: 768px; + height: 768px; + + background-color: $display-background; + + font-family: "Ecam", monospace !important; +} + +.oanc-waiting-screen { + position: absolute; + + width: 768px; + height: 768px; + + display: flex; + justify-content: center; + align-items: center; + + font-size: 24px; + + background-color: $display-background; + color: white; + + z-index: 9999; +} + +.oanc-label { + position: absolute; + + height: 17px; + + padding-left: 1px; + + font-family: "Ecam", monospace !important; + font-size: 19px; + + background-color: black; + color: white; + + white-space: pre; + + pointer-events: auto; +} + +.oanc-label:hover { + outline: 3px solid $display-magenta; + pointer-events: auto; + outline-offset: 4px; +} + +.oanc-label-style-taxiway { + color: $display-yellow; +} + +.oanc-label-style-terminal-building { + color: $display-cyan; +} + +.oanc-label-style-runway-end { + display: flex; + justify-content: center; + padding-top: 13px; + + width: 70px; + height: 40px; + + font-size: 32px; + + background-color: transparent; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAG8AAAA/CAYAAADuS5aXAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAHzSURBVHgB7ZrRbYNAEERPqcAluIR0QDpIOrA7IB04HbgESkgJlOASKIESJofYyA4Jx4EwvrnMk/YDycIWT7O+Xdu5DAHw4uvs6+Lr4GvnRLp0gnyVvmr8pvVVdVKdSAdLWWWCYmgsjXsntsdS9mFtcYzapDaB11RK40ZYyuqAjC59Jwy+4/z10dcnwmkslcaVuUlZO5GyIuJee5PbBO7VSX5zYhkmbCplDf5I2Yz3KNC3zdD9K6UxEv+gnidS1loyCrcSlsYjwmmsfR2c+AnCR/zbh1fizvOa0hgJroN0KGXnNVM247PtLI2h0+z/WgCklLJY0LfyCuNtNe8FAKYH6e8j/uYpm4OlscY4DXJYACB+kH5NJWWxoD/kVMhtAYCFgzQrYF8AYMVBmhUwLQCwwSDNClIdOfCAQZoVpLAAAOERPzU2TyMSHqRZwT0XAErZdmCtBQAyGaRZwZIFgL94R4aDNCuIXwDsHMb/sHOSsMeC8AKgGMq7QG0xOXBdALQhefrBMWEGrbR4coIWySNG8oiRPGIkjxjJI0byiJE8YiSPGMkjRvKIkTxiJI8YySNG8oiRPGIkjxjJI0byiJE8YiSPGMkjRvKIkTxiJI8YySNG8oiRPGIkjxjJI0byiJE8YiSPGMkjRvKIkTxiJI8YySPmC1pEKU9OGEigAAAAAElFTkSuQmCC'); + background-size: cover; +} + +.oanc-label-style-runway-axis { + color: $display-white; +} + +.oanc-button { + padding: 10px 6px; + background-color: gray; + color: white; + border: 3px solid white; + display: flex; + justify-content: center; + align-items: center; + pointer-events: auto; +} + +.oanc-top-mask { + width: 100%; + height: 60px; + background-color: $display-background; +} + +.oanc-bottom-mask { + display: flex; + justify-content: center; + align-items: center; + + position: absolute; + bottom: 0; + + width: 100%; + height: 32px; + background-color: $display-background; +} + +.oanc-position { + padding: 1px; + padding-left: 2px; + border: solid 1.5px $display-yellow; + color: $display-yellow; + font-size: 24px; +} + +.oanc-speed-info { + position: absolute; + top: 0; + left: 5px; + + font-size: 24px; + white-space: pre; +} + +#oanc-speed-info-1 { + padding-right: 35px; + + font-size: 20px; + vertical-align: bottom; +} + +#oanc-speed-info-2 { + padding-right: 7px; + color: $display-green; +} + +#oanc-speed-info-3 { + padding-right: 5px; + + font-size: 20px; + vertical-align: bottom; +} + +#oanc-speed-info-4 { + width: 40px; + padding-right: 10px; + color: $display-green; +} + +.oanc-wind-info { + position: absolute; + top: 27px; + left: 5px; + + font-size: 24px; + white-space: pre; +} + +#oanc-wind-info-1 { + color: $display-green; +} + +#oanc-wind-info-2 { + font-size: 20px; +} + +#oanc-wind-info-3 { + color: $display-green; +} + +.oanc-airport-info { + position: absolute; + right: 0; + color: $display-white; + background-color: $display-background; + + font-size: 20px; + white-space: pre; +} + +#oanc-airport-info-line1 { + top: 5px; +} + +#oanc-airport-info-line2 { + top: 31px; +} + +.oanc-airport-not-in-active-fpln { + position: absolute; + right: 310px; + color: $display-white; + background-color: $display-background; + text-align: center; + font-size: 20px; +} + +.oanc-airplane-shadow { + stroke: $display-background; + stroke-width: 10px; + fill: none; +} + +.oanc-airplane { + stroke: $display-magenta; + stroke-width: 7.75px; + fill: none; +} + +.oanc-svg { + font-family: "Ecam", monospace !important; + + //z-index: 9; +} + +.Magenta { + fill: none; + stroke: $display-magenta; +} + +.Magenta text, +text.Magenta { + fill: $display-magenta; + stroke: none; +} + +.Cyan { + fill: none; + stroke: $display-cyan; +} + +.Cyan text, +text.Cyan, +.Cyan tspan, +tspan.Cyan { + fill: $display-cyan; + stroke: none; +} + +tspan.Cyan { + fill: $display-cyan; + stroke: none; +} + +.White { + fill: none; + stroke: $display-white; +} + +.White.Fill { + fill: $display-white; + stroke: none; +} + +.White text, +text.White { + fill: $display-white; + stroke: none; +} + +.Green { + stroke: $display-green; + color: $display-green; + fill: none; +} + +.Green.Fill { + fill: $display-green; + stroke: none; +} + +.Green text, +text.Green, +.Green tspan, +tspan.Green { + fill: $display-green; + stroke: none; +} + +.Amber { + stroke: $display-amber; + fill: none; +} + +.Amber text, +text.Amber { + fill: $display-amber; + stroke: none; +} + +.Yellow { + stroke: $display-yellow; + fill: none; +} + +.Yellow.Fill { + fill: $display-yellow; + stroke: none; +} + +.Yellow text, +text.Yellow { + fill: $display-yellow; + stroke: none; +} + +.Red { + stroke: $display-red; + fill: none; +} + +.Red.Fill { + fill: $display-red; + stroke: none; +} + +.Red text, +text.Red { + fill: $display-red; + stroke: none; +} + +.Grey.Fill { + fill: $display-grey; + stroke: none; +} + +.BackgroundFill { + fill: $display-background; +} + +path.rounded { + stroke-linecap: round; + stroke-linejoin: round; +} + +.shadow { + stroke: $display-background; + fill: none; + stroke-width: 3.5px; +} +.shadow text { + stroke-width: 1.5px; +} + +text.shadow { + stroke: $display-background; + stroke-width: 1px; + paint-order: stroke; +} + + +$font-factor: 5; + +.FontLargest { + font-size: #{7px * $font-factor}; +} + +.FontLarge { + font-size: #{6.5px * $font-factor}; +} + +.FontMedium { + font-size: #{6px * $font-factor}; +} + +.FontIntermediate { + font-size: #{5.5px * $font-factor}; +} + +.FontSmall { + font-size: #{5px * $font-factor}; +} + +.FontSmallest { + font-size: #{4.5px * $font-factor}; +} + +.FontTiny { + font-size: #{4px * $font-factor}; +} + +.StartAlign { + text-align: start; + text-anchor: start; +} +.MiddleAlign { + text-align: center; + text-anchor: middle; +} +.EndAlign { + text-align: end; + text-anchor: end; +} diff --git a/fbw-a32nx/src/systems/instruments/src/OANC/tsconfig.json b/fbw-a32nx/src/systems/instruments/src/OANC/tsconfig.json new file mode 100644 index 00000000000..2874005b62d --- /dev/null +++ b/fbw-a32nx/src/systems/instruments/src/OANC/tsconfig.json @@ -0,0 +1,36 @@ +{ + "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 */ + "paths": { + "@datalink/aoc": ["../../../fbw-common/src/systems/datalink/aoc/src/index.ts"], + "@datalink/atc": ["../../../fbw-common/src/systems/datalink/atc/src/index.ts"], + "@datalink/common": ["../../../fbw-common/src/systems/datalink/common/src/index.ts"], + "@datalink/router": ["../../../fbw-common/src/systems/datalink/router/src/index.ts"], + "@failures": ["./failures/src/index.ts"], + "@fmgc/*": ["./fmgc/src/*"], + "@instruments/common/*": ["./instruments/src/Common/*"], + "@localization/*": ["../localization/*"], + "@sentry/*": ["./sentry-client/src/*"], + "@simbridge/*": ["./simbridge-client/src/*"], + "@shared/*": ["./shared/src/*"], + "@tcas/*": ["./tcas/src/*"], + "@typings/*": ["../../../fbw-common/src/typings/*"], + "@flybywiresim/fbw-sdk": ["../../../fbw-common/src/systems/index-no-react.ts"], + "@flybywiresim/navigation-display": ["../../../fbw-common/src/systems/instruments/src/ND/index.ts"], + "@flybywiresim/oanc": ["../../../fbw-common/src/systems/instruments/src/OANC/index.ts"], + "@flybywiresim/msfs-avionics-common": ["../../../fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/index.ts"] + } + } +} diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/Climb.flt b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/Climb.flt index 98a5dc9dd7c..d46f3dc0152 100644 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/Climb.flt +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/Climb.flt @@ -233,10 +233,12 @@ A32NX_EFIS_L_NAVAID_1_MODE=2 A32NX_EFIS_L_NAVAID_2_MODE=2 A32NX_EFIS_L_ND_MODE=3 A32NX_EFIS_L_ND_RANGE=2 +A32NX_EFIS_L_OANS_RANGE=4 A32NX_EFIS_R_NAVAID_1_MODE=2 A32NX_EFIS_R_NAVAID_2_MODE=2 A32NX_EFIS_R_ND_MODE=3 A32NX_EFIS_R_ND_RANGE=2 +A32NX_EFIS_R_OANS_RANGE=4 A32NX_FAC_1_PUSHBUTTON_PRESSED=1 A32NX_FAC_2_PUSHBUTTON_PRESSED=1 A32NX_GEAR_LEVER_POSITION_REQUEST = 0 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/apron.FLT b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/apron.FLT index 813b8a14f32..1e4039a8590 100644 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/apron.FLT +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/apron.FLT @@ -253,10 +253,12 @@ A32NX_EFIS_L_NAVAID_1_MODE=0 A32NX_EFIS_L_NAVAID_2_MODE=0 A32NX_EFIS_L_ND_MODE=3 A32NX_EFIS_L_ND_RANGE=1 +A32NX_EFIS_L_OANS_RANGE=4 A32NX_EFIS_R_NAVAID_1_MODE=0 A32NX_EFIS_R_NAVAID_2_MODE=0 A32NX_EFIS_R_ND_MODE=3 A32NX_EFIS_R_ND_RANGE=1 +A32NX_EFIS_R_OANS_RANGE=4 A32NX_EIS_DMC_SWITCHING_KNOB=1 A32NX_ELEC_IDG1LOCK_TOGGLE=0 A32NX_ELEC_IDG2LOCK_TOGGLE=0 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/cruise.FLT b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/cruise.FLT index 9d6cf3a4058..93de9067ff4 100644 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/cruise.FLT +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/cruise.FLT @@ -262,10 +262,12 @@ A32NX_EFIS_L_NAVAID_1_MODE=2 A32NX_EFIS_L_NAVAID_2_MODE=2 A32NX_EFIS_L_ND_MODE=3 A32NX_EFIS_L_ND_RANGE=5 +A32NX_EFIS_L_OANS_RANGE=4 A32NX_EFIS_R_NAVAID_1_MODE=2 A32NX_EFIS_R_NAVAID_2_MODE=2 A32NX_EFIS_R_ND_MODE=3 A32NX_EFIS_R_ND_RANGE=5 +A32NX_EFIS_R_OANS_RANGE=4 A32NX_EIS_DMC_SWITCHING_KNOB=1 A32NX_ELEC_IDG1LOCK_TOGGLE=0 A32NX_ELEC_IDG2LOCK_TOGGLE=0 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/final.FLT b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/final.FLT index 5e3b023ac43..17ddf0d7b18 100644 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/final.FLT +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/final.FLT @@ -261,10 +261,12 @@ A32NX_EFIS_L_NAVAID_1_MODE=2 A32NX_EFIS_L_NAVAID_2_MODE=2 A32NX_EFIS_L_ND_MODE=3 A32NX_EFIS_L_ND_RANGE=1 +A32NX_EFIS_L_OANS_RANGE=4 A32NX_EFIS_R_NAVAID_1_MODE=2 A32NX_EFIS_R_NAVAID_2_MODE=2 A32NX_EFIS_R_ND_MODE=3 A32NX_EFIS_R_ND_RANGE=1 +A32NX_EFIS_R_OANS_RANGE=4 A32NX_EIS_DMC_SWITCHING_KNOB=1 A32NX_ELEC_IDG1LOCK_TOGGLE=0 A32NX_ELEC_IDG2LOCK_TOGGLE=0 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/model/behaviour/legacy/AirlinerCommon.xml b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/model/behaviour/legacy/AirlinerCommon.xml index 4288445b875..3b9dbb7be10 100644 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/model/behaviour/legacy/AirlinerCommon.xml +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/model/behaviour/legacy/AirlinerCommon.xml @@ -50,81 +50,61 @@ AIRLINER_Knob_Autopilot_ND_#ID# AIRLINER_Knob_Autopilot_ND_#ID# AIRLINER_Knob_Autopilot_ND - AIRLINER_Knob_Autopilot_ND_#ID#_Push - AIRLINER_Knob_Autopilot_ND_#ID#_Push turnknob - autopilot_knob_push_button_on - 0.1 - autopilot_knob_push_button_off - 0.5 - - - 5 - L - A320_Neo_MFD_NAV_MODE - 0 - .25 - .5 - .75 - 1 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_ROSE_LS - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_ROSE_VOR - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_ROSE_NAV - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_ARC - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_PLAN - 0 - 1 - - - 4 - L - B747_8_MFD_NAV_MODE - - (>H:B747_8_MFD_KNOB_AUTOPILOT_CTR) - - 0 - .33 - .66 - 1 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_APP - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_VOR - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_MAP - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_PLN - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_MODE_SELECTOR_CTR_BTN - - - + TT:COCKPIT.TOOLTIPS.EFIS_CP_ND_MODE - - Horizontal - Curved - #KNOB_POSITION_TYPE# - #KNOB_POSITION_VAR# + + turnknob + 20 + + (L:#KNOB_POSITION_VAR#) 3 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) 7 < and if{ + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) 0 > if{ + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) ++ (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) + } + } + + (L:#KNOB_POSITION_VAR#) 4 < if{ + (L:#KNOB_POSITION_VAR#) ++ (>L:#KNOB_POSITION_VAR#) + } + + (L:#KNOB_POSITION_VAR#) 3 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) 1 > and if{ + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) -- (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) + } + + (L:#KNOB_POSITION_VAR#) 2 < if{ + 4 (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) 0 == if{ + 1 (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) + } + } + + + (L:#KNOB_POSITION_VAR#) 3 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) 7 < and if{ + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) 0 > if{ + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) ++ (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) + } + } + + (L:#KNOB_POSITION_VAR#) 0 > if{ + (L:#KNOB_POSITION_VAR#) -- (>L:#KNOB_POSITION_VAR#) + } + + (L:#KNOB_POSITION_VAR#) 3 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) 1 > and if{ + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) -- (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) + } + + (L:#KNOB_POSITION_VAR#) 2 < if{ + 4 (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) 0 == if{ + 1 (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_RANGE) + } + } + - - - - - - #BUTTON_ANIM_NAME# - - - #VALUE_TOOLTIP_PUSH# - - - - - - - - - - - @@ -143,53 +123,58 @@ AIRLINER_Knob_Autopilot_ND_Range_#ID# AIRLINER_Knob_Autopilot_ND_Range_#ID# AIRLINER_Knob_Autopilot_ND_Range - AIRLINER_Knob_Autopilot_ND_Range_#ID#_Push - AIRLINER_Knob_Autopilot_ND_Range_#ID#_Push turnknob - - - 8 - L - A320_Neo_MFD_Range - 0 - .125 - .250 - .375 - .5 - .625 - .750 - .875 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_RANGE_SELECTOR_SET_ZOOM - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_RANGE_SELECTOR_SET_10 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_RANGE_SELECTOR_SET_20 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_RANGE_SELECTOR_SET_40 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_RANGE_SELECTOR_SET_80 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_RANGE_SELECTOR_SET_160 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_RANGE_SELECTOR_SET_320 - TT:COCKPIT.TOOLTIPS.EFIS_PANEL_RANGE_SELECTOR_SET_640 - 0 - 1 - - + TT:COCKPIT.TOOLTIPS.EFIS_CP_ND_RANGE - - - - #KNOB_NUM_STATE# - Horizontal - Curved - #KNOB_POSITION_TYPE# - #KNOB_POSITION_VAR# - - - - - - - + + turnknob + 20 + + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_MODE) 0 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_MODE) 1 == or (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) 4 != and if{ + 4 (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) + } + + (L:#KNOB_POSITION_VAR#) 7 < (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) 4 == and if{ + (L:#KNOB_POSITION_VAR#) ++ (>L:#KNOB_POSITION_VAR#) + } + + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) 4 < (L:#KNOB_POSITION_VAR#) 0 == if{ + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) ++ (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) + } + + (L:#KNOB_POSITION_VAR#) 0 > (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) 4 != and if{ + 4 (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) + } + + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_MODE) 0 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_MODE) 1 == or (L:#KNOB_POSITION_VAR#) 0 == and if{ + 1 (>L:#KNOB_POSITION_VAR#) + } + + + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_MODE) 0 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_MODE) 1 == or (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) 4 != and if{ + 4 (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) + } + + (L:#KNOB_POSITION_VAR#) 0 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) 0 > and if{ + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) -- (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) + } + + (L:#KNOB_POSITION_VAR#) 0 > if{ + (L:#KNOB_POSITION_VAR#) -- (>L:#KNOB_POSITION_VAR#) + } + + (L:#KNOB_POSITION_VAR#) 0 > (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) 4 != and if{ + 4 (>L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_OANS_RANGE) + } + + (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_MODE) 0 == (L:A32NX_EFIS_#SIDE_SIMVAR_GROUP#_ND_MODE) 1 == or (L:#KNOB_POSITION_VAR#) 0 == and if{ + 1 (>L:#KNOB_POSITION_VAR#) + } + + diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/runway.FLT b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/runway.FLT index ce3ddf61070..948d159165a 100644 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/runway.FLT +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/runway.FLT @@ -269,10 +269,12 @@ A32NX_EFIS_L_NAVAID_1_MODE=2 A32NX_EFIS_L_NAVAID_2_MODE=2 A32NX_EFIS_L_ND_MODE=3 A32NX_EFIS_L_ND_RANGE=1 +A32NX_EFIS_L_OANS_RANGE=4 A32NX_EFIS_R_NAVAID_1_MODE=2 A32NX_EFIS_R_NAVAID_2_MODE=2 A32NX_EFIS_R_ND_MODE=3 A32NX_EFIS_R_ND_RANGE=1 +A32NX_EFIS_R_OANS_RANGE=4 A32NX_EIS_DMC_SWITCHING_KNOB=1 A32NX_ELEC_IDG1LOCK_TOGGLE=0 A32NX_ELEC_IDG2LOCK_TOGGLE=0 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/taxi.flt b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/taxi.flt index 50ac18c247d..38fdc0514e7 100644 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/taxi.flt +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/taxi.flt @@ -260,10 +260,12 @@ A32NX_EFIS_L_NAVAID_1_MODE=2 A32NX_EFIS_L_NAVAID_2_MODE=2 A32NX_EFIS_L_ND_MODE=3 A32NX_EFIS_L_ND_RANGE=1 +A32NX_EFIS_L_OANS_RANGE=4 A32NX_EFIS_R_NAVAID_1_MODE=2 A32NX_EFIS_R_NAVAID_2_MODE=2 A32NX_EFIS_R_ND_MODE=3 A32NX_EFIS_R_ND_RANGE=1 +A32NX_EFIS_R_OANS_RANGE=4 A32NX_EIS_DMC_SWITCHING_KNOB=1 A32NX_ELEC_IDG1LOCK_TOGGLE=0 A32NX_ELEC_IDG1_TOGGLE=1 diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-EIS-A380.ttf b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-EIS-A380.ttf index dd7120335b8..9ae6c7ef0e0 100644 Binary files a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-EIS-A380.ttf and b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Fonts/FBW-Display-EIS-A380.ttf differ 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 index a9f623d81b9..b192c10799a 100644 --- 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 @@ -114,7 +114,7 @@ - + diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/legacy/A32NX_NavSystem.js b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/legacy/A32NX_NavSystem.js new file mode 100644 index 00000000000..b97f65405a6 --- /dev/null +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Pages/VCockpit/Instruments/LegacyA380X/FCU/legacy/A32NX_NavSystem.js @@ -0,0 +1,2927 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +class NavSystem extends BaseInstrument { + constructor() { + super(...arguments); + this.soundSourceNode = "AS1000_MFD"; + this.eventAliases = []; + this.IndependentsElements = []; + this.pageGroups = []; + this.eventLinkedPageGroups = []; + this.currentEventLinkedPageGroup = null; + this.overridePage = null; + this.eventLinkedPopUpElements = []; + this.popUpElement = null; + this.popUpCloseCallback = null; + this.currentPageGroupIndex = 0; + this.currentInteractionState = 0; + this.cursorIndex = 0; + this.currentSelectableArray = []; + this.currentContextualMenu = null; + this.currentSearchFieldWaypoint = null; + this.contextualMenuDisplayBeginIndex = 0; + this.menuMaxElems = 6; + this.useUpdateBudget = true; + this.maxUpdateBudget = 6; + this.budgetedItemId = 0; + this.aspectRatioElement = null; + this.forcedAspectRatioSet = false; + this.forcedAspectRatio = 1; + this.forcedScreenRatio = 1; + this.initDuration = 0; + this.hasBeenOff = false; + this.needValidationAfterInit = false; + this.isStarted = false; + this.initAcknowledged = true; + this.reversionaryMode = false; + this.alwaysUpdateList = new Array(); + this.accumulatedDeltaTime = 0; + } + /** @type {FlightPlanManager} */ + get flightPlanManager() { + return this.currFlightPlanManager; + } + get instrumentAlias() { + return null; + } + connectedCallback() { + super.connectedCallback(); + this.contextualMenu = this.getChildById("ContextualMenu"); + this.contextualMenuTitle = this.getChildById("ContextualMenuTitle"); + this.contextualMenuElements = this.getChildById("ContextualMenuElements"); + this.menuSlider = this.getChildById("SliderMenu"); + this.menuSliderCursor = this.getChildById("SliderMenuCursor"); + this.currFlightPlanManager = null; + this.currFlightPlan = null; + this.currFlightPhaseManager = null; + } + get flightPhaseManager() { + return this.currFlightPhaseManager; + } + disconnectedCallback() { + super.disconnectedCallback(); + } + parseXMLConfig() { + super.parseXMLConfig(); + const soundSourceNodeElem = this.xmlConfig.getElementsByTagName("SoundSourceNode"); + if (soundSourceNodeElem.length > 0) { + this.soundSourceNode = soundSourceNodeElem[0].textContent; + } + if (this.instrumentXmlConfig) { + const skipValidationAfterInitElem = this.instrumentXmlConfig.getElementsByTagName("SkipValidationAfterInit"); + if (skipValidationAfterInitElem.length > 0 && this.needValidationAfterInit) { + this.needValidationAfterInit = skipValidationAfterInitElem[0].textContent != "True"; + } + const styleNode = this.instrumentXmlConfig.getElementsByTagName("Style"); + if (styleNode.length > 0) { + this.electricity.setAttribute("displaystyle", styleNode[0].textContent); + } + } + } + computeEvent(_event) { + if (this.isBootProcedureComplete()) { + for (let i = 0; i < this.eventLinkedPageGroups.length; i++) { + if (_event == this.eventLinkedPageGroups[i].popUpEvent) { + this.lastRelevantICAO = null; + this.lastRelevantICAOType = null; + if (this.overridePage) { + this.closeOverridePage(); + } + if (this.eventLinkedPageGroups[i] == this.currentEventLinkedPageGroup) { + this.exitEventLinkedPageGroup(); + } else { + const currentGroup = this.getCurrentPageGroup(); + if (currentGroup) { + currentGroup.onExit(); + } + this.currentEventLinkedPageGroup = this.eventLinkedPageGroups[i]; + this.currentEventLinkedPageGroup.pageGroup.onEnter(); + } + this.SwitchToInteractionState(0); + } + } + for (let i = 0; i < this.eventLinkedPopUpElements.length; i++) { + if (_event == this.eventLinkedPopUpElements[i].popUpEvent) { + if (this.popUpElement == this.eventLinkedPopUpElements[i]) { + this.popUpElement.onExit(); + this.popUpElement = null; + } else { + this.switchToPopUpPage(this.eventLinkedPopUpElements[i]); + } + } + } + for (let i = 0; i < this.IndependentsElements.length; i++) { + this.IndependentsElements[i].onEvent(_event); + } + if (this.popUpElement) { + this.popUpElement.onEvent(_event); + } + const currentPage = this.getCurrentPage(); + if (currentPage) { + currentPage.onEvent(_event); + } + switch (this.currentInteractionState) { + case 1: + if (this.currentSelectableArray[this.cursorIndex].SendEvent(_event)) { + break; + } + if (_event == "NavigationPush") { + this.SwitchToInteractionState(0); + } + if (_event == "NavigationLargeInc") { + this.cursorIndex = (this.cursorIndex + 1) % this.currentSelectableArray.length; + while (!this.currentSelectableArray[this.cursorIndex].onSelection(_event)) { + this.cursorIndex = (this.cursorIndex + 1) % this.currentSelectableArray.length; + } + } + if (_event == "NavigationLargeDec") { + this.cursorIndex = (this.cursorIndex - 1) < 0 ? (this.currentSelectableArray.length - 1) : (this.cursorIndex - 1); + while (!this.currentSelectableArray[this.cursorIndex].onSelection(_event)) { + this.cursorIndex = (this.cursorIndex - 1) < 0 ? (this.currentSelectableArray.length - 1) : (this.cursorIndex - 1); + } + } + if (_event == "MENU_Push") { + var defaultMenu; + if (this.popUpElement) { + defaultMenu = this.popUpElement.getDefaultMenu(); + } + if (!defaultMenu) { + var defaultMenu = this.getCurrentPage().defaultMenu; + } + if (defaultMenu != null) { + this.ShowContextualMenu(defaultMenu); + } + } + break; + case 2: + if (_event == "NavigationSmallInc") { + let count = 0; + do { + this.cursorIndex = (this.cursorIndex + 1) % this.currentContextualMenu.elements.length; + if (this.cursorIndex > (this.contextualMenuDisplayBeginIndex + 5)) { + this.contextualMenuDisplayBeginIndex++; + } + if (this.cursorIndex < (this.contextualMenuDisplayBeginIndex)) { + this.contextualMenuDisplayBeginIndex = 0; + } + count++; + } while (this.currentContextualMenu.elements[this.cursorIndex].isInactive() == true && count < this.currentContextualMenu.elements.length); + } + if (_event == "NavigationSmallDec") { + const count = 0; + do { + this.cursorIndex = (this.cursorIndex - 1) < 0 ? (this.currentContextualMenu.elements.length - 1) : (this.cursorIndex - 1); + if (this.cursorIndex < (this.contextualMenuDisplayBeginIndex)) { + this.contextualMenuDisplayBeginIndex--; + } + if (this.cursorIndex > (this.contextualMenuDisplayBeginIndex + 5)) { + this.contextualMenuDisplayBeginIndex = this.currentContextualMenu.elements.length - 5; + } + } while (this.currentContextualMenu.elements[this.cursorIndex].isInactive() == true && count < this.currentContextualMenu.elements.length); + } + if (_event == "MENU_Push") { + this.SwitchToInteractionState(0); + } + if (_event == "ENT_Push") { + this.currentContextualMenu.elements[this.cursorIndex].SendEvent(); + } + break; + case 3: + this.currentSearchFieldWaypoint.onInteractionEvent([_event]); + break; + case 0: + if (_event == "MENU_Push") { + var defaultMenu; + if (this.popUpElement) { + defaultMenu = this.popUpElement.getDefaultMenu(); + } + if (!defaultMenu) { + var defaultMenu = this.getCurrentPage().defaultMenu; + } + if (defaultMenu != null) { + this.ShowContextualMenu(defaultMenu); + } + } + if (_event == "NavigationSmallInc") { + this.lastRelevantICAO = null; + this.lastRelevantICAOType = null; + this.getCurrentPageGroup().nextPage(); + } + if (_event == "NavigationSmallDec") { + this.lastRelevantICAO = null; + this.lastRelevantICAOType = null; + this.getCurrentPageGroup().prevPage(); + } + if (_event == "NavigationLargeInc") { + this.lastRelevantICAO = null; + this.lastRelevantICAOType = null; + if (this.pageGroups.length > 1 && !this.currentEventLinkedPageGroup) { + this.pageGroups[this.currentPageGroupIndex].onExit(); + this.currentPageGroupIndex = (this.currentPageGroupIndex + 1) % this.pageGroups.length; + this.pageGroups[this.currentPageGroupIndex].onEnter(); + } + } + if (_event == "NavigationLargeDec") { + this.lastRelevantICAO = null; + this.lastRelevantICAOType = null; + if (this.pageGroups.length > 1 && !this.currentEventLinkedPageGroup) { + this.pageGroups[this.currentPageGroupIndex].onExit(); + this.currentPageGroupIndex = (this.currentPageGroupIndex + this.pageGroups.length - 1) % this.pageGroups.length; + this.pageGroups[this.currentPageGroupIndex].onEnter(); + } + } + if (_event == "NavigationPush") { + let defaultSelectableArray = this.getCurrentPage().element.getDefaultSelectables(); + if (this.popUpElement) { + defaultSelectableArray = this.popUpElement.element.getDefaultSelectables(); + } + if (defaultSelectableArray != null && defaultSelectableArray.length > 0) { + this.ActiveSelection(defaultSelectableArray); + } + } + break; + } + switch (_event) { + case "ActiveFPL_Modified": + this.currFlightPlan.FillWithCurrentFP(); + } + } + this.onEvent(_event); + } + exitEventLinkedPageGroup() { + this.currentEventLinkedPageGroup.pageGroup.onExit(); + this.currentEventLinkedPageGroup = null; + const currentGroup = this.getCurrentPageGroup(); + if (currentGroup) { + currentGroup.onEnter(); + } + } + DecomposeEventFromPrefix(_args) { + let search = this.instrumentIdentifier + "_"; + if (_args[0].startsWith(search)) { + return _args[0].slice(search.length); + } + search = this.instrumentAlias; + if (search != null && search != "") { + if (this.urlConfig.index) { + search += "_" + this.urlConfig.index; + } + search += "_"; + if (_args[0].startsWith(search)) { + return _args[0].slice(search.length); + } + } + search = this.templateID + "_"; + if (_args[0].startsWith(search)) { + const evt = _args[0].slice(search.length); + const separator = evt.search("_"); + if (separator >= 0) { + if (!isFinite(parseInt(evt.substring(0, separator)))) { + return evt; + } + } else if (!isFinite(parseInt(evt))) { + return evt; + } + } + search = "Generic_"; + if (_args[0].startsWith(search)) { + return _args[0].slice(search.length); + } + return null; + } + onInteractionEvent(_args) { + if (this.isElectricityAvailable() || SimVar.GetSimVarValue("L:A32NX_ELEC_DC_ESS_BUS_IS_POWERED", "Bool")) { + let event = this.DecomposeEventFromPrefix(_args); + if (event) { + if (event == "ElementSetAttribute" && _args.length >= 4) { + const element = this.getChildById(_args[1]); + if (element) { + Avionics.Utils.diffAndSetAttribute(element, _args[2], _args[3]); + } + } else { + this.computeEvent(event); + for (let i = 0; i < this.eventAliases.length; i++) { + if (this.eventAliases[i].source == event) { + this.computeEvent(this.eventAliases[i].output); + } + } + } + } else if (_args[0].startsWith("NavSystem_")) { + event = _args[0].slice("NavSystem_".length); + this.computeEvent(event); + for (let i = 0; i < this.eventAliases.length; i++) { + if (this.eventAliases[i].source == event) { + this.computeEvent(this.eventAliases[i].output); + } + } + } + } else { + console.log("Electricity Is NOT Available"); + } + } + reboot() { + super.reboot(); + this.startTime = Date.now(); + this.hasBeenOff = false; + this.isStarted = false; + this.initAcknowledged = true; + this.budgetedItemId = 0; + } + Update() { + super.Update(); + this.accumulatedDeltaTime += this.deltaTime; + + if (NavSystem._iterations < 10000) { + NavSystem._iterations += 1; + } + const t0 = performance.now(); + if (this.isElectricityAvailable()) { + if (!this.isStarted) { + this.onPowerOn(); + } + if (this.isBootProcedureComplete()) { + if (this.reversionaryMode) { + if (this.electricity) { + this.electricity.setAttribute("state", "Backup"); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_ScreenLuminosity", "number", 1); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_State", "number", 3); + } + } else { + if (this.electricity) { + this.electricity.setAttribute("state", "on"); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_ScreenLuminosity", "number", 1); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_State", "number", 2); + } + } + } else if (Date.now() - this.startTime > this.initDuration) { + if (this.electricity) { + this.electricity.setAttribute("state", "initWaitingValidation"); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_ScreenLuminosity", "number", 0.2); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_State", "number", 1); + } + } else { + if (this.electricity) { + this.electricity.setAttribute("state", "init"); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_ScreenLuminosity", "number", 0.2); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_State", "number", 1); + } + } + } else { + if (this.isStarted) { + this.onShutDown(); + } + if (this.electricity) { + this.electricity.setAttribute("state", "off"); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_ScreenLuminosity", "number", 0); + SimVar.SetSimVarValue("L:" + this.instrumentIdentifier + "_State", "number", 0); + } + } + this.updateAspectRatio(); + if (this.popUpElement) { + this.popUpElement.onUpdate(this.accumulatedDeltaTime); + } + if (this.pagesContainer) { + this.pagesContainer.setAttribute("state", this.getCurrentPage().htmlElemId); + } + if (this.useUpdateBudget) { + this.updateGroupsWithBudget(); + } else { + this.updateGroups(); + } + switch (this.currentInteractionState) { + case 0: + for (var i = 0; i < this.currentSelectableArray.length; i++) { + this.currentSelectableArray[i].updateSelection(false); + } + break; + case 1: + for (var i = 0; i < this.currentSelectableArray.length; i++) { + if (i == this.cursorIndex) { + this.currentSelectableArray[i].updateSelection(this.blinkGetState(400, 200)); + } else { + this.currentSelectableArray[i].updateSelection(false); + } + } + break; + case 2: + this.currentContextualMenu.Update(this, this.menuMaxElems); + break; + } + try { + this.onUpdate(this.accumulatedDeltaTime); + } catch (e) {} + const t = performance.now() - t0; + NavSystem.maxTimeUpdateAllTime = Math.max(t, NavSystem.maxTimeUpdateAllTime); + NavSystem.maxTimeUpdate = Math.max(t, NavSystem.maxTimeUpdate); + const factor = 1 / NavSystem._iterations; + NavSystem.mediumTimeUpdate *= (1 - factor); + NavSystem.mediumTimeUpdate += factor * t; + this.accumulatedDeltaTime = 0; + } + updateGroups() { + for (let i = 0; i < this.IndependentsElements.length; i++) { + this.IndependentsElements[i].onUpdate(this.accumulatedDeltaTime); + } + if (!this.overridePage) { + const currentGroup = this.getCurrentPageGroup(); + if (currentGroup) { + currentGroup.onUpdate(this.accumulatedDeltaTime); + } + } else { + this.overridePage.onUpdate(this.accumulatedDeltaTime); + } + } + updateGroupsWithBudget() { + const target = this.budgetedItemId + this.maxUpdateBudget; + while (this.budgetedItemId < target) { + if (this.budgetedItemId < this.IndependentsElements.length) { + this.IndependentsElements[this.budgetedItemId].onUpdate(this.accumulatedDeltaTime); + this.budgetedItemId++; + continue; + } + if (!this.overridePage) { + const currentGroup = this.getCurrentPageGroup(); + if (currentGroup) { + const itemId = this.budgetedItemId - this.IndependentsElements.length; + if (currentGroup.onUpdateSpecificItem(this.accumulatedDeltaTime, itemId)) { + this.budgetedItemId++; + continue; + } + } + } else { + this.overridePage.onUpdate(this.accumulatedDeltaTime); + } + this.budgetedItemId = 0; + break; + } + } + onEvent(_event) { + } + onUpdate(_deltaTime) { + } + GetComActiveFreq() { + return this.frequencyFormat(SimVar.GetSimVarValue("COM ACTIVE FREQUENCY:1", "MHz"), 3); + } + GetComStandbyFreq() { + return this.frequencyFormat(SimVar.GetSimVarValue("COM STANDBY FREQUENCY:1", "MHz"), 3); + } + GetNavActiveFreq() { + return this.frequencyFormat(SimVar.GetSimVarValue("NAV ACTIVE FREQUENCY:1", "MHz"), 2); + } + GetNavStandbyFreq() { + return this.frequencyFormat(SimVar.GetSimVarValue("NAV STANDBY FREQUENCY:1", "MHz"), 2); + } + UpdateSlider(_slider, _cursor, _index, _nbElem, _maxElems) { + if (_nbElem > _maxElems) { + _slider.setAttribute("state", "Active"); + _cursor.setAttribute("style", "height:" + (_maxElems * 100 / _nbElem) + + "%;top:" + (_index * 100 / _nbElem) + "%"); + } else { + _slider.setAttribute("state", "Inactive"); + } + } + frequencyFormat(_frequency, _nbDigits) { + const IntPart = Math.floor(_frequency); + const Digits = Math.round((_frequency - IntPart) * Math.pow(10, _nbDigits)); + return fastToFixed(IntPart, 0) + '.' + ('000' + (Digits)).slice(-_nbDigits) + ''; + } + frequencyListFormat(_airport, _baseId, _maxElem = -1, _firstElemIndex = 0) { + if (_airport && _airport.frequencies) { + let htmlFreq = ""; + let endIndex = _airport.frequencies.length; + if (_maxElem != -1) { + endIndex = Math.min(_airport.frequencies.length, _firstElemIndex + _maxElem); + } + for (let i = 0; i < Math.min(_airport.frequencies.length, _maxElem); i++) { + htmlFreq += '
' + _airport.frequencies[i + _firstElemIndex].name.replace(" ", " ").slice(0, 15) + '
' + this.frequencyFormat(_airport.frequencies[i + _firstElemIndex].mhValue, 3) + '
'; + } + return htmlFreq; + } else { + return ""; + } + } + airportPrivateTypeStrFromEnum(_enum) { + switch (_enum) { + case 0: + return "Unknown"; + case 1: + return "Public"; + case 2: + return "Military"; + case 3: + return "Private"; + } + } + longitudeFormat(_longitude) { + let format = ""; + if (_longitude < 0) { + format += "W"; + _longitude = Math.abs(_longitude); + } else { + format += "E"; + } + const degrees = Math.floor(_longitude); + const minutes = ((_longitude - degrees) * 60); + format += fastToFixed(degrees, 0); + format += "°"; + format += fastToFixed(minutes, 2); + format += "'"; + return format; + } + latitudeFormat(_latitude) { + let format = ""; + if (_latitude < 0) { + format += "S"; + _latitude = Math.abs(_latitude); + } else { + format += "N"; + } + const degrees = Math.floor(_latitude); + const minutes = ((_latitude - degrees) * 60); + format += fastToFixed(degrees, 0); + format += "°"; + format += fastToFixed(minutes, 2); + format += "'"; + return format; + } + InteractionStateOut() { + switch (this.currentInteractionState) { + case 0: + break; + case 1: + for (let i = 0; i < this.currentSelectableArray.length; i++) { + this.currentSelectableArray[i].updateSelection(false); + } + break; + case 2: + this.contextualMenu.setAttribute("state", "Inactive"); + break; + } + } + InteractionStateIn() { + switch (this.currentInteractionState) { + case 0: + break; + case 1: + this.cursorIndex = 0; + break; + case 2: + this.contextualMenu.setAttribute("state", "Active"); + this.contextualMenuDisplayBeginIndex = 0; + this.cursorIndex = 0; + if (this.currentContextualMenu.elements[0].isInactive()) { + this.computeEvent("NavigationSmallInc"); + } + break; + } + } + SwitchToInteractionState(_newState) { + this.InteractionStateOut(); + this.currentInteractionState = _newState; + this.InteractionStateIn(); + } + ShowContextualMenu(_menu) { + this.currentContextualMenu = _menu; + this.SwitchToInteractionState(2); + this.currentContextualMenu.Update(this, this.menuMaxElems); + } + ActiveSelection(_selectables) { + this.SwitchToInteractionState(1); + this.currentSelectableArray = _selectables; + const begin = this.cursorIndex; + while (!this.currentSelectableArray[this.cursorIndex].isActive) { + this.cursorIndex = (this.cursorIndex + 1) % this.currentSelectableArray.length; + if (this.cursorIndex == begin) { + this.SwitchToInteractionState(0); + return; + } + } + } + setOverridePage(_page) { + if (this.overridePage) { + this.overridePage.onExit(); + } + if (this.currentContextualMenu) { + this.SwitchToInteractionState(0); + } + this.overridePage = _page; + this.overridePage.onEnter(); + } + closeOverridePage() { + if (this.overridePage) { + this.overridePage.onExit(); + } + if (this.currentContextualMenu) { + this.SwitchToInteractionState(0); + } + this.overridePage = null; + } + SwitchToPageName(_menu, _page) { + this.lastRelevantICAO = null; + this.lastRelevantICAOType = null; + if (this.overridePage) { + this.closeOverridePage(); + } + if (this.currentEventLinkedPageGroup) { + this.currentEventLinkedPageGroup.pageGroup.onExit(); + this.currentEventLinkedPageGroup = null; + } + this.pageGroups[this.currentPageGroupIndex].onExit(); + if (this.currentContextualMenu) { + this.SwitchToInteractionState(0); + } + for (let i = 0; i < this.pageGroups.length; i++) { + if (this.pageGroups[i].name == _menu) { + this.currentPageGroupIndex = i; + } + } + this.pageGroups[this.currentPageGroupIndex].goToPage(_page, true); + } + SwitchToMenuName(_name) { + this.lastRelevantICAO = null; + this.lastRelevantICAOType = null; + this.pageGroups[this.currentPageGroupIndex].onExit(); + if (this.currentContextualMenu) { + this.SwitchToInteractionState(0); + } + for (let i = 0; i < this.pageGroups.length; i++) { + if (this.pageGroups[i].name == _name) { + this.currentPageGroupIndex = i; + } + } + this.pageGroups[this.currentPageGroupIndex].onEnter(); + } + GetInteractionState() { + return this.currentInteractionState; + } + blinkGetState(_blinkPeriod, _duration) { + return Math.round((new Date().getTime()) / _duration) % (_blinkPeriod / _duration) == 0; + } + IsEditingSearchField() { + return this.GetInteractionState() == 3; + } + OnSearchFieldEndEditing() { + this.SwitchToInteractionState(0); + } + addEventLinkedPageGroup(_event, _pageGroup) { + this.eventLinkedPageGroups.push(new NavSystemEventLinkedPageGroup(_pageGroup, _event)); + } + addEventLinkedPopupWindow(_popUp) { + this.eventLinkedPopUpElements.push(_popUp); + _popUp.gps = this; + } + addIndependentElementContainer(_container) { + _container.setGPS(this); + this.IndependentsElements.push(_container); + } + getCurrentPageGroup() { + if (this.currentEventLinkedPageGroup) { + return this.currentEventLinkedPageGroup.pageGroup; + } else { + return this.pageGroups[this.currentPageGroupIndex]; + } + } + getCurrentPage() { + if (!this.overridePage) { + const currentGroup = this.getCurrentPageGroup(); + if (currentGroup) { + return currentGroup.getCurrentPage(); + } + return undefined; + } else { + return this.overridePage; + } + } + leaveEventPage() { + this.lastRelevantICAO = null; + this.lastRelevantICAOType = null; + this.currentEventLinkedPageGroup.pageGroup.onExit(); + this.currentEventLinkedPageGroup = null; + this.getCurrentPageGroup().onEnter(); + } + addEventAlias(_source, _output) { + this.eventAliases.push(new NavSystemEventAlias(_source, _output)); + } + closePopUpElement() { + let callback = null; + if (this.popUpElement) { + callback = this.popUpCloseCallback; + this.popUpElement.onExit(); + } + this.popUpElement = null; + this.popUpCloseCallback = null; + if (this.currentContextualMenu) { + this.SwitchToInteractionState(0); + } + if (callback) { + callback(); + } + ; + } + getElementOfType(c) { + for (let i = 0; i < this.IndependentsElements.length; i++) { + const elem = this.IndependentsElements[i].getElementOfType(c); + if (elem) { + return elem; + } + } + const curr = this.getCurrentPage().element.getElementOfType(c); + if (curr) { + return curr; + } else { + for (let i = 0; i < this.pageGroups.length; i++) { + for (let j = 0; j < this.pageGroups[i].pages.length; j++) { + const elem = this.pageGroups[i].pages[j].getElementOfType(c); + if (elem) { + return elem; + } + } + } + } + return null; + } + switchToPopUpPage(_pageContainer, _PopUpCloseCallback = null) { + if (this.popUpElement) { + this.popUpElement.onExit(); + if (this.popUpCloseCallback) { + this.popUpCloseCallback(); + } + ; + } + if (this.currentContextualMenu) { + this.SwitchToInteractionState(0); + } + this.popUpCloseCallback = _PopUpCloseCallback; + this.popUpElement = _pageContainer; + this.popUpElement.onEnter(); + } + preserveAspectRatio(_HtmlElementId) { + this.aspectRatioElement = _HtmlElementId; + this.forcedAspectRatioSet = false; + this.updateAspectRatio(); + } + updateAspectRatio() { + if (this.forcedAspectRatioSet) { + return; + } + if (this.aspectRatioElement == null) { + return; + } + const frame = this.getChildById(this.aspectRatioElement); + if (!frame) { + return; + } + const vpRect = this.getBoundingClientRect(); + const vpWidth = vpRect.width; + const vpHeight = vpRect.height; + if (vpWidth <= 0 || vpHeight <= 0) { + return; + } + const frameStyle = window.getComputedStyle(frame); + if (!frameStyle) { + return; + } + const refWidth = parseInt(frameStyle.getPropertyValue('--refWidth')); + const refHeight = parseInt(frameStyle.getPropertyValue('--refHeight')); + const curWidth = parseInt(frameStyle.width); + const curHeight = parseInt(frameStyle.height); + const curRatio = curHeight / curWidth; + let refRatio = curRatio; + if (refWidth > 0 && refHeight > 0) { + console.log("Forcing aspectratio to " + refWidth + "*" + refHeight); + refRatio = refHeight / refWidth; + let newLeft = parseInt(frameStyle.left); + let newWidth = curWidth; + let newHeight = curWidth * refRatio; + if (newHeight > vpHeight) { + newWidth = vpHeight / refRatio; + newHeight = vpHeight; + newLeft += (curWidth - newWidth) * 0.5; + } + newLeft = Math.round(newLeft); + newWidth = Math.round(newWidth); + newHeight = Math.round(newHeight); + frame.style.left = newLeft + "px"; + frame.style.width = newWidth + "px"; + frame.style.height = newHeight + "px"; + window.document.documentElement.style.setProperty("--bodyHeightScale", (newHeight / vpHeight).toString()); + } + this.forcedScreenRatio = 1.0 / curRatio; + this.forcedAspectRatio = 1.0 / refRatio; + this.forcedAspectRatioSet = true; + } + isAspectRatioForced() { + return this.forcedAspectRatioSet; + } + getForcedScreenRatio() { + return this.forcedScreenRatio; + } + getForcedAspectRatio() { + return this.forcedAspectRatio; + } + isComputingAspectRatio() { + if (this.aspectRatioElement != null && !this.forcedAspectRatioSet) { + return false; + } + return true; + } + onSoundEnd(_eventId) { + for (let i = 0; i < this.pageGroups.length; i++) { + for (let j = 0; j < this.pageGroups[i].pages.length; j++) { + this.pageGroups[i].pages[j].onSoundEnd(_eventId); + } + } + for (let i = 0; i < this.IndependentsElements.length; i++) { + this.IndependentsElements[i].onSoundEnd(_eventId); + } + for (let i = 0; i < this.eventLinkedPopUpElements.length; i++) { + this.eventLinkedPopUpElements[i].onSoundEnd(_eventId); + } + } + onShutDown() { + console.log("System Turned Off"); + this.hasBeenOff = true; + this.isStarted = false; + this.initAcknowledged = false; + for (let i = 0; i < this.pageGroups.length; i++) { + for (let j = 0; j < this.pageGroups[i].pages.length; j++) { + this.pageGroups[i].pages[j].onShutDown(); + } + } + for (let i = 0; i < this.IndependentsElements.length; i++) { + this.IndependentsElements[i].onShutDown(); + } + for (let i = 0; i < this.eventLinkedPopUpElements.length; i++) { + this.eventLinkedPopUpElements[i].onShutDown(); + } + this.alwaysUpdateList.splice(0, this.alwaysUpdateList.length); + } + onPowerOn() { + console.log("System Turned ON"); + this.startTime = Date.now(); + this.isStarted = true; + this.budgetedItemId = 0; + for (let i = 0; i < this.pageGroups.length; i++) { + for (let j = 0; j < this.pageGroups[i].pages.length; j++) { + this.pageGroups[i].pages[j].onPowerOn(); + } + } + for (let i = 0; i < this.IndependentsElements.length; i++) { + this.IndependentsElements[i].onPowerOn(); + } + for (let i = 0; i < this.eventLinkedPopUpElements.length; i++) { + this.eventLinkedPopUpElements[i].onPowerOn(); + } + } + isBootProcedureComplete() { + if (((Date.now() - this.startTime > this.initDuration) || !this.hasBeenOff) && (this.initAcknowledged || !this.needValidationAfterInit)) { + return true; + } + return false; + } + acknowledgeInit() { + this.initAcknowledged = true; + } + isInReversionaryMode() { + return this.reversionaryMode; + } + wasTurnedOff() { + return this.hasBeenOff; + } + hasWeatherRadar() { + if (this.instrumentXmlConfig) { + const elem = this.instrumentXmlConfig.getElementsByTagName("WeatherRadar"); + if (elem.length > 0 && (elem[0].textContent.toLowerCase() == "off" || elem[0].textContent.toLowerCase() == "none")) { + return false; + } + } + return true; + } + alwaysUpdate(_element, _val) { + for (let i = 0; i < this.alwaysUpdateList.length; i++) { + if (this.alwaysUpdateList[i] == _element) { + if (!_val) { + this.alwaysUpdateList.splice(i, 1); + } + return; + } + } + if (_val) { + this.alwaysUpdateList.push(_element); + } + } +} +NavSystem.maxTimeUpdateAllTime = 0; +NavSystem.maxTimeUpdate = 0; +NavSystem.mediumMaxTimeUpdate = 0; +NavSystem.mediumTimeUpdate = 0; +NavSystem.maxTimeMapUpdateAllTime = 0; +NavSystem.maxTimeMapUpdate = 0; +NavSystem.mediumMaxTimeMapUpdate = 0; +NavSystem.mediumTimeMapUpdate = 0; +NavSystem._iterations = 0; +class NavSystemPageGroup { + constructor(_name, _gps, _pages) { + this._updatingWithBudget = false; + this.name = _name; + this.gps = _gps; + this.pages = _pages; + this.pageIndex = 0; + for (let i = 0; i < _pages.length; i++) { + _pages[i].pageGroup = this; + _pages[i].gps = this.gps; + } + } + getCurrentPage() { + return this.pages[this.pageIndex]; + } + onEnter() { + this.pages[this.pageIndex].onEnter(); + } + onUpdate(_deltaTime) { + if (!this._updatingWithBudget) { + this.pages[this.pageIndex].onUpdate(_deltaTime); + } + } + onUpdateSpecificItem(_deltaTime, _itemId) { + if (_itemId == 0) { + this._updatingWithBudget = true; + { + this.onUpdate(_deltaTime); + } + this._updatingWithBudget = false; + } + return this.pages[this.pageIndex].onUpdateSpecificItem(_deltaTime, _itemId); + } + onExit() { + this.pages[this.pageIndex].onExit(); + } + nextPage() { + if (this.pages.length > 1) { + this.pages[this.pageIndex].onExit(); + this.pageIndex = (this.pageIndex + 1) % this.pages.length; + this.pages[this.pageIndex].onEnter(); + } + } + prevPage() { + if (this.pages.length > 1) { + this.pages[this.pageIndex].onExit(); + this.pageIndex = (this.pageIndex + this.pages.length - 1) % this.pages.length; + this.pages[this.pageIndex].onEnter(); + } + } + goToPage(_name, _skipExit = false) { + if (!_skipExit) { + this.pages[this.pageIndex].onExit(); + } + for (let i = 0; i < this.pages.length; i++) { + if (this.pages[i].name == _name) { + this.pageIndex = i; + } + } + this.onEnter(); + } +} +class NavSystemEventLinkedPageGroup { + constructor(_pageGroup, _event) { + this.pageGroup = _pageGroup; + this.popUpEvent = _event; + } +} +class NavSystemElementContainer { + constructor(_name, _htmlElemId, _element) { + this.name = ""; + this.htmlElemId = ""; + this.isInitialized = false; + this._updatingWithBudget = false; + this.name = _name; + this.htmlElemId = _htmlElemId; + this.element = _element; + if (_element) { + _element.container = this; + } + } + getDefaultMenu() { + return this.defaultMenu; + } + init() { + } + checkInit() { + if (this.element) { + if (this.element.isReady()) { + if (!this.element.isInitialized) { + this.element.container = this; + this.element.setGPS(this.gps); + this.element.init(this.gps.getChildById(this.htmlElemId)); + this.element.isInitialized = true; + } + } else { + return false; + } + } + if (!this.isInitialized) { + this.init(); + this.isInitialized = true; + } + return this.isInitialized; + } + onEnter() { + if (!this.checkInit()) { + return; + } + if (this.element) { + this.element.onEnter(); + } + } + onUpdate(_deltaTime) { + if (!this._updatingWithBudget) { + if (!this.checkInit()) { + return; + } + if (this.element) { + this.element.onUpdate(_deltaTime); + } + } + } + onUpdateSpecificItem(_deltaTime, _itemId) { + if (!this.checkInit()) { + return; + } + if (_itemId == 0) { + this._updatingWithBudget = true; + { + this.onUpdate(_deltaTime); + } + this._updatingWithBudget = false; + } + if (this.element) { + return this.element.onUpdateSpecificItem(_deltaTime, _itemId); + } + return false; + } + onExit() { + if (this.element) { + this.element.onExit(); + } + } + onEvent(_event) { + if (this.element) { + this.element.onEvent(_event); + } + } + onSoundEnd(_eventId) { + if (this.element) { + this.element.onSoundEnd(_eventId); + } + } + onShutDown() { + if (this.element) { + this.element.onShutDown(); + } + } + onPowerOn() { + if (this.element) { + this.element.onPowerOn(); + } + } + setGPS(_gps) { + this.gps = _gps; + if (this.element) { + this.element.setGPS(_gps); + } + } + getElementOfType(c) { + if (this.element) { + return this.element.getElementOfType(c); + } + return null; + } +} +class NavSystemEventLinkedPopUpWindow extends NavSystemElementContainer { + constructor(_name, _htmlElemId, _element, _popUpEvent) { + super(_name, _htmlElemId, _element); + this.popUpEvent = _popUpEvent; + } +} +class SoftKeyHandler { +} +class NavSystemPage extends NavSystemElementContainer { + constructor() { + super(...arguments); + this.softKeys = new SoftKeysMenu(); + } + getSoftKeyMenu() { + return this.softKeys; + } +} +// class Updatable { +// } +class NavSystemElement extends Updatable { + constructor() { + super(...arguments); + this.isInitialized = false; + this.defaultSelectables = []; + this._alwaysUpdate = false; + } + set alwaysUpdate(_val) { + this._alwaysUpdate = _val; + if (this.gps) { + this.gps.alwaysUpdate(this, _val); + } + } + isReady() { + return true; + } + onSoundEnd(_eventId) { + } + onShutDown() { + } + onPowerOn() { + } + onUpdateSpecificItem(_deltaTime, _itemId) { + if (_itemId == 0) { + this.onUpdate(_deltaTime); + } + return false; + } + getDefaultSelectables() { + return this.defaultSelectables; + } + setGPS(_gps) { + if (this.gps && !_gps && this._alwaysUpdate) { + this.gps.alwaysUpdate(this, false); + } + this.gps = _gps; + if (this.gps) { + this.gps.alwaysUpdate(this, this._alwaysUpdate); + } + } + getElementOfType(c) { + if (this instanceof c) { + return this; + } else { + return null; + } + } + redraw() { + } +} +class NavSystemIFrameElement extends NavSystemElement { + constructor(_frameName) { + super(); + this.iFrame = this.gps.getChildById(_frameName); + } + isReady() { + if (this.iFrame) { + this.canvas = this.iFrame.contentWindow; + if (this.canvas) { + const readyToSet = this.canvas["readyToSet"]; + if (readyToSet) { + return true; + } + } + } + return false; + } +} +class NavSystemElementGroup extends NavSystemElement { + constructor(_elements) { + super(); + this._updatingWithBudget = false; + this.elements = _elements; + } + isReady() { + for (let i = 0; i < this.elements.length; i++) { + if (!this.elements[i].isReady()) { + return false; + } + } + return true; + } + init(_root) { + this.defaultSelectables = []; + for (let i = 0; i < this.elements.length; i++) { + if (!this.elements[i].isInitialized) { + this.elements[i].container = this.container; + this.elements[i].setGPS(this.gps); + this.elements[i].init(_root); + this.elements[i].isInitialized = true; + this.defaultSelectables.concat(this.elements[i].getDefaultSelectables()); + } + } + } + onEnter() { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onEnter(); + } + } + onUpdate(_deltaTime) { + if (!this._updatingWithBudget) { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onUpdate(_deltaTime); + } + } + } + onUpdateSpecificItem(_deltaTime, _itemId) { + if (_itemId == 0) { + this._updatingWithBudget = true; + { + this.onUpdate(_deltaTime); + } + this._updatingWithBudget = false; + } + if (_itemId < this.elements.length) { + this.elements[_itemId].onUpdate(_deltaTime); + if (_itemId + 1 < this.elements.length) { + return true; + } + } + return false; + } + onExit() { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onExit(); + } + } + onEvent(_event) { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onEvent(_event); + } + } + onSoundEnd(_eventId) { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onSoundEnd(_eventId); + } + } + onShutDown() { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onShutDown(); + } + } + onPowerOn() { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onPowerOn(); + } + } + getDefaultSelectables() { + this.defaultSelectables = []; + for (let i = 0; i < this.elements.length; i++) { + this.defaultSelectables.concat(this.elements[i].getDefaultSelectables()); + } + return this.defaultSelectables; + } + setGPS(_gps) { + this.gps = _gps; + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].setGPS(_gps); + } + } + getElementOfType(c) { + for (let i = 0; i < this.elements.length; i++) { + const elem = this.elements[i].getElementOfType(c); + if (elem) { + return elem; + } + } + return null; + } + addElement(elem) { + this.elements.push(elem); + } +} +class NavSystemElementSelector extends NavSystemElement { + constructor() { + super(...arguments); + this.elements = []; + this.index = 0; + } + isReady() { + for (let i = 0; i < this.elements.length; i++) { + if (!this.elements[i].isReady()) { + return false; + } + } + return true; + } + init(_root) { + for (let i = 0; i < this.elements.length; i++) { + if (!this.elements[i].isInitialized) { + this.elements[i].container = this.container; + this.elements[i].setGPS(this.gps); + this.elements[i].init(_root); + this.elements[i].isInitialized = true; + } + } + } + onEnter() { + this.elements[this.index].onEnter(); + } + onUpdate(_deltaTime) { + this.elements[this.index].onUpdate(_deltaTime); + } + onExit() { + this.elements[this.index].onExit(); + } + onEvent(_event) { + this.elements[this.index].onEvent(_event); + } + onSoundEnd(_eventId) { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onSoundEnd(_eventId); + } + } + onShutDown() { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onShutDown(); + } + } + onPowerOn() { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].onPowerOn(); + } + } + selectElement(_name) { + for (let i = 0; i < this.elements.length; i++) { + if (this.elements[i].name == _name) { + this.index = i; + } + } + } + addElement(_elem) { + this.elements.push(_elem); + } + getDefaultSelectables() { + return this.elements[this.index].getDefaultSelectables(); + } + setGPS(_gps) { + this.gps = _gps; + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].setGPS(_gps); + } + } + getElementOfType(c) { + for (let i = 0; i < this.elements.length; i++) { + const elem = this.elements[i].getElementOfType(c); + if (elem) { + return elem; + } + } + return null; + } + switchToIndex(_index) { + if (this.index < this.elements.length) { + this.elements[this.index].onExit(); + } + if (_index < this.elements.length) { + this.index = _index; + this.elements[this.index].onEnter(); + } + } +} +class SoftKeyElement { + constructor(_name = "", _callback = null, _stateCB = null) { + this.callback = _callback; + this.name = _name; + this.state = "None"; + this.stateCallback = _stateCB; + if (!_callback) { + this.state = "Greyed"; + } + } +} +class SoftKeysMenu { +} +class NavSystemEventAlias { + constructor(_from, _to) { + this.source = _from; + this.output = _to; + } +} +class CDIElement extends NavSystemElement { + init(_root) { + this.cdiCursor = this.gps.getChildById("CDICursor"); + this.toFrom = this.gps.getChildById("ToFrom"); + } + onEnter() { + } + onUpdate(_deltaTime) { + const CTD = SimVar.GetSimVarValue("GPS WP CROSS TRK", "nautical mile"); + this.cdiCursor.setAttribute("style", "left:" + ((CTD <= -1 ? -1 : CTD >= 1 ? 1 : CTD) * 50 + 50) + "%"); + } + onExit() { + } + onEvent(_event) { + } +} +var EMapDisplayMode; +(function (EMapDisplayMode) { + EMapDisplayMode[EMapDisplayMode["GPS"] = 0] = "GPS"; + EMapDisplayMode[EMapDisplayMode["RADAR"] = 1] = "RADAR"; +})(EMapDisplayMode || (EMapDisplayMode = {})); +var ERadarMode; +(function (ERadarMode) { + ERadarMode[ERadarMode["HORIZON"] = 0] = "HORIZON"; + ERadarMode[ERadarMode["VERTICAL"] = 1] = "VERTICAL"; +})(ERadarMode || (ERadarMode = {})); +class MapInstrumentElement extends NavSystemElement { + constructor() { + super(); + this.displayMode = EMapDisplayMode.GPS; + this.nexradOn = false; + this.radarMode = ERadarMode.HORIZON; + } + init(root) { + this.instrument = root.querySelector("map-instrument"); + if (this.instrument) { + TemplateElement.callNoBinding(this.instrument, () => { + this.onTemplateLoaded(); + }); + } + } + onTemplateLoaded() { + this.instrument.init(this.gps); + this.instrumentLoaded = true; + // This makes the black parts of the bing map blend perfectly with the backlight in weather and terrain modes + this.instrument.bingMap.m_imgElement.style.mixBlendMode = 'lighten'; + } + setGPS(_gps) { + super.setGPS(_gps); + if (this.instrument) { + this.instrument.init(this.gps); + } + } + onEnter() { + } + onUpdate(_deltaTime) { + if (this.instrumentLoaded) { + this.instrument.update(_deltaTime); + if (this.weatherTexts) { + const range = this.instrument.getWeatherRange(); + const ratio = 1.0 / this.weatherTexts.length; + for (let i = 0; i < this.weatherTexts.length; i++) { + this.weatherTexts[i].textContent = fastToFixed(range * ratio * (i + 1), 2) + "NM"; + } + } + } + } + onExit() { + } + onEvent(_event) { + if (this.instrument) { + this.instrument.onEvent(_event); + } + } + toggleDisplayMode() { + if (this.displayMode == EMapDisplayMode.GPS) { + this.gps.getCurrentPage().name = "WEATHER RADAR"; + this.displayMode = EMapDisplayMode.RADAR; + } else { + this.gps.getCurrentPage().name = "NAVIGATION MAP"; + this.displayMode = EMapDisplayMode.GPS; + } + this.updateWeather(); + } + setDisplayMode(_mode) { + this.displayMode = _mode; + if (_mode == EMapDisplayMode.GPS) { + this.gps.getCurrentPage().name = "NAVIGATION MAP"; + } else { + this.gps.getCurrentPage().name = "WEATHER RADAR"; + } + this.updateWeather(); + } + getDisplayMode() { + return this.displayMode; + } + toggleIsolines() { + if (this.instrument) { + if (this.instrument.getIsolines() == true) { + this.instrument.showIsolines(false); + } else { + this.instrument.showIsolines(true); + } + } + } + getIsolines() { + return this.instrument.getIsolines(); + } + toggleNexrad() { + this.nexradOn = !this.nexradOn; + this.updateWeather(); + } + getNexrad() { + return this.nexradOn; + } + setRadar(_mode) { + this.radarMode = _mode; + this.updateWeather(); + } + getRadarMode() { + return this.radarMode; + } + updateWeather() { + if (this.instrument) { + if (this.displayMode == EMapDisplayMode.GPS) { + if (this.nexradOn) { + this.setWeather(EWeatherRadar.TOPVIEW); + } else { + this.setWeather(EWeatherRadar.OFF); + } + } else { + if (this.radarMode == ERadarMode.HORIZON) { + this.setWeather(EWeatherRadar.HORIZONTAL); + } else { + this.setWeather(EWeatherRadar.VERTICAL); + } + } + } + } + setWeather(_mode) { + this.instrument.showWeather(_mode); + const svgRoot = this.instrument.weatherSVG; + if (svgRoot) { + Utils.RemoveAllChildren(svgRoot); + this.weatherTexts = null; + if (_mode == EWeatherRadar.HORIZONTAL || _mode == EWeatherRadar.VERTICAL) { + const circleRadius = 575; + const dashNbRect = 10; + const dashWidth = 8; + const dashHeight = 6; + if (_mode == EWeatherRadar.HORIZONTAL) { + this.instrument.setBingMapStyle("10.3%", "-13.3%", "127%", "157%"); + var coneAngle = 90; + svgRoot.setAttribute("viewBox", "0 0 400 400"); + var trsGroup = document.createElementNS(Avionics.SVG.NS, "g"); + trsGroup.setAttribute("transform", "translate(-125, 29) scale(1.63)"); + svgRoot.appendChild(trsGroup); + const viewBox = document.createElementNS(Avionics.SVG.NS, "svg"); + viewBox.setAttribute("viewBox", "-600 -600 1200 1200"); + trsGroup.appendChild(viewBox); + var circleGroup = document.createElementNS(Avionics.SVG.NS, "g"); + circleGroup.setAttribute("id", "Circles"); + viewBox.appendChild(circleGroup); + { + const rads = [0.25, 0.50, 0.75, 1.0]; + for (let r = 0; r < rads.length; r++) { + const rad = circleRadius * rads[r]; + let startDegrees = -coneAngle * 0.5; + const endDegrees = coneAngle * 0.5; + while (Math.floor(startDegrees) <= endDegrees) { + const line = document.createElementNS(Avionics.SVG.NS, "rect"); + const degree = (180 + startDegrees + 0.5); + line.setAttribute("x", "0"); + line.setAttribute("y", rad.toString()); + line.setAttribute("width", dashWidth.toString()); + line.setAttribute("height", dashHeight.toString()); + line.setAttribute("transform", "rotate(" + degree + " 0 0)"); + line.setAttribute("fill", "white"); + circleGroup.appendChild(line); + startDegrees += coneAngle / dashNbRect; + } + } + } + var lineGroup = document.createElementNS(Avionics.SVG.NS, "g"); + lineGroup.setAttribute("id", "Lines"); + viewBox.appendChild(lineGroup); + { + var coneStart = 180 - coneAngle * 0.5; + var coneStartLine = document.createElementNS(Avionics.SVG.NS, "line"); + coneStartLine.setAttribute("x1", "0"); + coneStartLine.setAttribute("y1", "0"); + coneStartLine.setAttribute("x2", "0"); + coneStartLine.setAttribute("y2", circleRadius.toString()); + coneStartLine.setAttribute("transform", "rotate(" + coneStart + " 0 0)"); + coneStartLine.setAttribute("stroke", "white"); + coneStartLine.setAttribute("stroke-width", "3"); + lineGroup.appendChild(coneStartLine); + var coneEnd = 180 + coneAngle * 0.5; + var coneEndLine = document.createElementNS(Avionics.SVG.NS, "line"); + coneEndLine.setAttribute("x1", "0"); + coneEndLine.setAttribute("y1", "0"); + coneEndLine.setAttribute("x2", "0"); + coneEndLine.setAttribute("y2", circleRadius.toString()); + coneEndLine.setAttribute("transform", "rotate(" + coneEnd + " 0 0)"); + coneEndLine.setAttribute("stroke", "white"); + coneEndLine.setAttribute("stroke-width", "3"); + lineGroup.appendChild(coneEndLine); + } + var textGroup = document.createElementNS(Avionics.SVG.NS, "g"); + textGroup.setAttribute("id", "Texts"); + viewBox.appendChild(textGroup); + { + this.weatherTexts = []; + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.setAttribute("x", "100"); + text.setAttribute("y", "-85"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "20"); + textGroup.appendChild(text); + this.weatherTexts.push(text); + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.setAttribute("x", "200"); + text.setAttribute("y", "-185"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "20"); + textGroup.appendChild(text); + this.weatherTexts.push(text); + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.setAttribute("x", "300"); + text.setAttribute("y", "-285"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "20"); + textGroup.appendChild(text); + this.weatherTexts.push(text); + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.setAttribute("x", "400"); + text.setAttribute("y", "-385"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "20"); + textGroup.appendChild(text); + this.weatherTexts.push(text); + } + } else if (_mode == EWeatherRadar.VERTICAL) { + this.instrument.setBingMapStyle("-75%", "-88%", "201%", "250%"); + var coneAngle = 51.43; + svgRoot.setAttribute("viewBox", "0 0 400 400"); + var trsGroup = document.createElementNS(Avionics.SVG.NS, "g"); + trsGroup.setAttribute("transform", "translate(402, -190) scale(1.95) rotate(90)"); + svgRoot.appendChild(trsGroup); + const viewBox = document.createElementNS(Avionics.SVG.NS, "svg"); + viewBox.setAttribute("viewBox", "-600 -600 1200 1200"); + trsGroup.appendChild(viewBox); + var circleGroup = document.createElementNS(Avionics.SVG.NS, "g"); + circleGroup.setAttribute("id", "Circles"); + viewBox.appendChild(circleGroup); + { + const rads = [0.25, 0.50, 0.75, 1.0]; + for (let r = 0; r < rads.length; r++) { + const rad = circleRadius * rads[r]; + let startDegrees = -coneAngle * 0.5; + const endDegrees = coneAngle * 0.5; + while (Math.floor(startDegrees) <= endDegrees) { + const line = document.createElementNS(Avionics.SVG.NS, "rect"); + const degree = (180 + startDegrees + 0.5); + line.setAttribute("x", "0"); + line.setAttribute("y", rad.toString()); + line.setAttribute("width", dashWidth.toString()); + line.setAttribute("height", dashHeight.toString()); + line.setAttribute("transform", "rotate(" + degree + " 0 0)"); + line.setAttribute("fill", "white"); + circleGroup.appendChild(line); + startDegrees += coneAngle / dashNbRect; + } + } + } + const limitGroup = document.createElementNS(Avionics.SVG.NS, "g"); + limitGroup.setAttribute("id", "Limits"); + viewBox.appendChild(limitGroup); + { + const endPosY = circleRadius + 50; + let posX = -130; + let posY = 50; + while (posY <= endPosY) { + const line = document.createElementNS(Avionics.SVG.NS, "rect"); + line.setAttribute("x", posX.toString()); + line.setAttribute("y", (-posY).toString()); + line.setAttribute("width", dashHeight.toString()); + line.setAttribute("height", dashWidth.toString()); + line.setAttribute("fill", "white"); + limitGroup.appendChild(line); + posY += dashWidth * 2; + } + posX = 130; + posY = 50; + while (posY <= endPosY) { + const line = document.createElementNS(Avionics.SVG.NS, "rect"); + line.setAttribute("x", posX.toString()); + line.setAttribute("y", (-posY).toString()); + line.setAttribute("width", dashHeight.toString()); + line.setAttribute("height", dashWidth.toString()); + line.setAttribute("fill", "white"); + limitGroup.appendChild(line); + posY += dashWidth * 2; + } + } + var lineGroup = document.createElementNS(Avionics.SVG.NS, "g"); + lineGroup.setAttribute("id", "Lines"); + viewBox.appendChild(lineGroup); + { + var coneStart = 180 - coneAngle * 0.5; + var coneStartLine = document.createElementNS(Avionics.SVG.NS, "line"); + coneStartLine.setAttribute("x1", "0"); + coneStartLine.setAttribute("y1", "0"); + coneStartLine.setAttribute("x2", "0"); + coneStartLine.setAttribute("y2", circleRadius.toString()); + coneStartLine.setAttribute("transform", "rotate(" + coneStart + " 0 0)"); + coneStartLine.setAttribute("stroke", "white"); + coneStartLine.setAttribute("stroke-width", "3"); + lineGroup.appendChild(coneStartLine); + var coneEnd = 180 + coneAngle * 0.5; + var coneEndLine = document.createElementNS(Avionics.SVG.NS, "line"); + coneEndLine.setAttribute("x1", "0"); + coneEndLine.setAttribute("y1", "0"); + coneEndLine.setAttribute("x2", "0"); + coneEndLine.setAttribute("y2", circleRadius.toString()); + coneEndLine.setAttribute("transform", "rotate(" + coneEnd + " 0 0)"); + coneEndLine.setAttribute("stroke", "white"); + coneEndLine.setAttribute("stroke-width", "3"); + lineGroup.appendChild(coneEndLine); + } + var textGroup = document.createElementNS(Avionics.SVG.NS, "g"); + textGroup.setAttribute("id", "Texts"); + viewBox.appendChild(textGroup); + { + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.textContent = "+60000FT"; + text.setAttribute("x", "50"); + text.setAttribute("y", "-150"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "20"); + text.setAttribute("transform", "rotate(-90)"); + textGroup.appendChild(text); + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.textContent = "-60000FT"; + text.setAttribute("x", "50"); + text.setAttribute("y", "160"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "20"); + text.setAttribute("transform", "rotate(-90)"); + textGroup.appendChild(text); + this.weatherTexts = []; + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.setAttribute("x", "85"); + text.setAttribute("y", "85"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "18"); + text.setAttribute("transform", "rotate(-90)"); + textGroup.appendChild(text); + this.weatherTexts.push(text); + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.setAttribute("x", "215"); + text.setAttribute("y", "160"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "18"); + text.setAttribute("transform", "rotate(-90)"); + textGroup.appendChild(text); + this.weatherTexts.push(text); + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.setAttribute("x", "345"); + text.setAttribute("y", "220"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "18"); + text.setAttribute("transform", "rotate(-90)"); + textGroup.appendChild(text); + this.weatherTexts.push(text); + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.setAttribute("x", "475"); + text.setAttribute("y", "280"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "18"); + text.setAttribute("transform", "rotate(-90)"); + textGroup.appendChild(text); + this.weatherTexts.push(text); + } + } + const legendGroup = document.createElementNS(Avionics.SVG.NS, "g"); + legendGroup.setAttribute("id", "legendGroup"); + svgRoot.appendChild(legendGroup); + { + const x = -5; + const y = 325; + const w = 70; + const h = 125; + const titleHeight = 20; + const scaleOffsetX = 5; + const scaleOffsetY = 5; + const scaleWidth = 13; + const scaleHeight = 24; + const left = x - w * 0.5; + const top = y - h * 0.5; + let rect = document.createElementNS(Avionics.SVG.NS, "rect"); + rect.setAttribute("x", left.toString()); + rect.setAttribute("y", top.toString()); + rect.setAttribute("width", w.toString()); + rect.setAttribute("height", h.toString()); + rect.setAttribute("stroke", "white"); + rect.setAttribute("stroke-width", "2"); + rect.setAttribute("stroke-opacity", "1"); + legendGroup.appendChild(rect); + rect = document.createElementNS(Avionics.SVG.NS, "rect"); + rect.setAttribute("x", left.toString()); + rect.setAttribute("y", top.toString()); + rect.setAttribute("width", w.toString()); + rect.setAttribute("height", titleHeight.toString()); + rect.setAttribute("stroke", "white"); + rect.setAttribute("stroke-width", "2"); + rect.setAttribute("stroke-opacity", "1"); + legendGroup.appendChild(rect); + var text = document.createElementNS(Avionics.SVG.NS, "text"); + text.textContent = "SCALE"; + text.setAttribute("x", x.toString()); + text.setAttribute("y", (top + titleHeight * 0.5).toString()); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "11"); + text.setAttribute("text-anchor", "middle"); + legendGroup.appendChild(text); + let scaleIndex = 0; + rect = document.createElementNS(Avionics.SVG.NS, "rect"); + rect.setAttribute("x", (left + scaleOffsetX).toString()); + rect.setAttribute("y", (top + titleHeight + scaleOffsetY + scaleIndex * scaleHeight).toString()); + rect.setAttribute("width", scaleWidth.toString()); + rect.setAttribute("height", scaleHeight.toString()); + rect.setAttribute("fill", "red"); + rect.setAttribute("stroke", "white"); + rect.setAttribute("stroke-width", "2"); + rect.setAttribute("stroke-opacity", "1"); + legendGroup.appendChild(rect); + text = document.createElementNS(Avionics.SVG.NS, "text"); + text.textContent = "HEAVY"; + text.setAttribute("x", (left + scaleOffsetX + scaleWidth + 5).toString()); + text.setAttribute("y", (top + titleHeight + scaleOffsetY + scaleIndex * scaleHeight + scaleHeight * 0.5).toString()); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "11"); + legendGroup.appendChild(text); + scaleIndex++; + rect = document.createElementNS(Avionics.SVG.NS, "rect"); + rect.setAttribute("x", (left + scaleOffsetX).toString()); + rect.setAttribute("y", (top + titleHeight + scaleOffsetY + scaleIndex * scaleHeight).toString()); + rect.setAttribute("width", scaleWidth.toString()); + rect.setAttribute("height", scaleHeight.toString()); + rect.setAttribute("fill", "yellow"); + rect.setAttribute("stroke", "white"); + rect.setAttribute("stroke-width", "2"); + rect.setAttribute("stroke-opacity", "1"); + legendGroup.appendChild(rect); + scaleIndex++; + rect = document.createElementNS(Avionics.SVG.NS, "rect"); + rect.setAttribute("x", (left + scaleOffsetX).toString()); + rect.setAttribute("y", (top + titleHeight + scaleOffsetY + scaleIndex * scaleHeight).toString()); + rect.setAttribute("width", scaleWidth.toString()); + rect.setAttribute("height", scaleHeight.toString()); + rect.setAttribute("fill", "green"); + rect.setAttribute("stroke", "white"); + rect.setAttribute("stroke-width", "2"); + rect.setAttribute("stroke-opacity", "1"); + legendGroup.appendChild(rect); + text = document.createElementNS(Avionics.SVG.NS, "text"); + text.textContent = "LIGHT"; + text.setAttribute("x", (left + scaleOffsetX + scaleWidth + 5).toString()); + text.setAttribute("y", (top + titleHeight + scaleOffsetY + scaleIndex * scaleHeight + scaleHeight * 0.5).toString()); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "11"); + legendGroup.appendChild(text); + scaleIndex++; + rect = document.createElementNS(Avionics.SVG.NS, "rect"); + rect.setAttribute("x", (left + scaleOffsetX).toString()); + rect.setAttribute("y", (top + titleHeight + scaleOffsetY + scaleIndex * scaleHeight).toString()); + rect.setAttribute("width", scaleWidth.toString()); + rect.setAttribute("height", scaleHeight.toString()); + rect.setAttribute("fill", "black"); + rect.setAttribute("stroke", "white"); + rect.setAttribute("stroke-width", "2"); + rect.setAttribute("stroke-opacity", "1"); + legendGroup.appendChild(rect); + } + } + } + } +} +class SoftKeyHtmlElement { + constructor(_elem) { + this.Element = _elem; + this.Value = ""; + } + fillFromElement(_elem) { + const val = _elem.name; + if (this.Value != val) { + this.Element.innerHTML = val; + this.Value = val; + } + if (_elem.stateCallback) { + _elem.state = _elem.stateCallback(); + } else if (!_elem.callback) { + _elem.state = "Greyed"; + } + if (_elem.state) { + Avionics.Utils.diffAndSetAttribute(this.Element, "state", _elem.state); + } + } +} +class SoftKeys extends NavSystemElement { + constructor(_softKeyHTMLClass = SoftKeyHtmlElement) { + super(); + this.softKeys = []; + this.softKeyHTMLClass = _softKeyHTMLClass; + } + init(root) { + for (let i = 1; i <= 12; i++) { + const name = "Key" + i.toString(); + const child = this.gps.getChildById(name); + if (child) { + const e = new this.softKeyHTMLClass(child); + this.softKeys.push(e); + } + } + this.isInitialized = true; + } + onEnter() { + } + onUpdate(_deltaTime) { + const currentPage = this.gps.getCurrentPage(); + if (currentPage) { + this.currentMenu = currentPage.getSoftKeyMenu(); + if (this.currentMenu && this.currentMenu.elements && this.currentMenu.elements.length > 0) { + for (let i = 0; i < this.currentMenu.elements.length; i++) { + this.softKeys[i].fillFromElement(this.currentMenu.elements[i]); + } + } + } + } + onExit() { + } + onEvent(_event) { + switch (_event) { + case "SOFTKEYS_1": + this.activeSoftKey(0); + break; + case "SOFTKEYS_2": + this.activeSoftKey(1); + break; + case "SOFTKEYS_3": + this.activeSoftKey(2); + break; + case "SOFTKEYS_4": + this.activeSoftKey(3); + break; + case "SOFTKEYS_5": + this.activeSoftKey(4); + break; + case "SOFTKEYS_6": + this.activeSoftKey(5); + break; + case "SOFTKEYS_7": + this.activeSoftKey(6); + break; + case "SOFTKEYS_8": + this.activeSoftKey(7); + break; + case "SOFTKEYS_9": + this.activeSoftKey(8); + break; + case "SOFTKEYS_10": + this.activeSoftKey(9); + break; + case "SOFTKEYS_11": + this.activeSoftKey(10); + break; + case "SOFTKEYS_12": + this.activeSoftKey(11); + break; + } + } + activeSoftKey(_number) { + if (this.currentMenu.elements[_number].callback) { + this.currentMenu.elements[_number].callback(); + } + } +} +var Annunciation_MessageType; +(function (Annunciation_MessageType) { + Annunciation_MessageType[Annunciation_MessageType["WARNING"] = 0] = "WARNING"; + Annunciation_MessageType[Annunciation_MessageType["CAUTION"] = 1] = "CAUTION"; + Annunciation_MessageType[Annunciation_MessageType["ADVISORY"] = 2] = "ADVISORY"; + Annunciation_MessageType[Annunciation_MessageType["SAFEOP"] = 3] = "SAFEOP"; +})(Annunciation_MessageType || (Annunciation_MessageType = {})); +; +class Annunciation_Message { + constructor() { + this.Visible = false; + this.Acknowledged = false; + } +} +; +class XMLCondition { + constructor() { + this.suffix = ""; + } +} +class Annunciation_Message_XML extends Annunciation_Message { + constructor() { + super(); + this.lastConditionIndex = -1; + this.conditions = []; + this.Handler = this.getHandlersValue.bind(this); + } + getHandlersValue() { + for (let i = 0; i < this.conditions.length; i++) { + if (this.conditions[i].logic.getValue() != 0) { + if (i != this.lastConditionIndex) { + this.lastConditionIndex = i; + this.Text = this.baseText + (this.conditions[i].suffix ? this.conditions[i].suffix : ""); + return false; + } + return true; + } + } + return false; + } +} +class Annunciation_Message_Timed extends Annunciation_Message { +} +; +class Annunciation_Message_Switch extends Annunciation_Message { + get Text() { + const index = this.Handler(); + if (index > 0) { + return this.Texts[index - 1]; + } else { + return ""; + } + } +} +class Condition { + constructor(_handler, _time = 0) { + this.beginTime = 0; + this.Handler = _handler; + this.Time = _time; + } +} +class Annunciator_Message_MultipleConditions extends Annunciation_Message { + constructor() { + super(); + this.Handler = this.getHandlersValue; + } + getHandlersValue() { + let result = false; + for (let i = 0; i < this.conditions.length; i++) { + if (this.conditions[i].Handler()) { + if (this.conditions[i].beginTime == 0) { + this.conditions[i].beginTime = Date.now() + 1000 * this.conditions[i].Time; + } else if (this.conditions[i].beginTime <= Date.now()) { + result = true; + } + } else { + this.conditions[i].beginTime = 0; + } + } + return result; + } +} +class Annunciations extends NavSystemElement { + constructor() { + super(...arguments); + this.allMessages = []; + this.alertLevel = 0; + this.alert = false; + this.needReload = true; + this.rootElementName = "Annunciations"; + } + init(root) { + this.engineType = Simplane.getEngineType(); + if (this.rootElementName != "") { + this.annunciations = this.gps.getChildById(this.rootElementName); + } + if (this.gps.xmlConfig) { + const annunciationsRoot = this.gps.xmlConfig.getElementsByTagName("Annunciations"); + if (annunciationsRoot.length > 0) { + const annunciations = annunciationsRoot[0].getElementsByTagName("Annunciation"); + for (let i = 0; i < annunciations.length; i++) { + this.addXmlMessage(annunciations[i]); + } + } + } + } + onEnter() { + } + onExit() { + } + addMessage(_type, _text, _handler) { + const msg = new Annunciation_Message(); + msg.Type = _type; + msg.Text = _text; + msg.Handler = _handler.bind(msg); + this.allMessages.push(msg); + } + addXmlMessage(_element) { + const msg = new Annunciation_Message_XML(); + switch (_element.getElementsByTagName("Type")[0].textContent) { + case "Warning": + msg.Type = Annunciation_MessageType.WARNING; + break; + case "Caution": + msg.Type = Annunciation_MessageType.CAUTION; + break; + case "Advisory": + msg.Type = Annunciation_MessageType.ADVISORY; + break; + case "SafeOp": + msg.Type = Annunciation_MessageType.SAFEOP; + break; + } + msg.baseText = _element.getElementsByTagName("Text")[0].textContent; + const conditions = _element.getElementsByTagName("Condition"); + for (let i = 0; i < conditions.length; i++) { + const condition = new XMLCondition(); + condition.logic = new CompositeLogicXMLElement(this.gps, conditions[i]); + condition.suffix = conditions[i].getAttribute("Suffix"); + msg.conditions.push(condition); + } + this.allMessages.push(msg); + } + addMessageTimed(_type, _text, _handler, _time) { + const msg = new Annunciation_Message_Timed(); + msg.Type = _type; + msg.Text = _text; + msg.Handler = _handler.bind(msg); + msg.timeNeeded = _time; + this.allMessages.push(msg); + } + addMessageSwitch(_type, _texts, _handler) { + const msg = new Annunciation_Message_Switch(); + msg.Type = _type; + msg.Texts = _texts; + msg.Handler = _handler.bind(msg); + this.allMessages.push(msg); + } + addMessageMultipleConditions(_type, _text, _conditions) { + const msg = new Annunciator_Message_MultipleConditions(); + msg.Type = _type; + msg.Text = _text; + msg.conditions = _conditions; + this.allMessages.push(msg); + } +} +class Cabin_Annunciations extends Annunciations { + constructor() { + super(...arguments); + this.displayWarning = []; + this.displayCaution = []; + this.displayAdvisory = []; + //this.warningToneNameZ = new Name_Z("CRC"); + this.cautionToneNameZ = new Name_Z("improved_tone_caution"); + this.warningTone = false; + this.firstAcknowledge = true; + this.offStart = false; + } + init(root) { + super.init(root); + this.alwaysUpdate = true; + this.isPlayingWarningTone = false; + for (let i = 0; i < this.allMessages.length; i++) { + const message = this.allMessages[i]; + let value = false; + if (message.Handler) { + value = message.Handler() != 0; + } + if (value != message.Visible) { + this.needReload = true; + message.Visible = value; + message.Acknowledged = !this.offStart; + if (value) { + switch (message.Type) { + case Annunciation_MessageType.WARNING: + this.displayWarning.push(message); + break; + case Annunciation_MessageType.CAUTION: + this.displayCaution.push(message); + break; + case Annunciation_MessageType.ADVISORY: + this.displayAdvisory.push(message); + break; + } + } + } + } + } + onEnter() { + } + onUpdate(_deltaTime) { + for (let i = 0; i < this.allMessages.length; i++) { + const message = this.allMessages[i]; + let value = false; + if (message.Handler) { + value = message.Handler() != 0; + } + if (value != message.Visible) { + this.needReload = true; + message.Visible = value; + message.Acknowledged = (this.gps.getTimeSinceStart() < 10000 && !this.offStart); + if (value) { + switch (message.Type) { + case Annunciation_MessageType.WARNING: + this.displayWarning.push(message); + break; + case Annunciation_MessageType.CAUTION: + this.displayCaution.push(message); + if (!message.Acknowledged && !this.isPlayingWarningTone && this.gps.isPrimary) { + const res = this.gps.playInstrumentSound("improved_tone_caution"); + if (res) { + this.isPlayingWarningTone = true; + } + } + break; + case Annunciation_MessageType.ADVISORY: + this.displayAdvisory.push(message); + break; + } + } else { + switch (message.Type) { + case Annunciation_MessageType.WARNING: + for (let i = 0; i < this.displayWarning.length; i++) { + if (this.displayWarning[i].Text == message.Text) { + this.displayWarning.splice(i, 1); + break; + } + } + break; + case Annunciation_MessageType.CAUTION: + for (let i = 0; i < this.displayCaution.length; i++) { + if (this.displayCaution[i].Text == message.Text) { + this.displayCaution.splice(i, 1); + break; + } + } + break; + case Annunciation_MessageType.ADVISORY: + for (let i = 0; i < this.displayAdvisory.length; i++) { + if (this.displayAdvisory[i].Text == message.Text) { + this.displayAdvisory.splice(i, 1); + break; + } + } + break; + } + } + } + } + if (this.annunciations) { + this.annunciations.setAttribute("state", this.gps.blinkGetState(800, 400) ? "Blink" : "None"); + } + if (this.needReload) { + let warningOn = 0; + let cautionOn = 0; + let messages = ""; + for (let i = this.displayWarning.length - 1; i >= 0; i--) { + messages += '
' + this.displayWarning[i].Text + "
"; + } + for (let i = this.displayCaution.length - 1; i >= 0; i--) { + messages += '
' + this.displayCaution[i].Text + "
"; + } + for (let i = this.displayAdvisory.length - 1; i >= 0; i--) { + messages += '
' + this.displayAdvisory[i].Text + "
"; + } + this.warningTone = warningOn > 0; + if (this.annunciations) { + this.annunciations.innerHTML = messages; + } + this.needReload = false; + } + /*if (this.warningTone && !this.isPlayingWarningTone && this.gps.isPrimary) { + this.isPlayingWarningTone = true; + }*/ + } + onEvent(_event) { + switch (_event) { + case "Master_Caution_Push": + for (let i = 0; i < this.allMessages.length; i++) { + if (this.allMessages[i].Type == Annunciation_MessageType.CAUTION && this.allMessages[i].Visible) { + this.allMessages[i].Acknowledged = true; + this.needReload = true; + } + } + break; + case "Master_Warning_Push": + for (let i = 0; i < this.allMessages.length; i++) { + if (this.allMessages[i].Type == Annunciation_MessageType.WARNING && this.allMessages[i].Visible) { + this.allMessages[i].Acknowledged = true; + this.needReload = true; + } + } + if (this.needReload && this.firstAcknowledge && this.gps.isPrimary) { + const res = this.gps.playInstrumentSound("aural_warning_ok"); + if (res) { + this.firstAcknowledge = false; + } + } + break; + } + } + onSoundEnd(_eventId) { + if (Name_Z.compare(_eventId, this.warningToneNameZ) || Name_Z.compare(_eventId, this.cautionToneNameZ)) { + this.isPlayingWarningTone = false; + } + } + onShutDown() { + for (let i = 0; i < this.allMessages.length; i++) { + this.allMessages[i].Acknowledged = false; + this.allMessages[i].Visible = false; + } + this.displayCaution = []; + this.displayWarning = []; + this.displayAdvisory = []; + this.firstAcknowledge = true; + this.needReload = true; + } + onPowerOn() { + this.offStart = true; + } + hasMessages() { + for (let i = 0; i < this.allMessages.length; i++) { + if (this.allMessages[i].Visible) { + return true; + } + } + return false; + } +} +class Engine_Annunciations extends Cabin_Annunciations { + init(root) { + super.init(root); + switch (this.engineType) { + case EngineType.ENGINE_TYPE_PISTON: + this.addMessage(Annunciation_MessageType.WARNING, "OIL PRESSURE", this.OilPressure); + this.addMessage(Annunciation_MessageType.WARNING, "LOW VOLTS", this.LowVoltage); + this.addMessage(Annunciation_MessageType.WARNING, "HIGH VOLTS", this.HighVoltage); + this.addMessage(Annunciation_MessageType.WARNING, "CO LVL HIGH", this.COLevelHigh); + this.addMessage(Annunciation_MessageType.CAUTION, "STBY BATT", this.StandByBattery); + this.addMessage(Annunciation_MessageType.CAUTION, "LOW VACUUM", this.LowVaccum); + this.addMessage(Annunciation_MessageType.CAUTION, "LOW FUEL R", this.LowFuelR); + this.addMessage(Annunciation_MessageType.CAUTION, "LOW FUEL L", this.LowFuelL); + break; + case EngineType.ENGINE_TYPE_TURBOPROP: + case EngineType.ENGINE_TYPE_JET: + this.addMessage(Annunciation_MessageType.WARNING, "FUEL OFF", this.fuelOff); + this.addMessage(Annunciation_MessageType.WARNING, "FUEL PRESS", this.fuelPress); + this.addMessage(Annunciation_MessageType.WARNING, "OIL PRESS", this.oilPressWarning); + this.addMessageMultipleConditions(Annunciation_MessageType.WARNING, "ITT", [ + new Condition(this.itt.bind(this, "1000")), + new Condition(this.itt.bind(this, "870"), 5), + new Condition(this.itt.bind(this, "840"), 20) + ]); + this.addMessage(Annunciation_MessageType.WARNING, "FLAPS ASYM", this.flapsAsym); + this.addMessage(Annunciation_MessageType.WARNING, "ELEC FEATH FAULT", this.elecFeathFault); + this.addMessage(Annunciation_MessageType.WARNING, "BLEED TEMP", this.bleedTemp); + this.addMessage(Annunciation_MessageType.WARNING, "CABIN ALTITUDE", this.cabinAltitude); + this.addMessage(Annunciation_MessageType.WARNING, "EDM", this.edm); + this.addMessage(Annunciation_MessageType.WARNING, "CABIN DIFF PRESS", this.cabinDiffPress); + this.addMessage(Annunciation_MessageType.WARNING, "DOOR", this.door); + this.addMessage(Annunciation_MessageType.WARNING, "USP ACTIVE", this.uspActive); + this.addMessage(Annunciation_MessageType.WARNING, "GEAR UNSAFE", this.gearUnsafe); + this.addMessage(Annunciation_MessageType.WARNING, "PARK BRAKE", this.parkBrake); + this.addMessage(Annunciation_MessageType.WARNING, "OXYGEN", this.oxygen); + this.addMessage(Annunciation_MessageType.CAUTION, "OIL PRESS", this.oilPressCaution); + this.addMessage(Annunciation_MessageType.CAUTION, "CHIP", this.chip); + this.addMessage(Annunciation_MessageType.CAUTION, "OIL TEMP", this.oilTemp); + this.addMessage(Annunciation_MessageType.CAUTION, "AUX BOOST PMP ON", this.auxBoostPmpOn); + this.addMessageSwitch(Annunciation_MessageType.CAUTION, ["FUEL LOW L", "FUEL LOW R", "FUEL LOW L-R"], this.fuelLowSelector); + this.addMessage(Annunciation_MessageType.CAUTION, "AUTO SEL", this.autoSel); + this.addMessageTimed(Annunciation_MessageType.CAUTION, "FUEL IMBALANCE", this.fuelImbalance, 30); + this.addMessageSwitch(Annunciation_MessageType.CAUTION, ["LOW LVL FAIL L", "LOW LVL FAIL R", "LOW LVL FAIL L-R"], this.lowLvlFailSelector); + this.addMessage(Annunciation_MessageType.CAUTION, "BAT OFF", this.batOff); + this.addMessage(Annunciation_MessageType.CAUTION, "BAT AMP", this.batAmp); + this.addMessage(Annunciation_MessageType.CAUTION, "MAIN GEN", this.mainGen); + this.addMessage(Annunciation_MessageType.CAUTION, "LOW VOLTAGE", this.lowVoltage); + this.addMessage(Annunciation_MessageType.CAUTION, "BLEED OFF", this.bleedOff); + this.addMessage(Annunciation_MessageType.CAUTION, "USE OXYGEN MASK", this.useOxygenMask); + this.addMessage(Annunciation_MessageType.CAUTION, "VACUUM LOW", this.vacuumLow); + this.addMessage(Annunciation_MessageType.CAUTION, "PROP DEICE FAIL", this.propDeiceFail); + this.addMessage(Annunciation_MessageType.CAUTION, "INERT SEP FAIL", this.inertSepFail); + this.addMessageSwitch(Annunciation_MessageType.CAUTION, ["PITOT NO HT L", "PITOT NO HT R", "PITOT NO HT L-R"], this.pitotNoHtSelector); + this.addMessageSwitch(Annunciation_MessageType.CAUTION, ["PITOT HT ON L", "PITOT HT ON R", "PITOT HT ON L-R"], this.pitotHtOnSelector); + this.addMessage(Annunciation_MessageType.CAUTION, "STALL NO HEAT", this.stallNoHeat); + this.addMessage(Annunciation_MessageType.CAUTION, "STALL HEAT ON", this.stallHeatOn); + this.addMessage(Annunciation_MessageType.CAUTION, "FRONT CARGO DOOR", this.frontCargoDoor); + this.addMessage(Annunciation_MessageType.CAUTION, "GPU DOOR", this.gpuDoor); + this.addMessage(Annunciation_MessageType.CAUTION, "IGNITION", this.ignition); + this.addMessage(Annunciation_MessageType.CAUTION, "STARTER", this.starter); + this.addMessage(Annunciation_MessageType.CAUTION, "MAX DIFF MODE", this.maxDiffMode); + this.addMessage(Annunciation_MessageType.CAUTION, "CPCS BACK UP MODE", this.cpcsBackUpMode); + break; + } + } + sayTrue() { + return true; + } + SafePropHeat() { + return false; + } + CautionPropHeat() { + return false; + } + StandByBattery() { + return false; + } + LowVaccum() { + return SimVar.GetSimVarValue("WARNING VACUUM", "Boolean"); + } + LowPower() { + return false; + } + LowFuelR() { + return SimVar.GetSimVarValue("FUEL RIGHT QUANTITY", "gallon") < 5; + } + LowFuelL() { + return SimVar.GetSimVarValue("FUEL LEFT QUANTITY", "gallon") < 5; + } + FuelTempFailed() { + return false; + } + ECUMinorFault() { + return false; + } + PitchTrim() { + return false; + } + StartEngage() { + return false; + } + OilPressure() { + return SimVar.GetSimVarValue("WARNING OIL PRESSURE", "Boolean"); + } + LowFuelPressure() { + const pressure = SimVar.GetSimVarValue("ENG FUEL PRESSURE", "psi"); + if (pressure <= 1) { + return true; + } + return false; + } + LowVoltage() { + const voltage = SimVar.GetSimVarValue("ELECTRICAL MAIN BUS VOLTAGE", "volts"); + if (voltage < 24) { + return true; + } + return false; + } + HighVoltage() { + const voltage = SimVar.GetSimVarValue("ELECTRICAL MAIN BUS VOLTAGE", "volts"); + if (voltage > 32) { + return true; + } + return false; + } + FuelTemperature() { + return false; + } + ECUMajorFault() { + return false; + } + COLevelHigh() { + return false; + } + fuelOff() { + return (SimVar.GetSimVarValue("FUEL TANK SELECTOR:1", "number") == 0); + } + fuelPress() { + return (SimVar.GetSimVarValue("GENERAL ENG FUEL PRESSURE:1", "psi") <= 10); + } + oilPressWarning() { + return (SimVar.GetSimVarValue("ENG OIL PRESSURE:1", "psi") <= 60); + } + itt(_limit = 840) { + const itt = SimVar.GetSimVarValue("TURB ENG ITT:1", "celsius"); + return (itt > _limit); + } + flapsAsym() { + return false; + } + elecFeathFault() { + return false; + } + bleedTemp() { + return false; + } + cabinAltitude() { + return SimVar.GetSimVarValue("PRESSURIZATION CABIN ALTITUDE", "feet") > 10000; + } + edm() { + return false; + } + cabinDiffPress() { + return SimVar.GetSimVarValue("PRESSURIZATION PRESSURE DIFFERENTIAL", "psi") > 6.2; + } + door() { + return SimVar.GetSimVarValue("EXIT OPEN:0", "percent") > 0; + } + uspActive() { + return false; + } + gearUnsafe() { + return false; + } + parkBrake() { + return SimVar.GetSimVarValue("L:A32NX_PARK_BRAKE_LEVER_POS", "Bool"); + } + oxygen() { + return false; + } + oilPressCaution() { + const press = SimVar.GetSimVarValue("ENG OIL PRESSURE:1", "psi"); + return (press <= 105 && press >= 60); + } + chip() { + return false; + } + oilTemp() { + const temp = SimVar.GetSimVarValue("GENERAL ENG OIL TEMPERATURE:1", "celsius"); + return (temp <= 0 || temp >= 104); + } + auxBoostPmpOn() { + return SimVar.GetSimVarValue("GENERAL ENG FUEL PUMP ON:1", "Bool"); + } + fuelLowSelector() { + const left = SimVar.GetSimVarValue("FUEL TANK LEFT MAIN QUANTITY", "gallon") < 9; + const right = SimVar.GetSimVarValue("FUEL TANK RIGHT MAIN QUANTITY", "gallon") < 9; + if (left && right) { + return 3; + } else if (left) { + return 1; + } else if (right) { + return 2; + } else { + return 0; + } + } + autoSel() { + return false; + } + fuelImbalance() { + const left = SimVar.GetSimVarValue("FUEL TANK LEFT MAIN QUANTITY", "gallon"); + const right = SimVar.GetSimVarValue("FUEL TANK RIGHT MAIN QUANTITY", "gallon"); + return Math.abs(left - right) > 15; + } + lowLvlFailSelector() { + return false; + } + batOff() { + return !SimVar.GetSimVarValue("ELECTRICAL MASTER BATTERY", "Bool"); + } + batAmp() { + return SimVar.GetSimVarValue("ELECTRICAL BATTERY BUS AMPS", "amperes") > 50; + } + mainGen() { + return !SimVar.GetSimVarValue("GENERAL ENG GENERATOR SWITCH:1", "Bool"); + } + lowVoltage() { + return SimVar.GetSimVarValue("ELECTRICAL MAIN BUS VOLTAGE", "volts") < 24.5; + } + bleedOff() { + return SimVar.GetSimVarValue("BLEED AIR SOURCE CONTROL", "Enum") == 1; + } + useOxygenMask() { + return SimVar.GetSimVarValue("PRESSURIZATION CABIN ALTITUDE", "feet") > 10000; + } + vacuumLow() { + return SimVar.GetSimVarValue("PARTIAL PANEL VACUUM", "Enum") == 1; + } + propDeiceFail() { + return false; + } + inertSepFail() { + return false; + } + pitotNoHtSelector() { + return 0; + } + pitotHtOnSelector() { + return 0; + } + stallNoHeat() { + return false; + } + stallHeatOn() { + return false; + } + frontCargoDoor() { + return false; + } + gpuDoor() { + return false; + } + ignition() { + return SimVar.GetSimVarValue("TURB ENG IS IGNITING:1", "Bool"); + } + starter() { + return SimVar.GetSimVarValue("GENERAL ENG STARTER ACTIVE:1", "Bool"); + } + maxDiffMode() { + return SimVar.GetSimVarValue("BLEED AIR SOURCE CONTROL", "Enum") == 3; + } + cpcsBackUpMode() { + return false; + } +} +class Warning_Data { + constructor(_shortText, _longText, _soundEvent, _Level, _callback, _once = false) { + this.hasPlayed = false; + this.shortText = _shortText; + this.longText = _longText; + this.soundEvent = _soundEvent; + this.soundEventId = new Name_Z(this.soundEvent); + this.level = _Level; + this.callback = _callback; + this.once = _once; + } +} +class Warning_Data_XML extends Warning_Data { + constructor(_gps, _shortText, _longText, _soundEvent, _Level, _logicElement, _once = false) { + super(_shortText, _longText, _soundEvent, _Level, null, _once); + this.xmlLogic = new CompositeLogicXMLElement(_gps, _logicElement); + this.callback = this.getXMLBoolean.bind(this); + } + getXMLBoolean() { + return this.xmlLogic.getValue() != 0; + } +} +class Warnings extends NavSystemElement { + constructor() { + super(...arguments); + this.warnings = []; + this.playingSounds = []; + this.pullUp_sinkRate_Points = [ + [1160, 0, 0], + [2320, 1070, 1460], + [4930, 2380, 2980], + [11600, 4285, 5360] + ]; + } + init(_root) { + let alertsFromXML = false; + if (this.gps.xmlConfig) { + const alertsGroup = this.gps.xmlConfig.getElementsByTagName("VoicesAlerts"); + if (alertsGroup.length > 0) { + alertsFromXML = true; + const alerts = alertsGroup[0].getElementsByTagName("Alert"); + for (let i = 0; i < alerts.length; i++) { + const typeParam = alerts[i].getElementsByTagName("Type"); + let type = 0; + if (typeParam.length > 0) { + switch (typeParam[0].textContent) { + case "Warning": + type = 3; + break; + case "Caution": + type = 2; + break; + case "Test": + type = 1; + break; + case "SoundOnly": + type = 0; + break; + } + } + let shortText = ""; + let longText = ""; + if (type != 0) { + const shortTextElem = alerts[i].getElementsByTagName("ShortText"); + if (shortTextElem.length > 0) { + shortText = shortTextElem[0].textContent; + } + const longTextElem = alerts[i].getElementsByTagName("LongText"); + if (longTextElem.length > 0) { + longText = longTextElem[0].textContent; + } + } + let soundEvent = ""; + const soundEventElem = alerts[i].getElementsByTagName("SoundEvent"); + if (soundEventElem.length > 0) { + soundEvent = soundEventElem[0].textContent; + } + const condition = alerts[i].getElementsByTagName("Condition")[0]; + let once = false; + const onceElement = alerts[i].getElementsByTagName("Once"); + if (onceElement.length > 0 && onceElement[0].textContent == "True") { + once = true; + } + this.warnings.push(new Warning_Data_XML(this.gps, shortText, longText, soundEvent, type, condition, once)); + } + } + } + if (!alertsFromXML) { + this.warnings.push(new Warning_Data("", "", "Garmin_Stall_f", 0, this.stallCallback.bind(this))); + this.warnings.push(new Warning_Data("PULL UP", "PULL UP", "Garmin_Pull_Up_f", 3, this.pullUpCallback.bind(this))); + this.warnings.push(new Warning_Data("TERRAIN", "SINK RATE", "Garmin_Sink_Rate_f", 2, this.sinkRateCallback.bind(this))); + this.warnings.push(new Warning_Data("", "", "Garmin_landing_gear_f", 0, this.landingGearCallback.bind(this))); + this.warnings.push(new Warning_Data("TAWS TEST", "", "", 1, this.tawsTestCallback.bind(this))); + this.warnings.push(new Warning_Data("", "", "Garmin_TAWS_System_Test_OK_f", 0, this.tawsTestFinishedCallback.bind(this), true)); + } + this.UID = parseInt(this.gps.getAttribute("Guid")) + 1; + SimVar.SetSimVarValue("L:AS1000_Warnings_Master_Set", "number", 0); + } + onUpdate(_deltaTime) { + const masterSet = SimVar.GetSimVarValue("L:AS1000_Warnings_Master_Set", "number"); + if (masterSet == 0) { + SimVar.SetSimVarValue("L:AS1000_Warnings_Master_Set", "number", this.UID); + } else if (masterSet == this.UID) { + let found = false; + let foundText = false; + let bestWarning = 0; + for (let i = 0; i < this.warnings.length; i++) { + const warning = this.warnings[i]; + if (!warning.once || !warning.hasPlayed) { + if (warning.callback()) { + if (warning.soundEvent != "") { + if ((this.playingSounds.length <= 0) || (i < this.playingSounds[this.playingSounds.length - 1])) { + const res = this.gps.playInstrumentSound(warning.soundEvent); + if (res) { + this.playingSounds.push(i); + warning.hasPlayed = true; + } + } + } + if (!foundText) { + bestWarning = i; + } + if (warning.shortText || warning.longText) { + foundText = true; + } + found = true; + } + } + } + if (found) { + SimVar.SetSimVarValue("L:AS1000_Warnings_WarningIndex", "number", bestWarning + 1); + } else { + SimVar.SetSimVarValue("L:AS1000_Warnings_WarningIndex", "number", 0); + } + } + } + onSoundEnd(_eventId) { + let i = 0; + while (i < this.playingSounds.length) { + const soundId = this.playingSounds[i]; + if (Name_Z.compare(this.warnings[soundId].soundEventId, _eventId)) { + this.playingSounds.splice(i, 1); + continue; + } + i++; + } + } + onShutDown() { + for (let i = 0; i < this.warnings.length; i++) { + this.warnings[i].hasPlayed = false; + } + this.playingSounds = []; + } + linearMultiPointsEvaluation(_points, _valueX, _valueY) { + let lastLowerIndex = -1; + for (let i = 0; i < _points.length; i++) { + if (_valueX > _points[i][0]) { + lastLowerIndex = i; + } else { + break; + } + } + if (lastLowerIndex == _points.length - 1) { + for (let i = 1; i < _points[lastLowerIndex].length; i++) { + if (_valueY < _points[lastLowerIndex][i]) { + return i; + } + } + return _points[lastLowerIndex].length; + } else if (lastLowerIndex == -1) { + for (let i = 1; i < _points[0].length; i++) { + if (_valueY < _points[0][i]) { + return i; + } + } + return _points[0].length; + } else { + const factorLower = (_valueX - _points[lastLowerIndex][0]) / _points[lastLowerIndex + 1][0]; + for (let i = 1; i < _points[lastLowerIndex].length; i++) { + const limit = _points[lastLowerIndex][i] * factorLower + _points[lastLowerIndex + 1][i] * (1 - factorLower); + if (_valueY < limit) { + return i; + } + } + return _points[lastLowerIndex].length; + } + } + pullUpCallback() { + const height = SimVar.GetSimVarValue("PLANE ALT ABOVE GROUND", "feet"); + const descentRate = -SimVar.GetSimVarValue("VERTICAL SPEED", "feet per minute"); + return this.linearMultiPointsEvaluation(this.pullUp_sinkRate_Points, descentRate, height) == 1; + } + sinkRateCallback() { + const height = SimVar.GetSimVarValue("PLANE ALT ABOVE GROUND", "feet"); + const descentRate = -SimVar.GetSimVarValue("VERTICAL SPEED", "feet per minute"); + return this.linearMultiPointsEvaluation(this.pullUp_sinkRate_Points, descentRate, height) == 2; + } + landingGearCallback() { + const gear = !SimVar.GetSimVarValue("IS GEAR RETRACTABLE", "Boolean") || SimVar.GetSimVarValue("L:A32NX_GEAR_HANDLE_POSITION", "Percent over 100") > 0.5; + const throttle = SimVar.GetSimVarValue("L:A32NX_AUTOTHRUST_TLA:1", "number"); + const flaps = SimVar.GetSimVarValue("L:A32NX_FLAPS_HANDLE_INDEX", "number"); + return !gear && (flaps > 1 || (throttle == 0)); + } + stallCallback() { + return SimVar.GetSimVarValue("STALL WARNING", "Boolean"); + } + tawsTestCallback() { + return this.gps.getTimeSinceStart() < 30000; + } + tawsTestFinishedCallback() { + return this.gps.getTimeSinceStart() >= 30000; + } +} +class Cabin_Warnings extends Warnings { + constructor() { + super(...arguments); + this.currentWarningLevel = 0; + this.currentWarningText = ""; + } + init(root) { + super.init(root); + this.alwaysUpdate = true; + } + onUpdate(_deltaTime) { + super.onUpdate(_deltaTime); + const warningIndex = SimVar.GetSimVarValue("L:AS1000_Warnings_WarningIndex", "number"); + let warningText; + let warningLevel; + if (warningIndex <= 0 || warningIndex >= this.warnings.length) { + warningText = ""; + warningLevel = 0; + } else { + warningText = this.warnings[warningIndex - 1].shortText; + warningLevel = this.warnings[warningIndex - 1].level; + } + if (this.currentWarningLevel != warningLevel || this.currentWarningText != warningText) { + this.currentWarningText = warningText; + this.currentWarningLevel = warningLevel; + if (this.warningBox && this.warningContent) { + this.warningContent.textContent = warningText; + switch (warningLevel) { + case 0: + this.warningBox.setAttribute("state", "Hidden"); + break; + case 1: + this.warningBox.setAttribute("state", "White"); + break; + case 2: + this.warningBox.setAttribute("state", "Yellow"); + break; + case 3: + this.warningBox.setAttribute("state", "Red"); + break; + } + } + } + } + getCurrentWarningLevel() { + return this.currentWarningLevel; + } + getCurrentWarningText() { + return this.currentWarningText; + } + onEnter() { } + onExit() { } + onEvent(_event) { } +} +class GlassCockpit_XMLEngine extends NavSystemElement { + constructor() { + super(...arguments); + this.xmlEngineDisplay = null; + this._t = 0; + } + init(_root) { + if (this.gps.xmlConfig) { + const engineRoot = this.gps.xmlConfig.getElementsByTagName("EngineDisplay"); + if (engineRoot.length > 0) { + this.xmlEngineDisplay = _root.querySelector("glasscockpit-xmlenginedisplay"); + this.xmlEngineDisplay.setConfiguration(this.gps, engineRoot[0]); + } + } + } + onEnter() { + } + onExit() { + } + onUpdate(_deltaTime) { + if (this.xmlEngineDisplay) { + this.xmlEngineDisplay.update(_deltaTime); + } + this._t++; + if (this._t > 30) { + this.gps.currFlightPlanManager.updateFlightPlan(); + this._t = 0; + } + } + onEvent(_event) { + this.xmlEngineDisplay.onEvent(_event); + } + onSoundEnd(_eventId) { + super.onSoundEnd(_eventId); + this.xmlEngineDisplay.onSoundEnd(_eventId); + } +} diff --git a/fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts b/fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts index a9eecc3689c..c7503cf9bac 100644 --- a/fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts +++ b/fbw-a380x/src/systems/atsu/src/com/vhf/VDL.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0 import { FmgcFlightPhase } from '@shared/flightphase'; -import { MathUtils } from '@shared/MathUtils'; +import { MathUtils } from '@flybywiresim/fbw-sdk'; import { AtsuMessage, AtsuMessageSerializationFormat } from '../../messages/AtsuMessage'; import { DatalinkProviders, OwnAircraft, MaxSearchRange } from './Common'; import { Vhf } from './VHF'; diff --git a/fbw-a380x/src/systems/extras-host/tsconfig.json b/fbw-a380x/src/systems/extras-host/tsconfig.json index b0f095d905f..42630ce4c75 100644 --- a/fbw-a380x/src/systems/extras-host/tsconfig.json +++ b/fbw-a380x/src/systems/extras-host/tsconfig.json @@ -11,7 +11,7 @@ "@instruments/common/*": ["./instruments/src/Common/*"], "@localization/*": ["../localization/*"], "@sentry/*": ["./sentry-client/src/*"], - "@simbridge/*": ["./simbridge-client/src/*"], + "@simbridge/*": ["../../../fbw-a32nx/src/systems/simbridge-client/src/*"], "@shared/*": ["./shared/src/*"], "@tcas/*": ["./tcas/src/*"], "@typings/*": ["../../../fbw-common/src/typings/*"], diff --git a/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/DATA/MfdFmsDataStatus.tsx b/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/DATA/MfdFmsDataStatus.tsx index 6149ef62f8a..2021f3f9d8c 100644 --- a/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/DATA/MfdFmsDataStatus.tsx +++ b/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/DATA/MfdFmsDataStatus.tsx @@ -1,3 +1,6 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + import { ClockEvents, FSComponent, Subject, VNode } from '@microsoft/msfs-sdk'; import './MfdFmsDataStatus.scss'; @@ -19,7 +22,7 @@ const monthLength = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; export class MfdFmsDataStatus extends FmsPage { private selectedPageIndex = Subject.create(0); - private navDatabase = Subject.create('LH72301001'); + private navDatabase = Subject.create('FBW2301001'); private activeDatabase = Subject.create('30DEC-27JAN'); diff --git a/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/FcuBusPublisher.ts b/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/FcuBusPublisher.ts index 29f9678db84..d68d8eedda4 100644 --- a/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/FcuBusPublisher.ts +++ b/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/FcuBusPublisher.ts @@ -1,5 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations -// +// Copyright (c) 2024 FlyByWire Simulations // SPDX-License-Identifier: GPL-3.0 import { EventBus, SimVarPublisher, SimVarValueType } from '@microsoft/msfs-sdk'; @@ -11,6 +10,7 @@ export interface FcuSimVars { option: EfisOption, navaidMode1: NavAidMode, navaidMode2: NavAidMode, + oansRange: number, /** State of the LS pushbutton on the EFIS control panel. */ efisLsActive: boolean, } @@ -24,6 +24,7 @@ export class FcuBusPublisher extends SimVarPublisher { ['navaidMode1', { name: `L:A32NX_EFIS_${efisSide}_NAVAID_1_MODE`, type: SimVarValueType.Enum }], ['navaidMode2', { name: `L:A32NX_EFIS_${efisSide}_NAVAID_2_MODE`, type: SimVarValueType.Enum }], ['efisLsActive', { name: `L:BTN_LS_${efisSide === 'L' ? 1 : 2}_FILTER_ACTIVE`, type: SimVarValueType.Bool }], + ['oansRange', { name: `L:A32NX_EFIS_${efisSide}_OANS_RANGE`, type: SimVarValueType.Number }], ]), bus); } } diff --git a/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/FmsOansPublisher.ts b/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/FmsOansPublisher.ts index 475df9bd5e2..84cdaef1d88 100644 --- a/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/FmsOansPublisher.ts +++ b/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/FmsOansPublisher.ts @@ -1,5 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations -// +// Copyright (c) 2024 FlyByWire Simulations // SPDX-License-Identifier: GPL-3.0 import { Arinc429Word, ArincEventBus } from '@flybywiresim/fbw-sdk'; diff --git a/fbw-a380x/src/systems/instruments/src/ND/.eslintrc.js b/fbw-a380x/src/systems/instruments/src/ND/.eslintrc.js index 6dfe57d6d59..6026632e704 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/.eslintrc.js +++ b/fbw-a380x/src/systems/instruments/src/ND/.eslintrc.js @@ -1,10 +1,14 @@ 'use strict'; module.exports = { - - extends: '../../../../../../.eslintrc.js', - - // overrides airbnb, use sparingly - rules: { 'react/react-in-jsx-scope': 'off', 'react/no-unknown-property': 'off', 'react/style-prop-object': 'off', 'arrow-body-style': 'off', 'camelcase': 'off' }, - + extends: '../../../../../../.eslintrc.js', + + // overrides airbnb, use sparingly + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/no-unknown-property': 'off', + 'react/style-prop-object': 'off', + 'arrow-body-style': 'off', + camelcase: 'off', + }, }; diff --git a/fbw-a380x/src/systems/instruments/src/ND/FmsSymbolsPublisher.ts b/fbw-a380x/src/systems/instruments/src/ND/FmsSymbolsPublisher.ts index c4138aed633..780c8427574 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/FmsSymbolsPublisher.ts +++ b/fbw-a380x/src/systems/instruments/src/ND/FmsSymbolsPublisher.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations +// Copyright (c) 2021-2024 FlyByWire Simulations // // SPDX-License-Identifier: GPL-3.0 @@ -8,52 +8,68 @@ import { EfisSide, NdSymbol, NdTraffic, GenericDataListenerSync } from '@flybywi import { PathVector } from '@fmgc/guidance/lnav/PathVector'; export interface FmsSymbolsData { - symbols: NdSymbol[], - vectorsActive: PathVector[], - vectorsDashed: PathVector[], - vectorsTemporary: PathVector[], - vectorsMissed: PathVector[], - vectorsAlternate: PathVector[], - vectorsSecondary: PathVector[], - traffic: NdTraffic[], + symbols: NdSymbol[]; + vectorsActive: PathVector[]; + vectorsDashed: PathVector[]; + vectorsTemporary: PathVector[]; + vectorsMissed: PathVector[]; + vectorsAlternate: PathVector[]; + vectorsSecondary: PathVector[]; + traffic: NdTraffic[]; } export class FmsSymbolsPublisher extends BasePublisher { - private readonly events: GenericDataListenerSync[] = []; + private readonly events: GenericDataListenerSync[] = []; - constructor(bus: EventBus, side: EfisSide) { - super(bus); + constructor(bus: EventBus, side: EfisSide) { + super(bus); - this.events.push(new GenericDataListenerSync((ev, data) => { - this.publish('symbols', data); - }, `A32NX_EFIS_${side}_SYMBOLS`)); + this.events.push( + new GenericDataListenerSync((ev, data) => { + this.publish('symbols', data); + }, `A32NX_EFIS_${side}_SYMBOLS`), + ); - this.events.push(new GenericDataListenerSync((ev, data: PathVector[]) => { - this.publish('vectorsActive', data); - }, `A32NX_EFIS_VECTORS_${side}_ACTIVE`)); + this.events.push( + new GenericDataListenerSync((ev, data: PathVector[]) => { + this.publish('vectorsActive', data); + }, `A32NX_EFIS_VECTORS_${side}_ACTIVE`), + ); - this.events.push(new GenericDataListenerSync((ev, data: PathVector[]) => { - this.publish('vectorsDashed', data); - }, `A32NX_EFIS_VECTORS_${side}_DASHED`)); + this.events.push( + new GenericDataListenerSync((ev, data: PathVector[]) => { + this.publish('vectorsDashed', data); + }, `A32NX_EFIS_VECTORS_${side}_DASHED`), + ); - this.events.push(new GenericDataListenerSync((ev, data: PathVector[]) => { - this.publish('vectorsTemporary', data); - }, `A32NX_EFIS_VECTORS_${side}_TEMPORARY`)); + this.events.push( + new GenericDataListenerSync((ev, data: PathVector[]) => { + this.publish('vectorsTemporary', data); + }, `A32NX_EFIS_VECTORS_${side}_TEMPORARY`), + ); - this.events.push(new GenericDataListenerSync((ev, data: PathVector[]) => { - this.publish('vectorsMissed', data); - }, `A32NX_EFIS_VECTORS_${side}_MISSED`)); + this.events.push( + new GenericDataListenerSync((ev, data: PathVector[]) => { + this.publish('vectorsMissed', data); + }, `A32NX_EFIS_VECTORS_${side}_MISSED`), + ); - this.events.push(new GenericDataListenerSync((ev, data: PathVector[]) => { - this.publish('vectorsAlternate', data); - }, `A32NX_EFIS_VECTORS_${side}_ALTERNATE`)); + this.events.push( + new GenericDataListenerSync((ev, data: PathVector[]) => { + this.publish('vectorsAlternate', data); + }, `A32NX_EFIS_VECTORS_${side}_ALTERNATE`), + ); - this.events.push(new GenericDataListenerSync((ev, data: PathVector[]) => { - this.publish('vectorsSecondary', data); - }, `A32NX_EFIS_VECTORS_${side}_SECONDARY`)); + this.events.push( + new GenericDataListenerSync((ev, data: PathVector[]) => { + this.publish('vectorsSecondary', data); + }, `A32NX_EFIS_VECTORS_${side}_SECONDARY`), + ); - this.events.push(new GenericDataListenerSync((ev, data: NdTraffic[]) => { - this.publish('traffic', data); - }, 'A32NX_TCAS_TRAFFIC')); - } + this.events.push( + new GenericDataListenerSync((ev, data: NdTraffic[]) => { + this.publish('traffic', data); + }, 'A32NX_TCAS_TRAFFIC'), + ); + } } diff --git a/fbw-a380x/src/systems/instruments/src/ND/NDControlEvents.ts b/fbw-a380x/src/systems/instruments/src/ND/NDControlEvents.ts index f35c8bd11ee..253518737e2 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/NDControlEvents.ts +++ b/fbw-a380x/src/systems/instruments/src/ND/NDControlEvents.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations +// Copyright (c) 2021-2024 FlyByWire Simulations // // SPDX-License-Identifier: GPL-3.0 @@ -8,73 +8,73 @@ import { EfisNdMode } from '@flybywiresim/fbw-sdk'; export interface NDControlEvents { - /** - * Set if the plane icon is visible - */ - set_show_plane: boolean, + /** + * Set if the plane icon is visible + */ + set_show_plane: boolean; - /** - * Set the X position of the plane icon (0 to 786) - */ - set_plane_x: number, + /** + * Set the X position of the plane icon (0 to 786) + */ + set_plane_x: number; - /** - * Set the Y position of the plane icon (0 to 786) - */ - set_plane_y: number, + /** + * Set the Y position of the plane icon (0 to 786) + */ + set_plane_y: number; - /** - * Set the rotation of the plane icon (degrees) - */ - set_plane_rotation: number, + /** + * Set the rotation of the plane icon (degrees) + */ + set_plane_rotation: number; - /** - * Set if the map is visible - */ - set_show_map: boolean, + /** + * Set if the map is visible + */ + set_show_map: boolean; - /** - * Set if the map is recomputing (RANGE CHANGE, MODE CHANGE) - */ - set_map_recomputing: boolean, + /** + * Set if the map is recomputing (RANGE CHANGE, MODE CHANGE) + */ + set_map_recomputing: boolean; - /** - * Set the center latitude of the map - */ - set_map_center_lat: number, + /** + * Set the center latitude of the map + */ + set_map_center_lat: number; - /** - * Set the center longitude of the map - */ - set_map_center_lon: number, + /** + * Set the center longitude of the map + */ + set_map_center_lon: number; - /** - * Set the center Y-axis bias of the map - */ - set_map_center_y_bias: number, + /** + * Set the center Y-axis bias of the map + */ + set_map_center_y_bias: number; - /** - * Set the true course up of the map - */ - set_map_up_course: number, + /** + * Set the true course up of the map + */ + set_map_up_course: number; - /** - * Set the pixel radius of the map - */ - set_map_pixel_radius: number, + /** + * Set the pixel radius of the map + */ + set_map_pixel_radius: number; - /** - * Set the range radius of the map - */ - set_map_range_radius: number, + /** + * Set the range radius of the map + */ + set_map_range_radius: number; - /** - * Set the EFIS ND mode of the map - */ - set_map_efis_mode: EfisNdMode, + /** + * Set the EFIS ND mode of the map + */ + set_map_efis_mode: EfisNdMode; - /** - * Event for the CHRONO button being pushed - */ - chrono_pushed: void, + /** + * Event for the CHRONO button being pushed + */ + chrono_pushed: void; } diff --git a/fbw-a380x/src/systems/instruments/src/ND/NDSimvarPublisher.tsx b/fbw-a380x/src/systems/instruments/src/ND/NDSimvarPublisher.tsx index b42d5be990f..27b5bf55328 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/NDSimvarPublisher.tsx +++ b/fbw-a380x/src/systems/instruments/src/ND/NDSimvarPublisher.tsx @@ -1,3 +1,6 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + import { EventBus, SimVarDefinition, SimVarValueType } from '@microsoft/msfs-sdk'; import { AdirsSimVarDefinitions, @@ -19,6 +22,8 @@ export type NDSimvars = AdirsSimVars & pposLat: Degrees; pposLong: Degrees; absoluteTime: Seconds; + kccuOnL: boolean; + kccuOnR: boolean; }; export enum NDVars { @@ -32,6 +37,8 @@ export enum NDVars { pposLat = 'PLANE LATITUDE', // TODO replace with fm position pposLong = 'PLANE LONGITUDE', // TODO replace with fm position absoluteTime = 'E:ABSOLUTE TIME', + kccuOnL = 'L:A32NX_KCCU_L_KBD_ON_OFF', + kccuOnR = 'L:A32NX_KCCU_R_KBD_ON_OFF', } /** A publisher to poll and publish nav/com simvars. */ @@ -49,6 +56,8 @@ export class NDSimvarPublisher extends UpdatableSimVarPublisher { ['pposLat', { name: NDVars.pposLat, type: SimVarValueType.Degree }], ['pposLong', { name: NDVars.pposLong, type: SimVarValueType.Degree }], ['absoluteTime', { name: NDVars.absoluteTime, type: SimVarValueType.Seconds }], + ['kccuOnL', { name: NDVars.kccuOnL, type: SimVarValueType.Bool }], + ['kccuOnR', { name: NDVars.kccuOnR, type: SimVarValueType.Bool }], ]); public constructor(bus: EventBus) { diff --git a/fbw-a380x/src/systems/instruments/src/ND/OANSRunwayInfoBox.tsx b/fbw-a380x/src/systems/instruments/src/ND/OANSRunwayInfoBox.tsx new file mode 100644 index 00000000000..c52741abff9 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/ND/OANSRunwayInfoBox.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { DisplayComponent, FSComponent, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import './oans-style.scss'; +import { EntityTypes } from './OansControlPanel'; + +interface OansRunwayInfoBoxProps { + rwyOrStand: Subscribable; + selectedEntity: Subscribable; + tora: Subscribable; + lda: Subscribable; + ldaIsReduced: Subscribable; + coordinate: Subscribable; +} +export class OansRunwayInfoBox extends DisplayComponent { + private rwyDivRef = FSComponent.createRef(); + + private standDivRef = FSComponent.createRef(); + + private setDivs() { + this.rwyDivRef.instance.style.display = 'none'; + this.standDivRef.instance.style.display = 'none'; + + if (this.props.rwyOrStand.get() === EntityTypes.RWY && this.props.selectedEntity.get()) { + this.rwyDivRef.instance.style.display = 'grid'; + this.standDivRef.instance.style.display = 'none'; + } else if (this.props.rwyOrStand.get() === EntityTypes.STAND && this.props.selectedEntity.get()) { + this.rwyDivRef.instance.style.display = 'none'; + this.standDivRef.instance.style.display = 'flex'; + } else { + this.rwyDivRef.instance.style.display = 'none'; + this.standDivRef.instance.style.display = 'none'; + } + } + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.setDivs(); + + this.props.rwyOrStand.sub(() => this.setDivs()); + this.props.selectedEntity.sub(() => this.setDivs()); + } + + render(): VNode { + return ( + <> + + + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.scss b/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.scss new file mode 100644 index 00000000000..5255e9639c9 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.scss @@ -0,0 +1,223 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +@import "../MsfsAvionicsCommon/definitions.scss"; + +@font-face { + font-family: "Ecam"; + //noinspection CssUnknownTarget + src: url("/Fonts/FBW-Display-EIS-A380.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} +$display-mfd-darker-grey: #3c3c3c; + +.oans-info-box { + background-color: $display-mfd-darker-grey; + font-family: "Ecam", monospace !important; + border: 2px outset $display-light-grey; + padding: 9px 12px 5px 12px; +} + +.oans-cp-map-data-tab { + flex: 1; + display: flex; + flex-direction: row; + height: 100%; +} + +.oans-cp-map-data-left { + flex: 1; + display: flex; + flex-direction: column; + justify-content: stretch; +} + +.oans-cp-map-data-entitytype { + border-right: 2px solid lightgrey; + height: 100%; +} + +.oans-cp-map-data-ldg-shift { + display: none; + flex: 3; + flex-direction: column; + margin: 5px 20px 5px 20px; +} + +.oans-cp-map-data-ldg-shift-rwy { + flex: 1; + display: flex; + justify-content: space-between; + border-bottom: 1px solid lightgrey; +} + +.oans-cp-map-data-ldg-shift-1 { + flex: 5; + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 10px; +} + +.oans-cp-map-data-ldg-shift-2 { + display: flex; + flex-direction: column; +} + +.oans-cp-map-data-ldg-shift-3 { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: 50px 50px; + align-items: center; +} + +.oans-cp-map-data-ldg-shift-return-button { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 10px; +} + +.oans-cp-map-data-main { + display: flex; + flex: 3; + flex-direction: column; + margin: 0px 20px 0px 20px; +} + +.oans-cp-map-data-main-2 { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.oans-cp-map-data-main-center { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 10px; +} + +.oans-cp-map-data-btv-fallback { + display: flex; + flex: 3; + flex-direction: column; + margin: 0px 20px 0px 20px; +} + +.oans-cp-map-data-btv-button { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + margin: 10px; + margin-bottom: 40px; +} + +.oans-cp-map-data-btv-rwy-length { +display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + margin: 10px; +} + +.oans-cp-arpt-sel { + flex: 1; + display: flex; + flex-direction: row; + height: 100%; +} +.oans-cp-arpt-sel-2 { + width: 30%; + display: flex; flex-direction: column; + justify-content: stretch; +} + +.oans-cp-arpt-sel-radio { + padding-top: 20px; + margin-top: 2px; + height: 100%; +} + +.oans-cp-arpt-sel-middle { + display: flex; + flex: 2; + flex-direction: column; + margin: 5px 20px 5px 20px; +} + +.oans-cp-arpt-sel-middle-1 { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + margin: 10px; +} + +.oans-cp-arpt-sel-display-arpt { + display: flex; + flex-direction: row; + justify-content: center; + margin: 10px; +} + +.oans-cp-arpt-sel-fms { + width: 20%; + display: flex; + flex-direction: column; + margin-top: 20px; + margin-bottom: 20px; + justify-content: space-between; + align-items: center; + border-left: 2px solid lightgrey; +} + +.oans-cp-status { + display: flex; + flex-direction: row; + border-bottom: 2px solid lightgray; + padding-bottom: 25px; + margin-left: 30px; + margin-right: 30px; +} + +.oans-cp-status-active { + flex: 3; + display: flex; + flex-direction: column; + align-items: center; +} + +.oans-cp-status-2 { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; +} + +.oans-cp-status-second { + flex: 3; + display: flex; + flex-direction: column; + align-items: center; +} + +.oans-cp-status-db { + display: flex; + flex-direction: row; + justify-content: space-between; + border-bottom: 2px solid lightgray; + margin: 0px 15px 0px 15px; + padding: 25px 10px 25px 10px; +} + +.oans-cp-status-1 { + display: flex; + flex-direction: row; + justify-content: space-between; + justify-content: center; + margin-top: 20px; + height: 20px; +} diff --git a/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.tsx b/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.tsx new file mode 100644 index 00000000000..edd871e472f --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.tsx @@ -0,0 +1,852 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import './oans-style.scss'; +import './OansControlPanel.scss'; + +import { + ArraySubject, + ClockEvents, + ComponentProps, + DisplayComponent, + EventBus, + FSComponent, + MapSubject, + MappedSubject, + MappedSubscribable, + SimVarValueType, + Subject, + Subscribable, + Subscription, + VNode, +} from '@microsoft/msfs-sdk'; +import { + BrakeToVacateUtils, + ControlPanelAirportSearchMode, + ControlPanelStore, + ControlPanelUtils, + FmsDataStore, + FmsOansDataArinc429, + NavigraphAmdbClient, + OansControlEvents, + globalToAirportCoordinates, +} from '@flybywiresim/oanc'; +import { + AmdbAirportSearchResult, + Arinc429RegisterSubject, + EfisSide, + FeatureType, + FeatureTypeString, + MathUtils, + Runway, +} from '@flybywiresim/fbw-sdk'; + +import { Button } from 'instruments/src/MFD/pages/common/Button'; +import { OansRunwayInfoBox } from './OANSRunwayInfoBox'; +import { DropdownMenu } from 'instruments/src/MFD/pages/common/DropdownMenu'; +import { RadioButtonGroup } from 'instruments/src/MFD/pages/common/RadioButtonGroup'; +import { InputField } from 'instruments/src/MFD/pages/common/InputField'; +import { LengthFormat } from 'instruments/src/MFD/pages/common/DataEntryFormats'; +import { IconButton } from 'instruments/src/MFD/pages/common/IconButton'; +import { TopTabNavigator, TopTabNavigatorPage } from 'instruments/src/MFD/pages/common/TopTabNavigator'; +import { Coordinates, distanceTo, placeBearingDistance } from 'msfs-geo'; +import { AdirsSimVars } from 'instruments/src/MsfsAvionicsCommon/SimVarTypes'; +import { NavigationDatabase, NavigationDatabaseBackend, NavigationDatabaseService } from '@fmgc/index'; +import { InternalKccuKeyEvent } from 'instruments/src/MFD/shared/MFDSimvarPublisher'; +import { NDSimvars } from 'instruments/src/ND/NDSimvarPublisher'; +import { InteractionMode } from 'instruments/src/MFD/MFD'; + +export interface OansProps extends ComponentProps { + bus: EventBus; + side: EfisSide; + isVisible: Subscribable; + togglePanel: () => void; +} + +export enum EntityTypes { + RWY, + TWY, + STAND, + OTHER, +} + +const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; +const monthLength = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +export class OansControlPanel extends DisplayComponent { + private readonly subs: (Subscription | MappedSubscribable)[] = []; + + private readonly navigraphAvailable = Subject.create(false); + + private amdbClient = new NavigraphAmdbClient(); + + private readonly oansMenuRef = FSComponent.createRef(); + + private readonly airportSearchAirportDropdownRef = FSComponent.createRef(); + + private readonly displayAirportButtonRef = FSComponent.createRef
+
+ + +
+
+
+
+
+ +
+
+
+
+ BTV MANUAL CONTROL / FALLBACK +
+
+
+
+ RUNWAY LENGTH +
+ + {this.runwayLda} + M + +
+
+
+ BTV STOP DISTANCE +
+
+ + dataEntryFormat={new LengthFormat(Subject.create(0), Subject.create(4000))} + dataHandlerDuringValidation={async (val) => { + if (this.navigraphAvailable.get() === false) { + SimVar.SetSimVarValue( + 'L:A32NX_OANS_BTV_REQ_STOPPING_DISTANCE', + SimVarValueType.Number, + val, + ); + + if (val && this.landingRunwayNavdata && this.arpCoordinates) { + const exitLocation = placeBearingDistance( + this.landingRunwayNavdata.thresholdLocation, + this.landingRunwayNavdata.bearing, + val / MathUtils.METRES_TO_NAUTICAL_MILES, + ); + const localExitPos = globalToAirportCoordinates(this.arpCoordinates, exitLocation); + + this.btvUtils.selectExitFromManualEntry(val, localExitPos); + } + } + }} + value={this.reqStoppingDistance} + mandatory={Subject.create(false)} + inactive={this.selectedEntityString.map((it) => !it)} + hEventConsumer={this.hEventConsumer} + interactionMode={this.interactionMode} + /> +
+
+
+ + + +
+
+
+ { + this.handleSelectAirport( + this.store.sortedAirports.get(newSelectedIndex ?? 0).idarpt, + newSelectedIndex ?? undefined, + ); + }} + freeTextAllowed={false} + numberOfDigitsForInputField={7} + alignLabels={this.store.airportSearchMode.map((it) => + it === ControlPanelAirportSearchMode.City ? 'flex-start' : 'center', + )} + idPrefix="oanc-search-airport" + hEventConsumer={this.hEventConsumer} + interactionMode={this.interactionMode} + /> +
+
+ { + switch (newSelectedIndex) { + case 0: + this.handleSelectSearchMode(ControlPanelAirportSearchMode.Icao); + break; + case 1: + this.handleSelectSearchMode(ControlPanelAirportSearchMode.Iata); + break; + default: + this.handleSelectSearchMode(ControlPanelAirportSearchMode.City); + break; + } + }} + idPrefix="oanc-search" + /> +
+
+
+
+ + {this.store.selectedAirport.map((it) => it?.name?.substring(0, 18).toUpperCase() ?? '')} + + + {this.store.selectedAirport.map((it) => { + if (!it) { + return ''; + } + + return `${it.idarpt} ${it.iata}`; + })} + + + {this.store.selectedAirport.map((it) => { + if (!it) { + return ''; + } + + return `${ControlPanelUtils.LAT_FORMATTER(it.coordinates.lat)}/${ControlPanelUtils.LONG_FORMATTER(it.coordinates.lon)}`; + })} + +
+
+
+
+
+
+
+
+ + +
+
+ + ACTIVE + + {this.activeDatabase} +
+
+
+
+ + SECOND + + {this.secondDatabase} +
+
+
+ AIRPORT DATABASE + {this.airportDatabase} +
+
+ +
+
+ +
+ + + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/ND/animations.scss b/fbw-a380x/src/systems/instruments/src/ND/animations.scss index 97f9bd61f66..a68c31e2883 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/animations.scss +++ b/fbw-a380x/src/systems/instruments/src/ND/animations.scss @@ -1,3 +1,6 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + @keyframes blinking { 0% {opacity: 0;} 50% {opacity: 0;} @@ -7,10 +10,10 @@ @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%);} + 0% {stroke: scale-color($color, $lightness: -50%);} + 50% {stroke: scale-color($color, $lightness: -50%);} + 51% {stroke: scale-color($color, $lightness: 50%);} + 100% {stroke: scale-color($color, $lightness: 50%);} } animation-name: $name; } @@ -69,3 +72,9 @@ animation-duration: 200ms; animation-iteration-count: infinite; } + +.RwyAheadAnimation { + @include GenericPulsingStroke($display-yellow, pulse-yellow-stroke); + animation-duration: 1s; + animation-iteration-count: infinite; +} diff --git a/fbw-a380x/src/systems/instruments/src/ND/config.json b/fbw-a380x/src/systems/instruments/src/ND/config.json index c99f13d05c9..4e7d8c4b38a 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/config.json +++ b/fbw-a380x/src/systems/instruments/src/ND/config.json @@ -1,7 +1,8 @@ { "index": "./instrument.tsx", - "isInteractive": false, + "isInteractive": true, "extraDeps": [ - "fbw-common/src/systems/instruments/src/ND" + "fbw-common/src/systems/instruments/src/ND", + "fbw-common/src/systems/instruments/src/OANC" ] } diff --git a/fbw-a380x/src/systems/instruments/src/ND/instrument.tsx b/fbw-a380x/src/systems/instruments/src/ND/instrument.tsx index 4bb5d4fad99..df44f692844 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/instrument.tsx +++ b/fbw-a380x/src/systems/instruments/src/ND/instrument.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations +// Copyright (c) 2021-2024 FlyByWire Simulations // // SPDX-License-Identifier: GPL-3.0 @@ -10,14 +10,38 @@ import { HEventPublisher, InstrumentBackplane, Subject, + Subscribable, + Wait, } from '@microsoft/msfs-sdk'; -import { a380EfisRangeSettings, ArincEventBus, EfisSide } from '@flybywiresim/fbw-sdk'; +import { + A380EfisNdRangeValue, + a380EfisRangeSettings, + ArincEventBus, + EfisNdMode, + EfisSide, +} from '@flybywiresim/fbw-sdk'; import { NDComponent } from '@flybywiresim/navigation-display'; - +import { + a380EfisZoomRangeSettings, + A380EfisZoomRangeValue, + BtvArincProvider, + BtvSimvarPublisher, + FmsOansArincProvider, + FmsOansSimvarPublisher, + Oanc, + OANC_RENDER_HEIGHT, + OANC_RENDER_WIDTH, + OansControlEvents, + ZOOM_TRANSITION_TIME_MS, +} from '@flybywiresim/oanc'; + +import { ContextMenu, ContextMenuElement } from 'instruments/src/MFD/pages/common/ContextMenu'; +import { MouseCursor } from 'instruments/src/MFD/pages/common/MouseCursor'; +import { OansControlPanel } from './OansControlPanel'; +import { FmsSymbolsPublisher } from './FmsSymbolsPublisher'; import { NDSimvarPublisher, NDSimvars } from './NDSimvarPublisher'; import { AdirsValueProvider } from '../MsfsAvionicsCommon/AdirsValueProvider'; import { FmsDataPublisher } from '../MsfsAvionicsCommon/providers/FmsDataPublisher'; -import { FmsSymbolsPublisher } from './FmsSymbolsPublisher'; import { VorBusPublisher } from '../MsfsAvionicsCommon/providers/VorBusPublisher'; import { TcasBusPublisher } from '../MsfsAvionicsCommon/providers/TcasBusPublisher'; import { FGDataPublisher } from '../MsfsAvionicsCommon/providers/FGDataPublisher'; @@ -26,13 +50,20 @@ import { CdsDisplayUnit, DisplayUnitID, getDisplayIndex } from '../MsfsAvionicsC import { EgpwcBusPublisher } from '../MsfsAvionicsCommon/providers/EgpwcBusPublisher'; import { DmcPublisher } from '../MsfsAvionicsCommon/providers/DmcPublisher'; import { FMBusPublisher } from '../MsfsAvionicsCommon/providers/FMBusPublisher'; -import { FcuBusPublisher } from '../MsfsAvionicsCommon/providers/FcuBusPublisher'; +import { FcuBusPublisher, FcuSimVars } from '../MsfsAvionicsCommon/providers/FcuBusPublisher'; +import { RopRowOansPublisher } from '@flybywiresim/msfs-avionics-common'; import './style.scss'; +import './oans-style.scss'; import { VerticalDisplayDummy } from 'instruments/src/ND/VerticalDisplay'; +declare type MousePosition = { + x: number; + y: number; +}; + class NDInstrument implements FsInstrument { - public readonly instrument: BaseInstrument; + public readonly instrument!: BaseInstrument; private readonly efisSide: EfisSide; @@ -46,6 +77,16 @@ class NDInstrument implements FsInstrument { private readonly fmsDataPublisher: FmsDataPublisher; + private readonly fmsOansSimvarPublisher: FmsOansSimvarPublisher; + + private readonly fmsOansArincProvider: FmsOansArincProvider; + + private readonly ropRowOansPublisher: RopRowOansPublisher; + + private readonly btvSimvarPublisher: BtvSimvarPublisher; + + private readonly btvArincProvider: BtvArincProvider; + private readonly fgDataPublisher: FGDataPublisher; private readonly fmBusPublisher: FMBusPublisher; @@ -60,12 +101,97 @@ class NDInstrument implements FsInstrument { private readonly egpwcBusPublisher: EgpwcBusPublisher; - private readonly hEventPublisher; + private readonly hEventPublisher: HEventPublisher; private readonly adirsValueProvider: AdirsValueProvider; private readonly clock: Clock; + public readonly controlPanelRef = FSComponent.createRef(); + + private readonly contextMenuVisible = Subject.create(false); + + private readonly contextMenuX = Subject.create(0); + + private readonly contextMenuY = Subject.create(0); + + private readonly controlPanelVisible = Subject.create(false); + + private readonly waitScreenRef = FSComponent.createRef(); + + private oansContextMenuItems: Subscribable = Subject.create([ + { + name: 'ADD CROSS', + disabled: true, + onPressed: () => + console.log( + `ADD CROSS at (${this.contextMenuPositionTriggered.get().x}, ${this.contextMenuPositionTriggered.get().y})`, + ), + }, + { + name: 'ADD FLAG', + disabled: true, + onPressed: () => + console.log( + `ADD FLAG at (${this.contextMenuPositionTriggered.get().x}, ${this.contextMenuPositionTriggered.get().y})`, + ), + }, + { + name: 'MAP DATA', + disabled: false, + onPressed: () => { + if (this.controlPanelRef.getOrDefault()) { + this.controlPanelVisible.set(!this.controlPanelVisible.get()); + } + }, + }, + { + name: 'ERASE ALL CROSSES', + disabled: true, + onPressed: () => console.log('ERASE ALL CROSSES'), + }, + { + name: 'ERASE ALL FLAGS', + disabled: true, + onPressed: () => console.log('ERASE ALL FLAGS'), + }, + { + name: 'CENTER ON ACFT', + disabled: false, + onPressed: async () => { + if (this.oansRef.getOrDefault() !== null) { + await this.oansRef.instance.enablePanningTransitions(); + this.oansRef.instance.panOffsetX.set(0); + this.oansRef.instance.panOffsetY.set(0); + await Wait.awaitDelay(ZOOM_TRANSITION_TIME_MS); + await this.oansRef.instance.disablePanningTransitions(); + } + }, + }, + ]); + + private contextMenuRef = FSComponent.createRef(); + + private contextMenuOpened = Subject.create(false); + + private contextMenuPositionTriggered = Subject.create({ x: 0, y: 0 }); + + private mouseCursorRef = FSComponent.createRef(); + + private topRef = FSComponent.createRef(); + + private efisNdMode = EfisNdMode.ARC; + + private efisCpRange: A380EfisNdRangeValue = 10; + + private oansRef = FSComponent.createRef>(); + + private oansContainerRef = FSComponent.createRef(); + + private oansControlPanelContainerRef = FSComponent.createRef(); + + private cursorVisible = Subject.create(true); + constructor() { const side: EfisSide = getDisplayIndex() === 1 ? 'L' : 'R'; const stateSubject = Subject.create<'L' | 'R'>(side); @@ -76,6 +202,11 @@ class NDInstrument implements FsInstrument { this.simVarPublisher = new NDSimvarPublisher(this.bus); this.fcuBusPublisher = new FcuBusPublisher(this.bus, side); this.fmsDataPublisher = new FmsDataPublisher(this.bus, stateSubject); + this.fmsOansSimvarPublisher = new FmsOansSimvarPublisher(this.bus); + this.fmsOansArincProvider = new FmsOansArincProvider(this.bus); + this.ropRowOansPublisher = new RopRowOansPublisher(this.bus); + this.btvSimvarPublisher = new BtvSimvarPublisher(this.bus); + this.btvArincProvider = new BtvArincProvider(this.bus); this.fgDataPublisher = new FGDataPublisher(this.bus); this.fmBusPublisher = new FMBusPublisher(this.bus); this.fmsSymbolsPublisher = new FmsSymbolsPublisher(this.bus, side); @@ -92,6 +223,9 @@ class NDInstrument implements FsInstrument { this.backplane.addPublisher('ndSimVars', this.simVarPublisher); this.backplane.addPublisher('fcu', this.fcuBusPublisher); this.backplane.addPublisher('fms', this.fmsDataPublisher); + this.backplane.addPublisher('fms-oans', this.fmsOansSimvarPublisher); + this.backplane.addPublisher('rop-row-oans', this.ropRowOansPublisher); + this.backplane.addPublisher('btv', this.btvSimvarPublisher); this.backplane.addPublisher('fg', this.fgDataPublisher); this.backplane.addPublisher('fms-arinc', this.fmBusPublisher); this.backplane.addPublisher('fms-symbols', this.fmsSymbolsPublisher); @@ -99,7 +233,10 @@ class NDInstrument implements FsInstrument { this.backplane.addPublisher('tcas', this.tcasBusPublisher); this.backplane.addPublisher('dmc', this.dmcPublisher); this.backplane.addPublisher('egpwc', this.egpwcBusPublisher); + this.backplane.addPublisher('hEvent', this.hEventPublisher); + this.backplane.addInstrument('btvArinc', this.btvArincProvider); + this.backplane.addInstrument('fms-arinc', this.fmsOansArincProvider); this.backplane.addInstrument('clock', this.clock); this.doInit(); @@ -111,20 +248,119 @@ class NDInstrument implements FsInstrument { this.adirsValueProvider.start(); FSComponent.render( - - - - , +
+ +
+ + +
+ + +
+ this.controlPanelVisible.set(!this.controlPanelVisible.get())} + /> +
+ + +
+
, document.getElementById('ND_CONTENT'), ); // Remove "instrument didn't load" text - document.getElementById('ND_CONTENT').querySelector(':scope > h1').remove(); + document.getElementById('ND_CONTENT')?.querySelector(':scope > h1')?.remove(); + + this.topRef.instance.addEventListener('mousemove', (ev) => { + if (this.oansRef.getOrDefault() && this.oansRef.instance.isPanning) { + this.cursorVisible.set(false); + } else { + this.mouseCursorRef.instance.updatePosition(ev.clientX, ev.clientY); + this.cursorVisible.set(true); + } + }); + + if (this.oansRef?.instance?.labelContainerRef?.instance) { + this.oansRef.instance.labelContainerRef.instance.addEventListener('contextmenu', (e) => { + // Not firing right now, use double click + this.contextMenuPositionTriggered.set({ x: e.clientX, y: e.clientY }); + this.contextMenuRef.instance.display(e.clientX, e.clientY); + }); + + this.oansRef.instance.labelContainerRef.instance.addEventListener('dblclick', (e) => { + this.contextMenuPositionTriggered.set({ x: e.clientX, y: e.clientY }); + this.contextMenuRef.instance.display(e.clientX, e.clientY); + }); + + this.oansRef.instance.labelContainerRef.instance.addEventListener('click', () => { + this.contextMenuRef.instance.hideMenu(); + }); + } + + const sub = this.bus.getSubscriber(); + + sub + .on('ndMode') + .whenChanged() + .handle((mode) => { + this.efisNdMode = mode; + this.updateNdOansVisibility(); + }); + + sub + .on('ndRangeSetting') + .whenChanged() + .handle((range) => { + this.efisCpRange = a380EfisRangeSettings[range]; + this.updateNdOansVisibility(); + }); + } + + private updateNdOansVisibility() { + if (this.oansContainerRef.getOrDefault()) { + if (this.efisCpRange === -1 && [EfisNdMode.PLAN, EfisNdMode.ARC, EfisNdMode.ROSE_NAV].includes(this.efisNdMode)) { + this.bus.getPublisher().pub('ndShowOans', true); + this.oansContainerRef.instance.style.display = 'block'; + this.oansControlPanelContainerRef.instance.style.display = 'block'; + } else { + this.bus.getPublisher().pub('ndShowOans', false); + this.oansContainerRef.instance.style.display = 'none'; + this.oansControlPanelContainerRef.instance.style.display = 'none'; + } + } } /** @@ -132,6 +368,10 @@ class NDInstrument implements FsInstrument { */ public Update(): void { this.backplane.onUpdate(); + + if (this.oansRef.getOrDefault()) { + this.oansRef.instance.Update(); + } } public onInteractionEvent(args: string[]): void { @@ -161,7 +401,7 @@ class A380X_ND extends FsBaseInstrument { } get isInteractive(): boolean { - return false; + return true; } get templateID(): string { diff --git a/fbw-a380x/src/systems/instruments/src/ND/oans-style.scss b/fbw-a380x/src/systems/instruments/src/ND/oans-style.scss new file mode 100644 index 00000000000..b8996782ebc --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/ND/oans-style.scss @@ -0,0 +1,522 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +@import "../MsfsAvionicsCommon/definitions"; + +.oans-control-panel-background { + font-family: "Ecam", monospace !important; + z-index: 2; + position: absolute; + top: 768px; + width: 768px; + height: 256px; + background-color: $display-background; +} + +.oans-control-panel { + display: flex; + height: 256px; +} + +.oanc-container { + width: 768px; + height: 768px; + + background-color: $display-background; + + font-family: "Ecam", monospace !important; +} + +.oanc-waiting-screen { + position: absolute; + + width: 768px; + height: 768px; + + display: flex; + justify-content: center; + align-items: center; + + font-size: 24px; + + background-color: $display-background; + color: white; + + z-index: 9999; +} + +.oanc-label { + position: absolute; + + height: 17px; + + padding-left: 1px; + + font-family: "Ecam", monospace !important; + font-size: 19px; + + background-color: black; + color: white; + + white-space: pre; + + pointer-events: auto; +} + +.oanc-label-style-taxiway { + color: $display-yellow; + outline: 0px solid $display-cyan; + outline-offset: 0px; +} + +.oanc-label-style-exit-line { + color: $display-yellow; + outline: 0px solid $display-cyan; + outline-offset: 0px; +} + +.oanc-label-style-exit-line:hover { + outline: 3px solid $display-cyan; + pointer-events: auto; + outline-offset: 4px; +} + +.oanc-label-style-exit-line-btv-selected { + color: $display-cyan; + outline: 3px solid $display-cyan; + outline-offset: 4px; +} + +.oanc-label-style-terminal-building { + color: $display-cyan; +} + +.oanc-label-style-runway-end { + display: flex; + justify-content: center; + padding-top: 13px; + + width: 60px; + height: 35px; + + font-size: 25px; + + background-color: transparent; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAG8AAAA/CAYAAADuS5aXAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAHzSURBVHgB7ZrRbYNAEERPqcAluIR0QDpIOrA7IB04HbgESkgJlOASKIESJofYyA4Jx4EwvrnMk/YDycIWT7O+Xdu5DAHw4uvs6+Lr4GvnRLp0gnyVvmr8pvVVdVKdSAdLWWWCYmgsjXsntsdS9mFtcYzapDaB11RK40ZYyuqAjC59Jwy+4/z10dcnwmkslcaVuUlZO5GyIuJee5PbBO7VSX5zYhkmbCplDf5I2Yz3KNC3zdD9K6UxEv+gnidS1loyCrcSlsYjwmmsfR2c+AnCR/zbh1fizvOa0hgJroN0KGXnNVM247PtLI2h0+z/WgCklLJY0LfyCuNtNe8FAKYH6e8j/uYpm4OlscY4DXJYACB+kH5NJWWxoD/kVMhtAYCFgzQrYF8AYMVBmhUwLQCwwSDNClIdOfCAQZoVpLAAAOERPzU2TyMSHqRZwT0XAErZdmCtBQAyGaRZwZIFgL94R4aDNCuIXwDsHMb/sHOSsMeC8AKgGMq7QG0xOXBdALQhefrBMWEGrbR4coIWySNG8oiRPGIkjxjJI0byiJE8YiSPGMkjRvKIkTxiJI8YySNG8oiRPGIkjxjJI0byiJE8YiSPGMkjRvKIkTxiJI8YySNG8oiRPGIkjxjJI0byiJE8YiSPGMkjRvKIkTxiJI8YySPmC1pEKU9OGEigAAAAAElFTkSuQmCC'); + background-size: cover; +} + +.oanc-label-style-runway-end:hover { + outline: 3px solid $display-cyan; + pointer-events: auto; + outline-offset: 0px !important; +} + +.oanc-label-style-runway-end-btv-selected { + display: flex; + justify-content: center; + padding-top: 10px; + + width: 60px; + height: 35px; + + font-size: 25px; + outline: 3px solid $display-cyan; + outline-offset: 0px !important; + color: $display-cyan; + background-color: transparent; + background-image: url('data: image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAG8AAAA/CAYAAADuS5aXAAAAAXNSR0IArs4c6QAAAu9JREFUeF7tmuF12zAMhA8bdISO0A3qDZoN4g2STpB0g3SDZIN0giYbdIN2hG6AGDHVZ9eSQvlRFE85/rF/8MnQfTqAgGxY4XL3DYAvAOLzDsAPM/u7tlu1tdyQu38AcAngwvbQjpYD9wAezOxpLfdMDy+57NKAbQ4UB/7s9t0CeDaz+E67KOEll11HajTgU5/6DoTDAs7GgI8De6jdSAUvueymLy12cHzvqu+HNc7dw5UB+mIAYkDuaiONG5uH17nMgJuh/JZcdmtmz2M50N3DgVEXtyNufEy1MT6bXk3CS8AiHQ66LNWuSHtHLstV290/J4i9tTJdP1Lvt1ZrY1Pw3D2AxWlxzGXhiLu3XDYBYrgxTqfxoAzVxoB4b2YPudetsW9xeG8d8UOElBa7dDZbv8bmxsXgdY20AXFq7F2+P0Q8lnJZrhvSAxWHm6uR0+yvpQcAVeG15LIJICOVX7XYclSBl9NIpyP+U22X5UJ8Td/7liMGAicTnJTeqw4AZoM3oZGO1BjTjtlq2RRAOXtTyxGHqkUHAMXhndtI54jW4p4lBwBF4JVspFsElBPTEgOAs+HVaKRzRGtxT62WYzK8JRrpFgHlxJTcONsAIAse4xE/R9yae+Zw4yi8lhvpmsKX/K2SA4ATeHJZSVTj10ol6OwBwD94a2mk60lf9pfOGQCYu8dsMaYGY2+k6RrpstLWu9qEAcDXgPdz4A87J2+k692CfulgHNf7D4Ddm5bNETwHYlJ+3fJ88T1i7QYAuxoXhnpdffC2rb1wfI+whu7Z3X93L4wFj+zJEDwyYIfhCp7gEStAHLqcJ3jEChCHLucJHrECxKHLeYJHrABx6HKe4BErQBy6nCd4xAoQhy7nCR6xAsShy3mCR6wAcehynuARK0AcupwneMQKEIcu5wkesQLEoct5gkesAHHocp7gEStAHLqcJ3jEChCHLucJHrECxKHLeYJHrABx6HKe4BErQBy6nCd4xAoQh/6/814ALkL7B3fOZ9UAAAAASUVORK5CYII='); + background-size: cover; +} + +.oanc-label-style-runway-end-btv-selected:hover { + outline-offset: 0px !important; +} + +.oanc-label-style-runway-end-fms-selected { + font-size: 0.1px; + color: transparent; + width: 0px; + height: 0px; + background-color: transparent; + border-style: solid; + border-width: 0 30px 27.5px 30px; + border-color: transparent transparent $display-green transparent; +} + +.oanc-label-style-runway-end-fms-selected:hover { + outline: 0px; +} + +.oanc-label-style-runway-arrow-btv-selected { + font-size: 0.1px; + color: transparent; + width: 50px; + height: 35px; + background-color: transparent; + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iMzUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBkPSJtNSw1bDIwLDI1bDIwLC0yNSIgc3Ryb2tlPSIjMDAwMDAwIiBmaWxsPSJub25lIiBzdHJva2Utd2lkdGg9IjMiIC8+CiAgPHBhdGggc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBkPSJtNSw1bDIwLDI1bDIwLC0yNSIgc3Ryb2tlPSIjMDBmZmZmIiBmaWxsPSJub25lIiBzdHJva2Utd2lkdGg9IjIiIC8+Cjwvc3ZnPg=="); + background-size: auto; + background-repeat: no-repeat; +} + +.oanc-label-style-runway-arrow-btv-selected:hover { + outline: 0px; +} + +.oanc-label-style-runway-axis { + color: $display-white; +} + +.oanc-label-style-btv-stop-line-green { + color: $display-green; +} + +.oanc-label-style-btv-stop-line-green:hover { + outline: 0px; +} + +.oanc-label-style-btv-stop-line-magenta { + color: $display-magenta; +} + +.oanc-label-style-btv-stop-line-magenta:hover { + outline: 0px; +} + +.oanc-label-style-btv-stop-line-amber { + color: $display-amber; +} + +.oanc-label-style-btv-stop-line-amber:hover { + outline: 0px; +} + +.oanc-label-style-btv-stop-line-red { + color: $display-red; +} + +.oanc-label-style-btv-stop-line-red:hover { + outline: 0px; +} + + +.oanc-button { + padding: 10px 6px; + background-color: gray; + color: white; + border: 3px solid white; + display: flex; + justify-content: center; + align-items: center; + pointer-events: auto; +} + +.oanc-top-mask { + width: 100%; + height: 60px; + background-color: $display-background; +} + +.oanc-bottom-mask { + display: flex; + justify-content: center; + align-items: center; + + position: absolute; + bottom: 0; + + width: 100%; + height: 32px; + background-color: $display-background; +} + +.oanc-position { + padding: 1px; + padding-left: 2px; + border: solid 1.5px $display-yellow; + color: $display-yellow; + font-size: 24px; +} + +.oanc-speed-info { + position: absolute; + top: 0; + left: 5px; + + font-size: 24px; + white-space: pre; +} + +#oanc-speed-info-1 { + padding-right: 35px; + + font-size: 20px; + vertical-align: bottom; +} + +#oanc-speed-info-2 { + padding-right: 7px; + color: $display-green; +} + +#oanc-speed-info-3 { + padding-right: 5px; + + font-size: 20px; + vertical-align: bottom; +} + +#oanc-speed-info-4 { + width: 40px; + padding-right: 10px; + color: $display-green; +} + +.oanc-wind-info { + position: absolute; + top: 27px; + left: 5px; + + font-size: 24px; + white-space: pre; +} + +#oanc-wind-info-1 { + color: $display-green; +} + +#oanc-wind-info-2 { + font-size: 20px; +} + +#oanc-wind-info-3 { + color: $display-green; +} + +.oanc-airport-info { + position: absolute; + right: 0; + color: $display-white; + background-color: $display-background; + + font-size: 20px; + white-space: pre; +} + +#oanc-airport-info-line1 { + top: 5px; +} + +#oanc-airport-info-line2 { + top: 31px; +} + +.oanc-airport-not-in-active-fpln { + position: absolute; + right: 310px; + color: $display-white; + background-color: $display-background; + text-align: center; + font-size: 20px; +} + +.oanc-airplane-shadow { + stroke: $display-background; + stroke-width: 10px; + fill: none; +} + +.oanc-airplane { + stroke: $display-magenta; + stroke-width: 7.75px; + fill: none; +} + +.oanc-svg { + font-family: "Ecam", monospace !important; + + //z-index: 9; +} + +.Magenta { + fill: none; + stroke: $display-magenta; +} + +.Magenta text, +text.Magenta { + fill: $display-magenta; + stroke: none; +} + +.Cyan { + fill: none; + stroke: $display-cyan; +} + +.Cyan text, +text.Cyan, +.Cyan tspan, +tspan.Cyan { + fill: $display-cyan; + stroke: none; +} + +tspan.Cyan { + fill: $display-cyan; + stroke: none; +} + +.White { + fill: none; + stroke: $display-white; +} + +.White.Fill { + fill: $display-white; + stroke: none; +} + +.White text, +text.White { + fill: $display-white; + stroke: none; +} + +.Green { + stroke: $display-green; + color: $display-green; + fill: none; +} + +.Green.Fill { + fill: $display-green; + stroke: none; +} + +.Green text, +text.Green, +.Green tspan, +tspan.Green { + fill: $display-green; + stroke: none; +} + +.Amber { + stroke: $display-amber; + fill: none; +} + +.Amber text, +text.Amber { + fill: $display-amber; + stroke: none; +} + +.Yellow { + stroke: $display-yellow; + fill: none; +} + +.Yellow.Fill { + fill: $display-yellow; + stroke: none; +} + +.Yellow text, +text.Yellow { + fill: $display-yellow; + stroke: none; +} + +.Red { + stroke: $display-red; + fill: none; +} + +.Red.Fill { + fill: $display-red; + stroke: none; +} + +.Red text, +text.Red { + fill: $display-red; + stroke: none; +} + +.Grey.Fill { + fill: $display-grey; + stroke: none; +} + +.BackgroundFill { + fill: $display-background; +} + +path.rounded { + stroke-linecap: round; + stroke-linejoin: round; +} + +.shadow { + stroke: $display-background; + fill: none; + stroke-width: 3.5px; +} + +.shadow text { + stroke-width: 1.5px; +} + +text.shadow { + stroke: $display-background; + stroke-width: 1px; + paint-order: stroke; +} + + +$font-factor: 5; + +.FontLargest { + font-size: #{7px * $font-factor}; +} + +.FontLarge { + font-size: #{6.5px * $font-factor}; +} + +.FontMedium { + font-size: #{6px * $font-factor}; +} + +.FontIntermediate { + font-size: #{5.5px * $font-factor}; +} + +.FontSmall { + font-size: #{5px * $font-factor}; +} + +.FontSmallest { + font-size: #{4.5px * $font-factor}; +} + +.FontTiny { + font-size: #{4px * $font-factor}; +} + +.StartAlign { + text-align: start; + text-anchor: start; +} + +.MiddleAlign { + text-align: center; + text-anchor: middle; +} + +.EndAlign { + text-align: end; + text-anchor: end; +} diff --git a/fbw-a380x/src/systems/instruments/src/ND/style.scss b/fbw-a380x/src/systems/instruments/src/ND/style.scss index 646b49894a2..9a52e2ea634 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/style.scss +++ b/fbw-a380x/src/systems/instruments/src/ND/style.scss @@ -1,3 +1,6 @@ +// Copyright (c) 2021-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + @import "../MsfsAvionicsCommon/definitions.scss"; @import "animations"; @@ -25,6 +28,7 @@ height: 768px; background: transparent; font-family: "Ecam", monospace !important; + pointer-events: none; } .nd-canvas-map { diff --git a/fbw-a380x/src/systems/instruments/src/ND/tsconfig.json b/fbw-a380x/src/systems/instruments/src/ND/tsconfig.json index d0a10372258..331218bba29 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/tsconfig.json +++ b/fbw-a380x/src/systems/instruments/src/ND/tsconfig.json @@ -5,7 +5,7 @@ "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 */, + "strict": true /* 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 */, @@ -23,12 +23,14 @@ "@instruments/common/*": ["./instruments/src/Common/*"], "@localization/*": ["../localization/*"], "@sentry/*": ["./sentry-client/src/*"], - "@simbridge/*": ["./simbridge-client/src/*"], + "@simbridge/*": ["../../../fbw-a32nx/src/systems/simbridge-client/src/*"], "@shared/*": ["./shared/src/*"], "@tcas/*": ["./tcas/src/*"], "@typings/*": ["../../../fbw-common/src/typings/*"], "@flybywiresim/fbw-sdk": ["../../../fbw-common/src/systems/index-no-react.ts"], - "@flybywiresim/navigation-display": ["../../../fbw-common/src/systems/instruments/src/ND/index.ts"] + "@flybywiresim/navigation-display": ["../../../fbw-common/src/systems/instruments/src/ND/index.ts"], + "@flybywiresim/oanc": ["../../../fbw-common/src/systems/instruments/src/OANC/index.ts"], + "@flybywiresim/msfs-avionics-common": ["../../../fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/index.ts"] } } } diff --git a/fbw-a380x/src/systems/instruments/src/OIT/tsconfig.json b/fbw-a380x/src/systems/instruments/src/OIT/tsconfig.json new file mode 100644 index 00000000000..befb760d707 --- /dev/null +++ b/fbw-a380x/src/systems/instruments/src/OIT/tsconfig.json @@ -0,0 +1,3 @@ + { + "extends": "../../tsconfig.json" + } \ No newline at end of file diff --git a/fbw-a380x/src/systems/systems-host/tsconfig.json b/fbw-a380x/src/systems/systems-host/tsconfig.json index 72df227efd5..dbad883f5c8 100644 --- a/fbw-a380x/src/systems/systems-host/tsconfig.json +++ b/fbw-a380x/src/systems/systems-host/tsconfig.json @@ -11,7 +11,7 @@ "@instruments/common/*": ["./instruments/src/Common/*"], "@localization/*": ["../localization/*"], "@sentry/*": ["./sentry-client/src/*"], - "@simbridge/*": ["./simbridge-client/src/*"], + "@simbridge/*": ["../../../fbw-a32nx/src/systems/simbridge-client/src/*"], "@shared/*": ["./shared/src/*"], "@tcas/*": ["./tcas/src/*"], "@typings/*": ["../../../fbw-common/src/typings/*"], diff --git a/fbw-a380x/src/systems/tcas/src/components/TcasComputer.ts b/fbw-a380x/src/systems/tcas/src/components/TcasComputer.ts index f29431b8190..8cdb85f74e6 100644 --- a/fbw-a380x/src/systems/tcas/src/components/TcasComputer.ts +++ b/fbw-a380x/src/systems/tcas/src/components/TcasComputer.ts @@ -3,7 +3,7 @@ /* eslint-disable no-useless-constructor */ /* eslint-disable no-underscore-dangle */ import { UpdateThrottler } from '@shared/UpdateThrottler'; -import { MathUtils } from '@shared/MathUtils'; +import { MathUtils } from '@flybywiresim/fbw-sdk'; import { Arinc429Word } from '@flybywiresim/fbw-sdk'; import { TcasComponent } from '@tcas/lib/TcasComponent'; import { LatLongData } from '@typings/fs-base-ui/html_ui/JS/Types'; diff --git a/fbw-a380x/src/systems/tsconfig.json b/fbw-a380x/src/systems/tsconfig.json index f3b44091e8a..d640a1bcbe4 100644 --- a/fbw-a380x/src/systems/tsconfig.json +++ b/fbw-a380x/src/systems/tsconfig.json @@ -17,6 +17,7 @@ "@atsu/*": ["./atsu/src/*"], "@fmgc/*": ["../../../fbw-a32nx/src/systems/fmgc/src/*"], "@flybywiresim/failures": ["failures"], + "@simbridge/*": ["../../../fbw-a32nx/src/systems/simbridge-client/src/*"], "@tcas/*": ["./tcas/src/*"], "@typings/*": ["../typings"], "@flybywiresim/fbw-sdk": ["../../../fbw-common/src/systems/index.ts"], diff --git a/fbw-common/src/systems/instruments/src/ND/Layer.tsx b/fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/Layer.tsx similarity index 93% rename from fbw-common/src/systems/instruments/src/ND/Layer.tsx rename to fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/Layer.tsx index 77c675eb3cf..34630892bbb 100644 --- a/fbw-common/src/systems/instruments/src/ND/Layer.tsx +++ b/fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/Layer.tsx @@ -1,3 +1,6 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + import { FSComponent, DisplayComponent, VNode, Subscribable, MappedSubject, ComponentProps } from '@microsoft/msfs-sdk'; export interface LayerProps extends ComponentProps { diff --git a/fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/tsconfig.json b/fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/tsconfig.json new file mode 100644 index 00000000000..7aa4d70a257 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/MsfsAvionicsCommon/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-common/src/systems/instruments/src/ND/FmMessages.tsx b/fbw-common/src/systems/instruments/src/ND/FmMessages.tsx index 8abf66ff027..e6f0a1652a4 100644 --- a/fbw-common/src/systems/instruments/src/ND/FmMessages.tsx +++ b/fbw-common/src/systems/instruments/src/ND/FmMessages.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations +// Copyright (c) 2021-2024 FlyByWire Simulations // // SPDX-License-Identifier: GPL-3.0 @@ -7,6 +7,7 @@ import { DisplayComponent, EventBus, FSComponent, + MappedSubject, Subject, Subscribable, VNode, @@ -14,7 +15,7 @@ import { import { FMMessage, FMMessageTypes } from '@flybywiresim/fbw-sdk'; import { EfisNdMode } from '../NavigationDisplay'; -import { Layer } from './Layer'; +import { Layer } from '../MsfsAvionicsCommon/Layer'; import { GenericFmsEvents } from './types/GenericFmsEvents'; export interface FmMessagesProps { @@ -26,6 +27,8 @@ export interface FmMessagesProps { export class FmMessages extends DisplayComponent { private readonly activeMessages = ArraySubject.create([]); + private readonly activeMessagesCount = Subject.create(0); + private readonly lastActiveMessage = Subject.create(null); private readonly boxRef = FSComponent.createRef(); @@ -36,21 +39,26 @@ export class FmMessages extends DisplayComponent { (it) => it === EfisNdMode.ARC || it === EfisNdMode.ROSE_NAV, ); - private readonly visible = this.props.mode.map((mode) => { - if (mode === EfisNdMode.ROSE_ILS || mode === EfisNdMode.ROSE_VOR) { - return false; - } + private readonly visible = MappedSubject.create( + ([mode, activeMessages]) => { + if (mode === EfisNdMode.ROSE_ILS || mode === EfisNdMode.ROSE_VOR || activeMessages === 0) { + return false; + } - return true; - }); + return true; + }, + this.props.mode, + this.activeMessagesCount, + ); onAfterRender(node: VNode) { super.onAfterRender(node); const sub = this.props.bus.getSubscriber(); - this.activeMessages.sub((_, type, ___, array) => { + this.activeMessages.sub((_, __, ___, array) => { this.lastActiveMessage.set(array[array.length - 1]); + this.activeMessagesCount.set(array.length); }); sub diff --git a/fbw-common/src/systems/instruments/src/ND/ND.tsx b/fbw-common/src/systems/instruments/src/ND/ND.tsx index 87fee1c77a1..1fffc031d58 100644 --- a/fbw-common/src/systems/instruments/src/ND/ND.tsx +++ b/fbw-common/src/systems/instruments/src/ND/ND.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations +// Copyright (c) 2021-2024 FlyByWire Simulations // // SPDX-License-Identifier: GPL-3.0 @@ -15,6 +15,8 @@ import { } from '@microsoft/msfs-sdk'; import { clampAngle } from 'msfs-geo'; +import { BtvRunwayInfo } from './shared/BtvRunwayInfo'; +import { RwyAheadAdvisory } from './shared/RwyAheadAdvisory'; import { SelectedHeadingBug } from './pages/arc/SelectedHeadingBug'; import { VnavStatus } from './shared/VnavStatus'; import { LnavStatus } from './shared/LnavStatus'; @@ -24,7 +26,7 @@ import { GenericFmsEvents } from './types/GenericFmsEvents'; import { GenericAdirsEvents } from './types/GenericAdirsEvents'; import { NDSimvars } from './NDSimvarPublisher'; import { ArcModePage } from './pages/arc'; -import { Layer } from './Layer'; +import { Layer } from '../MsfsAvionicsCommon/Layer'; import { FmMessages } from './FmMessages'; import { Flag, FlagProps } from './shared/Flag'; import { CanvasMap } from './shared/map/CanvasMap'; @@ -50,6 +52,7 @@ import { Arinc429ConsumerSubject } from '../../../shared/src/Arinc429ConsumerSub import { MathUtils } from '../../../shared/src/MathUtils'; import { SimVarString } from '../../../shared/src/simvar'; import { GenericDisplayManagementEvents } from './types/GenericDisplayManagementEvents'; +import { FmsOansData, OansControlEvents } from '../OANC'; const PAGE_GENERATION_BASE_DELAY = 500; const PAGE_GENERATION_RANDOM_DELAY = 70; @@ -143,6 +146,10 @@ export class NDComponent extends DisplayComponent> this.currentPageMode, ); + private showOans = Subject.create(false); + + private showOansRunwayInfo = Subject.create(false); + onAfterRender(node: VNode) { super.onAfterRender(node); @@ -152,7 +159,12 @@ export class NDComponent extends DisplayComponent> this.currentPageInstance.onShow(); const sub = this.props.bus.getSubscriber< - GenericFcuEvents & GenericDisplayManagementEvents & GenericFmsEvents & NDControlEvents & NDSimvars + GenericFcuEvents & + GenericDisplayManagementEvents & + GenericFmsEvents & + NDControlEvents & + NDSimvars & + OansControlEvents >(); sub @@ -204,11 +216,17 @@ export class NDComponent extends DisplayComponent> .whenChanged() .handle((mode) => { this.handleNewMapPage(mode); + this.shouldShowOansRunwayInfo(); }); this.mapRecomputing.sub((recomputing) => { this.props.bus.getPublisher().pub('set_map_recomputing', recomputing); }); + + sub + .on('ndShowOans') + .whenChanged() + .handle((show) => this.showOans.set(show)); } // eslint-disable-next-line arrow-body-style @@ -325,152 +343,177 @@ export class NDComponent extends DisplayComponent> } } + private shouldShowOansRunwayInfo() { + this.showOansRunwayInfo.set(true); // Maybe only active when this.currentPageMode.get() === EfisNdMode.PLAN ? + } + render(): VNode | null { return ( <> - {/* ND Vector graphics - bottom layer */} - - - - - - - - - - - - - - - it.isNormalOperation())} - /> - - - {false && } - {true && } - - - DISPLAY SYSTEM VERSION INCONSISTENCY - - - CHECK HDG - - - TRK - - - HDG - - - - RANGE CHANGE - - !rangeChange && pageChange, - this.rangeChangeInProgress, - this.pageChangeInProgress, - )} - x={384} - y={320} - class="Green FontIntermediate" - > - MODE CHANGE - - - - - - - - - {/* ND Raster map - middle layer */} - - - {/* ND Vector graphics - top layer */} - - - - - - - - !it)} - /> - - (m === EfisNdMode.ARC ? 'url(#arc-mode-map-clip)' : ''))} - > - (it ? 'block' : 'none')) }}> +
(it ? 'none' : 'block')) }}> + + + + +
+
(it ? 'block' : 'none')) }}> + + + + +
+ + + + + + +
(it ? 'none' : 'block')) }}> + {/* ND Vector graphics - bottom layer */} + + - - - + + + + + + + + + + + + it.isNormalOperation())} + /> + + + {false && } + {true && } + + + DISPLAY SYSTEM VERSION INCONSISTENCY + + + CHECK HDG + + + TRK + + + HDG + + + + RANGE CHANGE + + !rangeChange && pageChange, + this.rangeChangeInProgress, + this.pageChangeInProgress, + )} + x={384} + y={320} + class="Green FontIntermediate" + > + MODE CHANGE + + + + + + + + + {/* ND Raster map - middle layer */} + + + {/* ND Vector graphics - top layer */} + + + + + + + + !it)} + /> + + (m === EfisNdMode.ARC ? 'url(#arc-mode-map-clip)' : ''))} + > + + + + +
); } @@ -610,7 +653,7 @@ class GridTrack extends DisplayComponent { class TopMessages extends DisplayComponent<{ bus: EventBus; ndMode: Subscribable }> { private readonly sub = this.props.bus.getSubscriber< - ClockEvents & GenericDisplayManagementEvents & NDSimvars & GenericFmsEvents + ClockEvents & GenericDisplayManagementEvents & NDSimvars & GenericFmsEvents & FmsOansData >(); private readonly trueRefActive = Subject.create(false); @@ -629,6 +672,8 @@ class TopMessages extends DisplayComponent<{ bus: EventBus; ndMode: Subscribable private readonly approachMessageValue = Subject.create(''); + private readonly btvMessageValue = Subject.create(''); + private readonly isPlanMode = this.props.ndMode.map((mode) => mode === EfisNdMode.PLAN); private readonly gridTrack = MappedSubject.create( @@ -689,6 +734,13 @@ class TopMessages extends DisplayComponent<{ bus: EventBus; ndMode: Subscribable this.needApprMessageUpdate = true; }); + this.sub + .on('ndBtvMessage') + .whenChanged() + .handle((value) => { + this.btvMessageValue.set(value); + }); + this.sub.on('simTime').whenChangedBy(100).handle(this.refreshToWptIdent.bind(this)); this.sub.on('trueTrackRaw').handle((v) => this.trueTrackWord.setWord(v)); @@ -708,6 +760,7 @@ class TopMessages extends DisplayComponent<{ bus: EventBus; ndMode: Subscribable const ident = SimVarString.unpack([this.apprMessage0, this.apprMessage1]); this.approachMessageValue.set(ident); + this.needApprMessageUpdate = false; } } @@ -718,6 +771,18 @@ class TopMessages extends DisplayComponent<{ bus: EventBus; ndMode: Subscribable {/* TODO verify */} {this.approachMessageValue} + btv !== '' && !trueRef, + this.btvMessageValue, + this.trueRefVisible, + )} + > + {/* TODO verify */} + {this.btvMessageValue} + { + private readonly sub = this.props.bus.getArincSubscriber(); + + private readonly fmsRwyIdent = ConsumerSubject.create(this.sub.on('fmsLandingRunway'), null); + + private readonly runwayIdent = ConsumerSubject.create(this.sub.on('oansSelectedLandingRunway'), null); + + private readonly runwayLength = ConsumerSubject.create( + this.sub.on('oansSelectedLandingRunwayLength').withArinc429Precision(1), + Arinc429Word.empty(), + ); + + private readonly exitIdent = ConsumerSubject.create(this.sub.on('oansSelectedExit'), null); + + private readonly exitDistance = ConsumerSubject.create( + this.sub.on('oansRequestedStoppingDistance').withArinc429Precision(1), + Arinc429Word.empty(), + ); + + private readonly runwayInfoString = MappedSubject.create( + ([ident, length]) => + ident && length.isNormalOperation() + ? `${ident.substring(4).padStart(5, '\xa0')}${length.value.toFixed(0).padStart(6, '\xa0')}` + : '', + this.runwayIdent, + this.runwayLength, + ); + + private readonly runwayBearing = ConsumerSubject.create( + this.sub.on('oansSelectedLandingRunwayBearing').withArinc429Precision(1), + Arinc429Word.empty(), + ); + + private readonly btvFmsDisagree = MappedSubject.create( + ([btv, fms, exit]) => fms && btv && !exit && btv !== fms, + this.runwayIdent, + this.fmsRwyIdent, + this.exitIdent, + ); + + private readonly exitInfoString = MappedSubject.create( + ([ident, dist]) => + ident && dist.isNormalOperation() + ? `${ident.padStart(4, '\xa0')}${dist.value.toFixed(0).padStart(6, '\xa0')}` + : '', + this.exitIdent, + this.exitDistance, + ); + + private readonly btvRot = ConsumerSubject.create( + this.sub.on('btvRot').withArinc429Precision(1), + Arinc429Word.empty(), + ); + + private readonly rot = this.btvRot.map((rot) => + rot.isNormalOperation() ? rot.value.toFixed(0).padStart(4, '\xa0') : '', + ); + + private readonly turnaroundMaxRev = ConsumerSubject.create( + this.sub.on('btvTurnAroundMaxReverse').withArinc429Precision(1), + Arinc429Word.empty(), + ); + + private readonly turnaroundIdleRev = ConsumerSubject.create( + this.sub.on('btvTurnAroundIdleReverse').withArinc429Precision(1), + Arinc429Word.empty(), + ); + + onAfterRender(node: VNode) { + super.onAfterRender(node); + } + + render(): VNode | null { + return ( + <> + (it ? 'visible' : 'hidden'))}> + + + RWY + + + {this.runwayInfoString} + + + M + + + - + + + {this.runwayBearing.map((it) => (it.isNormalOperation() ? it.value.toFixed(0).padStart(3, '0') : ''))} + + + ° + + + + (it ? 'visible' : 'hidden'))}> + + + + BTV/FMS RWY DISAGREE + + + + (rwy !== null && !exit ? 'visible' : 'hidden'), + this.runwayIdent, + this.exitIdent, + )} + > + 2)} y={this.btvFmsDisagree.map((it) => (it ? 111 : 82))}> + + + FOR BTV:SELECT EXIT + + + + (it ? 'visible' : 'hidden'))}> + + + + EXIT + + + + {this.exitInfoString} + + + M + + + + (exit && rot ? 'visible' : 'hidden'), + this.exitIdent, + this.rot, + )} + > + + + + ROT + + + {this.rot} + + + " + + + + (exit && idle.isNormalOperation() && max.isNormalOperation() ? 'visible' : 'hidden'), + this.exitIdent, + this.turnaroundIdleRev, + this.turnaroundMaxRev, + )} + > + + + + TURNAROUND + + + {this.turnaroundMaxRev.map((t) => t.value.toFixed(0).padStart(3, '\xa0'))} + + + ' + + + / + + + {this.turnaroundIdleRev.map((t) => t.value.toFixed(0).padStart(3, '\xa0'))} + + + ' + + + + + ); + } +} diff --git a/fbw-common/src/systems/instruments/src/ND/shared/RwyAheadAdvisory.tsx b/fbw-common/src/systems/instruments/src/ND/shared/RwyAheadAdvisory.tsx new file mode 100644 index 00000000000..cda09dc19cc --- /dev/null +++ b/fbw-common/src/systems/instruments/src/ND/shared/RwyAheadAdvisory.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { ConsumerSubject, DisplayComponent, EventBus, FSComponent, VNode } from '@microsoft/msfs-sdk'; +import { Arinc429Word } from '@flybywiresim/fbw-sdk'; +import { FmsOansData } from '@flybywiresim/oanc'; +import { RopRowOansSimVars } from '../../MsfsAvionicsCommon/providers'; + +export interface RwyAheadAdvisoryProps { + bus: EventBus; +} + +export class RwyAheadAdvisory extends DisplayComponent { + private readonly sub = this.props.bus.getSubscriber(); + + private readonly ndRwyAheadQfu = ConsumerSubject.create(this.sub.on('ndRwyAheadQfu').whenChanged(), ''); + + private readonly oansWord1Raw = ConsumerSubject.create(this.sub.on('oansWord1Raw').whenChanged(), 0); + + private readonly flagDisplay = this.oansWord1Raw.map((word) => { + const w = new Arinc429Word(word); + return w.getBitValueOr(11, false) ? 'block' : 'none'; + }); + + onAfterRender(node: VNode) { + super.onAfterRender(node); + } + + render(): VNode | null { + return ( + + + + {this.ndRwyAheadQfu} + + + ); + } +} diff --git a/fbw-common/src/systems/instruments/src/ND/shared/WindIndicator.tsx b/fbw-common/src/systems/instruments/src/ND/shared/WindIndicator.tsx index d1fd9e4c395..6c18ba7603f 100644 --- a/fbw-common/src/systems/instruments/src/ND/shared/WindIndicator.tsx +++ b/fbw-common/src/systems/instruments/src/ND/shared/WindIndicator.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations +// Copyright (c) 2021-2024 FlyByWire Simulations // // SPDX-License-Identifier: GPL-3.0 @@ -7,7 +7,7 @@ import { Arinc429RegisterSubject } from '@flybywiresim/fbw-sdk'; import { GenericAdirsEvents } from '../types/GenericAdirsEvents'; import { GenericDisplayManagementEvents } from '../types/GenericDisplayManagementEvents'; -import { Layer } from '../Layer'; +import { Layer } from '../../MsfsAvionicsCommon/Layer'; const mod = (x: number, n: number) => x - Math.floor(x / n) * n; diff --git a/fbw-common/src/systems/instruments/src/ND/shared/map/PseudoWaypointLayer.ts b/fbw-common/src/systems/instruments/src/ND/shared/map/PseudoWaypointLayer.ts index 3e14c840549..934ee937714 100644 --- a/fbw-common/src/systems/instruments/src/ND/shared/map/PseudoWaypointLayer.ts +++ b/fbw-common/src/systems/instruments/src/ND/shared/map/PseudoWaypointLayer.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2023 FlyByWire Simulations +// Copyright (c) 2021-2024 FlyByWire Simulations // // SPDX-License-Identifier: GPL-3.0 @@ -11,7 +11,7 @@ import { MathUtils, } from '@flybywiresim/fbw-sdk'; -import { NDSimvars } from 'instruments/src/ND/NDSimvarPublisher'; +import { NDSimvars } from '../../NDSimvarPublisher'; import { MapLayer } from './MapLayer'; import { MapParameters } from '../utils/MapParameters'; import { PaintUtils } from './PaintUtils'; diff --git a/fbw-common/src/systems/instruments/src/ND/tsconfig.json b/fbw-common/src/systems/instruments/src/ND/tsconfig.json index 2d56720368e..23ca04c080e 100644 --- a/fbw-common/src/systems/instruments/src/ND/tsconfig.json +++ b/fbw-common/src/systems/instruments/src/ND/tsconfig.json @@ -19,15 +19,15 @@ "@datalink/common": ["../../../fbw-common/src/systems/datalink/common/src/index.ts"], "@datalink/router": ["../../../fbw-common/src/systems/datalink/router/src/index.ts"], "@failures": ["./failures/src/index.ts"], - "@fmgc/*": ["./fmgc/src/*"], + "@fmgc/*": ["../../../fbw-a32nx/src/systems/fmgc/src/*"], "@instruments/common/*": ["./instruments/src/Common/*"], "@localization/*": ["../localization/*"], "@sentry/*": ["./sentry-client/src/*"], - "@simbridge/*": ["./simbridge-client/src/*"], - "@shared/*": ["./shared/src/*"], + "@simbridge/*": ["../../../fbw-a32nx/src/systems/simbridge-client/src/*"], "@tcas/*": ["./tcas/src/*"], "@typings/*": ["../../../fbw-common/src/typings/*"], - "@flybywiresim/fbw-sdk": ["../../../fbw-common/src/systems/index-no-react.ts"] + "@flybywiresim/fbw-sdk": ["../../../fbw-common/src/systems/index-no-react.ts"], + "@flybywiresim/oanc": ["../../../fbw-common/src/systems/instruments/src/OANC/index.ts"], } } } diff --git a/fbw-common/src/systems/instruments/src/OANC/.eslintrc.js b/fbw-common/src/systems/instruments/src/OANC/.eslintrc.js new file mode 100644 index 00000000000..349563e5af1 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/.eslintrc.js @@ -0,0 +1,11 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +'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-common/src/systems/instruments/src/OANC/BrakeToVacateUtils.ts b/fbw-common/src/systems/instruments/src/OANC/BrakeToVacateUtils.ts new file mode 100644 index 00000000000..f78e78a5de8 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/BrakeToVacateUtils.ts @@ -0,0 +1,785 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { ConsumerSubject, EventBus, NodeReference, Subject, Subscribable } from '@microsoft/msfs-sdk'; +import { AmdbProperties } from '@flybywiresim/fbw-sdk'; +import { + Feature, + FeatureCollection, + Geometry, + Polygon, + Position, + booleanContains, + booleanDisjoint, + lineOffset, + lineString, + point, + polygon, +} from '@turf/turf'; +import { Arinc429Register, Arinc429SignStatusMatrix, Arinc429Word, MathUtils } from '@flybywiresim/fbw-sdk'; +import { Label, LabelStyle } from './'; +import { BtvData } from './BtvPublisher'; +import { FmsOansData } from './FmsOansPublisher'; +import { OancLabelManager } from './OancLabelManager'; +import { + fractionalPointAlongLine, + globalToAirportCoordinates, + pointAngle, + pointDistance, + pointToLineDistance, +} from './OancMapUtils'; +import { Coordinates, placeBearingDistance } from 'msfs-geo'; +import { GenericAdirsEvents } from '../ND/types/GenericAdirsEvents'; + +const MIN_TOUCHDOWN_ZONE_DISTANCE = 400; // Minimum distance from threshold to touch down zone +const CLAMP_DRY_STOPBAR_DISTANCE = 100; // If stop bar is <> meters behind end of runway, clamp to this distance behind end of runway +const CLAMP_WET_STOPBAR_DISTANCE = 200; // If stop bar is <> meters behind end of runway, clamp to this distance behind end of runway + +/** + * Utility class for brake to vacate (BTV) functions on the A380 + */ + +export class BrakeToVacateUtils { + constructor( + private readonly bus: EventBus, + private readonly labelManager?: OancLabelManager, + private readonly aircraftOnGround?: Subscribable, + private readonly aircraftPpos?: Position, + private readonly canvasRef?: NodeReference, + private readonly canvasCentreX?: Subscribable, + private readonly canvasCentreY?: Subscribable, + private readonly zoomLevelIndex?: Subscribable, + private getZoomLevelInverseScale?: () => number, + ) { + this.remaininingDistToExit.sub((v) => { + if (v < 0) { + Arinc429Word.toSimVarValue( + 'L:A32NX_OANS_BTV_REMAINING_DIST_TO_EXIT', + 0, + Arinc429SignStatusMatrix.NoComputedData, + ); + } else { + Arinc429Word.toSimVarValue( + 'L:A32NX_OANS_BTV_REMAINING_DIST_TO_EXIT', + v, + Arinc429SignStatusMatrix.NormalOperation, + ); + } + }); + + this.remaininingDistToRwyEnd.sub((v) => { + if (v < 0) { + Arinc429Word.toSimVarValue( + 'L:A32NX_OANS_BTV_REMAINING_DIST_TO_RWY_END', + 0, + Arinc429SignStatusMatrix.NoComputedData, + ); + } else { + Arinc429Word.toSimVarValue( + 'L:A32NX_OANS_BTV_REMAINING_DIST_TO_RWY_END', + v, + Arinc429SignStatusMatrix.NormalOperation, + ); + } + }); + + const sub = this.bus.getSubscriber(); + this.dryStoppingDistance.setConsumer(sub.on('dryStoppingDistance').whenChanged()); + this.wetStoppingDistance.setConsumer(sub.on('wetStoppingDistance').whenChanged()); + this.liveStoppingDistance.setConsumer(sub.on('stopBarDistance').whenChanged()); + this.radioAltitude1.setConsumer(sub.on('radioAltitude_1').whenChanged()); + this.radioAltitude2.setConsumer(sub.on('radioAltitude_2').whenChanged()); + this.fwsFlightPhase.setConsumer(sub.on('fwcFlightPhase').whenChanged()); + sub + .on('groundSpeed') + .atFrequency(2) + .handle((value) => this.groundSpeed.set(value)); + + this.dryStoppingDistance.sub(() => this.drawBtvLayer()); + this.wetStoppingDistance.sub(() => this.drawBtvLayer()); + this.liveStoppingDistance.sub(() => this.drawBtvLayer()); + this.remaininingDistToRwyEnd.sub(() => this.drawBtvLayer()); + this.aircraftOnGround?.sub(() => this.drawBtvLayer()); + this.zoomLevelIndex?.sub(() => this.drawBtvLayer()); + + this.clearSelection(); + } + + private readonly groundSpeed = Arinc429Register.empty(); + + /** Updated during deceleration on the ground. Counted from touchdown distance (min. 400m). */ + private readonly remaininingDistToExit = Subject.create(0); + + /** Updated during deceleration on the ground. Counted from touchdown distance (min. 400m). */ + private readonly remaininingDistToRwyEnd = Subject.create(0); + + readonly btvRunway = Subject.create(null); + + /** Landing distance available, in meters. Null if not set. */ + readonly btvRunwayLda = Subject.create(null); + + /** Runway heading, in degrees. Null if not set. */ + readonly btvRunwayBearingTrue = Subject.create(null); + + /** Threshold, used for runway end distance calculation */ + private btvThresholdPosition: Position; + + /** Opposite threshold, used for runway end distance calculation */ + private btvOppositeThresholdPosition: Position; + + /** Selected exit */ + readonly btvExit = Subject.create(null); + + /** Distance to exit, in meters. Null if not set. */ + readonly btvExitDistance = Subject.create(null); + + private btvExitPosition: Position; + + private btvPathGeometry: Position[]; + + /** Stopping distance for dry rwy conditions, in meters. Null if not set. Counted from touchdown distance (min. 400m). */ + private readonly dryStoppingDistance = ConsumerSubject.create(null, 0); + + /** Stopping distance for wet rwy conditions, in meters. Null if not set. Counted from touchdown distance (min. 400m). */ + private readonly wetStoppingDistance = ConsumerSubject.create(null, 0); + + /** Live remaining stopping distance during deceleration, in meters. Null if not set. Counted from actual aircraft position. */ + private readonly liveStoppingDistance = ConsumerSubject.create(null, 0); + + /** "runway ahead" advisory was triggered */ + private rwyAheadTriggered: boolean = false; + + /** QFU of currently active "RWY AHEAD" advisory. */ + private rwyAheadQfu: string = ''; + + /** Timestamp at which "runway ahead" advisory was triggered */ + private rwyAheadTriggeredTime: number = 0; + + private readonly rwyAheadArinc = Arinc429Word.empty(); + + private readonly radioAltitude1 = ConsumerSubject.create(null, 0); + + private readonly radioAltitude2 = ConsumerSubject.create(null, 0); + + private readonly fwsFlightPhase = ConsumerSubject.create(null, 0); + + selectRunwayFromOans( + runway: string, + centerlineFeature: Feature, + thresholdFeature: Feature, + ) { + this.clearSelection(); + + // Select opposite threshold location + const thrLoc = thresholdFeature.geometry.coordinates as Position; + this.btvThresholdPosition = thrLoc; + const firstEl = centerlineFeature.geometry.coordinates[0] as Position; + const dist1 = pointDistance(thrLoc[0], thrLoc[1], firstEl[0], firstEl[1]); + const lastEl = centerlineFeature.geometry.coordinates[ + centerlineFeature.geometry.coordinates.length - 1 + ] as Position; + const dist2 = pointDistance(thrLoc[0], thrLoc[1], lastEl[0], lastEl[1]); + if (dist1 > dist2) { + this.btvOppositeThresholdPosition = centerlineFeature.geometry.coordinates[0] as Position; + } else { + this.btvOppositeThresholdPosition = lastEl; + } + + // Derive LDA from geometry (if we take the LDA database value, there might be drawing errors) + const lda = dist1 > dist2 ? dist1 : dist2; + + const heading = thresholdFeature.properties?.brngtrue ?? 0; + + this.btvRunwayLda.set(lda); + this.btvRunwayBearingTrue.set(heading); + this.btvRunway.set(runway); + this.remaininingDistToRwyEnd.set(lda - MIN_TOUCHDOWN_ZONE_DISTANCE); + this.remaininingDistToExit.set(lda - MIN_TOUCHDOWN_ZONE_DISTANCE); + + const pub = this.bus.getPublisher(); + pub.pub('oansSelectedLandingRunway', runway, true); + Arinc429Word.toSimVarValue('L:A32NX_OANS_RWY_LENGTH', lda, Arinc429SignStatusMatrix.NormalOperation); + Arinc429Word.toSimVarValue('L:A32NX_OANS_RWY_BEARING', heading, Arinc429SignStatusMatrix.NormalOperation); + + this.drawBtvLayer(); + } + + selectExitFromOans(exit: string, feature: Feature) { + if (this.btvRunway.get() == null) { + return; + } + + const thrLoc = this.btvThresholdPosition; + const exitLastIndex = feature.geometry.coordinates.length - 1; + const exitLoc1 = feature.geometry.coordinates[0] as Position; + const exitLoc2 = feature.geometry.coordinates[exitLastIndex] as Position; + const exitDistFromCenterLine1 = pointToLineDistance( + exitLoc1, + this.btvThresholdPosition, + this.btvOppositeThresholdPosition, + ); + const exitDistFromCenterLine2 = pointToLineDistance( + exitLoc2, + this.btvThresholdPosition, + this.btvOppositeThresholdPosition, + ); + + // Check whether valid path: Exit start position (i.e. point of exit line closest to threshold) should be inside runway + const exitStartDistFromThreshold = + exitDistFromCenterLine1 < exitDistFromCenterLine2 + ? pointDistance(thrLoc[0], thrLoc[1], exitLoc1[0], exitLoc1[1]) + : pointDistance(thrLoc[0], thrLoc[1], exitLoc2[0], exitLoc2[1]); + + const exitAngle = + exitDistFromCenterLine1 < exitDistFromCenterLine2 + ? pointAngle(thrLoc[0], thrLoc[1], exitLoc1[0], exitLoc1[1]) - + pointAngle(exitLoc1[0], exitLoc1[1], feature.geometry.coordinates[1][0], feature.geometry.coordinates[1][1]) + : pointAngle(thrLoc[0], thrLoc[1], exitLoc2[0], exitLoc2[1]) - + pointAngle( + exitLoc2[0], + exitLoc2[1], + feature.geometry.coordinates[exitLastIndex - 1][0], + feature.geometry.coordinates[exitLastIndex - 1][1], + ); + // Don't run backwards, don't start outside of runway, don't start before minimum touchdown distance + if ( + Math.abs(exitAngle) > 120 || + Math.min(exitDistFromCenterLine1, exitDistFromCenterLine2) > 20 || + exitStartDistFromThreshold < MIN_TOUCHDOWN_ZONE_DISTANCE + ) { + return; + } + this.btvExitPosition = exitDistFromCenterLine1 < exitDistFromCenterLine2 ? exitLoc1 : exitLoc2; + + // Subtract 400m due to distance of touchdown zone from threshold + const exitDistance = + pointDistance(thrLoc[0], thrLoc[1], this.btvExitPosition[0], this.btvExitPosition[1]) - + MIN_TOUCHDOWN_ZONE_DISTANCE; + + this.bus.getPublisher().pub('oansSelectedExit', exit); + Arinc429Word.toSimVarValue( + 'L:A32NX_OANS_BTV_REQ_STOPPING_DISTANCE', + exitDistance, + Arinc429SignStatusMatrix.NormalOperation, + ); + + this.bus.getPublisher().pub('ndBtvMessage', `BTV ${this.btvRunway.get().substring(4)}/${exit}`, true); + + this.btvPathGeometry = Array.from(feature.geometry.coordinates as Position[]); + if (exitDistFromCenterLine1 < exitDistFromCenterLine2) { + this.btvPathGeometry.unshift(thrLoc); + } else { + this.btvPathGeometry.push(thrLoc); + } + + this.btvExitDistance.set(exitDistance); + this.btvExit.set(exit); + + this.drawBtvLayer(); + } + + selectRunwayFromNavdata( + runway: string, + lda: number, + heading: number, + btvThresholdPosition: Position, + btvOppositeThresholdPosition: Position, + ) { + this.clearSelection(); + + this.btvThresholdPosition = btvThresholdPosition; + this.btvOppositeThresholdPosition = btvOppositeThresholdPosition; + this.btvRunwayLda.set(lda); + this.btvRunwayBearingTrue.set(heading); + this.btvRunway.set(runway); + this.remaininingDistToRwyEnd.set(lda - MIN_TOUCHDOWN_ZONE_DISTANCE); + this.remaininingDistToExit.set(lda - MIN_TOUCHDOWN_ZONE_DISTANCE); + + const pub = this.bus.getPublisher(); + pub.pub('oansSelectedLandingRunway', runway, true); + + Arinc429Word.toSimVarValue('L:A32NX_OANS_RWY_LENGTH', lda, Arinc429SignStatusMatrix.NormalOperation); + Arinc429Word.toSimVarValue('L:A32NX_OANS_RWY_BEARING', heading, Arinc429SignStatusMatrix.NormalOperation); + } + + selectExitFromManualEntry(reqStoppingDistance: number, btvExitPosition: Position) { + this.btvExitPosition = btvExitPosition; + + // Account for touchdown zone distance + const correctedStoppingDistance = reqStoppingDistance - MIN_TOUCHDOWN_ZONE_DISTANCE; + + this.bus.getPublisher().pub('oansSelectedExit', 'N/A'); + Arinc429Word.toSimVarValue( + 'L:A32NX_OANS_BTV_REQ_STOPPING_DISTANCE', + correctedStoppingDistance, + Arinc429SignStatusMatrix.NormalOperation, + ); + + this.bus.getPublisher().pub('ndBtvMessage', `BTV ${this.btvRunway.get().substring(4)}/MANUAL`, true); + + this.btvExitDistance.set(correctedStoppingDistance); + this.btvExit.set('N/A'); + } + + clearSelection() { + this.btvThresholdPosition = []; + this.btvOppositeThresholdPosition = []; + this.btvRunwayLda.set(null); + this.btvRunwayBearingTrue.set(null); + this.btvRunway.set(null); + + this.btvExitPosition = []; + this.btvExitDistance.set(null); + this.btvExit.set(null); + this.btvPathGeometry = []; + this.drawBtvLayer(); + + const pub = this.bus.getPublisher(); + pub.pub('oansSelectedLandingRunway', null, true); + pub.pub('oansSelectedExit', null, true); + pub.pub('ndBtvMessage', '', true); + + Arinc429Word.toSimVarValue('L:A32NX_OANS_RWY_LENGTH', 0, Arinc429SignStatusMatrix.NoComputedData); + Arinc429Word.toSimVarValue('L:A32NX_OANS_RWY_BEARING', 0, Arinc429SignStatusMatrix.NoComputedData); + Arinc429Word.toSimVarValue('L:A32NX_OANS_BTV_REQ_STOPPING_DISTANCE', 0, Arinc429SignStatusMatrix.NoComputedData); + this.remaininingDistToExit.set(-1); + this.remaininingDistToRwyEnd.set(-1); + + Arinc429Word.toSimVarValue('L:A32NX_BTV_ROT', 0, Arinc429SignStatusMatrix.NoComputedData); + Arinc429Word.toSimVarValue('L:A32NX_BTV_TURNAROUND_IDLE_REVERSE', 0, Arinc429SignStatusMatrix.NoComputedData); + Arinc429Word.toSimVarValue('L:A32NX_BTV_TURNAROUND_MAX_REVERSE', 0, Arinc429SignStatusMatrix.NoComputedData); + } + + updateRemainingDistances(pos: Position) { + // Only update below 600ft AGL, and in landing FMGC phase + const ra1 = new Arinc429Word(this.radioAltitude1.get()); + const ra2 = new Arinc429Word(this.radioAltitude2.get()); + + if ( + (!ra1.isNormalOperation() && !ra2.isNormalOperation()) || + (ra1.isNormalOperation() ? ra1.value : ra2.value) > 600 || + this.fwsFlightPhase.get() < 6 || + this.fwsFlightPhase.get() > 9 + ) { + this.remaininingDistToRwyEnd.set(-1); + this.remaininingDistToExit.set(-1); + return; + } + + if (this.btvThresholdPosition && this.btvThresholdPosition.length > 0) { + if (this.btvOppositeThresholdPosition && this.btvOppositeThresholdPosition.length > 0) { + const rwyEndDistanceFromTdz = + pointDistance( + this.btvThresholdPosition[0], + this.btvThresholdPosition[1], + this.btvOppositeThresholdPosition[0], + this.btvOppositeThresholdPosition[1], + ) - MIN_TOUCHDOWN_ZONE_DISTANCE; + + const rwyEndDistance = pointDistance( + pos[0], + pos[1], + this.btvOppositeThresholdPosition[0], + this.btvOppositeThresholdPosition[1], + ); + + this.remaininingDistToRwyEnd.set(MathUtils.round(Math.min(rwyEndDistanceFromTdz, rwyEndDistance), 0.1)); + } + + if (this.btvExitPosition && this.btvExitPosition.length > 0) { + const exitDistanceFromTdz = + pointDistance( + this.btvThresholdPosition[0], + this.btvThresholdPosition[1], + this.btvExitPosition[0], + this.btvExitPosition[1], + ) - MIN_TOUCHDOWN_ZONE_DISTANCE; + + const exitDistance = pointDistance(pos[0], pos[1], this.btvExitPosition[0], this.btvExitPosition[1]); + + this.remaininingDistToExit.set(MathUtils.round(Math.min(exitDistanceFromTdz, exitDistance), 0.1)); + } + } + } + + drawBtvPath() { + if (!this.btvPathGeometry.length || !this.canvasRef?.getOrDefault()) { + return; + } + + const ctx = this.canvasRef.instance.getContext('2d'); + ctx.resetTransform(); + ctx.translate(this.canvasCentreX.get(), this.canvasCentreY.get()); + + ctx.lineWidth = 5; + ctx.strokeStyle = '#00ffff'; + + const path = new Path2D(); + ctx.beginPath(); + path.moveTo(this.btvPathGeometry[0][0], this.btvPathGeometry[0][1] * -1); + for (let i = 1; i < this.btvPathGeometry.length; i++) { + const point = this.btvPathGeometry[i]; + path.lineTo(point[0], point[1] * -1); + } + + ctx.stroke(path); + } + + drawStopLines() { + if (!this.btvThresholdPosition.length || !this.canvasRef?.getOrDefault()) { + return; + } + + const ctx = this.canvasRef.instance.getContext('2d'); + ctx.resetTransform(); + ctx.translate(this.canvasCentreX.get(), this.canvasCentreY.get()); + + const radioAlt1 = Arinc429Word.fromSimVarValue('L:A32NX_RA_1_RADIO_ALTITUDE'); + const radioAlt2 = Arinc429Word.fromSimVarValue('L:A32NX_RA_2_RADIO_ALTITUDE'); + const radioAlt = radioAlt1.isFailureWarning() || radioAlt1.isNoComputedData() ? radioAlt2 : radioAlt1; + + // Below 600ft RA, if somewhere on approach, update DRY/WET lines according to predicted touchdown point + const dryWetLinesAreUpdating = radioAlt.valueOr(1000) <= 600; + + // Aircraft distance after threshold + const aircraftDistFromThreshold = pointDistance( + this.btvThresholdPosition[0], + this.btvThresholdPosition[1], + this.aircraftPpos[0], + this.aircraftPpos[1], + ); + const aircraftDistFromRunwayEnd = pointDistance( + this.btvOppositeThresholdPosition[0], + this.btvOppositeThresholdPosition[1], + this.aircraftPpos[0], + this.aircraftPpos[1], + ); + const isPastThreshold = aircraftDistFromRunwayEnd < this.btvRunwayLda.get(); + // As soon as aircraft passes the touchdown zone distance, draw DRY and WET stop bars from there + const touchdownDistance = + dryWetLinesAreUpdating && isPastThreshold && aircraftDistFromThreshold > MIN_TOUCHDOWN_ZONE_DISTANCE + ? aircraftDistFromThreshold + : MIN_TOUCHDOWN_ZONE_DISTANCE; + const dryRunoverCondition = touchdownDistance + this.dryStoppingDistance.get() > this.btvRunwayLda.get(); + const wetRunoverCondition = touchdownDistance + this.wetStoppingDistance.get() > this.btvRunwayLda.get(); + + const latDistance = 27.5 / this.getZoomLevelInverseScale(); + const strokeWidth = 3.5 / this.getZoomLevelInverseScale(); + + // DRY stop line + if (this.dryStoppingDistance.get() > 0 && !this.aircraftOnGround.get()) { + const distanceToDraw = Math.min( + this.dryStoppingDistance.get(), + this.btvRunwayLda.get() - touchdownDistance + CLAMP_DRY_STOPBAR_DISTANCE, + ); + const dryStopLinePoint = fractionalPointAlongLine( + this.btvThresholdPosition[0], + this.btvThresholdPosition[1], + this.btvOppositeThresholdPosition[0], + this.btvOppositeThresholdPosition[1], + (touchdownDistance + distanceToDraw) / this.btvRunwayLda.get(), + ); + + const dryP1 = [ + latDistance * Math.cos((180 - this.btvRunwayBearingTrue.get()) * MathUtils.DEGREES_TO_RADIANS) + + dryStopLinePoint[0], + latDistance * Math.sin((180 - this.btvRunwayBearingTrue.get()) * MathUtils.DEGREES_TO_RADIANS) + + dryStopLinePoint[1], + ]; + const dryP2 = [ + latDistance * Math.cos(-this.btvRunwayBearingTrue.get() * MathUtils.DEGREES_TO_RADIANS) + dryStopLinePoint[0], + latDistance * Math.sin(-this.btvRunwayBearingTrue.get() * MathUtils.DEGREES_TO_RADIANS) + dryStopLinePoint[1], + ]; + + ctx.beginPath(); + ctx.lineWidth = strokeWidth + 10; + ctx.strokeStyle = '#000000'; + ctx.moveTo(dryP1[0], dryP1[1] * -1); + ctx.lineTo(dryStopLinePoint[0], dryStopLinePoint[1] * -1); + ctx.lineTo(dryP2[0], dryP2[1] * -1); + ctx.stroke(); + ctx.lineWidth = strokeWidth; + ctx.strokeStyle = dryRunoverCondition ? '#ff0000' : '#ff94ff'; + ctx.moveTo(dryP1[0], dryP1[1] * -1); + ctx.lineTo(dryStopLinePoint[0], dryStopLinePoint[1] * -1); + ctx.lineTo(dryP2[0], dryP2[1] * -1); + ctx.stroke(); + + // Label + const dryLabel: Label = { + text: 'DRY', + style: dryRunoverCondition ? LabelStyle.BtvStopLineRed : LabelStyle.BtvStopLineMagenta, + position: dryP2, + rotation: 0, + associatedFeature: null, + }; + this.labelManager.visibleLabels.insert(dryLabel); + this.labelManager.labels.push(dryLabel); + } + + // WET stop line + if (this.wetStoppingDistance.get() > 0 && !this.aircraftOnGround.get()) { + const distanceToDraw = Math.min( + this.wetStoppingDistance.get(), + this.btvRunwayLda.get() - touchdownDistance + CLAMP_WET_STOPBAR_DISTANCE, + ); + const wetStopLinePoint = fractionalPointAlongLine( + this.btvThresholdPosition[0], + this.btvThresholdPosition[1], + this.btvOppositeThresholdPosition[0], + this.btvOppositeThresholdPosition[1], + (touchdownDistance + distanceToDraw) / this.btvRunwayLda.get(), + ); + + const wetP1 = [ + latDistance * Math.cos((180 - this.btvRunwayBearingTrue.get()) * MathUtils.DEGREES_TO_RADIANS) + + wetStopLinePoint[0], + latDistance * Math.sin((180 - this.btvRunwayBearingTrue.get()) * MathUtils.DEGREES_TO_RADIANS) + + wetStopLinePoint[1], + ]; + const wetP2 = [ + latDistance * Math.cos(-this.btvRunwayBearingTrue.get() * MathUtils.DEGREES_TO_RADIANS) + wetStopLinePoint[0], + latDistance * Math.sin(-this.btvRunwayBearingTrue.get() * MathUtils.DEGREES_TO_RADIANS) + wetStopLinePoint[1], + ]; + + ctx.beginPath(); + ctx.lineWidth = strokeWidth + 10; + ctx.strokeStyle = '#000000'; + ctx.moveTo(wetP1[0], wetP1[1] * -1); + ctx.lineTo(wetStopLinePoint[0], wetStopLinePoint[1] * -1); + ctx.lineTo(wetP2[0], wetP2[1] * -1); + ctx.stroke(); + ctx.lineWidth = strokeWidth; + let labelStyle: LabelStyle; + if (!dryRunoverCondition && !wetRunoverCondition) { + ctx.strokeStyle = '#ff94ff'; // magenta + labelStyle = LabelStyle.BtvStopLineMagenta; + } else if (!dryRunoverCondition && wetRunoverCondition) { + ctx.strokeStyle = '#e68000'; // amber + labelStyle = LabelStyle.BtvStopLineAmber; + } else { + ctx.strokeStyle = '#ff0000'; + labelStyle = LabelStyle.BtvStopLineRed; + } + ctx.moveTo(wetP1[0], wetP1[1] * -1); + ctx.lineTo(wetStopLinePoint[0], wetStopLinePoint[1] * -1); + ctx.lineTo(wetP2[0], wetP2[1] * -1); + ctx.stroke(); + + // Label + const wetLabel: Label = { + text: 'WET', + style: labelStyle, + position: wetP2, + rotation: 0, + associatedFeature: null, + }; + this.labelManager.visibleLabels.insert(wetLabel); + this.labelManager.labels.push(wetLabel); + } + + // On ground & above 25kts: STOP line + if (this.liveStoppingDistance.get() > 0 && this.aircraftOnGround.get() && this.groundSpeed.value > 25) { + const liveRunOverCondition = this.remaininingDistToRwyEnd.get() - this.liveStoppingDistance.get() <= 0; + // If runover predicted, draw stop bar a little behind the runway end + const distanceToDraw = + liveRunOverCondition && + this.liveStoppingDistance.get() - this.remaininingDistToRwyEnd.get() > CLAMP_DRY_STOPBAR_DISTANCE + ? this.remaininingDistToRwyEnd.get() + 100 + : this.liveStoppingDistance.get(); + const stopLinePoint = fractionalPointAlongLine( + this.btvThresholdPosition[0], + this.btvThresholdPosition[1], + this.btvOppositeThresholdPosition[0], + this.btvOppositeThresholdPosition[1], + (aircraftDistFromThreshold + distanceToDraw) / this.btvRunwayLda.get(), + ); + + const stopP1 = [ + latDistance * Math.cos((180 - this.btvRunwayBearingTrue.get()) * MathUtils.DEGREES_TO_RADIANS) + + stopLinePoint[0], + latDistance * Math.sin((180 - this.btvRunwayBearingTrue.get()) * MathUtils.DEGREES_TO_RADIANS) + + stopLinePoint[1], + ]; + const stopP2 = [ + latDistance * Math.cos(-this.btvRunwayBearingTrue.get() * MathUtils.DEGREES_TO_RADIANS) + stopLinePoint[0], + latDistance * Math.sin(-this.btvRunwayBearingTrue.get() * MathUtils.DEGREES_TO_RADIANS) + stopLinePoint[1], + ]; + + ctx.beginPath(); + ctx.lineWidth = strokeWidth + 10; + ctx.strokeStyle = '#000000'; + ctx.moveTo(stopP1[0], stopP1[1] * -1); + ctx.lineTo(stopLinePoint[0], stopLinePoint[1] * -1); + ctx.lineTo(stopP2[0], stopP2[1] * -1); + ctx.stroke(); + ctx.lineWidth = strokeWidth; + ctx.strokeStyle = liveRunOverCondition ? '#ff0000' : '#00ff00'; + ctx.moveTo(stopP1[0], stopP1[1] * -1); + ctx.lineTo(stopLinePoint[0], stopLinePoint[1] * -1); + ctx.lineTo(stopP2[0], stopP2[1] * -1); + ctx.stroke(); + + // Label + const stoplineLabel: Label = { + text: '', + style: liveRunOverCondition ? LabelStyle.BtvStopLineRed : LabelStyle.BtvStopLineGreen, + position: stopP1, + rotation: 0, + associatedFeature: null, + }; + this.labelManager.visibleLabels.insert(stoplineLabel); + this.labelManager.labels.push(stoplineLabel); + } + } + + drawBtvLayer() { + if (!this.canvasRef?.getOrDefault()) { + return; + } + + const isStopLineStyle = (s: LabelStyle) => + [ + LabelStyle.BtvStopLineAmber, + LabelStyle.BtvStopLineGreen, + LabelStyle.BtvStopLineMagenta, + LabelStyle.BtvStopLineRed, + ].includes(s); + + this.canvasRef.instance + .getContext('2d') + .clearRect(0, 0, this.canvasRef.instance.width, this.canvasRef.instance.height); + while (this.labelManager.visibleLabels.getArray().findIndex((it) => isStopLineStyle(it.style)) !== -1) { + this.labelManager.visibleLabels.removeAt( + this.labelManager.visibleLabels.getArray().findIndex((it) => isStopLineStyle(it.style)), + ); + } + this.labelManager.labels = this.labelManager.labels.filter((it) => !isStopLineStyle(it.style)); + this.drawBtvPath(); + this.drawStopLines(); + } + + private skip = 0; + + /** + * Updates and issues OANS RWY AHEAD advisories on PFD and ND + * @param globalPos Aircraft position in WGS-84 + * @param airportRefPos Airport reference position in WGS-84 + * @param aircraftBearing Aircraft bearing in degrees + * @param runwayFeatures Runway AMDB feature collection + */ + updateRwyAheadAdvisory( + globalPos: Coordinates, + airportRefPos: Coordinates, + aircraftBearing: number, + runwayFeatures: FeatureCollection, + ): void { + // Only update every 10 position update, computation is around 4ms + this.skip++; + if (this.skip % 10 !== 0) { + return; + } + + if ( + this.aircraftOnGround.get() === false || + this.groundSpeed.ssm !== Arinc429SignStatusMatrix.NormalOperation || + this.groundSpeed.value > 40 || + this.groundSpeed.value < 1 + ) { + // Transmit no advisory + this.rwyAheadArinc.ssm = Arinc429SignStatusMatrix.NormalOperation; + this.rwyAheadArinc.setBitValue(11, false); + Arinc429Word.toSimVarValue('L:A32NX_OANS_WORD_1', this.rwyAheadArinc.value, this.rwyAheadArinc.ssm); + + this.bus.getPublisher().pub('ndRwyAheadQfu', '', true); + return; + } + + // Warn 7s before entering the runway area: Draw area from aircraft nose to 7s in front of aircraft (with a/c width), check if intersects with runway geometry + // Only available with AMDB data atm, i.e. Navigraph sub + const distNose = 73 / 2 / MathUtils.METRES_TO_NAUTICAL_MILES; + const dist7Sec = (this.groundSpeed.value / 60 / 60) * 7 + distNose; // Add distance to aircraft front + const nosePosition = placeBearingDistance(globalPos, aircraftBearing, distNose); + const predictionHorizon = placeBearingDistance(globalPos, aircraftBearing, dist7Sec); + const line = lineString([ + [nosePosition.lat, nosePosition.long], + [predictionHorizon.lat, predictionHorizon.long], + ]); + const leftLine = lineOffset(line, 30, { units: 'meters' }); + const rightLine = lineOffset(line, -30, { units: 'meters' }); + const volumeCoords = [ + globalToAirportCoordinates(airportRefPos, { + lat: leftLine.geometry.coordinates[0][0], + long: leftLine.geometry.coordinates[0][1], + }) as Position, + globalToAirportCoordinates(airportRefPos, { + lat: leftLine.geometry.coordinates[1][0], + long: leftLine.geometry.coordinates[1][1], + }) as Position, + globalToAirportCoordinates(airportRefPos, { + lat: rightLine.geometry.coordinates[1][0], + long: rightLine.geometry.coordinates[1][1], + }) as Position, + globalToAirportCoordinates(airportRefPos, { + lat: rightLine.geometry.coordinates[0][0], + long: rightLine.geometry.coordinates[0][1], + }) as Position, + globalToAirportCoordinates(airportRefPos, { + lat: leftLine.geometry.coordinates[0][0], + long: leftLine.geometry.coordinates[0][1], + }) as Position, + ]; + + // From here on comparing local to local coords + const predictionVolume = polygon([volumeCoords]); + const acLocalCoords = globalToAirportCoordinates(airportRefPos, globalPos); + + const insideRunways = []; + runwayFeatures.features.forEach((feat) => { + if (feat.properties.idrwy) { + const inside = booleanContains(feat.geometry as Polygon, point(acLocalCoords)); + if (inside) { + insideRunways.push(feat.properties.idrwy.replace('.', ' - ')); + } + } + }); + + const willEnterRunwaysNotInside = []; + for (const feat of runwayFeatures.features) { + if (feat.properties.idrwy) { + const qfu = feat.properties.idrwy.replace('.', ' - '); + const intersects = !booleanDisjoint(predictionVolume, feat.geometry as Polygon); // very costly + if (intersects && !insideRunways.includes(qfu)) { + willEnterRunwaysNotInside.push(qfu); + break; + } + } + } + + // Set rwyAhead to false (i.e. suppress), if: + // More than 30s since rwyAheadTriggeredTime, or + // Aircraft inside runway area + if ( + (this.rwyAheadTriggered && Date.now() - this.rwyAheadTriggeredTime > 30_000) || + willEnterRunwaysNotInside.length === 0 + ) { + this.rwyAheadTriggered = false; + this.rwyAheadTriggeredTime = 0; + this.rwyAheadQfu = ''; + } else { + if (!this.rwyAheadTriggered) { + this.rwyAheadTriggeredTime = Date.now(); + } + + this.rwyAheadTriggered = true; + this.rwyAheadQfu = willEnterRunwaysNotInside[0]; + } + + // Transmit on bus + this.rwyAheadArinc.ssm = Arinc429SignStatusMatrix.NormalOperation; + this.rwyAheadArinc.setBitValue(11, this.rwyAheadTriggered && this.rwyAheadQfu !== ''); + Arinc429Word.toSimVarValue('L:A32NX_OANS_WORD_1', this.rwyAheadArinc.value, this.rwyAheadArinc.ssm); + + this.bus.getPublisher().pub('ndRwyAheadQfu', this.rwyAheadQfu, true); + } +} diff --git a/fbw-common/src/systems/instruments/src/OANC/BtvPublisher.ts b/fbw-common/src/systems/instruments/src/OANC/BtvPublisher.ts new file mode 100644 index 00000000000..cdf79d52f8a --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/BtvPublisher.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { EventBus, Instrument, SimVarDefinition, SimVarPublisher, SimVarValueType } from '@microsoft/msfs-sdk'; +import { Arinc429Word, ArincEventBus } from '@flybywiresim/fbw-sdk'; + +export interface BtvData { + /** (BTV -> OANS) Estimated runway occupancy time (ROT), in seconds. */ + btvRotRaw: number; + /** (BTV -> OANS) Estimated turnaround time, when using idle reverse during deceleration, in minutes. */ + btvTurnAroundIdleReverseRaw: number; + /** (BTV -> OANS) Estimated turnaround time, when using max. reverse during deceleration, in minutes. */ + btvTurnAroundMaxReverseRaw: number; + /** (BTV -> OANS) Dry stopping distance */ + dryStoppingDistance: number; + /** (BTV -> OANS) Wet stopping distance */ + wetStoppingDistance: number; + /** (PRIM -> OANS) Remaining stop distance on ground, used for ROP */ + stopBarDistance: number; + + radioAltitude_1: number; + radioAltitude_2: number; + fwcFlightPhase: number; +} + +export enum BtvSimVars { + btvRotRaw = 'L:A32NX_BTV_ROT', + btvTurnAroundIdleReverseRaw = 'L:A32NX_BTV_TURNAROUND_IDLE_REVERSE', + btvTurnAroundMaxReverseRaw = 'L:A32NX_BTV_TURNAROUND_MAX_REVERSE', + dryStoppingDistance = 'L:A32NX_OANS_BTV_DRY_DISTANCE_ESTIMATED', + wetStoppingDistance = 'L:A32NX_OANS_BTV_WET_DISTANCE_ESTIMATED', + stopBarDistance = 'L:A32NX_OANS_BTV_STOP_BAR_DISTANCE_ESTIMATED', + radioAltitude_1 = 'L:A32NX_RA_1_RADIO_ALTITUDE', + radioAltitude_2 = 'L:A32NX_RA_2_RADIO_ALTITUDE', + fwcFlightPhase = 'L:A32NX_FWC_FLIGHT_PHASE', +} + +export class BtvSimvarPublisher extends SimVarPublisher { + private static simvars = new Map([ + ['btvRotRaw', { name: BtvSimVars.btvRotRaw, type: SimVarValueType.Number }], + ['btvTurnAroundIdleReverseRaw', { name: BtvSimVars.btvTurnAroundIdleReverseRaw, type: SimVarValueType.Number }], + ['btvTurnAroundMaxReverseRaw', { name: BtvSimVars.btvTurnAroundMaxReverseRaw, type: SimVarValueType.Number }], + ['dryStoppingDistance', { name: BtvSimVars.dryStoppingDistance, type: SimVarValueType.Number }], + ['wetStoppingDistance', { name: BtvSimVars.wetStoppingDistance, type: SimVarValueType.Number }], + ['stopBarDistance', { name: BtvSimVars.stopBarDistance, type: SimVarValueType.Number }], + ['radioAltitude_1', { name: BtvSimVars.radioAltitude_1, type: SimVarValueType.Number }], + ['radioAltitude_2', { name: BtvSimVars.radioAltitude_2, type: SimVarValueType.Number }], + ['fwcFlightPhase', { name: BtvSimVars.fwcFlightPhase, type: SimVarValueType.Enum }], + ]); + + public constructor(bus: ArincEventBus) { + super(BtvSimvarPublisher.simvars, bus); + } +} + +export interface BtvDataArinc429 { + /** (BTV -> OANS) Estimated runway occupancy time (ROT), in seconds. */ + btvRot: Arinc429Word; + /** (BTV -> OANS) Estimated turnaround time, when using idle reverse during deceleration, in minutes. */ + btvTurnAroundIdleReverse: Arinc429Word; + /** (BTV -> OANS) Estimated turnaround time, when using max. reverse during deceleration, in minutes. */ + btvTurnAroundMaxReverse: Arinc429Word; +} + +export class BtvArincProvider implements Instrument { + constructor(private readonly bus: EventBus) {} + + /** @inheritdoc */ + public init(): void { + const publisher = this.bus.getPublisher(); + const subscriber = this.bus.getSubscriber(); + + subscriber + .on('btvRotRaw') + .whenChanged() + .handle((w) => { + publisher.pub('btvRot', new Arinc429Word(w)); + }); + + subscriber + .on('btvTurnAroundIdleReverseRaw') + .whenChanged() + .handle((w) => { + publisher.pub('btvTurnAroundIdleReverse', new Arinc429Word(w)); + }); + + subscriber + .on('btvTurnAroundMaxReverseRaw') + .whenChanged() + .handle((w) => { + publisher.pub('btvTurnAroundMaxReverse', new Arinc429Word(w)); + }); + } + + /** @inheritdoc */ + public onUpdate(): void { + // noop + } +} diff --git a/fbw-common/src/systems/instruments/src/OANC/FcuBusPublisher.ts b/fbw-common/src/systems/instruments/src/OANC/FcuBusPublisher.ts new file mode 100644 index 00000000000..f2e5ab37749 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/FcuBusPublisher.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { EfisNdMode, EfisOption, NavAidMode } from '@flybywiresim/fbw-sdk'; + +export interface FcuSimVars { + ndRangeSetting: number; + ndMode: EfisNdMode; + option: EfisOption; + navaidMode1: NavAidMode; + navaidMode2: NavAidMode; + /** State of the LS pushbutton on the EFIS control panel. */ + efisLsActive: boolean; + oansRange: number; +} diff --git a/fbw-common/src/systems/instruments/src/OANC/FmsOansPublisher.ts b/fbw-common/src/systems/instruments/src/OANC/FmsOansPublisher.ts new file mode 100644 index 00000000000..9abbb588e27 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/FmsOansPublisher.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { EventBus, Instrument, SimVarDefinition, SimVarPublisher, SimVarValueType } from '@microsoft/msfs-sdk'; +import { Arinc429Word, ArincEventBus } from '@flybywiresim/fbw-sdk'; + +/** + * Transmitted from FMS to OANS + */ +export interface FmsOansData { + /** (FMS -> OANS) Selected origin airport. */ + fmsOrigin: string; + /** (FMS -> OANS) Selected destination airport. */ + fmsDestination: string; + /** (FMS -> OANS) Selected alternate airport. */ + fmsAlternate: string; + /** (FMS -> OANS) Identifier of departure runway. */ + fmsDepartureRunway: string; + /** (FMS -> OANS) Identifier of landing runway selected through FMS. */ + fmsLandingRunway: string; + /** Identifier of landing runway selected for BTV through OANS. */ + oansSelectedLandingRunway: string; + /** Length of landing runway selected for BTV through OANS, in meters. */ + oansSelectedLandingRunwayLengthRaw: number; + /** Bearing of landing runway selected for BTV through OANS, in degrees. */ + oansSelectedLandingRunwayBearingRaw: number; + /** Identifier of exit selected for BTV through OANS. */ + oansSelectedExit: string; + /** (OANS -> BTV) Requested stopping distance (through OANS), in meters. */ + oansRequestedStoppingDistanceRaw: number; + /** (OANS -> BTV) Distance to opposite end of runway, in meters. */ + oansRemainingDistToRwyEndRaw: number; + /** (OANS -> BTV) Distance to requested stopping distance, in meters. */ + oansRemainingDistToExitRaw: number; + /** (OANS -> ND) QFU to be displayed in flashing RWY AHEAD warning in ND */ + ndRwyAheadQfu: string; + /** (OANS -> ND) Message displayed at the top of the ND (instead of TRUE REF), e.g. BTV 08R/A13 */ + ndBtvMessage: string; +} + +export enum FmsOansSimVars { + oansRequestedStoppingDistanceRaw = 'L:A32NX_OANS_BTV_REQ_STOPPING_DISTANCE', + oansSelectedLandingRunwayLengthRaw = 'L:A32NX_OANS_RWY_LENGTH', + oansSelectedLandingRunwayBearingRaw = 'L:A32NX_OANS_RWY_BEARING', + oansRemainingDistToRwyEndRaw = 'L:A32NX_OANS_BTV_REMAINING_DIST_TO_RWY_END', + oansRemainingDistToExitRaw = 'L:A32NX_OANS_BTV_REMAINING_DIST_TO_EXIT', +} + +export class FmsOansSimvarPublisher extends SimVarPublisher { + private static simvars = new Map([ + [ + 'oansRequestedStoppingDistanceRaw', + { name: FmsOansSimVars.oansRequestedStoppingDistanceRaw, type: SimVarValueType.Number }, + ], + [ + 'oansSelectedLandingRunwayLengthRaw', + { name: FmsOansSimVars.oansSelectedLandingRunwayLengthRaw, type: SimVarValueType.Number }, + ], + [ + 'oansSelectedLandingRunwayBearingRaw', + { name: FmsOansSimVars.oansSelectedLandingRunwayBearingRaw, type: SimVarValueType.Number }, + ], + [ + 'oansRemainingDistToRwyEndRaw', + { name: FmsOansSimVars.oansRemainingDistToRwyEndRaw, type: SimVarValueType.Number }, + ], + ['oansRemainingDistToExitRaw', { name: FmsOansSimVars.oansRemainingDistToExitRaw, type: SimVarValueType.Number }], + ]); + + public constructor(bus: ArincEventBus) { + super(FmsOansSimvarPublisher.simvars, bus); + } +} + +export interface FmsOansDataArinc429 { + /** Length of landing runway selected for BTV through OANS, in meters. */ + oansSelectedLandingRunwayLength: Arinc429Word; + /** Bearing of landing runway selected for BTV through OANS, in degrees. */ + oansSelectedLandingRunwayBearing: Arinc429Word; + /** (OANS -> BTV) Requested stopping distance (through OANS), in meters. */ + oansRequestedStoppingDistance: Arinc429Word; + /** (OANS -> BTV) Distance to opposite end of runway, in meters. */ + oansRemainingDistToRwyEnd: Arinc429Word; + /** (OANS -> BTV) Distance to requested stopping distance, in meters. */ + oansRemainingDistToExit: Arinc429Word; +} + +export class FmsOansArincProvider implements Instrument { + constructor(private readonly bus: EventBus) {} + + /** @inheritdoc */ + public init(): void { + const publisher = this.bus.getPublisher(); + const subscriber = this.bus.getSubscriber(); + + subscriber + .on('oansSelectedLandingRunwayLengthRaw') + .whenChanged() + .handle((w) => { + publisher.pub('oansSelectedLandingRunwayLength', new Arinc429Word(w)); + }); + + subscriber + .on('oansSelectedLandingRunwayBearingRaw') + .whenChanged() + .handle((w) => { + publisher.pub('oansSelectedLandingRunwayBearing', new Arinc429Word(w)); + }); + + subscriber + .on('oansRequestedStoppingDistanceRaw') + .whenChanged() + .handle((w) => { + publisher.pub('oansRequestedStoppingDistance', new Arinc429Word(w)); + }); + + subscriber + .on('oansRemainingDistToRwyEndRaw') + .whenChanged() + .handle((w) => { + publisher.pub('oansRemainingDistToRwyEnd', new Arinc429Word(w)); + }); + + subscriber + .on('oansRemainingDistToExitRaw') + .whenChanged() + .handle((w) => { + publisher.pub('oansRemainingDistToExit', new Arinc429Word(w)); + }); + } + + /** @inheritdoc */ + public onUpdate(): void { + // noop + } +} diff --git a/fbw-common/src/systems/instruments/src/OANC/Oanc.tsx b/fbw-common/src/systems/instruments/src/OANC/Oanc.tsx new file mode 100644 index 00000000000..a9cb0b3896e --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/Oanc.tsx @@ -0,0 +1,1566 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { + ComponentProps, + ConsumerSubject, + DebounceTimer, + DisplayComponent, + EventBus, + FSComponent, + MappedSubject, + NodeReference, + SimVarValueType, + Subject, + Subscribable, + SubscribableArrayEventType, + UnitType, + VNode, + Wait, +} from '@microsoft/msfs-sdk'; +import { + AmdbFeatureCollection, + AmdbFeatureTypeStrings, + AmdbProjection, + AmdbProperties, + FeatureType, + FeatureTypeString, + MathUtils, + PolygonalStructureType, + EfisNdMode, + MapParameters, + EfisSide, +} from '@flybywiresim/fbw-sdk'; +import { + BBox, + bbox, + bboxPolygon, + booleanPointInPolygon, + centroid, + Feature, + featureCollection, + FeatureCollection, + Geometry, + LineString, + Point, + Polygon, + Position, +} from '@turf/turf'; +import { bearingTo, clampAngle, Coordinates, distanceTo, placeBearingDistance } from 'msfs-geo'; + +import { OansControlEvents } from './OansControlEventPublisher'; +import { reciprocal } from '@fmgc/guidance/lnav/CommonGeometry'; +import { FcuSimVars } from './FcuBusPublisher'; +import { FmsOansData } from './FmsOansPublisher'; +import { FmsDataStore } from './OancControlPanelUtils'; +import { BrakeToVacateUtils } from './BrakeToVacateUtils'; +import { STYLE_DATA } from './style-data'; +import { OancMovingModeOverlay, OancStaticModeOverlay } from './OancMovingModeOverlay'; +import { OancAircraftIcon } from './OancAircraftIcon'; +import { OancLabelManager } from './OancLabelManager'; +import { OancPositionComputer } from './OancPositionComputer'; +import { NavigraphAmdbClient } from './api/NavigraphAmdbClient'; +import { globalToAirportCoordinates, pointAngle, pointDistance } from './OancMapUtils'; + +export const OANC_RENDER_WIDTH = 768; +export const OANC_RENDER_HEIGHT = 768; + +const FEATURE_DRAW_PER_FRAME = 50; + +export const ZOOM_TRANSITION_TIME_MS = 300; + +const PAN_MIN_MOVEMENT = 10; + +const LABEL_FEATURE_TYPES = [ + FeatureType.TaxiwayElement, + FeatureType.VerticalPolygonalStructure, + FeatureType.PaintedCenterline, + FeatureType.ParkingStandLocation, + FeatureType.RunwayExitLine, +]; + +const LABEL_POLYGON_STRUCTURE_TYPES = [PolygonalStructureType.TerminalBuilding]; + +export type A320EfisZoomRangeValue = 0.2 | 0.5 | 1 | 2.5; + +export type A380EfisZoomRangeValue = 0.2 | 0.5 | 1 | 2 | 5; + +export const a320EfisZoomRangeSettings: A320EfisZoomRangeValue[] = [0.2, 0.5, 1, 2.5]; + +export const a380EfisZoomRangeSettings: A380EfisZoomRangeValue[] = [0.2, 0.5, 1, 2, 5]; + +const DEFAULT_SCALE_NM = 0.539957; + +const LAYER_VISIBILITY_RULES = [ + [true, true, true, true, false, false, true, true], + [true, true, true, true, false, false, false, true], + [false, true, false, false, true, true, false, true], + [false, true, false, false, true, true, false, true], + [false, true, false, false, true, true, false, true], +]; + +export const LABEL_VISIBILITY_RULES = [true, true, true, true, true]; + +export enum LabelStyle { + Taxiway = 'taxiway', + ExitLine = 'exit-line', + TerminalBuilding = 'terminal-building', + RunwayAxis = 'runway-axis', + RunwayEnd = 'runway-end', + FmsSelectedRunwayEnd = 'runway-end-fms-selected', + FmsSelectedRunwayAxis = 'runway-axis-fms-selected', + BtvSelectedRunwayEnd = 'runway-end-btv-selected', + BtvSelectedRunwayArrow = 'runway-arrow-btv-selected', + BtvSelectedExit = 'exit-line-btv-selected', + BtvStopLineMagenta = 'btv-stop-line-magenta', + BtvStopLineAmber = 'btv-stop-line-amber', + BtvStopLineRed = 'btv-stop-line-red', + BtvStopLineGreen = 'btv-stop-line-green', +} + +export interface Label { + text: string; + style: LabelStyle; + position: Position; + rotation: number | undefined; + associatedFeature: Feature; +} + +export interface ContextMenuItemData { + name: string; + + disabled?: boolean | Subscribable; + + onPressed?: () => void; +} + +export interface OancProps extends ComponentProps { + bus: EventBus; + side: EfisSide; + contextMenuVisible?: Subject; + contextMenuX?: Subject; + contextMenuY?: Subject; + contextMenuItems?: ContextMenuItemData[]; + waitScreenRef: NodeReference; + zoomValues: T[]; +} + +export class Oanc extends DisplayComponent> { + private readonly animationContainerRef = [ + FSComponent.createRef(), + FSComponent.createRef(), + ]; + + private readonly panContainerRef = [FSComponent.createRef(), FSComponent.createRef()]; + + private readonly layerCanvasRefs = [ + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + ]; + + private readonly layerCanvasScaleContainerRefs = [ + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + FSComponent.createRef(), + ]; + + public labelContainerRef = FSComponent.createRef(); + + private readonly positionTextRef = FSComponent.createRef(); + + public data: AmdbFeatureCollection | undefined; + + private dataBbox: BBox | undefined; + + private arpCoordinates: Coordinates | undefined; + + private canvasCenterCoordinates: Coordinates | undefined; + + private readonly dataAirportName = Subject.create(''); + + private readonly dataAirportIcao = Subject.create(''); + + private readonly dataAirportIata = Subject.create(''); + + private readonly positionString = Subject.create(''); + + private readonly positionVisible = Subject.create(false); + + private readonly airportInfoLine1 = this.dataAirportName.map((it) => it.toUpperCase()); + + private readonly airportInfoLine2 = MappedSubject.create( + ([icao, iata]) => `${icao} ${iata}`, + this.dataAirportIcao, + this.dataAirportIata, + ); + + private layerFeatures: FeatureCollection[] = [ + featureCollection([]), // Layer 0: TAXIWAY BG + TAXIWAY SHOULDER + featureCollection([]), // Layer 1: APRON + STAND BG + BUILDINGS (terminal only) + featureCollection([]), // Layer 2: RUNWAY (with markings) + featureCollection([]), // Layer 3: RUNWAY (without markings) + featureCollection([]), // Layer 4: TAXIWAY GUIDANCE LINES (scaled width), HOLD SHORT LINES + featureCollection([]), // Layer 5: TAXIWAY GUIDANCE LINES (unscaled width) + featureCollection([]), // Layer 6: STAND GUIDANCE LINES (scaled width) + featureCollection([]), // Layer 7: DYNAMIC CONTENT (BTV PATH, STOP LINES) + ]; + + public amdbClient = new NavigraphAmdbClient(); + + private labelManager = new OancLabelManager(this); + + private positionComputer = new OancPositionComputer(this); + + public dataLoading = false; + + public doneDrawing = false; + + private isPanningArmed = false; + + public isPanning = false; + + private lastPanX = 0; + + private lastPanY = 0; + + public panArmedX = Subject.create(0); + + public panArmedY = Subject.create(0); + + public panOffsetX = Subject.create(0); + + public panOffsetY = Subject.create(0); + + public panBeingAnimated = Subject.create(false); + + // eslint-disable-next-line arrow-body-style + private readonly isMapPanned = MappedSubject.create( + ([panX, panY, panBeingAnimated]) => { + return panX !== 0 || panY !== 0 || panBeingAnimated; + }, + this.panOffsetX, + this.panOffsetY, + this.panBeingAnimated, + ); + + public modeAnimationOffsetX = Subject.create(0); + + public modeAnimationOffsetY = Subject.create(0); + + private modeAnimationMapNorthUp = Subject.create(false); + + private canvasWidth = Subject.create(0); + + private canvasHeight = Subject.create(0); + + private canvasCentreX = Subject.create(0); + + private canvasCentreY = Subject.create(0); + + public readonly ppos: Coordinates = { lat: 0, long: 0 }; + + public readonly referencePos: Coordinates = { lat: 0, long: 0 }; + + public readonly aircraftWithinAirport = Subject.create(false); + + private readonly airportWithinRange = Subject.create(false); + + private readonly airportBearing = Subject.create(0); + + public readonly projectedPpos: Position = [0, 0]; + + private readonly aircraftOnGround = Subject.create(true); + + private readonly planeTrueHeading = Subject.create(0); + + private readonly mapHeading = Subject.create(0); + + public readonly interpolatedMapHeading = Subject.create(0); + + public readonly previousZoomLevelIndex: Subject = Subject.create(this.props.zoomValues.length - 1); + + public readonly zoomLevelIndex: Subject = Subject.create(this.props.zoomValues.length - 1); + + public readonly canvasCentreReferencedMapParams = new MapParameters(); + + public readonly arpReferencedMapParams = new MapParameters(); + + private readonly efisNDModeSub = ConsumerSubject.create(null, EfisNdMode.PLAN); + + private readonly efisOansRangeSub = ConsumerSubject.create(null, 4); + + private readonly overlayNDModeSub = Subject.create(EfisNdMode.PLAN); + + private readonly ndModeSwitchDelayDebouncer = new DebounceTimer(); + + private readonly fmsDataStore = new FmsDataStore(this.props.bus); + + private readonly btvUtils = new BrakeToVacateUtils( + this.props.bus, + this.labelManager, + this.aircraftOnGround, + this.projectedPpos, + this.layerCanvasRefs[7], + this.canvasCentreX, + this.canvasCentreY, + this.zoomLevelIndex, + this.getZoomLevelInverseScale.bind(this), + ); + + private readonly airportNotInActiveFpln = MappedSubject.create( + ([ndMode, arpt, origin, dest, altn]) => ndMode !== EfisNdMode.ARC && ![origin, dest, altn].includes(arpt), + this.overlayNDModeSub, + this.dataAirportIcao, + this.fmsDataStore.origin, + this.fmsDataStore.destination, + this.fmsDataStore.alternate, + ); + + // eslint-disable-next-line arrow-body-style + public usingPposAsReference = MappedSubject.create( + ([overlayNDMode, aircraftOnGround, aircraftWithinAirport]) => { + return (aircraftOnGround && aircraftWithinAirport) || overlayNDMode === EfisNdMode.ARC; + }, + this.overlayNDModeSub, + this.aircraftOnGround, + this.aircraftWithinAirport, + ); + + // eslint-disable-next-line arrow-body-style + private readonly showAircraft = this.usingPposAsReference; + + private readonly aircraftX = Subject.create(0); + + private readonly aircraftY = Subject.create(0); + + private readonly aircraftRotation = Subject.create(0); + + private readonly zoomLevelScales: number[] = this.props.zoomValues.map((it) => 1 / ((it * 2) / DEFAULT_SCALE_NM)); + + public getZoomLevelInverseScale() { + const multiplier = this.overlayNDModeSub.get() === EfisNdMode.ROSE_NAV ? 0.5 : 1; + + return this.zoomLevelScales[this.zoomLevelIndex.get()] * multiplier; + } + + onAfterRender(node: VNode) { + super.onAfterRender(node); + + this.labelContainerRef.instance.addEventListener('mousedown', this.handleCursorPanStart.bind(this)); + this.labelContainerRef.instance.addEventListener('mousemove', this.handleCursorPanMove.bind(this)); + this.labelContainerRef.instance.addEventListener('mouseup', this.handleCursorPanStop.bind(this)); + + const sub = this.props.bus.getSubscriber(); + + this.efisNDModeSub.setConsumer(sub.on('ndMode')); + + this.efisNDModeSub.sub((mode) => { + this.handleNDModeChange(mode); + this.handleLabelFilter(); + }, true); + + this.efisOansRangeSub.setConsumer(sub.on('oansRange')); + + this.efisOansRangeSub.sub((range) => this.zoomLevelIndex.set(range), true); + + sub + .on('oansDisplayAirport') + .whenChanged() + .handle((airport) => { + this.loadAirportMap(airport); + }); + + this.fmsDataStore.origin.sub(() => this.updateLabelClasses()); + this.fmsDataStore.departureRunway.sub(() => this.updateLabelClasses()); + this.fmsDataStore.destination.sub(() => this.updateLabelClasses()); + this.fmsDataStore.landingRunway.sub(() => this.updateLabelClasses()); + this.btvUtils.btvRunway.sub(() => this.updateLabelClasses()); + this.btvUtils.btvExit.sub(() => { + this.updateLabelClasses(); + }); + + this.labelManager.visibleLabels.sub((index, type, item) => { + switch (type) { + case SubscribableArrayEventType.Added: { + if (Array.isArray(item)) { + for (const label of item as Label[]) { + const element = this.createLabelElement(label); + + this.labelContainerRef.instance.appendChild(element); + this.labelManager.visibleLabelElements.set(label, element); + } + } else { + const element = this.createLabelElement(item as Label); + + this.labelContainerRef.instance.appendChild(element); + this.labelManager.visibleLabelElements.set(item as Label, element); + } + break; + } + case SubscribableArrayEventType.Removed: { + if (Array.isArray(item)) { + for (const label of item as Label[]) { + const element = this.labelManager.visibleLabelElements.get(label); + this.labelContainerRef.instance.removeChild(element); + this.labelManager.visibleLabelElements.delete(label); + } + } else { + const element = this.labelManager.visibleLabelElements.get(item as Label); + if (element) { + this.labelContainerRef.instance.removeChild(element); + this.labelManager.visibleLabelElements.delete(item as Label); + } + } + break; + } + default: + break; + } + }); + + this.zoomLevelIndex.sub(() => this.handleLabelFilter(), true); + + MappedSubject.create(this.panOffsetX, this.panOffsetY).sub(([x, y]) => { + this.panContainerRef[0].instance.style.transform = `translate(${x}px, ${y}px)`; + this.panContainerRef[1].instance.style.transform = `translate(${x}px, ${y}px)`; + + this.labelManager.reflowLabels( + this.fmsDataStore.departureRunway.get(), + this.fmsDataStore.landingRunway.get(), + this.btvUtils.btvRunway.get(), + this.btvUtils.btvExit.get(), + ); + }); + + MappedSubject.create( + ([x, y]) => { + this.animationContainerRef[0].instance.style.transform = `translate(${x}px, ${y}px)`; + this.animationContainerRef[1].instance.style.transform = `translate(${x}px, ${y}px)`; + }, + this.modeAnimationOffsetX, + this.modeAnimationOffsetY, + ); + + this.positionVisible.sub( + (visible) => (this.positionTextRef.instance.style.visibility = visible ? 'inherit' : 'hidden'), + ); + } + + private handleLabelFilter() { + this.labelManager.showLabels = false; + + if (this.efisNDModeSub.get() === EfisNdMode.ARC) { + switch (this.zoomLevelIndex.get()) { + case 4: + case 3: + this.labelManager.currentFilter = { type: 'none' }; + break; + case 2: + this.labelManager.currentFilter = { type: 'major' }; + break; + default: + this.labelManager.currentFilter = { type: 'null' }; + break; + } + } else { + switch (this.zoomLevelIndex.get()) { + case 0: + this.labelManager.currentFilter = { type: 'runwayBtvSelection', runwayIdent: null, showAdjacent: true }; + break; + default: + this.labelManager.currentFilter = { type: 'runwayBtvSelection', runwayIdent: null, showAdjacent: false }; + break; + } + } + + this.handleLayerVisibilities(); + + setTimeout(() => (this.labelManager.showLabels = true), ZOOM_TRANSITION_TIME_MS + 200); + } + + public async loadAirportMap(icao: string) { + this.dataLoading = true; + + if (this.props.waitScreenRef.getOrDefault()) { + this.props.waitScreenRef.instance.style.visibility = 'visible'; + } + + this.clearData(); + this.clearMap(); + this.btvUtils.clearSelection(); + + const includeFeatureTypes: FeatureType[] = Object.values(STYLE_DATA).reduce( + (acc, it) => [ + ...acc, + ...it.reduce((acc, it) => [...acc, ...(it?.dontFetchFromAmdb ? [] : it.forFeatureTypes)], []), + ], + [], + ); + const includeLayers = includeFeatureTypes.map((it) => AmdbFeatureTypeStrings[it]); + + // Additional stuff we need that isn't handled by the canvas renderer + includeLayers.push(FeatureTypeString.AerodromeReferencePoint); + includeLayers.push(FeatureTypeString.ParkingStandLocation); + includeLayers.push(FeatureTypeString.PaintedCenterline); + includeLayers.push(FeatureTypeString.RunwayThreshold); + + const data = await this.amdbClient.getAirportData(icao, includeLayers, undefined); + const wgs84ArpDat = await this.amdbClient.getAirportData( + icao, + [FeatureTypeString.AerodromeReferencePoint], + undefined, + AmdbProjection.Epsg4326, + ); + + const features = Object.values(data).reduce( + (acc, it) => [...acc, ...it.features], + [] as Feature[], + ); + const airportMap: AmdbFeatureCollection = featureCollection(features); + + const wgs84ReferencePoint = wgs84ArpDat.aerodromereferencepoint.features[0]; + + if (!wgs84ReferencePoint) { + console.error('[OANC](loadAirportMap) Invalid airport data - aerodrome reference point not found'); + return; + } + + const refPointLat = (wgs84ReferencePoint.geometry as Point).coordinates[1]; + const refPointLong = (wgs84ReferencePoint.geometry as Point).coordinates[0]; + const projectionScale = 1000; + + if (!refPointLat || !refPointLong || !projectionScale) { + console.error( + '[OANC](loadAirportMap) Invalid airport data - aerodrome reference point does not contain lat/long/scale custom properties', + ); + return; + } + this.arpCoordinates = { lat: refPointLat, long: refPointLong }; + + this.data = airportMap; + + this.dataAirportName.set(wgs84ReferencePoint.properties.name); + this.dataAirportIcao.set(icao); + this.dataAirportIata.set(wgs84ReferencePoint.properties.iata); + + // Figure out the boundaries of the map data + const dataBbox = bbox(airportMap); + + this.updatePosition(); + this.aircraftWithinAirport.set(booleanPointInPolygon(this.projectedPpos, bboxPolygon(dataBbox))); + + const width = (dataBbox[2] - dataBbox[0]) * 1; + const height = (dataBbox[3] - dataBbox[1]) * 1; + + this.canvasWidth.set(width); + this.canvasHeight.set(height); + this.canvasCentreX.set(Math.abs(dataBbox[0])); + this.canvasCentreY.set(Math.abs(dataBbox[3])); + + this.canvasCenterCoordinates = this.calculateCanvasCenterCoordinates(); + + this.sortDataIntoLayers(this.data); + this.generateAllLabels(this.data); + + this.dataLoading = false; + } + + private calculateCanvasCenterCoordinates() { + const pxDistanceToCanvasCentre = pointDistance( + this.canvasWidth.get() / 2, + this.canvasHeight.get() / 2, + this.canvasCentreX.get(), + this.canvasCentreY.get(), + ); + const nmDistanceToCanvasCentre = UnitType.NMILE.convertFrom(pxDistanceToCanvasCentre / 1_000, UnitType.KILOMETER); + const angleToCanvasCentre = clampAngle( + pointAngle( + this.canvasWidth.get() / 2, + this.canvasHeight.get() / 2, + this.canvasCentreX.get(), + this.canvasCentreY.get(), + ) + 90, + ); + + return placeBearingDistance(this.arpCoordinates, reciprocal(angleToCanvasCentre), nmDistanceToCanvasCentre); + } + + private createLabelElement(label: Label): HTMLDivElement { + const element = document.createElement('div'); + + element.classList.add('oanc-label'); + element.classList.add(`oanc-label-style-${label.style}`); + element.textContent = label.text; + + if (label.style === LabelStyle.RunwayEnd) { + element.addEventListener('click', () => { + const thresholdFeature = this.data.features.filter( + (it) => it.properties.feattype === FeatureType.RunwayThreshold && it.properties?.idthr === label.text, + ); + this.btvUtils.selectRunwayFromOans( + `${this.dataAirportIcao.get()}${label.text}`, + label.associatedFeature, + thresholdFeature[0], + ); + }); + } + if ( + label.style === LabelStyle.ExitLine && + label.associatedFeature.properties.feattype === FeatureType.RunwayExitLine + ) { + element.addEventListener('click', () => { + this.btvUtils.selectExitFromOans(label.text, label.associatedFeature); + }); + } + + return element; + } + + private sortDataIntoLayers(data: FeatureCollection) { + for (let i = 0; i < this.layerFeatures.length; i++) { + const layer = this.layerFeatures[i]; + + const layerFeatureTypes = STYLE_DATA[i].reduce( + (acc, rule) => [...acc, ...rule.forFeatureTypes], + [] as FeatureType[], + ); + const layerPolygonStructureTypes = STYLE_DATA[i].reduce( + (acc, rule) => [...acc, ...(rule.forPolygonStructureTypes ?? [])], + [] as PolygonalStructureType[], + ); + const layerData = data.features.filter((it) => { + if (it.properties.feattype === FeatureType.VerticalPolygonalStructure) { + return ( + layerFeatureTypes.includes(FeatureType.VerticalPolygonalStructure) && + layerPolygonStructureTypes.includes(it.properties.plysttyp) + ); + } + return layerFeatureTypes.includes(it.properties.feattype); + }); + + layer.features.push(...layerData); + } + } + + private generateAllLabels(data: FeatureCollection) { + for (const feature of data.features) { + if (!LABEL_FEATURE_TYPES.includes(feature.properties.feattype)) { + continue; + } + + // Only include "Taxiway" features that have a valid "idlin" property + if (feature.properties.feattype === FeatureType.TaxiwayElement && !feature.properties.idlin) { + continue; + } + + // Only include "VerticalPolygonObject" features whose "plysttyp" property has what we want + if ( + feature.properties.feattype === FeatureType.VerticalPolygonalStructure && + !LABEL_POLYGON_STRUCTURE_TYPES.includes(feature.properties.plysttyp) + ) { + continue; + } + + let labelPosition: Position; + switch (feature.geometry.type) { + case 'Point': { + const point = feature.geometry as Point; + + labelPosition = point.coordinates; + break; + } + case 'Polygon': { + const polygon = feature.geometry as Polygon; + + labelPosition = centroid(polygon).geometry.coordinates; + break; + } + case 'LineString': { + const lineString = feature.geometry as LineString; + + labelPosition = centroid(lineString).geometry.coordinates; + break; + } + default: { + console.error(`[OANC] Cannot determine label position for geometry of type '${feature.geometry.type}'`); + } + } + + if (feature.properties.feattype === FeatureType.PaintedCenterline) { + const designators: string[] = []; + if (feature.properties.idrwy) { + designators.push(...feature.properties.idrwy.split('.')); + } + + if (designators.length === 0) { + console.error(`Runway feature (id=${feature.properties.id}) does not have a valid idrwy value`); + continue; + } + + const runwayLine = feature.geometry as LineString; + const runwayLineStart = runwayLine.coordinates[0]; + const runwayLineEnd = runwayLine.coordinates[runwayLine.coordinates.length - 1]; + const runwayLineBearing = clampAngle( + -Math.atan2(runwayLineStart[1] - runwayLineEnd[1], runwayLineStart[0] - runwayLineEnd[0]) * + MathUtils.RADIANS_TO_DEGREES + + 90, + ); + + // If reciprocal bearing doesn't match to rwy designator[0], swap designators + if ( + Math.abs(Number(designators[0].replace(/\D/g, '')) * 10 - reciprocal(runwayLineBearing)) > 90 && + designators.length === 2 + ) { + const copied = Array.from(designators); + designators.length = 0; + designators.push(copied[1], copied[0]); + } + + const isFmsOrigin = this.dataAirportIcao.get() === this.fmsDataStore.origin.get(); + const isFmsDestination = this.dataAirportIcao.get() === this.fmsDataStore.origin.get(); + const isSelectedRunway = + (isFmsOrigin && designators.includes(this.fmsDataStore.departureRunway.get()?.substring(4))) || + (isFmsDestination && designators.includes(this.fmsDataStore.landingRunway.get()?.substring(4))); + + const label1: Label = { + text: designators[0], + style: + this.btvUtils.btvRunway.get() === designators[0] ? LabelStyle.BtvSelectedRunwayEnd : LabelStyle.RunwayEnd, + position: runwayLineStart, + rotation: reciprocal(runwayLineBearing), + associatedFeature: feature, + }; + this.labelManager.visibleLabels.insert(label1); + this.labelManager.labels.push(label1); + + // Sometimes, runways have only one designator (e.g. EDDF 18R) + if (designators[1]) { + const label2: Label = { + text: designators[1], + style: + this.btvUtils.btvRunway.get() === designators[1] ? LabelStyle.BtvSelectedRunwayEnd : LabelStyle.RunwayEnd, + position: runwayLineEnd, + rotation: runwayLineBearing, + associatedFeature: feature, + }; + this.labelManager.visibleLabels.insert(label2); + this.labelManager.labels.push(label2); + + const label3: Label = { + text: `${designators[0]}-${designators[1]}`, + style: isSelectedRunway ? LabelStyle.FmsSelectedRunwayAxis : LabelStyle.RunwayAxis, + position: runwayLineEnd, + rotation: runwayLineBearing, + associatedFeature: feature, + }; + this.labelManager.visibleLabels.insert(label3); + this.labelManager.labels.push(label3); + } else { + const label3: Label = { + text: designators[0], + style: isSelectedRunway ? LabelStyle.FmsSelectedRunwayAxis : LabelStyle.RunwayAxis, + position: runwayLineEnd, + rotation: runwayLineBearing, + associatedFeature: feature, + }; + this.labelManager.visibleLabels.insert(label3); + this.labelManager.labels.push(label3); + } + + // Selected FMS runway (origin or destination) + const labelFms1: Label = { + text: designators[0], + style: LabelStyle.FmsSelectedRunwayEnd, + position: runwayLineStart, + rotation: reciprocal(runwayLineBearing), + associatedFeature: feature, + }; + this.labelManager.labels.push(labelFms1); + this.labelManager.visibleLabels.insert(labelFms1); + + const labelFms2: Label = { + text: designators[1], + style: LabelStyle.FmsSelectedRunwayEnd, + position: runwayLineEnd, + rotation: runwayLineBearing, + associatedFeature: feature, + }; + this.labelManager.labels.push(labelFms2); + this.labelManager.visibleLabels.insert(labelFms2); + + // BTV selected runway + const btvSelectedArrow1: Label = { + text: designators[0], + style: LabelStyle.BtvSelectedRunwayArrow, + position: runwayLineStart, + rotation: reciprocal(runwayLineBearing), + associatedFeature: feature, + }; + this.labelManager.labels.push(btvSelectedArrow1); + this.labelManager.visibleLabels.insert(btvSelectedArrow1); + + const btvSelectedArrow2: Label = { + text: designators[1], + style: LabelStyle.BtvSelectedRunwayArrow, + position: runwayLineEnd, + rotation: runwayLineBearing, + associatedFeature: feature, + }; + this.labelManager.labels.push(btvSelectedArrow2); + this.labelManager.visibleLabels.insert(btvSelectedArrow2); + } else { + const text = feature.properties.idlin ?? feature.properties.idstd ?? feature.properties.ident ?? undefined; + + if ( + feature.properties.feattype === FeatureType.ParkingStandLocation && + text !== undefined && + text.includes('_') + ) { + continue; + } + + if (text !== undefined) { + let style: LabelStyle.TerminalBuilding | LabelStyle.Taxiway | LabelStyle.ExitLine; + switch (feature.properties.feattype) { + case FeatureType.VerticalPolygonalStructure: + style = LabelStyle.TerminalBuilding; + break; + case FeatureType.ParkingStandLocation: + style = LabelStyle.TerminalBuilding; + break; + case FeatureType.RunwayExitLine: + style = LabelStyle.ExitLine; + break; + default: + style = LabelStyle.Taxiway; + break; + } + + const existing = this.labelManager.labels.filter((it) => it.text === text); + + const shortestDistance = existing.reduce((shortestDistance, label) => { + const distance = pointDistance(label.position[0], label.position[1], labelPosition[0], labelPosition[1]); + + return distance > shortestDistance ? distance : shortestDistance; + }, Number.MAX_SAFE_INTEGER); + + if ( + (feature.properties.feattype === FeatureType.ParkingStandLocation && + existing.some((it) => feature.properties.termref === it.associatedFeature.properties.termref)) || + shortestDistance < 50 + ) { + continue; + } + + const label = { + text: text.toUpperCase(), + style, + position: labelPosition, + rotation: undefined, + associatedFeature: feature, + }; + + this.labelManager.labels.push(label); + this.labelManager.visibleLabels.insert(label); + } + } + } + } + + private lastLayerDrawnIndex = 0; + + private lastFeatureDrawnIndex = 0; + + private lastTime = 0; + + private projectCoordinates(coordinates: Coordinates): [number, number] { + return globalToAirportCoordinates(this.arpCoordinates, coordinates); + } + + public Update() { + const now = Date.now(); + const deltaTime = (now - this.lastTime) / 1_000; + this.lastTime = now; + + this.updatePosition(); + + this.aircraftOnGround.set( + ![5, 6, 7].includes(SimVar.GetSimVarValue('L:A32NX_FWC_FLIGHT_PHASE', SimVarValueType.Number)), + ); + + const distToArpt = this.ppos && this.arpCoordinates ? distanceTo(this.ppos, this.arpCoordinates) : 9999; + + // If in ARC mode and airport more than 30nm away, apply a hack to not create a huge canvas (only shift airport a little bit out of view with a static offset) + const airportTooFarAwayAndInArcMode = this.usingPposAsReference.get() && distToArpt > 30; + + if (this.arpCoordinates) { + this.airportWithinRange.set(distToArpt < this.props.zoomValues[this.zoomLevelIndex.get()] + 3); // Add 3nm for airport dimension, FIXME better estimation + this.airportBearing.set(bearingTo(this.ppos, this.arpCoordinates)); + } + + if (this.usingPposAsReference.get() || !this.arpCoordinates) { + this.referencePos.lat = this.ppos.lat; + this.referencePos.long = this.ppos.long; + } else { + this.referencePos.lat = this.arpCoordinates.lat; + this.referencePos.long = this.arpCoordinates.long; + } + + if (!this.data || this.dataLoading) { + return; + } + + const position = this.positionComputer.computePosition(); + + if (position) { + this.positionVisible.set(true); + this.positionString.set(position); + } else { + this.positionVisible.set(false); + } + + const mapTargetHeading = this.modeAnimationMapNorthUp.get() ? 0 : this.planeTrueHeading.get(); + this.mapHeading.set(mapTargetHeading); + + const interpolatedMapHeading = this.interpolatedMapHeading.get(); + + if (Math.abs(mapTargetHeading - interpolatedMapHeading) > 0.1) { + const rotateLeft = MathUtils.diffAngle(interpolatedMapHeading, mapTargetHeading) < 0; + + if (rotateLeft) { + this.interpolatedMapHeading.set(clampAngle(interpolatedMapHeading - deltaTime * 90)); + + if (MathUtils.diffAngle(this.interpolatedMapHeading.get(), mapTargetHeading) > 0) { + this.interpolatedMapHeading.set(mapTargetHeading); + } + } else { + this.interpolatedMapHeading.set(clampAngle(interpolatedMapHeading + deltaTime * 90)); + + if (MathUtils.diffAngle(this.interpolatedMapHeading.get(), mapTargetHeading) < 0) { + this.interpolatedMapHeading.set(mapTargetHeading); + } + } + } + + const mapCurrentHeading = this.interpolatedMapHeading.get(); + + this.canvasCentreReferencedMapParams.compute(this.canvasCenterCoordinates, 0, 0.539957, 1_000, mapCurrentHeading); + this.arpReferencedMapParams.compute(this.arpCoordinates, 0, 0.539957, 1_000, mapCurrentHeading); + + let [offsetX, offsetY]: [number, number] = [0, 0]; + if (airportTooFarAwayAndInArcMode) { + const shiftBy = 5 * Math.max(this.canvasWidth.get(), this.canvasHeight.get()); + [offsetX, offsetY] = [shiftBy, shiftBy]; + } else { + [offsetX, offsetY] = this.canvasCentreReferencedMapParams.coordinatesToXYy(this.referencePos); + } + + [this.projectedPpos[0], this.projectedPpos[1]] = this.projectCoordinates(this.ppos); + + if (this.props.side === 'L') { + this.btvUtils.updateRemainingDistances(this.projectedPpos); + this.btvUtils.updateRwyAheadAdvisory( + this.ppos, + this.arpCoordinates, + this.planeTrueHeading.get(), + this.layerFeatures[2], + ); + } + + // TODO figure out how to not need this + offsetY *= -1; + + const rotate = -mapCurrentHeading; + + // Transform layers + for (let i = 0; i < this.layerCanvasRefs.length; i++) { + const canvas = this.layerCanvasRefs[i].instance; + const canvasScaleContainer = this.layerCanvasScaleContainerRefs[i].instance; + + const scale = this.getZoomLevelInverseScale(); + + const translateX = -(this.canvasWidth.get() / 2) + OANC_RENDER_WIDTH / 2; + const translateY = -(this.canvasHeight.get() / 2) + OANC_RENDER_HEIGHT / 2; + + canvas.style.transform = `translate(${-offsetX}px, ${offsetY}px) rotate(${rotate}deg)`; + + canvasScaleContainer.style.left = `${translateX}px`; + canvasScaleContainer.style.top = `${translateY}px`; + canvasScaleContainer.style.transform = `scale(${scale})`; + + const context = canvas.getContext('2d'); + + context.resetTransform(); + + context.translate(this.canvasCentreX.get(), this.canvasCentreY.get()); + } + + // Transform airplane + this.aircraftX.set(384); + this.aircraftY.set(384); + this.aircraftRotation.set(this.planeTrueHeading.get() - mapCurrentHeading); + + // FIXME Use this to update pan offset when zooming + /* if (this.previousZoomLevelIndex.get() !== this.zoomLevelIndex.get()) { + // In PLAN mode, re-pan to zoom in to center of screen + this.panOffsetX.set(this.panOffsetX.get() / this.zoomLevelScales[this.previousZoomLevelIndex.get()] * this.zoomLevelScales[this.zoomLevelIndex.get()]); + this.panOffsetY.set(this.panOffsetY.get() / this.zoomLevelScales[this.previousZoomLevelIndex.get()] * this.zoomLevelScales[this.zoomLevelIndex.get()]); + + this.previousZoomLevelIndex.set(this.zoomLevelIndex.get()); + } */ + + // Reflow labels if necessary + if (this.lastLayerDrawnIndex > this.layerCanvasRefs.length - 1) { + this.doneDrawing = true; + + if (this.props.waitScreenRef.getOrDefault()) { + this.props.waitScreenRef.instance.style.visibility = 'hidden'; + } + + this.labelManager.reflowLabels( + this.fmsDataStore.departureRunway.get(), + this.fmsDataStore.landingRunway.get(), + this.btvUtils.btvRunway.get(), + this.btvUtils.btvExit.get(), + ); + + return; + } + + const layerFeatures = this.layerFeatures[this.lastLayerDrawnIndex]; + const layerCanvas = this.layerCanvasRefs[this.lastLayerDrawnIndex].instance.getContext('2d'); + + if (this.lastFeatureDrawnIndex < layerFeatures.features.length) { + renderFeaturesToCanvas( + this.lastLayerDrawnIndex, + layerCanvas, + layerFeatures, + this.lastFeatureDrawnIndex, + this.lastFeatureDrawnIndex + FEATURE_DRAW_PER_FRAME, + ); + + this.lastFeatureDrawnIndex += FEATURE_DRAW_PER_FRAME; + } else { + this.lastLayerDrawnIndex++; + this.lastFeatureDrawnIndex = 0; + } + } + + private updatePosition(): void { + this.ppos.lat = SimVar.GetSimVarValue('PLANE LATITUDE', 'Degrees'); + this.ppos.long = SimVar.GetSimVarValue('PLANE LONGITUDE', 'Degrees'); + this.planeTrueHeading.set(SimVar.GetSimVarValue('PLANE HEADING DEGREES TRUE', 'Degrees')); + + if (this.arpCoordinates) { + [this.projectedPpos[0], this.projectedPpos[1]] = this.projectCoordinates(this.ppos); + } + } + + private updateLabelClasses() { + this.labelManager.updateLabelClasses( + this.fmsDataStore, + this.dataAirportIcao.get() === this.fmsDataStore.origin.get(), + this.dataAirportIcao.get() === this.fmsDataStore.destination.get(), + this.btvUtils.btvRunway.get(), + this.btvUtils.btvExit.get(), + ); + } + + private clearData(): void { + for (const layer of this.layerFeatures) { + layer.features.length = 0; + } + + this.labelManager.clearLabels(); + } + + private clearMap(): void { + this.lastLayerDrawnIndex = 0; + this.lastFeatureDrawnIndex = 0; + + for (const layer of this.layerCanvasRefs) { + const ctx = layer.instance.getContext('2d'); + const cw = this.canvasWidth.get(); + const ch = this.canvasHeight.get(); + + ctx.clearRect(0, 0, cw, ch); + } + + this.panOffsetX.set(0); + this.panOffsetY.set(0); + } + + public async disablePanningTransitions(): Promise { + for (const container of this.panContainerRef) { + container.instance.style.transition = 'reset'; + } + + await Wait.awaitFrames(1); + + this.panBeingAnimated.set(false); + } + + public async enablePanningTransitions(): Promise { + for (const container of this.panContainerRef) { + container.instance.style.transition = `transform ${ZOOM_TRANSITION_TIME_MS}ms linear`; + } + + await Wait.awaitFrames(1); + + this.panBeingAnimated.set(true); + } + + private async handleNDModeChange(newMode: EfisNdMode) { + if (this.panOffsetX.get() !== 0 || this.panOffsetY.get() !== 0) { + // We need to first animate to the default position + await this.enablePanningTransitions(); + + this.panOffsetX.set(0); + this.panOffsetY.set(0); + await Wait.awaitDelay(ZOOM_TRANSITION_TIME_MS); + + await this.disablePanningTransitions(); + } + + switch (newMode) { + case EfisNdMode.ROSE_NAV: + this.modeAnimationOffsetX.set(0); + this.modeAnimationOffsetY.set(0); + break; + case EfisNdMode.ARC: + this.modeAnimationOffsetX.set(0); + this.modeAnimationOffsetY.set(620 - OANC_RENDER_HEIGHT / 2); + break; + case EfisNdMode.PLAN: + this.modeAnimationOffsetX.set(0); + this.modeAnimationOffsetY.set(0); + break; + default: + // noop + } + + this.ndModeSwitchDelayDebouncer.schedule(() => { + this.overlayNDModeSub.set(newMode); + + switch (newMode) { + case EfisNdMode.ROSE_NAV: + this.modeAnimationMapNorthUp.set(false); + break; + case EfisNdMode.ARC: + this.modeAnimationMapNorthUp.set(false); + break; + case EfisNdMode.PLAN: + this.modeAnimationMapNorthUp.set(true); + break; + default: + // noop + } + }, ZOOM_TRANSITION_TIME_MS); + } + + private handleLayerVisibilities() { + const rule = LAYER_VISIBILITY_RULES[this.zoomLevelIndex.get()]; + + for (let i = 0; i < this.layerCanvasScaleContainerRefs.length; i++) { + const shouldBeVisible = rule[i]; + + const layerContainer = this.layerCanvasScaleContainerRefs[i].instance; + + layerContainer.style.visibility = shouldBeVisible ? 'inherit' : 'hidden'; + } + } + + public handleZoomIn(): void { + if (this.zoomLevelIndex.get() !== 0) { + this.zoomLevelIndex.set(this.zoomLevelIndex.get() - 1); + } + } + + public handleZoomOut(): void { + if (this.zoomLevelIndex.get() !== this.props.zoomValues.length - 1) { + this.zoomLevelIndex.set(this.zoomLevelIndex.get() + 1); + } + } + + public handleCursorPanStart(event: MouseEvent): void { + this.isPanningArmed = true; + this.panArmedX.set(event.screenX); + this.panArmedY.set(event.screenY); + } + + public handleCursorPanMove(event: MouseEvent): void { + if (this.isPanningArmed) { + const adx = Math.abs(event.screenX - this.panArmedX.get()); + const ady = Math.abs(event.screenY - this.panArmedY.get()); + + // We only actually start panning if we move more than a certain amount - this is to ensure we can differentiate between panning + // and opening the context menu + if (adx > PAN_MIN_MOVEMENT || ady > PAN_MIN_MOVEMENT) { + this.isPanningArmed = false; + this.isPanning = true; + } + } + + if (this.isPanning) { + this.panOffsetX.set(this.panOffsetX.get() + event.screenX - this.lastPanX); + this.panOffsetY.set(this.panOffsetY.get() + event.screenY - this.lastPanY); + } + + this.lastPanX = event.screenX; + this.lastPanY = event.screenY; + } + + public handleCursorPanStop(event: MouseEvent): void { + this.props.contextMenuX?.set(event.screenX); + this.props.contextMenuY?.set(event.screenY); + if (!this.isPanning) { + this.isPanningArmed = false; + this.props.contextMenuVisible?.set(!this.props.contextMenuVisible.get()); + } + this.isPanning = false; + } + + public projectPoint(coordinates: Position): [number, number] { + const labelX = coordinates[0]; + const labelY = coordinates[1]; + + // eslint-disable-next-line prefer-const + let [offsetX, offsetY] = this.arpReferencedMapParams.coordinatesToXYy(this.referencePos); + + // TODO figure out how to not need this + offsetY *= -1; + + const mapCurrentHeading = this.interpolatedMapHeading.get(); + const rotate = -mapCurrentHeading; + + const hypotenuse = Math.sqrt(labelX ** 2 + labelY ** 2) * this.getZoomLevelInverseScale(); + const angle = clampAngle(Math.atan2(labelY, labelX) * MathUtils.RADIANS_TO_DEGREES); + + const rotationAdjustX = hypotenuse * Math.cos((angle - rotate) * MathUtils.DEGREES_TO_RADIANS); + const rotationAdjustY = hypotenuse * Math.sin((angle - rotate) * MathUtils.DEGREES_TO_RADIANS); + + const scaledOffsetX = offsetX * this.getZoomLevelInverseScale(); + const scaledOffsetY = offsetY * this.getZoomLevelInverseScale(); + + let labelScreenX = OANC_RENDER_WIDTH / 2 + rotationAdjustX + -scaledOffsetX + this.panOffsetX.get(); + let labelScreenY = OANC_RENDER_HEIGHT / 2 + -rotationAdjustY + scaledOffsetY + this.panOffsetY.get(); + + labelScreenX += this.modeAnimationOffsetX.get(); + labelScreenY += this.modeAnimationOffsetY.get(); + + return [labelScreenX, labelScreenY]; + } + + render(): VNode | null { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+
+ this.props.zoomValues[it])} + ndMode={this.overlayNDModeSub} + rotation={this.interpolatedMapHeading} + isMapPanned={this.isMapPanned} + airportWithinRange={this.airportWithinRange} + airportBearing={this.airportBearing} + airportIcao={this.dataAirportIcao} + /> +
+
+ +
+
+
+ + {this.positionString} + +
+ + + {this.airportInfoLine1} + + + {this.airportInfoLine2} + + (it ? 'inherit' : 'none')) }} + > + ARPT NOT IN +
+ ACTIVE F/PLN +
+
+ + this.props.zoomValues[it])} + ndMode={this.overlayNDModeSub} + rotation={this.interpolatedMapHeading} + isMapPanned={this.isMapPanned} + airportWithinRange={this.airportWithinRange} + airportBearing={this.airportBearing} + airportIcao={this.dataAirportIcao} + /> + + ); + } +} + +const pathCache = new Map(); +const pathIdCache = new Map(); + +function renderFeaturesToCanvas( + layer: number, + ctx: CanvasRenderingContext2D, + data: FeatureCollection, + startIndex: number, + endIndex: number, +) { + const styleRules = STYLE_DATA[layer]; + + for (let i = startIndex; i < Math.min(endIndex, data.features.length); i++) { + const feature = data.features[i]; + let doStroke = false; + + let doFill = false; + + const matchingRule = styleRules.find((it) => { + if (feature.properties.feattype === FeatureType.VerticalPolygonalStructure) { + return ( + it.forFeatureTypes.includes(feature.properties.feattype) && + it.forPolygonStructureTypes.includes(feature.properties.plysttyp) + ); + } + + return it.forFeatureTypes.includes(feature.properties.feattype); + }); + + if (!matchingRule) { + console.error( + `No matching style rule for feature (feattype=${feature.properties.feattype}) in rules for layer #${layer}`, + ); + continue; + } + + if (matchingRule.styles.doStroke !== undefined) { + doStroke = matchingRule.styles.doStroke; + } + + if (matchingRule.styles.doFill !== undefined) { + doFill = matchingRule.styles.doFill; + } + + if (matchingRule.styles.strokeStyle !== undefined) { + ctx.strokeStyle = matchingRule.styles.strokeStyle; + } + + if (matchingRule.styles.lineWidth !== undefined) { + ctx.lineWidth = matchingRule.styles.lineWidth; + } + + if (matchingRule.styles.fillStyle !== undefined) { + ctx.fillStyle = matchingRule.styles.fillStyle; + } + + let id = pathIdCache.get(feature); + if (!id) { + id = `${feature.properties.id}-${feature.properties.feattype}`; + + pathIdCache.set(feature, id); + } + + const cachedPaths = pathCache.get(id); + + switch (feature.geometry.type) { + case 'LineString': { + const outline = feature.geometry as LineString; + + let path: Path2D; + if (cachedPaths) { + // eslint-disable-next-line prefer-destructuring + path = cachedPaths[0]; + } else { + path = new Path2D(); + + path.moveTo(outline.coordinates[0][0], outline.coordinates[0][1] * -1); + + for (let i = 1; i < outline.coordinates.length; i++) { + const point = outline.coordinates[i]; + path.lineTo(point[0], point[1] * -1); + } + + pathCache.set(`${feature.properties.id}-${feature.properties.feattype}`, [path]); + } + + if (doFill) { + ctx.fill(path); + } + + if (doStroke) { + ctx.stroke(path); + } + + break; + } + case 'Polygon': { + const polygon = feature.geometry as Polygon; + + let paths: Path2D[] = []; + if (cachedPaths) { + paths = cachedPaths; + } else { + for (const outline of polygon.coordinates) { + const toCachePath = new Path2D(); + + toCachePath.moveTo(outline[0][0], outline[0][1] * -1); + + for (let i = 1; i < outline.length; i++) { + const point = outline[i]; + toCachePath.lineTo(point[0], point[1] * -1); + } + + paths.push(toCachePath); + } + + pathCache.set(`${feature.properties.id}-${feature.properties.feattype}`, paths); + } + + for (const path of paths) { + if (doStroke) { + ctx.stroke(path); + } + + if (doFill) { + ctx.fill(path); + } + } + break; + } + default: { + console.log(`Could not draw geometry of type: ${feature.geometry.type}`); + break; + } + } + } +} diff --git a/fbw-common/src/systems/instruments/src/OANC/OancAircraftIcon.tsx b/fbw-common/src/systems/instruments/src/OANC/OancAircraftIcon.tsx new file mode 100644 index 00000000000..6c472c314ff --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/OancAircraftIcon.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { + DisplayComponent, + FSComponent, + MappedSubject, + MappedSubscribable, + Subscribable, + Subscription, + VNode, +} from '@microsoft/msfs-sdk'; + +export interface OancAircraftIconProps { + isVisible: Subscribable; + x: Subscribable; + y: Subscribable; + rotation: Subscribable; +} + +export class OancAircraftIcon extends DisplayComponent { + private svgRef = FSComponent.createRef(); + + private readonly subscriptions: (Subscription | MappedSubscribable)[] = []; + + onAfterRender() { + this.subscriptions.push( + this.props.isVisible.sub((isVisible) => { + this.svgRef.instance.style.visibility = isVisible ? 'visible' : 'hidden'; + }), + MappedSubject.create(this.props.x, this.props.y, this.props.rotation).sub(([x, y, rotation]) => { + this.svgRef.instance.style.transform = `translate(${x - 45}px, ${y - 39.625}px) rotate(${rotation}deg)`; + }), + ); + } + + destroy() { + for (const subscription of this.subscriptions) { + subscription.destroy(); + } + } + + render(): VNode | null { + return ( + + + + + ); + } +} diff --git a/fbw-common/src/systems/instruments/src/OANC/OancArcModeCompass.tsx b/fbw-common/src/systems/instruments/src/OANC/OancArcModeCompass.tsx new file mode 100644 index 00000000000..e38a6d666b3 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/OancArcModeCompass.tsx @@ -0,0 +1,465 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { Arinc429WordData, MathUtils } from '@flybywiresim/fbw-sdk'; +import { DisplayComponent, EventBus, FSComponent, MappedSubject, Subscribable, VNode } from '@microsoft/msfs-sdk'; +import { Layer } from '../MsfsAvionicsCommon/Layer'; + +export interface ArcModeOverlayProps { + bus: EventBus; + visible: Subscribable; + rotation: Subscribable; + oansRange: Subscribable; + doClip: boolean; + yOffset: number; + airportWithinRange: Subscribable; + airportBearing: Subscribable; + airportIcao: Subscribable; +} + +export class ArcModeUnderlay extends DisplayComponent { + private readonly rotationValid = this.props.rotation.map((it) => it.isNormalOperation()); + + private readonly rotationToAirport = MappedSubject.create( + ([bearing, rot]) => MathUtils.diffAngle(rot.value, bearing).toFixed(2), + this.props.airportBearing, + this.props.rotation, + ); + + render(): VNode | null { + return ( + + {/* C = 384,620 */} + + + `rotate(${MathUtils.diffAngle(rotation.value, 60).toFixed(2)} 384 620)`, + )} + > + + + + + {/* R = 246 */} + + + + {/* C = 384,620 */} + + + `rotate(${MathUtils.diffAngle(rotation.value, 60).toFixed(2)} 384 620)`, + )} + > + + + + + {/* R = 246 */} + (v ? 'White' : 'Red'))} + stroke-dasharray="10 6" + clip-path="url(#arc-mode-overlay-clip-2)" + /> + + + `translate(369 250) rotate(-90) rotate(${it} -370 0)`)} + class="White" + fill="none" + stroke-width={3} + stroke-linecap="round" + visibility={this.props.airportWithinRange.map((it) => (it ? 'hidden' : 'inherit'))} + > + + `translate(60 60) rotate(${-it + 90})`)}> + + {this.props.airportIcao} + + + + + ); + } +} + +class ArcModeOverlayHeadingRing extends DisplayComponent<{ isAvailable: Subscribable }> { + render(): VNode | null { + return ( + <> + {/* R = 492 */} + + + (v ? 'inherit' : 'hidden'))}> + + + + + 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 + + + + + + + + + + 26 + + + + + + + + + + 27 + + + + + + + + + + 28 + + + + + + + + + + 29 + + + + + + + + + + 30 + + + + + + + + + + 31 + + + + + + + + + + 32 + + + + + + + + + + 33 + + + + + + + + + + 34 + + + + + + + + + + 35 + + + + + + + + ); + } +} diff --git a/fbw-common/src/systems/instruments/src/OANC/OancConfiguration.ts b/fbw-common/src/systems/instruments/src/OANC/OancConfiguration.ts new file mode 100644 index 00000000000..7d61576f51a --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/OancConfiguration.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +export interface OancConfiguration { + /** Whether the Software Control Panel is enabled */ + enableScp: boolean; + + /** Whether Brake-To-Vacate functionality is available */ + enableBtv: boolean; + + /** The URL to the map configuration JSON file to use */ + mapConfigurationUrl: string; +} diff --git a/fbw-common/src/systems/instruments/src/OANC/OancControlPanelUtils.ts b/fbw-common/src/systems/instruments/src/OANC/OancControlPanelUtils.ts new file mode 100644 index 00000000000..abb1a78acaf --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/OancControlPanelUtils.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { ArraySubject, ConsumerSubject, DmsFormatter2, EventBus, Subject, UnitType } from '@microsoft/msfs-sdk'; +import { FmsOansData } from './FmsOansPublisher'; +import { AmdbAirportSearchResult } from '@flybywiresim/fbw-sdk'; + +export enum ControlPanelAirportSearchMode { + Icao, + Iata, + City, +} + +export class ControlPanelUtils { + static readonly LAT_FORMATTER = DmsFormatter2.create('{dd}°{mm}.{s}{+[N]-[S]}', UnitType.DEGREE, 0.1); + + static readonly LONG_FORMATTER = DmsFormatter2.create('{ddd}°{mm}.{s}{+[E]-[W]}', UnitType.DEGREE, 0.1); + + static readonly LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + + static getSearchModeProp(mode: ControlPanelAirportSearchMode): keyof AmdbAirportSearchResult { + let prop: keyof AmdbAirportSearchResult; + switch (mode) { + default: + case ControlPanelAirportSearchMode.Icao: + prop = 'idarpt'; + break; + case ControlPanelAirportSearchMode.Iata: + prop = 'iata'; + break; + case ControlPanelAirportSearchMode.City: + prop = 'name'; + break; + } + return prop; + } +} + +export class ControlPanelStore { + public readonly airports = ArraySubject.create(); + + public readonly sortedAirports = ArraySubject.create(); + + public readonly airportSearchMode = Subject.create(ControlPanelAirportSearchMode.Icao); + + public readonly airportSearchData = ArraySubject.create(); + + public readonly airportSearchSelectedSearchLetterIndex = Subject.create(null); + + public readonly airportSearchSelectedAirportIndex = Subject.create(null); + + public readonly selectedAirport = Subject.create(null); + + public readonly loadedAirport = Subject.create(null); + + public readonly isAirportSelectionPending = Subject.create(false); +} + +export class FmsDataStore { + constructor(private bus: EventBus) { + const sub = this.bus.getSubscriber(); + this.origin.setConsumer(sub.on('fmsOrigin')); + this.destination.setConsumer(sub.on('fmsDestination')); + this.alternate.setConsumer(sub.on('fmsAlternate')); + this.departureRunway.setConsumer(sub.on('fmsDepartureRunway')); + this.landingRunway.setConsumer(sub.on('fmsLandingRunway')); + } + + public readonly origin = ConsumerSubject.create(null, null); + + public readonly destination = ConsumerSubject.create(null, null); + + public readonly alternate = ConsumerSubject.create(null, null); + + public readonly departureRunway = ConsumerSubject.create(null, null); + + public readonly landingRunway = ConsumerSubject.create(null, null); +} diff --git a/fbw-common/src/systems/instruments/src/OANC/OancLabelFilter.ts b/fbw-common/src/systems/instruments/src/OANC/OancLabelFilter.ts new file mode 100644 index 00000000000..3378a16aa7f --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/OancLabelFilter.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { FeatureType } from '@flybywiresim/fbw-sdk'; +import { FmsDataStore } from './OancControlPanelUtils'; +import { Label, LabelStyle } from './Oanc'; + +export interface BaseOancLabelFilter { + type: string; +} + +export interface RunwayBtvSelectionLabelFilter extends BaseOancLabelFilter { + type: 'runwayBtvSelection'; + runwayIdent: string; + showAdjacent: boolean; +} + +export interface NoneLabelFilter extends BaseOancLabelFilter { + type: 'none'; +} + +export interface NullLabelFilter extends BaseOancLabelFilter { + type: 'null'; +} + +export interface MajorLabelFilter extends BaseOancLabelFilter { + type: 'major'; +} + +export type OancLabelFilter = RunwayBtvSelectionLabelFilter | NoneLabelFilter | NullLabelFilter | MajorLabelFilter; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function filterLabel( + label: Label, + filter: OancLabelFilter, + fmsDepRunway?: string, + fmsLdgRunway?: string, + btvSelectedRunway?: string, + btvSelectedExit?: string, +): boolean { + if (label.style === LabelStyle.FmsSelectedRunwayEnd && label.text) { + return label.text === fmsDepRunway?.substring(4) || label.text === fmsLdgRunway?.substring(4); + } + if (label.style === LabelStyle.BtvSelectedRunwayArrow && label.text) { + return label.text === btvSelectedRunway?.substring(4); + } + if ( + btvSelectedRunway && + label.associatedFeature?.properties.feattype === FeatureType.PaintedCenterline && + label.text === btvSelectedRunway?.substring(4) + ) { + return true; + } + if (btvSelectedExit && label.style === LabelStyle.BtvSelectedExit) { + return true; + } + if ( + [ + LabelStyle.BtvStopLineMagenta, + LabelStyle.BtvStopLineAmber, + LabelStyle.BtvStopLineRed, + LabelStyle.BtvStopLineGreen, + ].includes(label.style) + ) { + return true; + } + + switch (filter.type) { + default: + case 'none': + return false; + case 'null': + return label.associatedFeature?.properties.feattype !== FeatureType.RunwayExitLine; + case 'major': + return ( + label.associatedFeature?.properties.feattype === FeatureType.PaintedCenterline || + (label.text.length < 2 && label.associatedFeature?.properties.feattype !== FeatureType.RunwayExitLine) + ); + case 'runwayBtvSelection': + return ( + label.associatedFeature?.properties.feattype === FeatureType.PaintedCenterline || + label.associatedFeature?.properties.feattype === FeatureType.RunwayExitLine || + filter.showAdjacent + ); // FIXME lower opacity if associated rwy not selected + } +} + +export function labelStyle( + label: Label, + fmsDataStore: FmsDataStore, + isFmsOrigin: boolean, + isFmsDestination: boolean, + btvSelectedRunway: string, + btvSelectedExit: string, +): LabelStyle { + if (label.style === LabelStyle.RunwayEnd || label.style === LabelStyle.BtvSelectedRunwayEnd) { + return btvSelectedRunway?.substring(4) === label.text ? LabelStyle.BtvSelectedRunwayEnd : LabelStyle.RunwayEnd; + } + if (label.style === LabelStyle.RunwayAxis || label.style === LabelStyle.FmsSelectedRunwayAxis) { + const isSelectedRunway = + (isFmsOrigin && label.text === fmsDataStore.departureRunway.get()?.substring(4)) || + (isFmsDestination && label.text === fmsDataStore.landingRunway.get()?.substring(4)); + return isSelectedRunway ? LabelStyle.FmsSelectedRunwayAxis : LabelStyle.RunwayAxis; + } + if (label.style === LabelStyle.ExitLine || label.style === LabelStyle.BtvSelectedExit) { + return label.text === btvSelectedExit ? LabelStyle.BtvSelectedExit : LabelStyle.ExitLine; + } + return label.style; +} diff --git a/fbw-common/src/systems/instruments/src/OANC/OancLabelManager.ts b/fbw-common/src/systems/instruments/src/OANC/OancLabelManager.ts new file mode 100644 index 00000000000..0aa71c8f2d7 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/OancLabelManager.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2023-2024 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { clampAngle } from 'msfs-geo'; +import { Feature, LineString } from '@turf/turf'; +import { ArraySubject } from '@microsoft/msfs-sdk'; +import { MathUtils } from '@flybywiresim/fbw-sdk'; +import { FmsDataStore } from './'; +import { filterLabel, labelStyle, OancLabelFilter } from './OancLabelFilter'; +import { Label, LabelStyle, LABEL_VISIBILITY_RULES, Oanc, OANC_RENDER_HEIGHT, OANC_RENDER_WIDTH } from './Oanc'; +import { intersectLineWithRectangle, isPointInRectangle, midPoint, pointAngle } from './OancMapUtils'; + +export class OancLabelManager { + constructor(public oanc: Oanc) {} + + public showLabels = false; + + public currentFilter: OancLabelFilter = { type: 'null' }; + + public labels: Label[] = []; + + public visibleLabels = ArraySubject.create