diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index cb838a49cb1..ad5f19c4215 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -140,6 +140,8 @@ 1. [EFB] Set EFB Auto Brightness to default to On - @MrJigs7 (MrJigs) 1. [FMS] Allow airport to be loaded as fixes in instrument procedures - @tracernz (Mike) 1. [A380X/ND] Fix Terr text wrong position on terrain radar - @MrJigs7 (MrJigs.) +1. [A380X/BTV] Add EXIT MISSED indication on FMA and aural triple click - @flogross89 (floridude) +1. [A380X/OANS] Add flags/crosses capability, change cursor to magenta, implement ARPT NAV reset button - @flogross89 (floridude) ## 0.12.0 diff --git a/fbw-a32nx/src/systems/instruments/src/EFB/index.tsx b/fbw-a32nx/src/systems/instruments/src/EFB/index.tsx index 3c73ee13f29..200a16179c8 100644 --- a/fbw-a32nx/src/systems/instruments/src/EFB/index.tsx +++ b/fbw-a32nx/src/systems/instruments/src/EFB/index.tsx @@ -60,6 +60,7 @@ render( registrationDecal: true, wheelChocks: true, cabinLighting: false, + oansPerformanceMode: false, }, throttle: { numberOfAircraftThrottles: 2, diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/model/A380_COCKPIT.xml b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/model/A380_COCKPIT.xml index 1e1b9771bb1..544e7157575 100644 --- a/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/model/A380_COCKPIT.xml +++ b/fbw-a380x/src/base/flybywire-aircraft-a380-842/SimObjects/AirPlanes/FlyByWire_A380_842/model/A380_COCKPIT.xml @@ -4982,6 +4982,9 @@ </Component> <Component ID="Overhead_Reset_Panel"> + <UseTemplate Name="FBW_Airbus_RESET_PANEL_BUTTON"> + <NAME>ARPT_NAV</NAME> + </UseTemplate> <UseTemplate Name="FBW_Airbus_RESET_PANEL_BUTTON"> <NAME>FMC_A</NAME> </UseTemplate> diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/fbw-a380x/oans/oans-cross.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/fbw-a380x/oans/oans-cross.png new file mode 100644 index 00000000000..05051858ab1 Binary files /dev/null and b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/fbw-a380x/oans/oans-cross.png differ diff --git a/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/fbw-a380x/oans/oans-flag.png b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/fbw-a380x/oans/oans-flag.png new file mode 100644 index 00000000000..1c6688fc694 Binary files /dev/null and b/fbw-a380x/src/base/flybywire-aircraft-a380-842/html_ui/Images/fbw-a380x/oans/oans-flag.png differ diff --git a/fbw-a380x/src/images/oans/oans-cross.svg b/fbw-a380x/src/images/oans/oans-cross.svg new file mode 100644 index 00000000000..2cd52aedb76 --- /dev/null +++ b/fbw-a380x/src/images/oans/oans-cross.svg @@ -0,0 +1,10 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" version="1.1" viewBox="163.055 83.355 54.707 51.783"> + <defs> + <radialGradient gradientUnits="userSpaceOnUse" cx="327.5" cy="243.75" r="36.5" id="gradient-0"> + <stop offset="0" style="stop-color: rgb(255, 182, 182);"/> + <stop offset="1" style="stop-color: rgb(255, 0, 0);"/> + </radialGradient> + </defs> + <circle style="stroke: rgb(0, 0, 0); fill: url("#gradient-0"); fill-rule: nonzero; stroke-width: 3.16437px;" transform="matrix(0.65445, 0, 0, 0.609625, -37.958115, -49.774086)" cx="348.5" cy="262" r="36.5"/> + <polygon style="stroke: rgb(0, 0, 0); stroke-width: 0px; fill: rgb(255, 255, 255);" points="180.582 94.82 189.748 104.302 199.969 95.03 206.185 101.562 196.071 110.308 205.131 119.37 198.494 125.164 189.327 115.893 179.738 124.426 173.417 117.789 182.794 109.043 174.154 100.298"/> +</svg> diff --git a/fbw-a380x/src/images/oans/oans-flag.svg b/fbw-a380x/src/images/oans/oans-flag.svg new file mode 100644 index 00000000000..ba79384e2dc --- /dev/null +++ b/fbw-a380x/src/images/oans/oans-flag.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" version="1.1" viewBox="125.128 151.914 39.804 59.914"> + <defs> + <linearGradient id="gradient-2"> + <stop style="stop-color: rgb(75, 193, 70);" offset="0"/> + <stop offset="0.257" style="stop-color: rgb(213, 238, 214);"/> + <stop offset="0.449" style="stop-color: rgb(184, 226, 184);"/> + <stop style="stop-color: rgb(0, 119, 3);" offset="0.971"/> + <stop offset="1" style="stop-color: rgb(204, 239, 200);"/> + </linearGradient> + </defs> + <g transform="matrix(1, 0, 0, 1, 186.60524, 161.414047)"/> + <rect x="126.526" y="161.974" width="2.443" height="48.65" style="fill: rgb(213, 213, 213); stroke: rgb(0, 0, 0); stroke-width: 0.5px;" rx="2.012" ry="2.012"/> + <path style="stroke: rgb(0, 0, 0); paint-order: fill; fill-rule: nonzero; fill: url("#gradient-2"); stroke-width: 0.5px;" d="M 128.532 185.718 C 128.532 185.718 131.43 187.593 133.412 183.186 C 135.394 178.779 136.38 175.496 141.501 176.044 C 146.622 176.592 143.234 185.384 148.172 185.384 C 153.11 185.384 163.901 182.126 163.901 182.126 L 163.718 161.608 C 163.718 161.608 157.994 165.491 153.97 165.857 C 149.946 166.223 148.51 165.491 147.667 161.83 C 146.824 158.169 146.598 157.668 144.586 154.742 C 142.574 151.816 137.045 152.901 133.753 156.559 C 130.461 160.217 128.237 162.157 128.237 162.157 L 128.532 185.718 Z"/> +</svg> diff --git a/fbw-a380x/src/systems/instruments/src/AtcMailbox/AtcMailbox.tsx b/fbw-a380x/src/systems/instruments/src/AtcMailbox/AtcMailbox.tsx index 57c394aff8b..f1824432f36 100644 --- a/fbw-a380x/src/systems/instruments/src/AtcMailbox/AtcMailbox.tsx +++ b/fbw-a380x/src/systems/instruments/src/AtcMailbox/AtcMailbox.tsx @@ -31,6 +31,7 @@ export class AtcMailbox extends DisplayComponent<AtcMailboxProps> { destroy(): void { this.topRef.getOrDefault()?.removeEventListener('mousemove', this.onMouseMoveHandler); + this.mouseCursorRef.getOrDefault()?.destroy(); super.destroy(); } diff --git a/fbw-a380x/src/systems/instruments/src/EFB/index.tsx b/fbw-a380x/src/systems/instruments/src/EFB/index.tsx index f9b00f56a4c..f40dcbeab04 100644 --- a/fbw-a380x/src/systems/instruments/src/EFB/index.tsx +++ b/fbw-a380x/src/systems/instruments/src/EFB/index.tsx @@ -57,6 +57,7 @@ render( registrationDecal: false, // TODO FIXME: Enable when dynamic registration decal is completed wheelChocks: false, cabinLighting: true, + oansPerformanceMode: true, }, throttle: { numberOfAircraftThrottles: 4, diff --git a/fbw-a380x/src/systems/instruments/src/MFD/MFD.tsx b/fbw-a380x/src/systems/instruments/src/MFD/MFD.tsx index cd60b114481..226be5a8efd 100644 --- a/fbw-a380x/src/systems/instruments/src/MFD/MFD.tsx +++ b/fbw-a380x/src/systems/instruments/src/MFD/MFD.tsx @@ -328,6 +328,8 @@ export class MfdComponent extends DisplayComponent<MfdComponentProps> implements destroy(): void { this.topRef.getOrDefault()?.removeEventListener('mousemove', this.onMouseMoveHandler); + this.mouseCursorRef.getOrDefault()?.destroy(); + this.duplicateNamesRef.getOrDefault()?.destroy(); super.destroy(); } diff --git a/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/UiWidgets/MouseCursor.tsx b/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/UiWidgets/MouseCursor.tsx index 6b5e296411f..8bc6ad6fa73 100644 --- a/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/UiWidgets/MouseCursor.tsx +++ b/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/UiWidgets/MouseCursor.tsx @@ -7,22 +7,36 @@ import { FSComponent, Subject, Subscribable, + SubscribableUtils, Subscription, VNode, } from '@microsoft/msfs-sdk'; +export enum MouseCursorColor { + Yellow, + Magenta, +} + interface MouseCursorProps extends ComponentProps { side: Subscribable<'CAPT' | 'FO'>; isDoubleScreenMfd?: boolean; visible?: Subject<boolean>; + color?: Subscribable<MouseCursorColor> | MouseCursorColor; } export class MouseCursor extends DisplayComponent<MouseCursorProps> { - private subs: Subscription[] = []; + private readonly subs: Subscription[] = []; private readonly divRef = FSComponent.createRef<HTMLSpanElement>(); - private readonly fillColor = '#ffff00'; // or ff94ff = purple, but not sure where that is used + private readonly color: Subscribable<MouseCursorColor> = SubscribableUtils.toSubscribable( + this.props.color ?? MouseCursorColor.Yellow, + true, + ); + + private readonly fillColor = this.color.map((c) => (c === MouseCursorColor.Magenta ? '#ff94ff' : '#ffff00')); + + private readonly rotation = this.props.side.map((side) => `rotate(${side === 'FO' ? 90 : 0} 40 40)`); private hideTimer: ReturnType<typeof setTimeout> | undefined = undefined; @@ -60,17 +74,25 @@ export class MouseCursor extends DisplayComponent<MouseCursorProps> { if (this.props.visible) { this.subs.push(this.props.visible.sub((vis) => (vis ? this.show() : this.hide()), true)); } + + this.subs.push(this.fillColor, this.rotation); + } + + destroy(): void { + for (const s of this.subs) { + s.destroy(); + } } render(): VNode { return ( <div ref={this.divRef} class="mfd-mouse-cursor"> <svg width="80" height="80" xmlns="http://www.w3.org/2000/svg"> - <g transform={this.props.side.map((side) => `rotate(${side === 'FO' ? 90 : 0} 40 40)`)}> - <polyline points="0,0 40,35 80,0" style={`fill: none; stroke: ${this.fillColor}; stroke-width: 3`} /> - <line x1="40" y1="39" x2="40" y2="41" style={`stroke: ${this.fillColor}; stroke-width: 2`} /> - <line x1="39" y1="40" x2="41" y2="40" style={`stroke: ${this.fillColor}; stroke-width: 2`} /> - <polyline points="0,80 40,45 80,80" style={`fill: none; stroke: ${this.fillColor}; stroke-width: 3`} /> + <g transform={this.rotation}> + <polyline points="0,0 40,35 80,0" style={{ fill: 'none', stroke: this.fillColor, 'stroke-width': '3' }} /> + <line x1="40" y1="39" x2="40" y2="41" style={{ stroke: this.fillColor, 'stroke-width': '2' }} /> + <line x1="39" y1="40" x2="41" y2="40" style={{ stroke: this.fillColor, 'stroke-width': '2' }} /> + <polyline points="0,80 40,45 80,80" style={{ fill: 'none', stroke: this.fillColor, 'stroke-width': '3' }} /> </g> </svg> </div> diff --git a/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/ResetPanelPublisher.tsx b/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/ResetPanelPublisher.tsx index 5a2a98a24b7..880ae550188 100644 --- a/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/ResetPanelPublisher.tsx +++ b/fbw-a380x/src/systems/instruments/src/MsfsAvionicsCommon/providers/ResetPanelPublisher.tsx @@ -9,6 +9,7 @@ import { EventBus, SimVarDefinition, SimVarValueType, SimVarPublisher } from '@m * Functionally, these behave similarly to circuit breakers, however they only interrupt software. If pulled out, execution of SW is halted. */ export type ResetPanelSimvars = { + a380x_reset_panel_arpt_nav: boolean; a380x_reset_panel_fmc_a: boolean; a380x_reset_panel_fmc_b: boolean; a380x_reset_panel_fmc_c: boolean; @@ -16,6 +17,7 @@ export type ResetPanelSimvars = { export class ResetPanelSimvarPublisher extends SimVarPublisher<ResetPanelSimvars> { private static simvars = new Map<keyof ResetPanelSimvars, SimVarDefinition>([ + ['a380x_reset_panel_arpt_nav', { name: 'L:A32NX_RESET_PANEL_ARPT_NAV', type: SimVarValueType.Bool }], ['a380x_reset_panel_fmc_a', { name: 'L:A32NX_RESET_PANEL_FMC_A', type: SimVarValueType.Bool }], ['a380x_reset_panel_fmc_b', { name: 'L:A32NX_RESET_PANEL_FMC_B', type: SimVarValueType.Bool }], ['a380x_reset_panel_fmc_c', { name: 'L:A32NX_RESET_PANEL_FMC_C', type: SimVarValueType.Bool }], diff --git a/fbw-a380x/src/systems/instruments/src/ND/OANSRunwayInfoBox.tsx b/fbw-a380x/src/systems/instruments/src/ND/OANSRunwayInfoBox.tsx index c52741abff9..baef653c351 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/OANSRunwayInfoBox.tsx +++ b/fbw-a380x/src/systems/instruments/src/ND/OANSRunwayInfoBox.tsx @@ -3,10 +3,10 @@ import { DisplayComponent, FSComponent, Subscribable, VNode } from '@microsoft/msfs-sdk'; import './oans-style.scss'; -import { EntityTypes } from './OansControlPanel'; +import { ControlPanelMapDataSearchMode } from '@flybywiresim/oanc'; interface OansRunwayInfoBoxProps { - rwyOrStand: Subscribable<EntityTypes | null>; + rwyOrStand: Subscribable<ControlPanelMapDataSearchMode | null>; selectedEntity: Subscribable<string | null>; tora: Subscribable<string | null>; lda: Subscribable<string | null>; @@ -22,10 +22,10 @@ export class OansRunwayInfoBox extends DisplayComponent<OansRunwayInfoBoxProps> this.rwyDivRef.instance.style.display = 'none'; this.standDivRef.instance.style.display = 'none'; - if (this.props.rwyOrStand.get() === EntityTypes.RWY && this.props.selectedEntity.get()) { + if (this.props.rwyOrStand.get() === ControlPanelMapDataSearchMode.Runway && 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()) { + } else if (this.props.rwyOrStand.get() === ControlPanelMapDataSearchMode.Stand && this.props.selectedEntity.get()) { this.rwyDivRef.instance.style.display = 'none'; this.standDivRef.instance.style.display = 'flex'; } else { diff --git a/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.tsx b/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.tsx index bf8db36a391..015e59bfe1d 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.tsx +++ b/fbw-a380x/src/systems/instruments/src/ND/OansControlPanel.tsx @@ -8,12 +8,12 @@ import { ArraySubject, ClockEvents, ComponentProps, + ConsumerSubject, DisplayComponent, EventBus, FSComponent, MapSubject, MappedSubject, - MappedSubscribable, SimVarValueType, Subject, Subscribable, @@ -22,6 +22,7 @@ import { } from '@microsoft/msfs-sdk'; import { ControlPanelAirportSearchMode, + ControlPanelMapDataSearchMode, ControlPanelStore, ControlPanelUtils, FmsDataStore, @@ -33,6 +34,7 @@ import { } from '@flybywiresim/oanc'; import { AmdbAirportSearchResult, + AmdbProperties, Arinc429LocalVarConsumerSubject, BtvData, EfisSide, @@ -41,6 +43,7 @@ import { FmsOansData, MathUtils, NXDataStore, + NXLogicConfirmNode, Runway, } from '@flybywiresim/fbw-sdk'; @@ -55,9 +58,10 @@ import { TopTabNavigator, TopTabNavigatorPage } from 'instruments/src/MsfsAvioni import { Coordinates, distanceTo, placeBearingDistance } from 'msfs-geo'; import { AdirsSimVars } from 'instruments/src/MsfsAvionicsCommon/SimVarTypes'; import { NavigationDatabase, NavigationDatabaseBackend, NavigationDatabaseService } from '@fmgc/index'; -import { InteractionMode, InternalKccuKeyEvent } from 'instruments/src/MFD/shared/MFDSimvarPublisher'; import { NDSimvars } from 'instruments/src/ND/NDSimvarPublisher'; -import { Position } from '@turf/turf'; +import { Feature, Geometry, LineString, Point, Position } from '@turf/turf'; +import { ResetPanelSimvars } from 'instruments/src/MsfsAvionicsCommon/providers/ResetPanelPublisher'; +import { InteractionMode, InternalKccuKeyEvent } from 'instruments/src/MFD/shared/MFDSimvarPublisher'; export interface OansProps extends ComponentProps { bus: EventBus; @@ -66,23 +70,32 @@ export interface OansProps extends ComponentProps { togglePanel: () => void; } -export enum EntityTypes { - RWY, - TWY, - STAND, - OTHER, -} - const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; export class OansControlPanel extends DisplayComponent<OansProps> { - private readonly subs: (Subscription | MappedSubscribable<any>)[] = []; + private readonly subs: Subscription[] = []; - private readonly sub = this.props.bus.getSubscriber<ClockEvents & FmsOansData & AdirsSimVars & NDSimvars & BtvData>(); + private readonly sub = this.props.bus.getSubscriber< + ClockEvents & FmsOansData & AdirsSimVars & NDSimvars & BtvData & OansControlEvents & ResetPanelSimvars + >(); /** If navigraph not available, this class will compute BTV features */ private readonly navigraphAvailable = Subject.create(false); + private readonly oansResetPulled = ConsumerSubject.create(this.sub.on('a380x_reset_panel_arpt_nav'), false); + + private oansPerformanceModeSettingSub = () => {}; + private readonly oansPerformanceMode = Subject.create(false); + private showOans = false; + private lastUpdateTime: number | null = null; + private readonly oansPerformanceModeAndMovedOutOfZoomRange = new NXLogicConfirmNode(60, true); + + private readonly oansAvailable = MappedSubject.create( + ([ng, reset]) => ng && !reset, + this.navigraphAvailable, + this.oansResetPulled, + ); + private amdbClient = new NavigraphAmdbClient(); private readonly oansMenuRef = FSComponent.createRef<HTMLDivElement>(); @@ -105,13 +118,17 @@ export class OansControlPanel extends DisplayComponent<OansProps> { private readonly activeTabIndex = Subject.create<number>(2); - private readonly availableEntityTypes = Object.values(EntityTypes).filter((v) => typeof v === 'string') as string[]; + private readonly availableEntityTypes = ['RWY', 'TWY', 'STAND', 'OTHER']; + + private mapDataFeatures: Feature<Geometry, AmdbProperties>[] | undefined = undefined; private readonly thresholdShift = Subject.create<number | null>(null); private readonly endShift = Subject.create<number | null>(null); - private readonly selectedEntityType = Subject.create<EntityTypes | null>(EntityTypes.RWY); + private readonly selectedEntityType = Subject.create<ControlPanelMapDataSearchMode | null>( + ControlPanelMapDataSearchMode.Runway, + ); private readonly availableEntityList = ArraySubject.create(['']); @@ -119,6 +136,30 @@ export class OansControlPanel extends DisplayComponent<OansProps> { private readonly selectedEntityString = Subject.create<string | null>(null); + private readonly entityIsNotSelected = this.selectedEntityIndex.map((i) => i === null); + + private selectedEntityPosition: Position = []; + + private readonly selectedFeatureId = Subject.create<number | null>(null); + private readonly selectedFeatureType = Subject.create<FeatureType | null>(null); + + private readonly symbolsForFeatureIds = ConsumerSubject.create(this.sub.on('oans_symbols_for_feature_ids'), { + featureIdsWithCrosses: [], + featureIdsWithFlags: [], + }); + + private readonly flagExistsForEntity = MappedSubject.create( + ([symbols, id]) => symbols.featureIdsWithFlags.some((f) => f === id), + this.symbolsForFeatureIds, + this.selectedFeatureId, + ); + + private readonly crossExistsForEntity = MappedSubject.create( + ([symbols, id]) => symbols.featureIdsWithCrosses.some((f) => f === id), + this.symbolsForFeatureIds, + this.selectedFeatureId, + ); + private manualAirportSelection = false; // TODO: Should be using GPS position interpolated with IRS velocity data @@ -126,7 +167,7 @@ export class OansControlPanel extends DisplayComponent<OansProps> { private readonly pposLongWord = Arinc429LocalVarConsumerSubject.create(this.sub.on('longitude')); - private presentPos = MappedSubject.create( + private readonly presentPos = MappedSubject.create( ([lat, lon]) => { return { lat: lat.value, long: lon.value } as Coordinates; }, @@ -134,18 +175,23 @@ export class OansControlPanel extends DisplayComponent<OansProps> { this.pposLongWord, ); - private presentPosNotAvailable = MappedSubject.create( + private readonly presentPosNotAvailable = MappedSubject.create( ([lat, long]) => !lat.isNormalOperation() || !long.isNormalOperation(), this.pposLatWord, this.pposLongWord, ); + private readonly setPlanModeConsumer = ConsumerSubject.create(this.sub.on('oans_show_set_plan_mode'), false); + private readonly setPlanModeDisplay = this.setPlanModeConsumer.map((it) => (it ? 'inherit' : 'none')); + private readonly fmsDataStore = new FmsDataStore(this.props.bus); private readonly runwayTora = Subject.create<string | null>(null); private readonly runwayLda = Subject.create<string | null>(null); + private readonly standCoordinateString = Subject.create<string>(''); + private readonly oansRequestedStoppingDistance = Arinc429LocalVarConsumerSubject.create( this.sub.on('oansRequestedStoppingDistance'), ); @@ -241,69 +287,159 @@ export class OansControlPanel extends DisplayComponent<OansProps> { this.subs.push(this.activeTabIndex.sub((_index) => Coherent.trigger('UNFOCUS_INPUT_FIELD'))); this.subs.push( - this.navigraphAvailable.sub((v) => { + this.oansAvailable.sub((v) => { if (this.mapDataMainRef.getOrDefault() && this.mapDataBtvFallback.getOrDefault()) { this.mapDataMainRef.instance.style.display = v ? 'block' : 'none'; this.mapDataBtvFallback.instance.style.display = v ? 'none' : 'block'; } SimVar.SetSimVarValue('L:A32NX_OANS_AVAILABLE', SimVarValueType.Bool, v); - this.props.bus.getPublisher<OansControlEvents>().pub('oansNotAvail', !v, true); + this.props.bus.getPublisher<OansControlEvents>().pub('oans_not_avail', !v, true, false); }, true), ); - this.fmsDataStore.landingRunway.sub(async (it) => { - // Set control panel display - if (it) { - this.availableEntityList.set([it.substring(4)]); - this.selectedEntityType.set(EntityTypes.RWY); - this.selectedEntityIndex.set(0); - this.selectedEntityString.set(it.substring(4)); - - // Load runway data - const destination = this.fmsDataStore.destination.get(); - if (destination && this.navigraphAvailable.get() === true) { - const data = await this.amdbClient.getAirportData(destination, [FeatureTypeString.RunwayThreshold]); - const thresholdFeature = data.runwaythreshold?.features.filter( - (td) => td.properties.feattype === FeatureType.RunwayThreshold && td.properties?.idthr === it.substring(4), - ); - if (thresholdFeature && thresholdFeature[0]?.properties.lda && thresholdFeature[0]?.properties.tora) { - this.runwayLda.set( - (thresholdFeature[0].properties.lda > 0 ? thresholdFeature[0].properties.lda : 0).toFixed(0), - ); - this.runwayTora.set( - (thresholdFeature[0]?.properties.tora > 0 ? thresholdFeature[0].properties.tora : 0).toFixed(0), - ); - } else { - this.runwayLda.set('N/A'); - this.runwayTora.set('N/A'); + this.subs.push( + this.oansResetPulled.sub((v) => { + if (v) { + this.unloadCurrentAirport(); + } + }, true), + ); + + this.oansPerformanceModeSettingSub = NXDataStore.getAndSubscribe( + 'CONFIG_A380X_OANS_PERFORMANCE_MODE', + (_, v) => this.oansPerformanceMode.set(v === '1'), + '0', + ); + + this.subs.push( + this.fmsDataStore.landingRunway.sub(async (it) => { + // Set control panel display + if (it) { + // Load runway data + const destination = this.fmsDataStore.destination.get(); + if (destination && this.navigraphAvailable.get() === false) { + this.setBtvRunwayFromFmsRunway(); } - } else if (destination && this.navigraphAvailable.get() === false) { - this.setBtvRunwayFromFmsRunway(); } - } - }); + }), + ); + + this.subs.push( + this.sub + .on('nd_show_oans') + .whenChanged() + .handle((showOans) => { + if (this.props.side === showOans.side) { + this.showOans = showOans.show; + } + }), + ); + + this.subs.push( + this.sub + .on('realTime') + .atFrequency(0.5) + .handle((time) => { + this.oansPerformanceModeAndMovedOutOfZoomRange.write( + this.oansPerformanceMode.get() && !this.showOans, + this.lastUpdateTime === null ? 0 : time - this.lastUpdateTime, + ); + this.props.bus.getPublisher<OansControlEvents>().pub( + 'oans_performance_mode_hide', + { + side: this.props.side, + hide: this.oansPerformanceModeAndMovedOutOfZoomRange.read(), + }, + true, + ); + this.autoLoadAirport(); + + this.lastUpdateTime = time; + }), + ); + + this.subs.push( + this.sub + .on('realTime') + .atFrequency(5) + .handle((_) => { + if (this.arpCoordinates && this.navigraphAvailable.get() === false) { + globalToAirportCoordinates(this.arpCoordinates, this.presentPos.get(), this.localPpos); + this.props.bus.getPublisher<FmsOansData>().pub('oansAirportLocalCoordinates', this.localPpos, true); + } + }), + ); - this.sub - .on('realTime') - .atFrequency(1) - .handle((_) => this.autoLoadAirport()); - - this.sub - .on('realTime') - .atFrequency(5) - .handle((_) => { - if (this.arpCoordinates && this.navigraphAvailable.get() === false) { - globalToAirportCoordinates(this.arpCoordinates, this.presentPos.get(), this.localPpos); - this.props.bus.getPublisher<FmsOansData>().pub('oansAirportLocalCoordinates', this.localPpos, true); + this.subs.push(this.sub.on('oans_display_airport').handle((arpt) => this.handleSelectAirport(arpt))); + + this.subs.push( + this.selectedEntityIndex.sub((val) => { + const searchMode = this.selectedEntityType.get(); + if (searchMode !== null && this.mapDataFeatures && val !== null) { + const prop = ControlPanelUtils.getMapDataSearchModeProp(searchMode); + const idx = this.mapDataFeatures.findIndex((f) => f.properties[prop] === this.availableEntityList.get(val)); + this.selectedEntityString.set( + idx !== -1 ? this.mapDataFeatures[idx]?.properties[prop]?.toString() ?? '' : '', + ); + + if ( + (idx !== -1 && searchMode === ControlPanelMapDataSearchMode.Runway) || + searchMode === ControlPanelMapDataSearchMode.Stand + ) { + const feature = this.mapDataFeatures[idx] as Feature<Point>; + this.selectedEntityPosition = feature.geometry.coordinates; + this.selectedFeatureId.set(feature.properties?.id); + this.selectedFeatureType.set(feature.properties?.feattype); + } else if ( + idx !== -1 && + (searchMode === ControlPanelMapDataSearchMode.Taxiway || searchMode === ControlPanelMapDataSearchMode.Other) + ) { + const taxiway = this.mapDataFeatures[idx] as Feature<LineString, AmdbProperties>; + this.selectedEntityPosition = taxiway.properties.midpoint?.coordinates ?? [0, 0]; + this.selectedFeatureId.set(taxiway.properties?.id); + this.selectedFeatureType.set(taxiway.properties?.feattype); + } + + if (idx !== -1 && this.selectedEntityType.get() === ControlPanelMapDataSearchMode.Runway) { + this.runwayLda.set(this.mapDataFeatures[idx].properties.lda?.toFixed(0) ?? ''); + this.runwayTora.set(this.mapDataFeatures[idx].properties.tora?.toFixed(0) ?? ''); + } + } else { + this.selectedEntityString.set(''); + this.runwayLda.set(''); + this.runwayTora.set(''); } - }); + }, true), + ); + this.subs.push( + this.selectedEntityType.sub((v) => this.handleSelectMapDataSearchMode(v ?? ControlPanelMapDataSearchMode.Runway)), + ); - this.selectedEntityIndex.sub((val) => this.selectedEntityString.set(this.availableEntityList.get(val ?? 0))); + this.subs.push( + this.sub + .on(this.props.side === 'L' ? 'kccuOnL' : 'kccuOnR') + .whenChanged() + .handle((it) => this.interactionMode.set(it ? InteractionMode.Kccu : InteractionMode.Touchscreen)), + ); - this.sub - .on(this.props.side === 'L' ? 'kccuOnL' : 'kccuOnR') - .whenChanged() - .handle((it) => this.interactionMode.set(it ? InteractionMode.Kccu : InteractionMode.Touchscreen)); + this.subs.push( + this.setPlanModeConsumer, + this.setPlanModeDisplay, + this.oansResetPulled, + this.oansAvailable, + this.entityIsNotSelected, + this.symbolsForFeatureIds, + this.flagExistsForEntity, + this.crossExistsForEntity, + this.pposLatWord, + this.pposLongWord, + this.presentPos, + this.presentPosNotAvailable, + this.oansRequestedStoppingDistance, + this.reqStoppingDistance, + this.fmsLandingRunwayVisibility, + this.airportDatabase, + ); } public updateAirportSearchData() { @@ -343,7 +479,7 @@ export class OansControlPanel extends DisplayComponent<OansProps> { this.store.sortedAirports.set(array.filter((it) => it[prop] !== null)); } - private handleSelectAirport = (icao: string, indexInSearchData?: number) => { + private handleSelectAirport = async (icao: string, indexInSearchData?: number) => { const airport = this.store.airports.getArray().find((it) => it.idarpt === icao); const prop = ControlPanelUtils.getSearchModeProp( this.store.airportSearchMode.get() ?? ControlPanelAirportSearchMode.Icao, @@ -363,10 +499,48 @@ export class OansControlPanel extends DisplayComponent<OansProps> { this.store.airportSearchSelectedAirportIndex.set(airportIndexInSearchData); this.store.selectedAirport.set(airport); + + this.handleSelectMapDataSearchMode(ControlPanelMapDataSearchMode.Runway); + this.store.isAirportSelectionPending.set(true); }; - private handleSelectSearchMode = (newSearchMode: ControlPanelAirportSearchMode) => { + private handleSelectMapDataSearchMode = async (newSearchMode: ControlPanelMapDataSearchMode) => { + const selectedAirport = this.store.selectedAirport.get(); + this.selectedEntityIndex.set(null); + + if (selectedAirport !== null) { + let featureType: FeatureTypeString = FeatureTypeString.RunwayThreshold; + switch (newSearchMode) { + case ControlPanelMapDataSearchMode.Runway: + featureType = FeatureTypeString.RunwayThreshold; + break; + case ControlPanelMapDataSearchMode.Taxiway: + featureType = FeatureTypeString.TaxiwayGuidanceLine; + break; + case ControlPanelMapDataSearchMode.Stand: + featureType = FeatureTypeString.ParkingStandLocation; + break; + case ControlPanelMapDataSearchMode.Other: + featureType = FeatureTypeString.DeicingArea; + break; + default: + break; + } + // Populate MAP DATA + const data = await this.amdbClient.getAirportData(selectedAirport.idarpt, [featureType]); + this.mapDataFeatures = data[featureType]?.features; + if (this.mapDataFeatures) { + const prop = ControlPanelUtils.getMapDataSearchModeProp(newSearchMode); + const entityData = this.mapDataFeatures + .map((f) => f.properties[prop]?.toString().trim().substring(0, 6) ?? '') + .filter((it) => it); + this.availableEntityList.set([...new Set(entityData)].sort()); + } + } + }; + + private handleSelectAirportSearchMode = (newSearchMode: ControlPanelAirportSearchMode) => { const selectedAirport = this.store.selectedAirport.get(); this.store.airportSearchMode.set(newSearchMode); @@ -392,22 +566,62 @@ export class OansControlPanel extends DisplayComponent<OansProps> { } this.manualAirportSelection = true; - this.props.bus.getPublisher<OansControlEvents>().pub('oansDisplayAirport', selectedArpt.idarpt, true); + this.props.bus.getPublisher<OansControlEvents>().pub('oans_display_airport', selectedArpt.idarpt, true); this.store.loadedAirport.set(selectedArpt); this.store.isAirportSelectionPending.set(false); // TODO should be done when airport is fully loaded }; - private autoLoadAirport() { - // If we don't have ppos, do not try to auto load - if (this.presentPosNotAvailable.get()) { - return; + private handleCrossButton() { + { + const selId = this.selectedFeatureId.get(); + const selFeatType = this.selectedFeatureType.get(); + if (selId !== null && selFeatType !== null) { + this.props.bus + .getPublisher<OansControlEvents>() + .pub( + this.crossExistsForEntity.get() ? 'oans_remove_cross_at_feature' : 'oans_add_cross_at_feature', + { id: selId, feattype: selFeatType }, + true, + ); + } + } + } + + private handleFlagButton() { + { + const selId = this.selectedFeatureId.get(); + const selFeatType = this.selectedFeatureType.get(); + if (selId !== null && selFeatType !== null) { + this.props.bus + .getPublisher<OansControlEvents>() + .pub( + this.flagExistsForEntity.get() ? 'oans_remove_flag_at_feature' : 'oans_add_flag_at_feature', + { id: selId, feattype: selFeatType }, + true, + ); + } + } + } + + private unloadCurrentAirport() { + if (this.store.loadedAirport.get()) { + this.props.bus.getPublisher<OansControlEvents>().pub('oans_display_airport', '', true); + this.store.loadedAirport.set(null); + this.store.isAirportSelectionPending.set(false); } + } + private autoLoadAirport() { + // If we don't have ppos or airport unloaded due to performance reasons, do not try to auto load // If airport has been manually selected, do not auto load. + // FIXME reset manualAirportSelection after a while, to enable auto-load for destination even if departure was selected manually if ( + this.presentPosNotAvailable.get() || + this.oansPerformanceModeAndMovedOutOfZoomRange.read() || this.manualAirportSelection === true || this.store.loadedAirport.get() !== this.store.selectedAirport.get() || - this.store.airports.length === 0 + this.store.airports.length === 0 || + this.oansResetPulled.get() ) { return; } @@ -425,8 +639,7 @@ export class OansControlPanel extends DisplayComponent<OansProps> { if (sortedAirports.length > 0) { const ap = sortedAirports[0]; if (ap.idarpt !== this.store.loadedAirport.get()?.idarpt) { - this.handleSelectAirport(ap.idarpt); - this.props.bus.getPublisher<OansControlEvents>().pub('oansDisplayAirport', ap.idarpt, true); + this.props.bus.getPublisher<OansControlEvents>().pub('oans_display_airport', ap.idarpt, true); this.store.loadedAirport.set(ap); this.store.isAirportSelectionPending.set(false); // TODO should be done when airport is fully loaded } @@ -438,8 +651,7 @@ export class OansControlPanel extends DisplayComponent<OansProps> { const destArpt = this.store.airports.getArray().find((it) => it.idarpt === this.fmsDataStore.destination.get()); if (destArpt && destArpt.idarpt !== this.store.loadedAirport.get()?.idarpt) { if (distanceTo(this.presentPos.get(), { lat: destArpt.coordinates.lat, long: destArpt.coordinates.lon }) < 50) { - this.handleSelectAirport(destArpt.idarpt); - this.props.bus.getPublisher<OansControlEvents>().pub('oansDisplayAirport', destArpt.idarpt, true); + this.props.bus.getPublisher<OansControlEvents>().pub('oans_display_airport', destArpt.idarpt, true); this.store.loadedAirport.set(destArpt); this.store.isAirportSelectionPending.set(false); // TODO should be done when airport is fully loaded return; @@ -449,11 +661,14 @@ export class OansControlPanel extends DisplayComponent<OansProps> { } private async setBtvRunwayFromFmsRunway() { - [this.landingRunwayNavdata, this.arpCoordinates] = await this.btvUtils.setBtvRunwayFromFmsRunway(this.fmsDataStore); + const destination = this.fmsDataStore.destination.get(); + const rwyIdent = this.fmsDataStore.landingRunway.get(); - if (this.landingRunwayNavdata) { - this.runwayLda.set(this.landingRunwayNavdata.length.toFixed(0)); - this.runwayTora.set(this.landingRunwayNavdata.length.toFixed(0)); + if (destination && rwyIdent) { + [this.landingRunwayNavdata, this.arpCoordinates] = await this.btvUtils.setBtvRunwayFromFmsRunway( + destination, + rwyIdent, + ); } } @@ -473,15 +688,33 @@ export class OansControlPanel extends DisplayComponent<OansProps> { } } + destroy(): void { + for (const s of this.subs) { + s.destroy(); + } + this.oansPerformanceModeSettingSub(); + super.destroy(); + } + render(): VNode { return ( <> - <IconButton - ref={this.closePanelButtonRef} - onClick={() => this.props.togglePanel()} - icon="double-up" - containerStyle="z-index: 10; width: 49px; height: 45px; position: absolute; right: 2px; top: 768px;" - /> + <div style={{ display: this.props.isVisible.map((v) => (v ? 'inherit' : 'none')) }}> + <IconButton + ref={this.closePanelButtonRef} + onClick={() => this.props.togglePanel()} + icon="double-up" + containerStyle="z-index: 10; width: 49px; height: 45px; position: absolute; right: 2px; top: 768px;" + /> + </div> + <div style={{ display: this.props.isVisible.map((v) => (v ? 'none' : 'inherit')) }}> + <IconButton + ref={this.closePanelButtonRef} + onClick={() => this.props.togglePanel()} + icon="double-down" + containerStyle="z-index: 10; width: 49px; height: 45px; position: absolute; right: 2px; top: 768px;" + /> + </div> <div class="oans-control-panel-background"> <div ref={this.oansMenuRef} class="oans-control-panel" style={this.style}> <TopTabNavigator @@ -501,14 +734,13 @@ export class OansControlPanel extends DisplayComponent<OansProps> { idPrefix="oanc-search-letter" freeTextAllowed={false} onModified={(i) => this.selectedEntityIndex.set(i)} - inactive={Subject.create(true)} hEventConsumer={this.hEventConsumer} interactionMode={this.interactionMode} /> <div class="oans-cp-map-data-entitytype"> <RadioButtonGroup values={this.availableEntityTypes} - valuesDisabled={Subject.create(Array(4).fill(true))} + valuesDisabled={Subject.create(Array(4).fill(false))} selectedIndex={this.selectedEntityType} idPrefix="entityTypesRadio" /> @@ -558,16 +790,16 @@ export class OansControlPanel extends DisplayComponent<OansProps> { <div ref={this.mapDataMainRef} class="oans-cp-map-data-main"> <div class="oans-cp-map-data-main-2"> <Button - label="ADD CROSS" - onClick={() => console.log('ADD CROSS')} + label={this.crossExistsForEntity.map((e) => (e ? <>DEL CROSS</> : <>ADD CROSS</>))} + onClick={() => this.handleCrossButton()} buttonStyle="flex: 1" - disabled={Subject.create(true)} + disabled={this.entityIsNotSelected} /> <Button - label="ADD FLAG" - onClick={() => console.log('ADD FLAG')} + label={this.flagExistsForEntity.map((e) => (e ? <>DEL FLAG</> : <>ADD FLAG</>))} + onClick={() => this.handleFlagButton()} buttonStyle="flex: 1; margin-left: 10px; margin-right: 10px" - disabled={Subject.create(true)} + disabled={this.entityIsNotSelected} /> <Button label="LDG SHIFT" @@ -578,13 +810,17 @@ export class OansControlPanel extends DisplayComponent<OansProps> { </div> <div class="oans-cp-map-data-main-center"> <Button - label={`CENTER MAP ON ${this.availableEntityList.get(this.selectedEntityIndex.get() ?? 0)}`} - onClick={() => - console.log( - `CENTER MAP ON ${this.availableEntityList.get(this.selectedEntityIndex.get() ?? 0)}`, - ) - } - disabled={Subject.create(true)} + label={this.selectedEntityString.map((s) => ( + <>`CENTER MAP ON ${s}`</> + ))} + onClick={() => { + if (this.selectedEntityPosition) { + this.props.bus + .getPublisher<OansControlEvents>() + .pub('oans_center_map_on', this.selectedEntityPosition, true); + } + }} + disabled={this.entityIsNotSelected} /> </div> <OansRunwayInfoBox @@ -679,13 +915,13 @@ export class OansControlPanel extends DisplayComponent<OansProps> { onModified={(newSelectedIndex) => { switch (newSelectedIndex) { case 0: - this.handleSelectSearchMode(ControlPanelAirportSearchMode.Icao); + this.handleSelectAirportSearchMode(ControlPanelAirportSearchMode.Icao); break; case 1: - this.handleSelectSearchMode(ControlPanelAirportSearchMode.Iata); + this.handleSelectAirportSearchMode(ControlPanelAirportSearchMode.Iata); break; default: - this.handleSelectSearchMode(ControlPanelAirportSearchMode.City); + this.handleSelectAirportSearchMode(ControlPanelAirportSearchMode.City); break; } }} @@ -716,6 +952,9 @@ export class OansControlPanel extends DisplayComponent<OansProps> { return `${ControlPanelUtils.LAT_FORMATTER(it.coordinates.lat)}/${ControlPanelUtils.LONG_FORMATTER(it.coordinates.lon)}`; })} </span> + <span class="mfd-label bigger" style={{ display: this.setPlanModeDisplay }}> + SET PLAN MODE + </span> </div> <div style="flex-grow: 1;" /> <div class="oans-cp-arpt-sel-display-arpt"> @@ -802,3 +1041,71 @@ export class OansControlPanel extends DisplayComponent<OansProps> { ); } } + +interface EraseSymbolsDialogProps extends ComponentProps { + visible: Subscribable<boolean>; + confirmAction: () => void; + hideDialog: () => void; + /** True: Cross, false: flag */ + isCross: boolean; +} + +/* + * ERASE ALL xxx dialog + */ +export class EraseSymbolsDialog extends DisplayComponent<EraseSymbolsDialogProps> { + // Make sure to collect all subscriptions here, otherwise page navigation doesn't work. + private subs = [] as Subscription[]; + + private topRef = FSComponent.createRef<HTMLDivElement>(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.subs.push( + this.props.visible.sub((val) => { + if (this.topRef.getOrDefault()) { + this.topRef.instance.style.display = val ? 'block' : 'none'; + } + }, true), + ); + } + + public destroy(): void { + // Destroy all subscriptions to remove all references to this instance. + for (const x of this.subs) { + x.destroy(); + } + super.destroy(); + } + + render(): VNode { + return ( + <div ref={this.topRef}> + <div class="mfd-dialog" style="left: 209px; top: 350px; width: 350px;"> + <div class="mfd-dialog-title" style="margin-bottom: 20px;"> + <span class="mfd-label">{`ERASE ALL ${this.props.isCross ? 'CROSSES' : 'FLAGS'}`}</span> + <span> + <img + style="position: relative; top: -5px; left: 10px" + width="25px" + src={`/Images/fbw-a380x/oans/oans-${this.props.isCross ? 'cross' : 'flag'}.svg`} + /> + </span> + </div> + <div class="mfd-dialog-buttons"> + <Button label="CANCEL" onClick={() => this.props.hideDialog()} /> + <Button + label="CONFIRM" + onClick={() => { + this.props.confirmAction(); + this.props.hideDialog(); + }} + buttonStyle="padding-right: 6px;" + /> + </div> + </div> + </div> + ); + } +} diff --git a/fbw-a380x/src/systems/instruments/src/ND/instrument.tsx b/fbw-a380x/src/systems/instruments/src/ND/instrument.tsx index 294d8bcf19f..a9b6a5eb8c7 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/instrument.tsx +++ b/fbw-a380x/src/systems/instruments/src/ND/instrument.tsx @@ -10,9 +10,8 @@ import { FsInstrument, HEventPublisher, InstrumentBackplane, + MappedSubject, Subject, - Subscribable, - Wait, } from '@microsoft/msfs-sdk'; import { A380EfisNdRangeValue, @@ -27,17 +26,11 @@ import { FmsOansSimvarPublisher, } from '@flybywiresim/fbw-sdk'; import { NDComponent } from '@flybywiresim/navigation-display'; -import { - a380EfisZoomRangeSettings, - A380EfisZoomRangeValue, - Oanc, - OansControlEvents, - ZOOM_TRANSITION_TIME_MS, -} from '@flybywiresim/oanc'; +import { a380EfisZoomRangeSettings, A380EfisZoomRangeValue, Oanc, OansControlEvents } from '@flybywiresim/oanc'; import { ContextMenu, ContextMenuElement } from 'instruments/src/MsfsAvionicsCommon/UiWidgets/ContextMenu'; -import { MouseCursor } from 'instruments/src/MsfsAvionicsCommon/UiWidgets/MouseCursor'; -import { OansControlPanel } from './OansControlPanel'; +import { MouseCursor, MouseCursorColor } from 'instruments/src/MsfsAvionicsCommon/UiWidgets/MouseCursor'; +import { EraseSymbolsDialog, OansControlPanel } from './OansControlPanel'; import { FmsSymbolsPublisher } from './FmsSymbolsPublisher'; import { NDSimvarPublisher, NDSimvars } from './NDSimvarPublisher'; import { AdirsValueProvider } from '../MsfsAvionicsCommon/AdirsValueProvider'; @@ -50,6 +43,7 @@ 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 { ResetPanelSimvarPublisher, ResetPanelSimvars } from '../MsfsAvionicsCommon/providers/ResetPanelPublisher'; import { RopRowOansPublisher } from '@flybywiresim/msfs-avionics-common'; import { SimplaneValueProvider } from 'instruments/src/MsfsAvionicsCommon/providers/SimplaneValueProvider'; @@ -99,6 +93,8 @@ class NDInstrument implements FsInstrument { private readonly hEventPublisher: HEventPublisher; + private readonly resetPanelPublisher: ResetPanelSimvarPublisher; + private readonly adirsValueProvider: AdirsValueProvider<NDSimvars>; private readonly simplaneValueProvider: SimplaneValueProvider; @@ -115,56 +111,15 @@ class NDInstrument implements FsInstrument { private readonly controlPanelVisible = Subject.create(false); - private oansContextMenuItems: Subscribable<ContextMenuElement[]> = 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 readonly eraseAllCrossesDialogVisible = Subject.create(false); + + private readonly eraseAllFlagsDialogVisible = Subject.create(false); + + private readonly eraseCrossIndex = Subject.create<number | null>(null); + + private readonly eraseFlagIndex = Subject.create<number | null>(null); + + private oansContextMenuItems = Subject.create(this.getOansContextMenu(false, false)); private contextMenuRef = FSComponent.createRef<ContextMenu>(); @@ -209,6 +164,7 @@ class NDInstrument implements FsInstrument { this.dmcPublisher = new DmcPublisher(this.bus); this.egpwcBusPublisher = new EgpwcBusPublisher(this.bus, side); this.hEventPublisher = new HEventPublisher(this.bus); + this.resetPanelPublisher = new ResetPanelSimvarPublisher(this.bus); this.adirsValueProvider = new AdirsValueProvider(this.bus, this.simVarPublisher, side); this.simplaneValueProvider = new SimplaneValueProvider(this.bus); @@ -229,6 +185,7 @@ class NDInstrument implements FsInstrument { this.backplane.addPublisher('dmc', this.dmcPublisher); this.backplane.addPublisher('egpwc', this.egpwcBusPublisher); this.backplane.addPublisher('hEvent', this.hEventPublisher); + this.backplane.addPublisher('resetPanel', this.resetPanelPublisher); this.backplane.addInstrument('Simplane', this.simplaneValueProvider); this.backplane.addInstrument('clock', this.clock); @@ -293,6 +250,32 @@ class NDInstrument implements FsInstrument { idPrefix="contextMenu" values={this.oansContextMenuItems} /> + <EraseSymbolsDialog + visible={MappedSubject.create( + ([crosses, flags, oans]) => crosses && !flags && oans, + this.eraseAllCrossesDialogVisible, + this.eraseAllFlagsDialogVisible, + this.oansShown, + )} + confirmAction={() => + this.bus.getPublisher<OansControlEvents>().pub('oans_erase_all_crosses', true, true, false) + } + hideDialog={() => this.eraseAllCrossesDialogVisible.set(false)} + isCross={true} + /> + <EraseSymbolsDialog + visible={MappedSubject.create( + ([crosses, flags, oans]) => !crosses && flags && oans, + this.eraseAllCrossesDialogVisible, + this.eraseAllFlagsDialogVisible, + this.oansShown, + )} + confirmAction={() => + this.bus.getPublisher<OansControlEvents>().pub('oans_erase_all_flags', true, true, false) + } + hideDialog={() => this.eraseAllFlagsDialogVisible.set(false)} + isCross={false} + /> <div style={{ display: this.oansShown.map((v) => (v ? 'block' : 'none')), @@ -310,6 +293,7 @@ class NDInstrument implements FsInstrument { ref={this.mouseCursorRef} side={Subject.create(this.efisSide === 'L' ? 'CAPT' : 'FO')} visible={this.cursorVisible} + color={this.oansShown.map((it) => (it ? MouseCursorColor.Magenta : MouseCursorColor.Yellow))} /> <VerticalDisplayDummy bus={this.bus} side={this.efisSide} /> </CdsDisplayUnit> @@ -330,13 +314,10 @@ class NDInstrument implements FsInstrument { }); 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.bus + .getPublisher<OansControlEvents>() + .pub('oans_query_symbols_at_cursor', { side: this.efisSide, cursorPosition: [e.clientX, e.clientY] }); this.contextMenuPositionTriggered.set({ x: e.clientX, y: e.clientY }); this.contextMenuRef.instance.display(e.clientX, e.clientY); }); @@ -346,9 +327,9 @@ class NDInstrument implements FsInstrument { }); } - const sub = this.bus.getSubscriber<FcuSimVars & OansControlEvents>(); + const sub = this.bus.getSubscriber<FcuSimVars & OansControlEvents & ResetPanelSimvars>(); - this.oansNotAvailable.setConsumer(sub.on('oansNotAvail')); + this.oansNotAvailable.setConsumer(sub.on('oans_not_avail')); sub .on('ndMode') @@ -365,18 +346,85 @@ class NDInstrument implements FsInstrument { this.efisCpRange = a380EfisRangeSettings[range]; this.updateNdOansVisibility(); }); + + sub.on('oans_answer_symbols_at_cursor').handle((symbols) => { + if (symbols.side === this.efisSide) { + this.eraseCrossIndex.set(symbols.cross); + this.eraseFlagIndex.set(symbols.flag); + this.oansContextMenuItems.set(this.getOansContextMenu(symbols.cross !== null, symbols.flag !== null)); + } + }); } private updateNdOansVisibility() { if (this.efisCpRange === -1 && [EfisNdMode.PLAN, EfisNdMode.ARC, EfisNdMode.ROSE_NAV].includes(this.efisNdMode)) { - this.bus.getPublisher<OansControlEvents>().pub('ndShowOans', true); + this.bus.getPublisher<OansControlEvents>().pub('nd_show_oans', { side: this.efisSide, show: true }, true, false); this.oansShown.set(true); } else { - this.bus.getPublisher<OansControlEvents>().pub('ndShowOans', false); + this.bus.getPublisher<OansControlEvents>().pub('nd_show_oans', { side: this.efisSide, show: false }, true, false); this.oansShown.set(false); } } + getOansContextMenu(hasCross: boolean, hasFlag: boolean): ContextMenuElement[] { + const crossIndex = this.eraseCrossIndex.get(); + const flagIndex = this.eraseFlagIndex.get(); + return [ + { + name: hasCross ? 'DELETE CROSS' : 'ADD CROSS', + disabled: false, + onPressed: () => + hasCross && crossIndex !== null + ? this.bus.getPublisher<OansControlEvents>().pub('oans_erase_cross_id', crossIndex) + : this.bus + .getPublisher<OansControlEvents>() + .pub('oans_add_cross_at_cursor', [ + this.contextMenuPositionTriggered.get().x, + this.contextMenuPositionTriggered.get().y, + ]), + }, + { + name: hasFlag ? 'DELETE FLAG' : 'ADD FLAG', + disabled: false, + onPressed: () => + hasFlag && flagIndex !== null + ? this.bus.getPublisher<OansControlEvents>().pub('oans_erase_flag_id', flagIndex) + : this.bus + .getPublisher<OansControlEvents>() + .pub('oans_add_flag_at_cursor', [ + 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: false, + onPressed: () => this.eraseAllCrossesDialogVisible.set(true), + }, + { + name: 'ERASE ALL FLAGS', + disabled: false, + onPressed: () => this.eraseAllFlagsDialogVisible.set(true), + }, + { + name: 'CENTER ON ACFT', + disabled: false, + onPressed: async () => { + this.bus.getPublisher<OansControlEvents>().pub('oans_center_on_acft', true, true, false); + }, + }, + ]; + } + /** * A callback called when the instrument gets a frame update. */ diff --git a/fbw-a380x/src/systems/instruments/src/ND/oans-style.scss b/fbw-a380x/src/systems/instruments/src/ND/oans-style.scss index 9330f4215cb..7c001e7c789 100644 --- a/fbw-a380x/src/systems/instruments/src/ND/oans-style.scss +++ b/fbw-a380x/src/systems/instruments/src/ND/oans-style.scss @@ -199,6 +199,39 @@ outline: 0px; } +.oanc-label-style-cross-symbol { + font-size: 0.1px; + color: transparent; + width: 35px; + height: 35px; + background-color: transparent; + background-image: url('/Images/fbw-a380x/oans/oans-cross.png'); + background-size: contain; + background-repeat: no-repeat; +} + +.oanc-label-style-cross-symbol:hover { + outline: 3px solid $display-cyan; + pointer-events: auto; + outline-offset: 0px !important; +} + +.oanc-label-style-flag-symbol { + font-size: 0.1px; + color: transparent; + width: 40px; + height: 40px; + background-color: transparent; + background-image: url('/Images/fbw-a380x/oans/oans-flag.png'); + background-size: contain; + background-repeat: no-repeat; +} + +.oanc-label-style-flag-symbol:hover { + outline: 3px solid $display-cyan; + pointer-events: auto; + outline-offset: 0px !important; +} .oanc-button { padding: 10px 6px; diff --git a/fbw-a380x/src/systems/instruments/src/PFD/FMA.tsx b/fbw-a380x/src/systems/instruments/src/PFD/FMA.tsx index fd9cc280592..488eb4efd8f 100644 --- a/fbw-a380x/src/systems/instruments/src/PFD/FMA.tsx +++ b/fbw-a380x/src/systems/instruments/src/PFD/FMA.tsx @@ -89,6 +89,8 @@ export class FMA extends DisplayComponent<{ bus: EventBus; isAttExcessive: Subsc private readonly approachCapability = ConsumerSubject.create(this.sub.on('approachCapability'), 0); + private readonly btvExitMissed = ConsumerSubject.create(this.sub.on('btvExitMissed'), false); + private disconnectApForLdg = MappedSubject.create( ([ap1, ap2, ra, altitude, landingElevation, verticalMode, selectedFpa, selectedVs, approachCapability]) => { return ( @@ -130,6 +132,7 @@ export class FMA extends DisplayComponent<{ bus: EventBus; isAttExcessive: Subsc this.tcasRaInhibited.get(), this.tdReached, this.disconnectApForLdg.get(), + this.btvExitMissed.get(), )[0] !== null; const engineMessage = this.athrModeMessage; @@ -164,6 +167,8 @@ export class FMA extends DisplayComponent<{ bus: EventBus; isAttExcessive: Subsc this.disconnectApForLdg.sub(() => this.handleFMABorders()); + this.btvExitMissed.sub(() => this.handleFMABorders()); + this.props.isAttExcessive.sub((_a) => { this.handleFMABorders(); }); @@ -235,6 +240,7 @@ export class FMA extends DisplayComponent<{ bus: EventBus; isAttExcessive: Subsc bus={this.props.bus} isAttExcessive={this.props.isAttExcessive} disconnectApForLdg={this.disconnectApForLdg} + btvExitMissed={this.btvExitMissed} AB3Message={this.AB3Message} /> </g> @@ -395,6 +401,7 @@ class Row3 extends DisplayComponent<{ bus: EventBus; isAttExcessive: Subscribable<boolean>; disconnectApForLdg: Subscribable<boolean>; + btvExitMissed: Subscribable<boolean>; AB3Message: Subscribable<boolean>; }> { private cellsToHide = FSComponent.createRef<SVGGElement>(); @@ -422,6 +429,7 @@ class Row3 extends DisplayComponent<{ <BC3Cell isAttExcessive={this.props.isAttExcessive} disconnectApForLdg={this.props.disconnectApForLdg} + btvExitMissed={this.props.btvExitMissed} bus={this.props.bus} /> <E3Cell bus={this.props.bus} /> @@ -1351,6 +1359,7 @@ const getBC3Message = ( tcasRaInhibited: boolean, tdReached: boolean, disconnectApForLdg: boolean, + exitMissed: boolean, ) => { const armedVerticalBitmask = armedVerticalMode; const TCASArmed = (armedVerticalBitmask >> 6) & 1; @@ -1392,7 +1401,7 @@ const getBC3Message = ( } else if (setHoldSpeed) { text = 'SET HOLD SPD'; className = 'FontMedium White'; - } else if (false) { + } else if (exitMissed) { text = 'EXIT MISSED'; className = 'White'; } else { @@ -1405,6 +1414,7 @@ const getBC3Message = ( class BC3Cell extends DisplayComponent<{ isAttExcessive: Subscribable<boolean>; disconnectApForLdg: Subscribable<boolean>; + btvExitMissed: Subscribable<boolean>; bus: EventBus; }> { private sub = this.props.bus.getSubscriber<PFDSimvars & Arinc429Values>(); @@ -1434,6 +1444,7 @@ class BC3Cell extends DisplayComponent<{ this.tcasRaInhibited, this.tdReached, this.props.disconnectApForLdg.get(), + this.props.btvExitMissed.get(), ); this.classNameSub.set(`FontMedium MiddleAlign ${className}`); if (text !== null) { @@ -1455,6 +1466,10 @@ class BC3Cell extends DisplayComponent<{ this.fillBC3Cell(); }); + this.props.btvExitMissed.sub(() => { + this.fillBC3Cell(); + }); + this.sub .on('fmaVerticalArmed') .whenChanged() diff --git a/fbw-a380x/src/systems/instruments/src/PFD/shared/PFDSimvarPublisher.tsx b/fbw-a380x/src/systems/instruments/src/PFD/shared/PFDSimvarPublisher.tsx index de69cc28db8..e9379de97fa 100644 --- a/fbw-a380x/src/systems/instruments/src/PFD/shared/PFDSimvarPublisher.tsx +++ b/fbw-a380x/src/systems/instruments/src/PFD/shared/PFDSimvarPublisher.tsx @@ -167,6 +167,7 @@ export interface PFDSimvars { spoilersArmed: boolean; fcuLeftVelocityVectorOn: boolean; fcuRightVelocityVectorOn: boolean; + btvExitMissed: boolean; } export enum PFDVars { @@ -334,6 +335,7 @@ export enum PFDVars { spoilersArmed = 'L:A32NX_SPOILERS_ARMED', fcuLeftVelocityVectorOn = 'L:A380X_EFIS_L_VV_BUTTON_IS_ON', fcuRightVelocityVectorOn = 'L:A380X_EFIS_R_VV_BUTTON_IS_ON', + btvExitMissed = 'L:A32NX_BTV_EXIT_MISSED', } /** A publisher to poll and publish nav/com simvars. */ @@ -500,6 +502,7 @@ export class PFDSimvarPublisher extends UpdatableSimVarPublisher<PFDSimvars> { ['spoilersArmed', { name: PFDVars.spoilersArmed, type: SimVarValueType.Bool }], ['fcuLeftVelocityVectorOn', { name: PFDVars.fcuLeftVelocityVectorOn, type: SimVarValueType.Bool }], ['fcuRightVelocityVectorOn', { name: PFDVars.fcuRightVelocityVectorOn, type: SimVarValueType.Bool }], + ['btvExitMissed', { name: PFDVars.btvExitMissed, type: SimVarValueType.Bool }], ]); public constructor(bus: EventBus) { diff --git a/fbw-a380x/src/systems/systems-host/systems/FlightWarningSystem/FwsCore.ts b/fbw-a380x/src/systems/systems-host/systems/FlightWarningSystem/FwsCore.ts index 9d4cf36fa3b..77b5923fd22 100644 --- a/fbw-a380x/src/systems/systems-host/systems/FlightWarningSystem/FwsCore.ts +++ b/fbw-a380x/src/systems/systems-host/systems/FlightWarningSystem/FwsCore.ts @@ -1176,6 +1176,8 @@ export class FwsCore { public autoBrakeOffMemoInhibited = false; + public btvExitMissedPulseNode = new NXLogicPulseNode(); + /* NAVIGATION */ public readonly adirsRemainingAlignTime = Subject.create(0); @@ -2622,6 +2624,15 @@ export class FwsCore { this.autoBrakeOffAuralTriggered = true; } + this.btvExitMissedPulseNode.write( + SimVar.GetSimVarValue('L:A32NX_BTV_EXIT_MISSED', SimVarValueType.Bool), + deltaTime, + ); + + if (this.btvExitMissedPulseNode.read()) { + this.soundManager.enqueueSound('tripleClick'); + } + // Engine Logic this.thrustLeverNotSet.set(this.autothrustLeverWarningFlex.get() || this.autothrustLeverWarningToga.get()); // FIXME ECU doesn't have the necessary output words so we go purely on TLA diff --git a/fbw-a380x/src/wasm/systems/a380_systems/src/hydraulic/autobrakes.rs b/fbw-a380x/src/wasm/systems/a380_systems/src/hydraulic/autobrakes.rs index 3ccc558662a..a17d543f8d3 100644 --- a/fbw-a380x/src/wasm/systems/a380_systems/src/hydraulic/autobrakes.rs +++ b/fbw-a380x/src/wasm/systems/a380_systems/src/hydraulic/autobrakes.rs @@ -960,6 +960,7 @@ struct BtvDecelScheduler { rot_estimation_id: VariableIdentifier, turnaround_idle_reverse_estimation_id: VariableIdentifier, turnaround_max_reverse_estimation_id: VariableIdentifier, + exit_missed_id: VariableIdentifier, runway_length: Arinc429Word<Length>, @@ -985,6 +986,9 @@ struct BtvDecelScheduler { wet_prediction: Length, distance_to_rwy_end: Length, + + exit_missed_confirmation: DelayedTrueLogicGate, + exit_missed: bool, } impl BtvDecelScheduler { // Target decel when optimizing runway time before braking @@ -1008,6 +1012,8 @@ impl BtvDecelScheduler { const MIN_DECEL_SAFETY_MARGIN_RATIO: f64 = 1.15; const DECEL_SAFETY_MARGIN_SHAPING_FACTOR: f64 = 0.4; + const EXIT_MISSED_CONFIRMATION_TIME_S: u64 = 5; + const REMAINING_BRAKING_DISTANCE_END_OF_RUNWAY_OFFSET_METERS: f64 = 300.; fn new(context: &mut InitContext) -> Self { @@ -1022,6 +1028,7 @@ impl BtvDecelScheduler { .get_identifier("BTV_TURNAROUND_IDLE_REVERSE".to_owned()), turnaround_max_reverse_estimation_id: context .get_identifier("BTV_TURNAROUND_MAX_REVERSE".to_owned()), + exit_missed_id: context.get_identifier("BTV_EXIT_MISSED".to_owned()), runway_length: Arinc429Word::new(Length::default(), SignStatus::NoComputedData), rolling_distance: Length::default(), @@ -1050,6 +1057,11 @@ impl BtvDecelScheduler { wet_prediction: Length::default(), distance_to_rwy_end: Length::default(), + + exit_missed_confirmation: DelayedTrueLogicGate::new(Duration::from_secs( + Self::EXIT_MISSED_CONFIRMATION_TIME_S, + )), + exit_missed: false, } } @@ -1099,6 +1111,9 @@ impl BtvDecelScheduler { self.compute_decel(context); + self.exit_missed_confirmation + .update(context, self.exit_missed); + self.state = self.update_state(context); } @@ -1138,6 +1153,17 @@ impl BtvDecelScheduler { let target_deceleration_safety_corrected = target_deceleration_raw * self.safety_margin(); + // If EXIT MISSED already confirmed for 5s, keep until disengaged + if !self.exit_missed_confirmation.output() { + // Target deceleration shoots up when nearing release speed, hence only check above twice the release speed + self.exit_missed = target_deceleration_safety_corrected + < Self::MAX_DECEL_DRY_MS2 + && delta_speed_to_achieve + > Velocity::new::<meter_per_second>( + Self::TARGET_SPEED_TO_RELEASE_BTV_M_S, + ); + } + self.deceleration_request = Acceleration::new::<meter_per_second_squared>( target_deceleration_safety_corrected.clamp( self.desired_deceleration.get::<meter_per_second_squared>(), @@ -1147,6 +1173,7 @@ impl BtvDecelScheduler { } BTVState::Armed | BTVState::Disabled => { self.deceleration_request = Acceleration::new::<meter_per_second_squared>(5.); + self.exit_missed = false; } } } @@ -1341,6 +1368,8 @@ impl SimulationElement for BtvDecelScheduler { turnaround_time_estimated_in_minutes[0].value(), turnaround_time_estimated_in_minutes[0].ssm(), ); + + writer.write(&self.exit_missed_id, self.exit_missed_confirmation.output()); } fn read(&mut self, reader: &mut SimulatorReader) { diff --git a/fbw-common/src/systems/instruments/src/EFB/AircraftContext.ts b/fbw-common/src/systems/instruments/src/EFB/AircraftContext.ts index 4e5fa1b2e99..9f061400199 100644 --- a/fbw-common/src/systems/instruments/src/EFB/AircraftContext.ts +++ b/fbw-common/src/systems/instruments/src/EFB/AircraftContext.ts @@ -52,6 +52,7 @@ interface SimOptions { registrationDecal: boolean; wheelChocks: boolean; cabinLighting: boolean; + oansPerformanceMode: boolean; } interface ThrottleOptions { @@ -102,6 +103,7 @@ export const AircraftContext = createContext<AircraftEfbContext>({ registrationDecal: false, wheelChocks: false, cabinLighting: false, + oansPerformanceMode: false, }, throttle: { numberOfAircraftThrottles: 0, diff --git a/fbw-common/src/systems/instruments/src/EFB/Localization/data/en.json b/fbw-common/src/systems/instruments/src/EFB/Localization/data/en.json index 14b4edef027..60d712da9dc 100644 --- a/fbw-common/src/systems/instruments/src/EFB/Localization/data/en.json +++ b/fbw-common/src/systems/instruments/src/EFB/Localization/data/en.json @@ -663,6 +663,7 @@ "Left": "Left", "LoadOnly": "Load Only", "None": "None", + "OansPerformanceMode": "OANS Performance Mode (Un-Load Airport Map When Not Used)", "Off": "Off", "PilotSeat": "Pilot Seat for Control", "Right": "Right", diff --git a/fbw-common/src/systems/instruments/src/EFB/Settings/Pages/SimOptionsPage.tsx b/fbw-common/src/systems/instruments/src/EFB/Settings/Pages/SimOptionsPage.tsx index 60c27bb96d0..9169d8d838b 100644 --- a/fbw-common/src/systems/instruments/src/EFB/Settings/Pages/SimOptionsPage.tsx +++ b/fbw-common/src/systems/instruments/src/EFB/Settings/Pages/SimOptionsPage.tsx @@ -47,6 +47,11 @@ export const SimOptionsPage = () => { const [cabinManualBrightness, setCabinManualBrightness] = usePersistentNumberProperty('CABIN_MANUAL_BRIGHTNESS', 0); const [pilotSeat, setPilotSeat] = usePersistentProperty('CONFIG_PILOT_SEAT', DefaultPilotSeatConfig); + const [oansPerformanceMode, setOansPerformanceMode] = usePersistentNumberProperty( + 'CONFIG_A380X_OANS_PERFORMANCE_MODE', + 0, + ); + const defaultBaroButtons: ButtonType[] = [ { name: t('Settings.SimOptions.Auto'), setting: 'AUTO' }, { name: t('Settings.SimOptions.inHg'), setting: 'IN HG' }, @@ -281,6 +286,12 @@ export const SimOptionsPage = () => { )} </SettingGroup> )} + + {aircraftContext.settingsPages.sim.oansPerformanceMode && ( + <SettingItem name={t('Settings.SimOptions.OansPerformanceMode')}> + <Toggle value={!!oansPerformanceMode} onToggle={(value) => setOansPerformanceMode(value ? 1 : 0)} /> + </SettingItem> + )} </SettingsPage> )} <ThrottleConfig isShown={showThrottleSettings} onClose={() => setShowThrottleSettings(false)} /> diff --git a/fbw-common/src/systems/instruments/src/ND/ND.tsx b/fbw-common/src/systems/instruments/src/ND/ND.tsx index 04d142918bc..05679ae68d4 100644 --- a/fbw-common/src/systems/instruments/src/ND/ND.tsx +++ b/fbw-common/src/systems/instruments/src/ND/ND.tsx @@ -225,9 +225,13 @@ export class NDComponent<T extends number> extends DisplayComponent<NDProps<T>> }); sub - .on('ndShowOans') + .on('nd_show_oans') .whenChanged() - .handle((show) => this.showOans.set(show)); + .handle((data) => { + if (data.side === this.props.side) { + this.showOans.set(data.show); + } + }); } // eslint-disable-next-line arrow-body-style diff --git a/fbw-common/src/systems/instruments/src/ND/pages/arc/LubberLine.tsx b/fbw-common/src/systems/instruments/src/ND/pages/arc/LubberLine.tsx index e85297ec9c0..736cd7152c1 100644 --- a/fbw-common/src/systems/instruments/src/ND/pages/arc/LubberLine.tsx +++ b/fbw-common/src/systems/instruments/src/ND/pages/arc/LubberLine.tsx @@ -11,6 +11,7 @@ export interface LubberLineProps { visible: Subscribable<boolean>; ndMode: Subscribable<EfisNdMode>; rotation: Subscribable<number>; + colorClass?: 'Yellow' | 'Magenta'; } export class LubberLine extends DisplayComponent<LubberLineProps> { @@ -34,7 +35,7 @@ export class LubberLine extends DisplayComponent<LubberLineProps> { y1={this.props.ndMode.map((mode) => (mode === EfisNdMode.ARC ? 108 : 116))} x2={384} y2={this.props.ndMode.map((mode) => (mode === EfisNdMode.ARC ? 148 : 152))} - class="Yellow" + class={this.props.colorClass ?? 'Yellow'} stroke-width={5} stroke-linejoin="round" stroke-linecap="round" diff --git a/fbw-common/src/systems/instruments/src/OANC/Oanc.tsx b/fbw-common/src/systems/instruments/src/OANC/Oanc.tsx index 443ed5e0a99..8c64a9508f0 100644 --- a/fbw-common/src/systems/instruments/src/OANC/Oanc.tsx +++ b/fbw-common/src/systems/instruments/src/OANC/Oanc.tsx @@ -35,7 +35,6 @@ import { Arinc429LocalVarConsumerSubject, } from '@flybywiresim/fbw-sdk'; import { - BBox, bbox, bboxPolygon, booleanPointInPolygon, @@ -60,8 +59,11 @@ import { OancMovingModeOverlay, OancStaticModeOverlay } from './OancMovingModeOv import { OancAircraftIcon } from './OancAircraftIcon'; import { OancLabelManager } from './OancLabelManager'; import { OancPositionComputer } from './OancPositionComputer'; +import { OancMarkerManager } from './OancMarkerManager'; +import { ResetPanelSimvars } from './ResetPanelPublisher'; import { NavigraphAmdbClient } from './api/NavigraphAmdbClient'; import { globalToAirportCoordinates, pointAngle, pointDistance } from './OancMapUtils'; +import { LubberLine } from '../ND/pages/arc/LubberLine'; export const OANC_RENDER_WIDTH = 768; export const OANC_RENDER_HEIGHT = 768; @@ -117,6 +119,8 @@ export enum LabelStyle { BtvStopLineAmber = 'btv-stop-line-amber', BtvStopLineRed = 'btv-stop-line-red', BtvStopLineGreen = 'btv-stop-line-green', + CrossSymbol = 'cross-symbol', + FlagSymbol = 'flag-symbol', } export interface Label { @@ -124,7 +128,7 @@ export interface Label { style: LabelStyle; position: Position; rotation: number | undefined; - associatedFeature: Feature<Geometry, AmdbProperties>; + associatedFeature?: Feature<Geometry, AmdbProperties>; } export interface ContextMenuItemData { @@ -183,9 +187,7 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { public data: AmdbFeatureCollection | undefined; - private dataBbox: BBox | undefined; - - private arpCoordinates: Subject<Coordinates | undefined> = Subject.create(undefined); + private arpCoordinates = Subject.create<Coordinates | undefined>(undefined); private canvasCenterCoordinates: Coordinates | undefined; @@ -207,6 +209,11 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.dataAirportIata, ); + private readonly resetPulled = ConsumerSubject.create( + this.props.bus.getSubscriber<ResetPanelSimvars>().on('a380x_reset_panel_arpt_nav'), + false, + ); + private layerFeatures: FeatureCollection<Geometry, AmdbProperties>[] = [ featureCollection([]), // Layer 0: TAXIWAY BG + TAXIWAY SHOULDER featureCollection([]), // Layer 1: APRON + STAND BG + BUILDINGS (terminal only) @@ -215,14 +222,14 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { 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) + featureCollection([]), // Layer 7: DYNAMIC BTV CONTENT (BTV PATH, STOP LINES) ]; - public amdbClient = new NavigraphAmdbClient(); + public readonly amdbClient = new NavigraphAmdbClient(); - private labelManager = new OancLabelManager<T>(this); + private readonly labelManager = new OancLabelManager<T>(this); - private positionComputer = new OancPositionComputer<T>(this); + private readonly positionComputer = new OancPositionComputer<T>(this); public dataLoading = false; @@ -289,15 +296,17 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { private readonly airportWithinRange = Subject.create(false); + private readonly airportTooFarAwayAndInArcNavMode = Subject.create(false); + private readonly airportBearing = Subject.create(0); - public readonly projectedPpos = MappedSubject.create( - ([ppos, arpCoordinates], previous: Position) => { + public readonly projectedPpos = MappedSubject.create<[Coordinates, Coordinates | undefined], Position>( + ([ppos, arpCoordinates], previous?: Position | undefined) => { if (arpCoordinates) { return globalToAirportCoordinates(arpCoordinates, ppos, [0, 0]); } - return previous; + return previous ?? [0, 0]; }, this.ppos, this.arpCoordinates, @@ -317,7 +326,10 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { public readonly arpReferencedMapParams = new MapParameters(); - private readonly oansVisible = ConsumerSubject.create<boolean>(null, false); + private readonly oansVisible = ConsumerSubject.create<{ side: EfisSide; show: boolean }>(null, { + side: this.props.side, + show: false, + }); private readonly efisNDModeSub = ConsumerSubject.create<EfisNdMode>(null, EfisNdMode.PLAN); @@ -341,6 +353,8 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.getZoomLevelInverseScale.bind(this), ); + private readonly markerManager = new OancMarkerManager<T>(this, this.labelManager, this.props.bus); + private readonly airportNotInActiveFpln = MappedSubject.create( ([ndMode, arpt, origin, dest, altn]) => ndMode !== EfisNdMode.ARC && ![origin, dest, altn].includes(arpt), this.overlayNDModeSub, @@ -405,6 +419,8 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.pleaseWaitFlagVisible, ); + private readonly oansPerformanceModeHide = Subject.create(false); + public getZoomLevelInverseScale() { const multiplier = this.overlayNDModeSub.get() === EfisNdMode.ROSE_NAV ? 0.5 : 1; @@ -418,8 +434,8 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.labelContainerRef.instance.addEventListener('mousemove', this.handleCursorPanMove.bind(this)); this.labelContainerRef.instance.addEventListener('mouseup', this.handleCursorPanStop.bind(this)); - this.oansVisible.setConsumer(this.sub.on('ndShowOans')); - this.oansNotAvailable.setConsumer(this.sub.on('oansNotAvail')); + this.oansVisible.setConsumer(this.sub.on('nd_show_oans')); + this.oansNotAvailable.setConsumer(this.sub.on('oans_not_avail')); this.efisNDModeSub.setConsumer(this.sub.on('ndMode')); this.efisNDModeSub.sub((mode) => { @@ -431,13 +447,63 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.efisOansRangeSub.sub((range) => this.zoomLevelIndex.set(range), true); + this.airportTooFarAwayAndInArcNavMode.sub((v) => + this.props.bus.getPublisher<OansControlEvents>().pub('oans_show_set_plan_mode', v, true), + ); + this.sub - .on('oansDisplayAirport') + .on('oans_display_airport') .whenChanged() .handle((airport) => { - this.loadAirportMap(airport); + if (this.oansPerformanceModeHide.get()) { + this.dataAirportIcao.set(airport); + } else { + this.loadAirportMap(airport); + } }); + this.sub + .on('oans_performance_mode_hide') + .whenChanged() + .handle((perfHide) => { + if (this.props.side === perfHide.side) { + this.oansPerformanceModeHide.set(perfHide.hide); + } + }); + + this.oansPerformanceModeHide.sub((hide) => { + if (hide) { + this.unloadAirportMap(); + } else if (this.dataAirportIcao.get()) { + this.loadAirportMap(this.dataAirportIcao.get()); + } + }); + + this.sub.on('oans_center_on_acft').handle(() => this.centerOnAcft()); + this.sub.on('oans_center_map_on').handle((coords) => this.centerMapOn(coords)); + this.sub.on('oans_add_cross_at_feature').handle((f) => this.markerManager.addCrossAtFeature(f.id, f.feattype)); + this.sub.on('oans_add_flag_at_feature').handle((f) => this.markerManager.addFlagAtFeature(f.id, f.feattype)); + this.sub + .on('oans_remove_cross_at_feature') + .handle((f) => this.markerManager.removeCrossAtFeature(f.id, f.feattype)); + this.sub.on('oans_remove_flag_at_feature').handle((f) => this.markerManager.removeFlagAtFeature(f.id, f.feattype)); + this.sub + .on('oans_add_cross_at_cursor') + .handle(([x, y]) => this.markerManager.addCross(this.unprojectPoint([x, y]))); + this.sub.on('oans_add_flag_at_cursor').handle(([x, y]) => this.markerManager.addFlag(this.unprojectPoint([x, y]))); + this.sub.on('oans_erase_all_crosses').handle(() => this.markerManager.eraseAllCrosses()); + this.sub.on('oans_erase_all_flags').handle(() => this.markerManager.eraseAllFlags()); + this.sub.on('oans_erase_cross_id').handle((id) => this.markerManager.removeCross(id)); + this.sub.on('oans_erase_flag_id').handle((id) => this.markerManager.removeFlag(id)); + this.sub.on('oans_query_symbols_at_cursor').handle((data) => { + const foundSymbols = this.markerManager.findSymbolAtCursor(this.unprojectPoint(data.cursorPosition)); + this.props.bus.getPublisher<OansControlEvents>().pub('oans_answer_symbols_at_cursor', { + side: data.side, + cross: foundSymbols.cross, + flag: foundSymbols.flag, + }); + }); + this.fmsDataStore.origin.sub(() => this.updateLabelClasses()); this.fmsDataStore.departureRunway.sub(() => this.updateLabelClasses()); this.fmsDataStore.destination.sub(() => this.updateLabelClasses()); @@ -447,7 +513,7 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.updateLabelClasses(); }); - this.labelManager.visibleLabels.sub((index, type, item) => { + this.labelManager.visibleLabels.sub((_index, type, item) => { switch (type) { case SubscribableArrayEventType.Added: { if (Array.isArray(item)) { @@ -469,8 +535,10 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { 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); + if (element) { + this.labelContainerRef.instance.removeChild(element); + this.labelManager.visibleLabelElements.delete(label); + } } } else { const element = this.labelManager.visibleLabelElements.get(item as Label); @@ -492,11 +560,16 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.panContainerRef[0].instance.style.transform = `translate(${x}px, ${y}px)`; this.panContainerRef[1].instance.style.transform = `translate(${x}px, ${y}px)`; + const depRwy = this.fmsDataStore.departureRunway.get(); + const ldgRwy = this.fmsDataStore.landingRunway.get(); + const btvRwy = this.btvUtils.btvRunway.get(); + const btvExit = this.btvUtils.btvExit.get(); + this.labelManager.reflowLabels( - this.fmsDataStore.departureRunway.get(), - this.fmsDataStore.landingRunway.get(), - this.btvUtils.btvRunway.get(), - this.btvUtils.btvExit.get(), + depRwy !== null ? depRwy : undefined, + ldgRwy !== null ? ldgRwy : undefined, + btvRwy !== null ? btvRwy : undefined, + btvExit !== null ? btvExit : undefined, ); }); @@ -542,21 +615,52 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { setTimeout(() => (this.labelManager.showLabels = true), ZOOM_TRANSITION_TIME_MS + 200); } + public unloadAirportMap() { + this.btvUtils.clearSelection(); + this.markerManager.eraseAllCrosses(); + this.markerManager.eraseAllFlags(); + this.clearMap(); + this.clearData(); + + this.arpCoordinates.set(undefined); + this.data = undefined; + this.aircraftWithinAirport.set(false); + } + + /** + * + * @param icao four letter ICAO code of airport to load + * @returns + */ public async loadAirportMap(icao: string) { this.dataLoading = true; - this.airportLoading.set(true); - this.clearData(); - this.clearMap(); - this.btvUtils.clearSelection(); + this.unloadAirportMap(); + + if (!icao) { + this.dataLoading = false; + this.airportLoading.set(false); + + this.dataAirportName.set(''); + this.dataAirportIcao.set(''); + this.dataAirportIata.set(''); + + return; + } const includeFeatureTypes: FeatureType[] = Object.values(STYLE_DATA).reduce( (acc, it) => [ ...acc, - ...it.reduce((acc, it) => [...acc, ...(it?.dontFetchFromAmdb ? [] : it.forFeatureTypes)], []), + ...it.reduce( + (acc, it) => [ + ...acc, + ...(it?.dontFetchFromAmdb || it.forFeatureTypes === undefined ? [] : it.forFeatureTypes), + ], + [] as FeatureType[], + ), ], - [], + [] as FeatureType[], ); const includeLayers = includeFeatureTypes.map((it) => AmdbFeatureTypeStrings[it]); @@ -602,7 +706,7 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { ); const airportMap: AmdbFeatureCollection = featureCollection(features); - const wgs84ReferencePoint = wgs84ArpDat.aerodromereferencepoint.features[0]; + const wgs84ReferencePoint = wgs84ArpDat.aerodromereferencepoint?.features[0]; if (!wgs84ReferencePoint) { console.error('[OANC](loadAirportMap) Invalid airport data - aerodrome reference point not found'); @@ -623,9 +727,11 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.data = airportMap; - this.dataAirportName.set(wgs84ReferencePoint.properties.name); - this.dataAirportIcao.set(icao); - this.dataAirportIata.set(wgs84ReferencePoint.properties.iata); + if (wgs84ReferencePoint) { + 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); @@ -653,23 +759,27 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { } 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( + const arpCoordinates = this.arpCoordinates.get(); + + if (arpCoordinates) { + const pxDistanceToCanvasCentre = pointDistance( this.canvasWidth.get() / 2, this.canvasHeight.get() / 2, this.canvasCentreX.get(), this.canvasCentreY.get(), - ) + 90, - ); + ); + 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.get(), reciprocal(angleToCanvasCentre), nmDistanceToCanvasCentre); + return placeBearingDistance(arpCoordinates, reciprocal(angleToCanvasCentre), nmDistanceToCanvasCentre); + } } private createLabelElement(label: Label): HTMLDivElement { @@ -681,22 +791,26 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { if (label.style === LabelStyle.RunwayEnd) { element.addEventListener('click', () => { - const thresholdFeature = this.data.features.filter( + 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 (thresholdFeature && label.associatedFeature) { + this.btvUtils.selectRunwayFromOans( + `${this.dataAirportIcao.get()}${label.text}`, + label.associatedFeature, + thresholdFeature[0], + ); + } }); } if ( label.style === LabelStyle.ExitLine && - label.associatedFeature.properties.feattype === FeatureType.RunwayExitLine + label.associatedFeature?.properties.feattype === FeatureType.RunwayExitLine ) { element.addEventListener('click', () => { - this.btvUtils.selectExitFromOans(label.text, label.associatedFeature); + if (label.associatedFeature) { + this.btvUtils.selectExitFromOans(label.text, label.associatedFeature); + } }); } @@ -708,7 +822,7 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { const layer = this.layerFeatures[i]; const layerFeatureTypes = STYLE_DATA[i].reduce( - (acc, rule) => [...acc, ...rule.forFeatureTypes], + (acc, rule) => [...acc, ...(rule.forFeatureTypes !== undefined ? rule.forFeatureTypes : [])], [] as FeatureType[], ); const layerPolygonStructureTypes = STYLE_DATA[i].reduce( @@ -719,6 +833,7 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { if (it.properties.feattype === FeatureType.VerticalPolygonalStructure) { return ( layerFeatureTypes.includes(FeatureType.VerticalPolygonalStructure) && + it.properties.plysttyp && layerPolygonStructureTypes.includes(it.properties.plysttyp) ); } @@ -743,12 +858,13 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { // Only include "VerticalPolygonObject" features whose "plysttyp" property has what we want if ( feature.properties.feattype === FeatureType.VerticalPolygonalStructure && + feature.properties.plysttyp && !LABEL_POLYGON_STRUCTURE_TYPES.includes(feature.properties.plysttyp) ) { continue; } - let labelPosition: Position; + let labelPosition: Position = [0, 0]; switch (feature.geometry.type) { case 'Point': { const point = feature.geometry as Point; @@ -805,9 +921,11 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { const isFmsOrigin = this.dataAirportIcao.get() === this.fmsDataStore.origin.get(); const isFmsDestination = this.dataAirportIcao.get() === this.fmsDataStore.origin.get(); + const depRwy = this.fmsDataStore.departureRunway.get()?.substring(4); + const ldgRwy = this.fmsDataStore.landingRunway.get()?.substring(4); const isSelectedRunway = - (isFmsOrigin && designators.includes(this.fmsDataStore.departureRunway.get()?.substring(4))) || - (isFmsDestination && designators.includes(this.fmsDataStore.landingRunway.get()?.substring(4))); + (isFmsOrigin && depRwy && designators.includes(depRwy)) || + (isFmsDestination && ldgRwy && designators.includes(ldgRwy)); const label1: Label = { text: designators[0], @@ -933,7 +1051,7 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { if ( (feature.properties.feattype === FeatureType.ParkingStandLocation && - existing.some((it) => feature.properties.termref === it.associatedFeature.properties.termref)) || + existing.some((it) => feature.properties.termref === it.associatedFeature?.properties.termref)) || shortestDistance < 50 ) { continue; @@ -965,42 +1083,47 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { const deltaTime = (now - this.lastTime) / 1_000; this.lastTime = now; - if (!this.data || this.dataLoading) return; + if (this.data && this.resetPulled.get()) { + this.unloadAirportMap(); + } + + if (!this.data || this.dataLoading || this.resetPulled.get()) return; this.aircraftOnGround.set( ![6, 7, 8, 9].includes(SimVar.GetSimVarValue('L:A32NX_FWC_FLIGHT_PHASE', SimVarValueType.Number)), ); - // This will always be false without ppos, otherwise it will be updated below - let airportTooFarAwayAndInArcMode = false; - - if (!this.pposNotAvailable.get()) { + const arpCoordinates = this.arpCoordinates.get(); + if (!this.pposNotAvailable.get() && arpCoordinates) { this.aircraftWithinAirport.set(booleanPointInPolygon(this.projectedPpos.get(), bboxPolygon(bbox(this.data)))); - const distToArpt = this.arpCoordinates.get() ? distanceTo(this.ppos.get(), this.arpCoordinates.get()) : 9999; + const distToArpt = this.arpCoordinates.get() ? distanceTo(this.ppos.get(), 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) - airportTooFarAwayAndInArcMode = this.usingPposAsReference.get() && distToArpt > 30; + this.airportTooFarAwayAndInArcNavMode.set( + !this.pposNotAvailable.get() && this.usingPposAsReference.get() && distToArpt > 30, + ); if (this.arpCoordinates.get()) { 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.get(), this.arpCoordinates.get())); + this.airportBearing.set(bearingTo(this.ppos.get(), arpCoordinates)); } else { this.airportWithinRange.set(true); this.airportBearing.set(0); } } else { + this.airportTooFarAwayAndInArcNavMode.set(false); this.aircraftWithinAirport.set(false); this.airportWithinRange.set(true); } - if (this.usingPposAsReference.get() || !this.arpCoordinates.get()) { + if (this.usingPposAsReference.get() || !arpCoordinates) { this.referencePos = this.ppos.get(); } else { - this.referencePos = this.arpCoordinates.get(); + this.referencePos = arpCoordinates; } - if (!this.pposNotAvailable.get()) { + if (!this.pposNotAvailable.get() && arpCoordinates) { const position = this.positionComputer.computePosition(); if (position) { @@ -1013,7 +1136,7 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.props.bus.getPublisher<FmsOansData>().pub('oansAirportLocalCoordinates', this.projectedPpos.get(), true); this.btvUtils.updateRwyAheadAdvisory( this.ppos.get(), - this.arpCoordinates.get(), + arpCoordinates, this.trueHeadingWord.get().value, this.layerFeatures[2], ); @@ -1022,7 +1145,7 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { } // If OANS is not visible on this side (i.e. range selector is not on ZOOM), don't continue here to save runtime - if (!this.oansVisible.get()) { + if (this.oansVisible.get().side === this.props.side && !this.oansVisible.get().show) { return; } @@ -1051,11 +1174,15 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { const mapCurrentHeading = this.interpolatedMapHeading.get(); - this.canvasCentreReferencedMapParams.compute(this.canvasCenterCoordinates, 0, 0.539957, 1_000, mapCurrentHeading); - this.arpReferencedMapParams.compute(this.arpCoordinates.get(), 0, 0.539957, 1_000, mapCurrentHeading); + if (this.canvasCenterCoordinates) { + this.canvasCentreReferencedMapParams.compute(this.canvasCenterCoordinates, 0, 0.539957, 1_000, mapCurrentHeading); + } + if (arpCoordinates) { + this.arpReferencedMapParams.compute(arpCoordinates, 0, 0.539957, 1_000, mapCurrentHeading); + } let [offsetX, offsetY]: [number, number] = [0, 0]; - if (airportTooFarAwayAndInArcMode) { + if (this.airportTooFarAwayAndInArcNavMode.get()) { const shiftBy = 5 * Math.max(this.canvasWidth.get(), this.canvasHeight.get()); [offsetX, offsetY] = [shiftBy, shiftBy]; } else { @@ -1084,10 +1211,10 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { canvasScaleContainer.style.transform = `scale(${scale})`; const context = canvas.getContext('2d'); - - context.resetTransform(); - - context.translate(this.canvasCentreX.get(), this.canvasCentreY.get()); + if (context) { + context.resetTransform(); + context.translate(this.canvasCentreX.get(), this.canvasCentreY.get()); + } } // Transform airplane @@ -1110,20 +1237,24 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { this.airportLoading.set(false); + const depRwy = this.fmsDataStore.departureRunway.get(); + const ldgRwy = this.fmsDataStore.landingRunway.get(); + const btvRwy = this.btvUtils.btvRunway.get(); + const btvExit = this.btvUtils.btvExit.get(); + this.labelManager.reflowLabels( - this.fmsDataStore.departureRunway.get(), - this.fmsDataStore.landingRunway.get(), - this.btvUtils.btvRunway.get(), - this.btvUtils.btvExit.get(), + depRwy !== null ? depRwy : undefined, + ldgRwy !== null ? ldgRwy : undefined, + btvRwy !== null ? btvRwy : undefined, + btvExit !== null ? btvExit : undefined, ); - return; } const layerFeatures = this.layerFeatures[this.lastLayerDrawnIndex]; const layerCanvas = this.layerCanvasRefs[this.lastLayerDrawnIndex].instance.getContext('2d'); - if (this.lastFeatureDrawnIndex < layerFeatures.features.length) { + if (this.lastFeatureDrawnIndex < layerFeatures.features.length && layerCanvas) { renderFeaturesToCanvas( this.lastLayerDrawnIndex, layerCanvas, @@ -1140,12 +1271,14 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { } private updateLabelClasses() { + const btvRwy = this.btvUtils.btvRunway.get(); + const btvExit = this.btvUtils.btvExit.get(); 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(), + btvRwy !== null ? btvRwy : undefined, + btvExit !== null ? btvExit : undefined, ); } @@ -1155,6 +1288,8 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { } this.labelManager.clearLabels(); + this.markerManager.eraseAllFlags(); + this.markerManager.eraseAllCrosses(); } private clearMap(): void { @@ -1166,9 +1301,17 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { const cw = this.canvasWidth.get(); const ch = this.canvasHeight.get(); - ctx.clearRect(0, 0, cw, ch); + if (ctx) { + ctx.clearRect(0, 0, cw, ch); + } } + this.canvasWidth.set(0); + this.canvasHeight.set(0); + + this.canvasCentreX.set(0); + this.canvasCentreY.set(0); + this.panOffsetX.set(0); this.panOffsetY.set(0); } @@ -1336,6 +1479,70 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { return [labelScreenX, labelScreenY]; } + public unprojectPoint(screenCoordinates: [number, number]): Position { + let [labelScreenX, labelScreenY] = screenCoordinates; + + // Undo animation offsets + labelScreenX -= this.modeAnimationOffsetX.get() + OANC_RENDER_WIDTH / 2 + this.panOffsetX.get(); + labelScreenY -= this.modeAnimationOffsetY.get() + OANC_RENDER_HEIGHT / 2 + this.panOffsetY.get(); + + const zoomLevelScale = this.getZoomLevelInverseScale(); + const [offsetX, offsetY] = this.arpReferencedMapParams.coordinatesToXYy(this.referencePos); + + // Undo scaling offsets + const scaledOffsetX = offsetX * zoomLevelScale; + const scaledOffsetY = -1 * (offsetY * zoomLevelScale); + labelScreenX += scaledOffsetX; + labelScreenY -= scaledOffsetY; + + // Reverse rotation + const mapCurrentHeading = this.interpolatedMapHeading.get(); + const rotate = -mapCurrentHeading; // Original rotation + const reverseRotate = -rotate; // Undo rotation + + const hypotenuse = Math.sqrt(labelScreenX ** 2 + labelScreenY ** 2); + const angle = clampAngle(Math.atan2(-labelScreenY, labelScreenX) * MathUtils.RADIANS_TO_DEGREES); + + const originalX = hypotenuse * Math.cos((angle - reverseRotate) * MathUtils.DEGREES_TO_RADIANS); + const originalY = hypotenuse * Math.sin((angle - reverseRotate) * MathUtils.DEGREES_TO_RADIANS); + + // Scale back the original position + const labelX = originalX / zoomLevelScale; + const labelY = originalY / zoomLevelScale; + + return [labelX, labelY]; + } + + public offsetToPoint(coordinates: Position): [number, number] { + const projected = this.projectPoint(coordinates); + const xOffset = -(projected[0] - this.panOffsetX.get() - OANC_RENDER_WIDTH / 2 - this.modeAnimationOffsetX.get()); + const yOffset = -(projected[1] - this.panOffsetY.get() - OANC_RENDER_HEIGHT / 2 - this.modeAnimationOffsetY.get()); + + return [xOffset, yOffset]; + } + + async centerOnAcft() { + await this.enablePanningTransitions(); + this.panOffsetX.set(0); + this.panOffsetY.set(0); + await Wait.awaitDelay(ZOOM_TRANSITION_TIME_MS); + await this.disablePanningTransitions(); + } + + /** + * Centers map on point supplied in parameters + * @param x X position in local coordinate system + * @param y Y position in local coordinate system + */ + async centerMapOn(pos: Position) { + const xy = this.offsetToPoint(pos); + await this.enablePanningTransitions(); + this.panOffsetX.set(xy[0]); + this.panOffsetY.set(xy[1]); + await Wait.awaitDelay(ZOOM_TRANSITION_TIME_MS); + await this.disablePanningTransitions(); + } + render(): VNode | null { return ( <> @@ -1448,6 +1655,26 @@ export class Oanc<T extends number> extends DisplayComponent<OancProps<T>> { y={this.aircraftY} rotation={this.aircraftRotation} /> + + <svg + class="nd-svg nd-top-layer" + viewBox="0 0 768 768" + style={this.efisNDModeSub.map( + (mode) => `transform: translateY(${mode === EfisNdMode.ARC ? -236 : 0}px);`, + )} + > + <LubberLine + bus={this.props.bus} + visible={MappedSubject.create( + ([ac, mode]) => ac && mode !== EfisNdMode.PLAN, + this.showAircraft, + this.efisNDModeSub, + )} + rotation={Subject.create(0)} + ndMode={this.efisNDModeSub} + colorClass="Magenta" + /> + </svg> </div> </div> @@ -1551,12 +1778,13 @@ function renderFeaturesToCanvas( const matchingRule = styleRules.find((it) => { if (feature.properties.feattype === FeatureType.VerticalPolygonalStructure) { return ( - it.forFeatureTypes.includes(feature.properties.feattype) && - it.forPolygonStructureTypes.includes(feature.properties.plysttyp) + it.forFeatureTypes?.includes(feature.properties.feattype) && + feature.properties.plysttyp && + it.forPolygonStructureTypes?.includes(feature.properties.plysttyp) ); } - return it.forFeatureTypes.includes(feature.properties.feattype); + return it.forFeatureTypes?.includes(feature.properties.feattype); }); if (!matchingRule) { diff --git a/fbw-common/src/systems/instruments/src/OANC/OancControlPanelUtils.ts b/fbw-common/src/systems/instruments/src/OANC/OancControlPanelUtils.ts index b60886419ea..bcb3098bfc6 100644 --- a/fbw-common/src/systems/instruments/src/OANC/OancControlPanelUtils.ts +++ b/fbw-common/src/systems/instruments/src/OANC/OancControlPanelUtils.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0 import { ArraySubject, ConsumerSubject, DmsFormatter2, EventBus, Subject, UnitType } from '@microsoft/msfs-sdk'; -import { AmdbAirportSearchResult, FmsOansData } from '@flybywiresim/fbw-sdk'; +import { AmdbAirportSearchResult, AmdbProperties, FmsOansData } from '@flybywiresim/fbw-sdk'; export enum ControlPanelAirportSearchMode { Icao, @@ -10,6 +10,13 @@ export enum ControlPanelAirportSearchMode { City, } +export enum ControlPanelMapDataSearchMode { + Runway, + Taxiway, + Stand, + Other, +} + export class ControlPanelUtils { static readonly LAT_FORMATTER = DmsFormatter2.create('{dd}°{mm}.{s}{+[N]-[S]}', UnitType.DEGREE, 0.1); @@ -33,6 +40,26 @@ export class ControlPanelUtils { } return prop; } + + static getMapDataSearchModeProp(mode: ControlPanelMapDataSearchMode): keyof AmdbProperties { + let prop: keyof AmdbProperties; + switch (mode) { + default: + case ControlPanelMapDataSearchMode.Runway: + prop = 'idthr'; + break; + case ControlPanelMapDataSearchMode.Taxiway: + prop = 'idlin'; + break; + case ControlPanelMapDataSearchMode.Stand: + prop = 'idstd'; + break; + case ControlPanelMapDataSearchMode.Other: + prop = 'ident'; + break; + } + return prop; + } } export class ControlPanelStore { @@ -48,6 +75,10 @@ export class ControlPanelStore { public readonly airportSearchSelectedAirportIndex = Subject.create<number | null>(null); + public readonly mapDataSearchMode = Subject.create<number | null>(ControlPanelMapDataSearchMode.Runway); + + public readonly mapDataSearchData = ArraySubject.create<string>(); + public readonly selectedAirport = Subject.create<AmdbAirportSearchResult | null>(null); public readonly loadedAirport = Subject.create<AmdbAirportSearchResult | null>(null); diff --git a/fbw-common/src/systems/instruments/src/OANC/OancLabelFilter.ts b/fbw-common/src/systems/instruments/src/OANC/OancLabelFilter.ts index 3378a16aa7f..0418bb4cfca 100644 --- a/fbw-common/src/systems/instruments/src/OANC/OancLabelFilter.ts +++ b/fbw-common/src/systems/instruments/src/OANC/OancLabelFilter.ts @@ -11,7 +11,7 @@ export interface BaseOancLabelFilter { export interface RunwayBtvSelectionLabelFilter extends BaseOancLabelFilter { type: 'runwayBtvSelection'; - runwayIdent: string; + runwayIdent: string | null; showAdjacent: boolean; } @@ -60,6 +60,8 @@ export function filterLabel( LabelStyle.BtvStopLineAmber, LabelStyle.BtvStopLineRed, LabelStyle.BtvStopLineGreen, + LabelStyle.CrossSymbol, + LabelStyle.FlagSymbol, ].includes(label.style) ) { return true; @@ -90,8 +92,8 @@ export function labelStyle( fmsDataStore: FmsDataStore, isFmsOrigin: boolean, isFmsDestination: boolean, - btvSelectedRunway: string, - btvSelectedExit: string, + btvSelectedRunway?: string, + btvSelectedExit?: string, ): LabelStyle { if (label.style === LabelStyle.RunwayEnd || label.style === LabelStyle.BtvSelectedRunwayEnd) { return btvSelectedRunway?.substring(4) === label.text ? LabelStyle.BtvSelectedRunwayEnd : LabelStyle.RunwayEnd; diff --git a/fbw-common/src/systems/instruments/src/OANC/OancLabelManager.ts b/fbw-common/src/systems/instruments/src/OANC/OancLabelManager.ts index f24fccdc05e..a34443c6b2a 100644 --- a/fbw-common/src/systems/instruments/src/OANC/OancLabelManager.ts +++ b/fbw-common/src/systems/instruments/src/OANC/OancLabelManager.ts @@ -100,13 +100,14 @@ export class OancLabelManager<T extends number> { element.style.left = `${labelScreenX}px`; element.style.top = `${labelScreenY}px`; + const labelRotation = label.rotation ?? 0; if (label.style === LabelStyle.RunwayEnd || label.style === LabelStyle.BtvSelectedRunwayEnd) { - element.style.transform = `translate(-50%, -50%) rotate(${label.rotation - mapCurrentHeading}deg) translate(0px, 50px)`; + element.style.transform = `translate(-50%, -50%) rotate(${labelRotation - mapCurrentHeading}deg) translate(0px, 50px)`; } else if (label.style === LabelStyle.BtvSelectedRunwayArrow) { - element.style.transform = `translate(-50%, -50%) rotate(${label.rotation - mapCurrentHeading}deg) translate(0px, -100px) rotate(-180deg)`; + element.style.transform = `translate(-50%, -50%) rotate(${labelRotation - mapCurrentHeading}deg) translate(0px, -100px) rotate(-180deg)`; } else if (label.style === LabelStyle.FmsSelectedRunwayEnd) { - element.style.transform = `translate(-50%, -50%) rotate(${label.rotation - mapCurrentHeading}deg) translate(0px, 82.5px)`; + element.style.transform = `translate(-50%, -50%) rotate(${labelRotation - mapCurrentHeading}deg) translate(0px, 82.5px)`; } else { element.style.transform = 'translate(-50%, -50%)'; } @@ -205,8 +206,8 @@ export class OancLabelManager<T extends number> { fmsDataStore: FmsDataStore, isFmsOrigin: boolean, isFmsDestination: boolean, - btvSelectedRunway: string, - btvSelectedExit: string, + btvSelectedRunway?: string, + btvSelectedExit?: string, ) { this.visibleLabelElements.forEach((val, key) => { const newLabelStyle = labelStyle( diff --git a/fbw-common/src/systems/instruments/src/OANC/OancMarkerManager.ts b/fbw-common/src/systems/instruments/src/OANC/OancMarkerManager.ts new file mode 100644 index 00000000000..4056f9490df --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/OancMarkerManager.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2025 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { ArraySubject, EventBus } from '@microsoft/msfs-sdk'; +import { along, Feature, Geometry, length, LineString, Point, Position } from '@turf/turf'; +import { Label, LabelStyle, Oanc, OansControlEvents } from './'; +import { OancLabelManager } from './OancLabelManager'; +import { AmdbProperties, FeatureType } from '@flybywiresim/fbw-sdk'; + +const MAX_SYMBOL_DIST_NEIGHBORHOOD_SEARCH = 20; +const TAXIWAY_SYMBOL_SPACING = 250; + +export class OancMarkerManager<T extends number> { + constructor( + public oanc: Oanc<T>, + private readonly labelManager: OancLabelManager<T>, + private readonly bus: EventBus, + ) { + this.crosses.sub(() => this.updateSymbolsForFeatureIds(), true); + this.flags.sub(() => this.updateSymbolsForFeatureIds(), true); + } + + private crosses = ArraySubject.create<Label>(); + + private flags = ArraySubject.create<Label>(); + + private nextCrossId = 0; + private nextFlagId = 0; + + addCross(coords: Position, feature?: Feature<Geometry, AmdbProperties>) { + const crossSymbolLabel: Label = { + text: (this.nextCrossId++).toString(), + style: LabelStyle.CrossSymbol, + position: coords, + rotation: 0, + associatedFeature: feature, + }; + this.labelManager.visibleLabels.insert(crossSymbolLabel); + this.labelManager.labels.push(crossSymbolLabel); + this.crosses.insert(crossSymbolLabel); + } + + addFlag(coords: Position, feature?: Feature<Geometry, AmdbProperties>) { + const flagSymbolLabel: Label = { + text: (this.nextFlagId++).toString(), + style: LabelStyle.FlagSymbol, + position: coords, + rotation: 0, + associatedFeature: feature, + }; + this.labelManager.visibleLabels.insert(flagSymbolLabel); + this.labelManager.labels.push(flagSymbolLabel); + this.flags.insert(flagSymbolLabel); + } + + removeCross(id: number) { + if (!this.crosses.getArray().some((l) => l.text === id.toString())) { + return; + } + + const label = this.crosses.getArray().filter((l) => l.text === id.toString())[0]; + this.labelManager.visibleLabels.removeAt( + this.labelManager.visibleLabels + .getArray() + .findIndex((it) => it.text === label.text && it.style === LabelStyle.CrossSymbol), + ); + this.labelManager.labels = this.labelManager.labels.filter( + (it) => !(it.text === label.text && it.style === LabelStyle.CrossSymbol), + ); + this.crosses.removeAt(this.crosses.getArray().findIndex((l) => l.text === id.toString())); + } + + removeFlag(id: number) { + if (!this.flags.getArray().some((l) => l.text === id.toString())) { + return; + } + + const label = this.flags.getArray().filter((l) => l.text === id.toString())[0]; + this.labelManager.visibleLabels.removeAt( + this.labelManager.visibleLabels + .getArray() + .findIndex((it) => it.text === label.text && it.style === LabelStyle.FlagSymbol), + ); + this.labelManager.labels = this.labelManager.labels.filter( + (it) => !(it.text === label.text && it.style === LabelStyle.FlagSymbol), + ); + this.flags.removeAt(this.flags.getArray().findIndex((l) => l.text === id.toString())); + } + + eraseAllCrosses() { + while (this.labelManager.visibleLabels.getArray().findIndex((it) => it.style === LabelStyle.CrossSymbol) !== -1) { + this.labelManager.visibleLabels.removeAt( + this.labelManager.visibleLabels.getArray().findIndex((it) => it.style === LabelStyle.CrossSymbol), + ); + } + this.labelManager.labels = this.labelManager.labels.filter((it) => !(it.style === LabelStyle.CrossSymbol)); + this.crosses.clear(); + } + + eraseAllFlags() { + while (this.labelManager.visibleLabels.getArray().findIndex((it) => it.style === LabelStyle.FlagSymbol) !== -1) { + this.labelManager.visibleLabels.removeAt( + this.labelManager.visibleLabels.getArray().findIndex((it) => it.style === LabelStyle.FlagSymbol), + ); + } + this.labelManager.labels = this.labelManager.labels.filter((it) => !(it.style === LabelStyle.FlagSymbol)); + this.flags.clear(); + } + + updateSymbolsForFeatureIds() { + const data = { + featureIdsWithCrosses: [ + ...new Set( + this.crosses + .getArray() + .map((l) => l.associatedFeature?.properties.id) + .filter((it) => it !== undefined), + ), + ], + featureIdsWithFlags: [ + ...new Set( + this.flags + .getArray() + .map((l) => l.associatedFeature?.properties.id) + .filter((it) => it !== undefined), + ), + ], + }; + this.bus.getPublisher<OansControlEvents>().pub('oans_symbols_for_feature_ids', data, true); + } + + addSymbolAtFeature( + id: number, + feattype: FeatureType, + addFunction: (coords: Position, feature?: Feature<Geometry, AmdbProperties>) => void, + ) { + // Find feature by id in loaded airport data + const feature = this.oanc.data?.features.filter( + (it) => it.properties?.id === id && it.properties.feattype === feattype, + ); + if (feature) { + if (feattype === FeatureType.TaxiwayGuidanceLine) { + // Look up all taxiways by name + const taxiwayLines = this.oanc.data?.features.filter( + (f) => + f.properties.idlin === feature[0].properties.idlin && + (f.properties.feattype === FeatureType.TaxiwayGuidanceLine || + f.properties.feattype === FeatureType.RunwayExitLine), + ); + if (taxiwayLines) { + for (const tw of taxiwayLines) { + if (tw.geometry.type === 'LineString') { + const twLength = length(tw, { units: 'degrees' }); + const lineString = tw.geometry as LineString; + if (twLength > TAXIWAY_SYMBOL_SPACING) { + // One point every 250m + for (let alongDistance = 0; alongDistance < twLength; alongDistance += TAXIWAY_SYMBOL_SPACING) { + addFunction( + along(lineString, Math.min(alongDistance, twLength), { units: 'degrees' }).geometry.coordinates, + tw, + ); + } + } else { + addFunction(lineString.coordinates[0], tw); + addFunction(lineString.coordinates[lineString.coordinates.length - 1], tw); + } + } + } + } + } else if (feattype === FeatureType.RunwayThreshold || feattype === FeatureType.ParkingStandLocation) { + const geo = this.oanc.data?.features.filter( + (f) => + f.properties.id === id && + (f.properties.feattype === FeatureType.RunwayThreshold || + f.properties.feattype === FeatureType.ParkingStandLocation), + ); + if (geo && geo[0].geometry.type === 'Point') { + const point = geo[0].geometry as Point; + addFunction(point.coordinates, geo[0]); + } + } + } + } + + addCrossAtFeature(id: number, feattype: FeatureType) { + this.addSymbolAtFeature(id, feattype, this.addCross.bind(this)); + } + + addFlagAtFeature(id: number, feattype: FeatureType) { + this.addSymbolAtFeature(id, feattype, this.addFlag.bind(this)); + } + + removeSymbolAtFeature( + id: number, + feattype: FeatureType, + symbols: readonly Label[], + removeFunction: (index: number) => void, + ) { + const isTaxiway = symbols.some( + (v) => + v.associatedFeature?.properties.id === id && + v.associatedFeature.properties.feattype === FeatureType.TaxiwayGuidanceLine, + ); + + if (isTaxiway) { + // Find by name + let taxiwayName = ''; + for (const label of symbols) { + if (label.associatedFeature?.properties.id === id && label.associatedFeature.properties.feattype === feattype) { + taxiwayName = label.associatedFeature.properties.idlin ?? ''; + return; + } + } + + const idsToDelete = symbols + .map((label) => (label.associatedFeature?.properties.idlin === taxiwayName ? parseInt(label.text) : null)) + .filter((i) => i !== null); + idsToDelete.forEach((i) => removeFunction(i)); + } else { + // Find by ID + const idsToDelete = symbols + .map((label) => + label.associatedFeature?.properties.id === id && label.associatedFeature.properties.feattype === feattype + ? parseInt(label.text) + : null, + ) + .filter((i) => i !== null); + idsToDelete.forEach((i) => removeFunction(i)); + } + } + + removeCrossAtFeature(id: number, feattype: FeatureType) { + this.removeSymbolAtFeature(id, feattype, this.crosses.getArray(), this.removeCross.bind(this)); + } + + removeFlagAtFeature(id: number, feattype: FeatureType) { + this.removeSymbolAtFeature(id, feattype, this.flags.getArray(), this.removeFlag.bind(this)); + } + + findSymbolAtCursor(position: Position): { cross: number | null; flag: number | null } { + const flag = this.flags + .getArray() + .find( + (label) => + Math.hypot(position[0] - label.position[0], position[1] - label.position[1]) < + MAX_SYMBOL_DIST_NEIGHBORHOOD_SEARCH, + ); + const cross = this.crosses + .getArray() + .find( + (label) => + Math.hypot(position[0] - label.position[0], position[1] - label.position[1]) < + MAX_SYMBOL_DIST_NEIGHBORHOOD_SEARCH, + ); + + return { + cross: cross !== undefined ? parseInt(cross.text) : null, + flag: flag !== undefined ? parseInt(flag.text) : null, + }; + } + + findSymbolAtFeature(id: number, feattype: FeatureType): { hasCross: boolean; hasFlag: boolean } { + return { + hasCross: this.crosses + .getArray() + .some( + (label) => + label.associatedFeature?.properties.id === id && label.associatedFeature.properties.feattype === feattype, + ), + hasFlag: this.flags + .getArray() + .some( + (label) => + label.associatedFeature?.properties.id === id && label.associatedFeature.properties.feattype === feattype, + ), + }; + } +} diff --git a/fbw-common/src/systems/instruments/src/OANC/OancMovingModeOverlay.tsx b/fbw-common/src/systems/instruments/src/OANC/OancMovingModeOverlay.tsx index fd9b39d408f..75539b4500f 100644 --- a/fbw-common/src/systems/instruments/src/OANC/OancMovingModeOverlay.tsx +++ b/fbw-common/src/systems/instruments/src/OANC/OancMovingModeOverlay.tsx @@ -1,8 +1,16 @@ // Copyright (c) 2023-2024 FlyByWire Simulations // SPDX-License-Identifier: GPL-3.0 -import { DisplayComponent, EventBus, FSComponent, MappedSubject, Subscribable, VNode } from '@microsoft/msfs-sdk'; -import { Arinc429SignStatusMatrix, Arinc429Word, EfisNdMode } from '@flybywiresim/fbw-sdk'; +import { + DisplayComponent, + EventBus, + FSComponent, + MappedSubject, + Subscribable, + Subscription, + VNode, +} from '@microsoft/msfs-sdk'; +import { Arinc429RegisterSubject, Arinc429SignStatusMatrix, EfisNdMode } from '@flybywiresim/fbw-sdk'; import { OANC_RENDER_HEIGHT, OANC_RENDER_WIDTH } from './'; import { ArcModeUnderlay } from './OancArcModeCompass'; @@ -28,6 +36,8 @@ export interface OancMapOverlayProps { } export class OancMovingModeOverlay extends DisplayComponent<OancMapOverlayProps> { + private subs: Subscription[] = []; + private readonly arcModeVisible = MappedSubject.create( ([ndMode, isPanning]) => ndMode === EfisNdMode.ARC && isPanning, this.props.ndMode, @@ -40,6 +50,26 @@ export class OancMovingModeOverlay extends DisplayComponent<OancMapOverlayProps> this.props.isMapPanned, ); + private readonly rotationArinc429Word = Arinc429RegisterSubject.createEmpty(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.subs.push( + this.props.rotation.sub((r) => { + this.rotationArinc429Word.setValueSsm(r, Arinc429SignStatusMatrix.NormalOperation); + }), + ); + + this.subs.push(this.arcModeVisible, this.roseModeVisible); + } + + destroy(): void { + for (const s of this.subs) { + s.destroy(); + } + } + render(): VNode | null { return ( <svg @@ -50,14 +80,7 @@ export class OancMovingModeOverlay extends DisplayComponent<OancMapOverlayProps> <RoseModeUnderlay bus={this.props.bus} visible={this.roseModeVisible} - rotation={this.props.rotation.map((r) => { - const word = Arinc429Word.empty(); - - word.ssm = Arinc429SignStatusMatrix.NormalOperation; - word.value = r; - - return word; - })} + rotation={this.rotationArinc429Word} oansRange={this.props.oansRange} doClip={false} /> @@ -65,14 +88,7 @@ export class OancMovingModeOverlay extends DisplayComponent<OancMapOverlayProps> <ArcModeUnderlay bus={this.props.bus} visible={this.arcModeVisible} - rotation={this.props.rotation.map((r) => { - const word = Arinc429Word.empty(); - - word.ssm = Arinc429SignStatusMatrix.NormalOperation; - word.value = r; - - return word; - })} + rotation={this.rotationArinc429Word} oansRange={this.props.oansRange} doClip={false} yOffset={620 - 384} @@ -86,6 +102,8 @@ export class OancMovingModeOverlay extends DisplayComponent<OancMapOverlayProps> } export class OancStaticModeOverlay extends DisplayComponent<OancMapOverlayProps> { + private subs: Subscription[] = []; + private readonly arcModeVisible = MappedSubject.create( ([ndMode, isPanning]) => ndMode === EfisNdMode.ARC && !isPanning, this.props.ndMode, @@ -98,6 +116,24 @@ export class OancStaticModeOverlay extends DisplayComponent<OancMapOverlayProps> this.props.isMapPanned, ); + private readonly rotationArinc429Word = Arinc429RegisterSubject.createEmpty(); + + onAfterRender(node: VNode): void { + super.onAfterRender(node); + + this.subs.push( + this.props.rotation.sub((r) => { + this.rotationArinc429Word.setValueSsm(r, Arinc429SignStatusMatrix.NormalOperation); + }), + ); + } + + destroy(): void { + for (const s of this.subs) { + s.destroy(); + } + } + render(): VNode | null { return ( <svg @@ -108,14 +144,7 @@ export class OancStaticModeOverlay extends DisplayComponent<OancMapOverlayProps> <RoseModeUnderlay bus={this.props.bus} visible={this.roseModeVisible} - rotation={this.props.rotation.map((r) => { - const word = Arinc429Word.empty(); - - word.ssm = Arinc429SignStatusMatrix.NormalOperation; - word.value = r; - - return word; - })} + rotation={this.rotationArinc429Word} oansRange={this.props.oansRange} doClip /> @@ -123,14 +152,7 @@ export class OancStaticModeOverlay extends DisplayComponent<OancMapOverlayProps> <ArcModeUnderlay bus={this.props.bus} visible={this.arcModeVisible} - rotation={this.props.rotation.map((r) => { - const word = Arinc429Word.empty(); - - word.ssm = Arinc429SignStatusMatrix.NormalOperation; - word.value = r; - - return word; - })} + rotation={this.rotationArinc429Word} oansRange={this.props.oansRange} doClip yOffset={0} diff --git a/fbw-common/src/systems/instruments/src/OANC/OancPositionComputer.ts b/fbw-common/src/systems/instruments/src/OANC/OancPositionComputer.ts index 1dd8629865e..561e9a0d1c0 100644 --- a/fbw-common/src/systems/instruments/src/OANC/OancPositionComputer.ts +++ b/fbw-common/src/systems/instruments/src/OANC/OancPositionComputer.ts @@ -18,6 +18,9 @@ export class OancPositionComputer<T extends number> { constructor(private readonly oanc: Oanc<T>) {} public computePosition(): string | undefined { + if (!this.oanc.data) { + return; + } const features = this.oanc.data.features; for (const feature of features) { @@ -39,7 +42,7 @@ export class OancPositionComputer<T extends number> { return feature.properties.idlin; case FeatureType.RunwayElement: case FeatureType.Stopway: - return feature.properties.idrwy.replace('.', '-'); + return feature.properties.idrwy?.replace('.', '-'); case FeatureType.BlastPad: case FeatureType.RunwayDisplacedArea: return feature.properties.idthr; diff --git a/fbw-common/src/systems/instruments/src/OANC/OansBrakeToVacateSelection.ts b/fbw-common/src/systems/instruments/src/OANC/OansBrakeToVacateSelection.ts index c5ffdacca57..2e77b50191c 100644 --- a/fbw-common/src/systems/instruments/src/OANC/OansBrakeToVacateSelection.ts +++ b/fbw-common/src/systems/instruments/src/OANC/OansBrakeToVacateSelection.ts @@ -17,7 +17,7 @@ import { Position, } from '@turf/turf'; import { Arinc429Register, Arinc429SignStatusMatrix, MathUtils } from '@flybywiresim/fbw-sdk'; -import { FmsDataStore, Label, LabelStyle } from '.'; +import { Label, LabelStyle } from '.'; import { BtvData } from '../../../shared/src/publishers/OansBtv/BtvPublisher'; import { OancLabelManager } from './OancLabelManager'; import { @@ -80,10 +80,10 @@ export class OansBrakeToVacateSelection<T extends number> { readonly btvRunwayBearingTrue = Subject.create<number | null>(null); /** Threshold, used for runway end distance calculation */ - private btvThresholdPosition: Position; + private btvThresholdPosition: Position | undefined; /** Opposite threshold, used for runway end distance calculation */ - private btvOppositeThresholdPosition: Position; + private btvOppositeThresholdPosition: Position | undefined; /** Selected exit */ readonly btvExit = Subject.create<string | null>(null); @@ -91,7 +91,7 @@ export class OansBrakeToVacateSelection<T extends number> { /** Distance to exit, in meters. Null if not set. */ readonly btvExitDistance = Subject.create<number | null>(null); - private btvExitPosition: Position; + private btvExitPosition: Position | undefined; /** "runway ahead" advisory was triggered */ private rwyAheadTriggered: boolean = false; @@ -112,7 +112,7 @@ export class OansBrakeToVacateSelection<T extends number> { private readonly rwyAheadArinc = Arinc429Register.empty(); - private btvPathGeometry: 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(this.sub.on('dryStoppingDistance').whenChanged(), 0); @@ -195,7 +195,7 @@ export class OansBrakeToVacateSelection<T extends number> { // 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; + const heading = thresholdFeature.properties?.brngmag ?? 0; this.btvRunwayLda.set(lda); this.btvRunwayBearingTrue.set(heading); @@ -217,7 +217,7 @@ export class OansBrakeToVacateSelection<T extends number> { } selectExitFromOans(exit: string, feature: Feature<Geometry, AmdbProperties>) { - if (this.btvRunway.get() == null) { + if (this.btvRunway.get() == null || !this.btvThresholdPosition || !this.btvOppositeThresholdPosition) { return; } @@ -242,17 +242,13 @@ export class OansBrakeToVacateSelection<T extends number> { ? pointDistance(thrLoc[0], thrLoc[1], exitLoc1[0], exitLoc1[1]) : pointDistance(thrLoc[0], thrLoc[1], exitLoc2[0], exitLoc2[1]); + const geoCoords = feature.geometry.coordinates as Position[]; // trust me, bro 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(exitLoc1[0], exitLoc1[1], geoCoords[1][0], geoCoords[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], - ); + pointAngle(exitLoc2[0], exitLoc2[1], geoCoords[exitLastIndex - 1][0], geoCoords[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 || @@ -270,7 +266,9 @@ export class OansBrakeToVacateSelection<T extends number> { MIN_TOUCHDOWN_ZONE_DISTANCE; this.bus.getPublisher<FmsOansData>().pub('oansSelectedExit', exit); - this.bus.getPublisher<FmsOansData>().pub('ndBtvMessage', `BTV ${this.btvRunway.get().substring(4)}/${exit}`, true); + this.bus + .getPublisher<FmsOansData>() + .pub('ndBtvMessage', `BTV ${this.btvRunway.get()?.substring(4) ?? ''}/${exit}`, true); this.btvPathGeometry = Array.from(feature.geometry.coordinates as Position[]); if (exitDistFromCenterLine1 < exitDistFromCenterLine2) { @@ -313,37 +311,33 @@ export class OansBrakeToVacateSelection<T extends number> { this.runwayBearingArinc.writeToSimVar('L:A32NX_OANS_RWY_BEARING'); } - public async setBtvRunwayFromFmsRunway(fmsDataStore: FmsDataStore): Promise<[Runway, Coordinates]> { - const destination = fmsDataStore.destination.get(); - const rwyIdent = fmsDataStore.landingRunway.get(); - if (destination && rwyIdent) { - const db = NavigationDatabaseService.activeDatabase.backendDatabase; - - const arps = await db.getAirports([destination]); - const arpCoordinates = arps[0].location; - - const runways = await db.getRunways(destination); - const landingRunwayNavdata = runways.filter((rw) => rw.ident === rwyIdent)[0]; - const oppositeThreshold = placeBearingDistance( - landingRunwayNavdata.thresholdLocation, - landingRunwayNavdata.bearing, - landingRunwayNavdata.length / MathUtils.METRES_TO_NAUTICAL_MILES, - ); - const localThr: Position = [0, 0]; - const localOppThr: Position = [0, 0]; - globalToAirportCoordinates(arpCoordinates, landingRunwayNavdata.thresholdLocation, localThr); - globalToAirportCoordinates(arpCoordinates, oppositeThreshold, localOppThr); - - this.selectRunwayFromNavdata( - rwyIdent, - landingRunwayNavdata.length, - landingRunwayNavdata.bearing, - localThr, - localOppThr, - ); + public async setBtvRunwayFromFmsRunway(destination: string, rwyIdent: string): Promise<[Runway, Coordinates]> { + const db = NavigationDatabaseService.activeDatabase.backendDatabase; - return [landingRunwayNavdata, arpCoordinates]; - } + const arps = await db.getAirports([destination]); + const arpCoordinates = arps[0].location; + + const runways = await db.getRunways(destination); + const landingRunwayNavdata = runways.filter((rw) => rw.ident === rwyIdent)[0]; + const oppositeThreshold = placeBearingDistance( + landingRunwayNavdata.thresholdLocation, + landingRunwayNavdata.bearing, + landingRunwayNavdata.length / MathUtils.METRES_TO_NAUTICAL_MILES, + ); + const localThr: Position = [0, 0]; + const localOppThr: Position = [0, 0]; + globalToAirportCoordinates(arpCoordinates, landingRunwayNavdata.thresholdLocation, localThr); + globalToAirportCoordinates(arpCoordinates, oppositeThreshold, localOppThr); + + this.selectRunwayFromNavdata( + rwyIdent, + landingRunwayNavdata.length, + landingRunwayNavdata.bearing, + localThr, + localOppThr, + ); + + return [landingRunwayNavdata, arpCoordinates]; } selectExitFromManualEntry(reqStoppingDistance: number, btvExitPosition: Position) { @@ -356,7 +350,7 @@ export class OansBrakeToVacateSelection<T extends number> { pub.pub('oansSelectedExit', 'N/A'); pub.pub('oansExitPosition', this.btvExitPosition, true); - pub.pub('ndBtvMessage', `BTV ${this.btvRunway.get().substring(4)}/MANUAL`, true); + pub.pub('ndBtvMessage', `BTV ${this.btvRunway.get()?.substring(4) ?? ''}/MANUAL`, true); this.btvExitDistance.set(correctedStoppingDistance); this.btvExit.set('N/A'); @@ -392,13 +386,13 @@ export class OansBrakeToVacateSelection<T extends number> { } drawBtvPath() { - if (!this.btvPathGeometry.length || !this.canvasRef?.getOrDefault()) { + const ctx = this.canvasRef?.instance.getContext('2d'); + if (!this.btvPathGeometry.length || !this.canvasRef?.getOrDefault() || !ctx) { return; } - const ctx = this.canvasRef.instance.getContext('2d'); ctx.resetTransform(); - ctx.translate(this.canvasCentreX.get(), this.canvasCentreY.get()); + ctx.translate(this.canvasCentreX?.get() ?? 0, this.canvasCentreY?.get() ?? 0); ctx.lineWidth = 5; ctx.strokeStyle = '#00ffff'; @@ -415,13 +409,25 @@ export class OansBrakeToVacateSelection<T extends number> { } drawStopLines() { - if (!this.btvThresholdPosition.length || !this.canvasRef?.getOrDefault()) { + const ctx = this.canvasRef?.instance.getContext('2d'); + const aircraftPpos = this.aircraftPpos?.get(); + const acOnGround = this.aircraftOnGround?.get(); + const rwyBearingTrue = this.btvRunwayBearingTrue.get(); + if ( + !this.btvThresholdPosition?.length || + !this.btvOppositeThresholdPosition || + !this.canvasRef?.getOrDefault() || + !ctx || + !aircraftPpos || + !this.getZoomLevelInverseScale || + acOnGround === undefined || + rwyBearingTrue === null + ) { return; } - const ctx = this.canvasRef.instance.getContext('2d'); ctx.resetTransform(); - ctx.translate(this.canvasCentreX.get(), this.canvasCentreY.get()); + ctx.translate(this.canvasCentreX?.get() ?? 0, this.canvasCentreY?.get() ?? 0); const radioAlt = this.radioAltitude1.get().isFailureWarning() || this.radioAltitude1.get().isNoComputedData() @@ -432,53 +438,53 @@ export class OansBrakeToVacateSelection<T extends number> { const dryWetLinesAreUpdating = radioAlt.valueOr(1000) <= 600; // Aircraft distance after threshold + const aircraftDistFromThreshold = pointDistance( this.btvThresholdPosition[0], this.btvThresholdPosition[1], - this.aircraftPpos.get()[0], - this.aircraftPpos.get()[1], + aircraftPpos[0], + aircraftPpos[1], ); const aircraftDistFromRunwayEnd = pointDistance( this.btvOppositeThresholdPosition[0], this.btvOppositeThresholdPosition[1], - this.aircraftPpos.get()[0], - this.aircraftPpos.get()[1], + aircraftPpos[0], + aircraftPpos[1], ); - const isPastThreshold = aircraftDistFromRunwayEnd < this.btvRunwayLda.get(); + const rwyLda = this.btvRunwayLda.get() ?? 0; + const isPastThreshold = aircraftDistFromRunwayEnd < rwyLda; // 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 dryRunoverCondition = touchdownDistance + this.dryStoppingDistance.get() > rwyLda; + const wetRunoverCondition = touchdownDistance + this.wetStoppingDistance.get() > rwyLda; const latDistance = 27.5 / this.getZoomLevelInverseScale(); const strokeWidth = 3.5 / this.getZoomLevelInverseScale(); // DRY stop line - if (this.dryStoppingDistance.get() > 0 && !this.aircraftOnGround.get()) { + if (this.dryStoppingDistance.get() > 0 && !acOnGround) { const distanceToDraw = Math.min( this.dryStoppingDistance.get(), - this.btvRunwayLda.get() - touchdownDistance + CLAMP_DRY_STOPBAR_DISTANCE, + rwyLda - 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(), + (touchdownDistance + distanceToDraw) / rwyLda, ); 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], + latDistance * Math.cos((180 - rwyBearingTrue) * MathUtils.DEGREES_TO_RADIANS) + dryStopLinePoint[0], + latDistance * Math.sin((180 - rwyBearingTrue) * 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], + latDistance * Math.cos(-rwyBearingTrue * MathUtils.DEGREES_TO_RADIANS) + dryStopLinePoint[0], + latDistance * Math.sin(-rwyBearingTrue * MathUtils.DEGREES_TO_RADIANS) + dryStopLinePoint[1], ]; ctx.beginPath(); @@ -501,35 +507,33 @@ export class OansBrakeToVacateSelection<T extends number> { style: dryRunoverCondition ? LabelStyle.BtvStopLineRed : LabelStyle.BtvStopLineMagenta, position: dryP2, rotation: 0, - associatedFeature: null, + associatedFeature: undefined, }; - this.labelManager.visibleLabels.insert(dryLabel); - this.labelManager.labels.push(dryLabel); + this.labelManager?.visibleLabels.insert(dryLabel); + this.labelManager?.labels.push(dryLabel); } // WET stop line - if (this.wetStoppingDistance.get() > 0 && !this.aircraftOnGround.get()) { + if (this.wetStoppingDistance.get() > 0 && !acOnGround) { const distanceToDraw = Math.min( this.wetStoppingDistance.get(), - this.btvRunwayLda.get() - touchdownDistance + CLAMP_WET_STOPBAR_DISTANCE, + rwyLda - 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(), + (touchdownDistance + distanceToDraw) / rwyLda, ); 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], + latDistance * Math.cos((180 - rwyBearingTrue) * MathUtils.DEGREES_TO_RADIANS) + wetStopLinePoint[0], + latDistance * Math.sin((180 - rwyBearingTrue) * 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], + latDistance * Math.cos(-rwyBearingTrue * MathUtils.DEGREES_TO_RADIANS) + wetStopLinePoint[0], + latDistance * Math.sin(-rwyBearingTrue * MathUtils.DEGREES_TO_RADIANS) + wetStopLinePoint[1], ]; ctx.beginPath(); @@ -562,17 +566,17 @@ export class OansBrakeToVacateSelection<T extends number> { style: labelStyle, position: wetP2, rotation: 0, - associatedFeature: null, + associatedFeature: undefined, }; - this.labelManager.visibleLabels.insert(wetLabel); - this.labelManager.labels.push(wetLabel); + this.labelManager?.visibleLabels.insert(wetLabel); + this.labelManager?.labels.push(wetLabel); } // On ground & above 25kts: STOP line const distToRwyEnd = this.remaininingDistToRwyEnd.get(); if ( this.liveStoppingDistance.get() > 0 && - this.aircraftOnGround.get() && + acOnGround && this.groundSpeed.get().value > 25 && distToRwyEnd.ssm === Arinc429SignStatusMatrix.NormalOperation ) { @@ -587,18 +591,16 @@ export class OansBrakeToVacateSelection<T extends number> { this.btvThresholdPosition[1], this.btvOppositeThresholdPosition[0], this.btvOppositeThresholdPosition[1], - (aircraftDistFromThreshold + distanceToDraw) / this.btvRunwayLda.get(), + (aircraftDistFromThreshold + distanceToDraw) / rwyLda, ); 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], + latDistance * Math.cos((180 - rwyBearingTrue) * MathUtils.DEGREES_TO_RADIANS) + stopLinePoint[0], + latDistance * Math.sin((180 - rwyBearingTrue) * 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], + latDistance * Math.cos(-rwyBearingTrue * MathUtils.DEGREES_TO_RADIANS) + stopLinePoint[0], + latDistance * Math.sin(-rwyBearingTrue * MathUtils.DEGREES_TO_RADIANS) + stopLinePoint[1], ]; ctx.beginPath(); @@ -621,10 +623,10 @@ export class OansBrakeToVacateSelection<T extends number> { style: liveRunOverCondition ? LabelStyle.BtvStopLineRed : LabelStyle.BtvStopLineGreen, position: stopP1, rotation: 0, - associatedFeature: null, + associatedFeature: undefined, }; - this.labelManager.visibleLabels.insert(stoplineLabel); - this.labelManager.labels.push(stoplineLabel); + this.labelManager?.visibleLabels.insert(stoplineLabel); + this.labelManager?.labels.push(stoplineLabel); } } @@ -643,9 +645,9 @@ export class OansBrakeToVacateSelection<T extends number> { 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( + ?.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)), ); } @@ -675,11 +677,14 @@ export class OansBrakeToVacateSelection<T extends number> { return; } + const aircraftPpos = this.aircraftPpos?.get(); + if ( this.onGround.get() === false || this.groundSpeed.get().ssm !== Arinc429SignStatusMatrix.NormalOperation || this.groundSpeed.get().value > 40 || - this.groundSpeed.get().value < 1 + this.groundSpeed.get().value < 1 || + !aircraftPpos ) { // Transmit no advisory this.rwyAheadArinc.ssm = Arinc429SignStatusMatrix.NormalOperation; @@ -746,10 +751,10 @@ export class OansBrakeToVacateSelection<T extends number> { // From here on comparing local to local coords const predictionVolume = polygon([this.rwyAheadPredictionVolumePoints]); - const insideRunways = []; + const insideRunways: string[] = []; runwayFeatures.features.forEach((feat) => { if (feat.properties.idrwy) { - const inside = booleanContains(feat.geometry as Polygon, point(this.aircraftPpos.get())); + const inside = booleanContains(feat.geometry as Polygon, point(aircraftPpos)); if (inside) { insideRunways.push(feat.properties.idrwy.replace('.', ' - ')); } diff --git a/fbw-common/src/systems/instruments/src/OANC/OansControlEventPublisher.ts b/fbw-common/src/systems/instruments/src/OANC/OansControlEventPublisher.ts index bd5ac43ddd4..138e730d559 100644 --- a/fbw-common/src/systems/instruments/src/OANC/OansControlEventPublisher.ts +++ b/fbw-common/src/systems/instruments/src/OANC/OansControlEventPublisher.ts @@ -1,11 +1,34 @@ // Copyright (c) 2023-2024 FlyByWire Simulations // SPDX-License-Identifier: GPL-3.0 +import { EfisSide, FeatureType } from '@flybywiresim/fbw-sdk'; +import { Position } from '@turf/turf'; + export interface OansControlEvents { - ndShowOans: boolean; - ndSetContextMenu: { x: number; y: number }; - oansDisplayAirport: string; - oansZoomIn: number; - oansZoomOut: number; - oansNotAvail: boolean; + nd_show_oans: { side: EfisSide; show: boolean }; + oans_performance_mode_hide: { side: EfisSide; hide: boolean }; + oans_display_airport: string; + oans_not_avail: boolean; + oans_center_map_on: Position; + oans_center_on_acft: boolean; + oans_add_cross_at_feature: { id: number; feattype: FeatureType }; + oans_add_flag_at_feature: { id: number; feattype: FeatureType }; + oans_remove_cross_at_feature: { id: number; feattype: FeatureType }; + oans_remove_flag_at_feature: { id: number; feattype: FeatureType }; + /** OANC -> ND, whether symbol (flag or cross) already exists for feature id */ + oans_symbols_for_feature_ids: { featureIdsWithCrosses: number[]; featureIdsWithFlags: number[] }; + /** Mouse X, Y */ + oans_add_cross_at_cursor: [number, number]; + /** Mouse X, Y */ + oans_add_flag_at_cursor: [number, number]; + oans_erase_all_crosses: boolean; + oans_erase_all_flags: boolean; + /** Fires when context menu is called on the ND, to check whether there are symbols to delete */ + oans_query_symbols_at_cursor: { side: EfisSide; cursorPosition: [number, number] }; + /** Answer from OANC to ND: Whether symbols exist at the queried mouse cursor position, returns indices of symbols */ + oans_answer_symbols_at_cursor: { side: EfisSide; cross: number | null; flag: number | null }; + oans_erase_cross_id: number; + oans_erase_flag_id: number; + /** OANC -> ND: Show SET PLAN MODE in control panel, if in ARC/NAV mode and arpt too far away */ + oans_show_set_plan_mode: boolean; } diff --git a/fbw-common/src/systems/instruments/src/OANC/ResetPanelPublisher.tsx b/fbw-common/src/systems/instruments/src/OANC/ResetPanelPublisher.tsx new file mode 100644 index 00000000000..0e347d460d6 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/OANC/ResetPanelPublisher.tsx @@ -0,0 +1,11 @@ +// Copyright (c) 2025 FlyByWire Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +/** + * Events for reset panel on overhead panel. If pulled out, the LVar is set to true, if pushed in it's set to false. + * Functionally, these behave similarly to circuit breakers, however they only interrupt software. If pulled out, execution of SW is halted. + */ +export type ResetPanelSimvars = { + a380x_reset_panel_arpt_nav: boolean; +}; diff --git a/fbw-common/src/systems/instruments/src/OANC/tsconfig.json b/fbw-common/src/systems/instruments/src/OANC/tsconfig.json index c5f8a505543..b19ddd26ea6 100644 --- a/fbw-common/src/systems/instruments/src/OANC/tsconfig.json +++ b/fbw-common/src/systems/instruments/src/OANC/tsconfig.json @@ -4,7 +4,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 */, diff --git a/fbw-common/src/systems/shared/src/amdb.ts b/fbw-common/src/systems/shared/src/amdb.ts index 2110feebea9..81fbe354c09 100644 --- a/fbw-common/src/systems/shared/src/amdb.ts +++ b/fbw-common/src/systems/shared/src/amdb.ts @@ -1,7 +1,7 @@ // Copyright (c) 2021-2024 FlyByWire Simulations // SPDX-License-Identifier: GPL-3.0 -import { FeatureCollection, Geometry } from '@turf/turf'; +import { FeatureCollection, Geometry, Point } from '@turf/turf'; import { LatLonInterface } from '@microsoft/msfs-sdk'; export enum AmdbProjection { @@ -183,6 +183,8 @@ export interface AmdbProperties { brngmag?: number; brngtrue?: number; + + midpoint?: Point; } export type AmdbFeatureCollection = FeatureCollection<Geometry, AmdbProperties>; diff --git a/fbw-common/src/systems/shared/src/publishers/OansBtv/FmsOansPublisher.ts b/fbw-common/src/systems/shared/src/publishers/OansBtv/FmsOansPublisher.ts index 316cb85bc10..1927cf2233f 100644 --- a/fbw-common/src/systems/shared/src/publishers/OansBtv/FmsOansPublisher.ts +++ b/fbw-common/src/systems/shared/src/publishers/OansBtv/FmsOansPublisher.ts @@ -20,13 +20,13 @@ export interface FmsOansData { /** (FMS -> OANS) Identifier of landing runway selected through FMS. */ fmsLandingRunway: string; /** Identifier of landing runway selected for BTV through OANS. */ - oansSelectedLandingRunway: string; + oansSelectedLandingRunway: string | null; /** Arinc429: Length of landing runway selected for BTV through OANS, in meters. */ oansSelectedLandingRunwayLength: number; /** Arinc429: Bearing of landing runway selected for BTV through OANS, in degrees. */ oansSelectedLandingRunwayBearing: number; /** Identifier of exit selected for BTV through OANS. */ - oansSelectedExit: string; + oansSelectedExit: string | null; /** (OANS -> ND) QFU to be displayed in flashing RWY AHEAD warning in ND */ ndRwyAheadQfu: string; /** (OANS -> BTV) Arinc429: Requested stopping distance (through OANS), in meters. */