diff --git a/app/components/live-atc/cockpit/RadioPanel.vue b/app/components/live-atc/cockpit/RadioPanel.vue index 8bfa6b6..333afe0 100644 --- a/app/components/live-atc/cockpit/RadioPanel.vue +++ b/app/components/live-atc/cockpit/RadioPanel.vue @@ -52,6 +52,14 @@ const stationFor = (frequency: string): string => { const activeStation = computed(() => stationFor(props.active)) const standbyStation = computed(() => stationFor(props.standby)) +/** Tooltip: role, frequency, which airport, and whether it is in range. */ +const channelTitle = (entry: DisplayAirportFrequencyEntry): string => { + const parts = [`${channelLabel(entry)} · ${entry.frequency}`] + if (entry.airportIcao) parts.push(entry.airportIcao) + if (entry.reachable === false) parts.push('out of VHF range from your position') + return parts.join(' · ') +} + const isTuned = (entry: DisplayAirportFrequencyEntry) => Boolean(props.active) && normalizedFrequencyValue(entry.frequency) === normalizedFrequencyValue(props.active) @@ -193,11 +201,16 @@ const channelLabel = (entry: DisplayAirportFrequencyEntry) => FREQ_ROLE_LABEL[en :key="entry.displayKey" type="button" class="chan" - :class="{ on: isTuned(entry) }" - :title="`${channelLabel(entry)} · ${entry.frequency}`" + :class="{ on: isTuned(entry), 'chan--far': entry.airportRole === 'destination', 'chan--unreachable': entry.reachable === false }" + :title="channelTitle(entry)" @click="emit('select-channel', entry)" > - {{ channelLabel(entry) }} + + {{ channelLabel(entry) }} + + {{ entry.airportIcao }} + + {{ entry.frequency }} @@ -368,6 +381,23 @@ const channelLabel = (entry: DisplayAirportFrequencyEntry) => FREQ_ROLE_LABEL[en background: rgba(34, 211, 238, 0.08); } +/* The far end of the flight: available to dial ahead, but visibly not here. */ +.chan--far { + border-style: dashed; +} + +/* Beyond VHF line of sight from the reported position. Dimmed rather than + removed — it becomes workable again as the aircraft climbs or closes in. */ +.chan--unreachable { + opacity: 0.45; +} + +.chan__airport { + margin-left: 4px; + opacity: 0.65; + letter-spacing: 0.08em; +} + .chan__role { font-size: 9px; font-weight: 700; diff --git a/app/composables/useFrequencyPresets.ts b/app/composables/useFrequencyPresets.ts index 7aeebfe..bb7343b 100644 --- a/app/composables/useFrequencyPresets.ts +++ b/app/composables/useFrequencyPresets.ts @@ -2,6 +2,11 @@ import { ref, computed } from 'vue' import { useApi } from '~/composables/useApi' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { normalizeManualFreq, normalizedFrequencyValue } from '../../shared/utils/frequency' +import { + offeredAirports, + stationReachable, + type AirportRole, +} from '../../shared/utils/stationAvailability' import type { AtisReport, AtisStation } from '../../shared/utils/atisReport' export type AirportFrequencyEntry = { @@ -19,6 +24,14 @@ export type DisplayAirportFrequencyEntry = AirportFrequencyEntry & { displayKey: string sourceList: Array<'vatsim' | 'openaip'> sourceLabel: string + /** Which airport of the flight this station belongs to. */ + airportIcao?: string + airportRole?: AirportRole + /** + * False only when the position proves the station is beyond VHF line of + * sight. Unknown position means true — see stationAvailability. + */ + reachable: boolean } export type FrequencyVariableUpdate = Partial> @@ -254,10 +267,23 @@ export function useFrequencyPresets( const displayAirportFrequencies = computed(() => { const grouped = new Map() - for (const entry of airportFrequencies.value) { + // The airport being flown first, then the other end of the flight. Both are + // listed: a pilot has to be able to dial ahead to the destination, and on + // the way home the departure field's Ground is what they will need again. + const home = activeAirportIcao.value?.trim().toUpperCase() + const tagged: Array<{ entry: AirportFrequencyEntry; icao?: string; role?: AirportRole }> = [ + ...airportFrequencies.value.map(entry => ({ + entry, icao: home, role: 'departure' as AirportRole, + })), + ...destinationFrequencies.value.map(entry => ({ + entry, icao: destinationIcao.value, role: 'destination' as AirportRole, + })), + ] + + for (const { entry, icao, role } of tagged) { if (!entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER) continue - const key = frequencyDisplayKey(entry) + const key = `${icao ?? ''}::${frequencyDisplayKey(entry)}` const existing = grouped.get(key) if (!existing) { @@ -266,6 +292,14 @@ export function useFrequencyPresets( displayKey: key, sourceList: [entry.source], sourceLabel: sourceLabel([entry.source]), + airportIcao: icao, + airportRole: role, + reachable: stationReachable({ + distanceNm: role === 'destination' + ? distanceToDestinationNm.value + : distanceToDepartureNm.value, + altitudeFt: altitudeFt.value, + }), }) continue } @@ -280,6 +314,10 @@ export function useFrequencyPresets( } return [...grouped.values()].sort((a, b) => { + // The airport being flown stays at the top; the far end follows. + const aFar = a.airportRole === 'destination' ? 1 : 0 + const bFar = b.airportRole === 'destination' ? 1 : 0 + if (aFar !== bFar) return aFar - bFar const aRoleIndex = FREQ_ROLE_ORDER.includes(a.type) ? FREQ_ROLE_ORDER.indexOf(a.type) : Number.MAX_SAFE_INTEGER const bRoleIndex = FREQ_ROLE_ORDER.includes(b.type) ? FREQ_ROLE_ORDER.indexOf(b.type) : Number.MAX_SAFE_INTEGER const roleDiff = aRoleIndex - bRoleIndex @@ -359,6 +397,49 @@ export function useFrequencyPresets( } } + // The destination's stations are kept in their own list rather than merged + // into airportFrequencies. Everything that resolves *which frequency this + // phase expects* — airportFreqMap, expectedFrequencyForState, the + // wrong-frequency gate, the ATIS wiring — reads the primary list, and folding + // a second airport's Tower into it would let the gate accept the wrong field's + // frequency. The two are only brought together for display. + const destinationIcao = ref(undefined) + const destinationFrequencies = ref([]) + + // Position-derived values from the backend's telemetry response — it already + // holds the airport coordinates, so the browser does not need its own copy. + // Undefined without a bridge, which stationReachable reads as "range unknown" + // and therefore reachable. + const distanceToDepartureNm = ref(undefined) + const distanceToDestinationNm = ref(undefined) + const altitudeFt = ref(undefined) + + /** Apply the derived_position block of a backend decision response. */ + const applyDerivedPosition = (derived: Record | undefined | null) => { + if (!derived) return + const read = (key: string) => (typeof derived[key] === 'number' ? derived[key] : undefined) + distanceToDepartureNm.value = read('distance_to_dep_nm') + distanceToDestinationNm.value = read('distance_to_dest_nm') + altitudeFt.value = read('altitude_ft') + } + + const fetchDestinationFrequencies = async (icao: string | undefined) => { + destinationIcao.value = icao?.trim().toUpperCase() || undefined + destinationFrequencies.value = [] + if (!destinationIcao.value) return + if (destinationIcao.value === activeAirportIcao.value?.trim().toUpperCase()) return + try { + const response = await api.get(`/api/airports/${encodeURIComponent(destinationIcao.value)}/frequencies`) + destinationFrequencies.value = Array.isArray(response?.frequencies) + ? response.frequencies as AirportFrequencyEntry[] + : [] + } catch (err) { + // Best-effort: the destination's stations are a convenience, and the + // flight is entirely flyable without them. + console.error('Failed to load destination frequencies:', err) + } + } + const fetchAirportFrequencies = async (icao: string | undefined, options: { silent?: boolean } = {}) => { if (!icao) return @@ -549,6 +630,9 @@ export function useFrequencyPresets( syncLocalFrequenciesWithEngine, applyFrequencyVariablesFromList, fetchAirportFrequencies, + fetchDestinationFrequencies, + destinationFrequencies, + applyDerivedPosition, setActiveFrequencyFromList, setStandbyFrequencyFromList, swapFrequencies, diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index b86189f..7053adf 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -92,6 +92,7 @@ export function useLiveAtcSession( expectedFrequencyForState, acceptedFrequenciesForState, runwayInUse, informationLetter, atisReport, fetchAirportFrequencies, setActiveFrequencyFromList, + fetchDestinationFrequencies, applyDerivedPosition, } = freq const { @@ -337,6 +338,10 @@ export function useLiveAtcSession( currentScreen.value = 'complete' } + // Position-derived distances, used to work out which ground stations are + // still within VHF range. Absent without a bridge — then nothing is ranged. + applyDerivedPosition((response as any).derived_position) + // Re-arm (or clear) the silence auto-advance for whatever state we're now on. armSilenceTimer() // And dial in the new frequency if this decision handed us to one. @@ -629,6 +634,13 @@ export function useLiveAtcSession( // Fetch real airport frequencies BEFORE building backendVariables so that // all freq vars are resolved from live VATSIM/OpenAIP data. await fetchAirportFrequencies(scenarioIcao) + // The other end of the flight, so its stations can be dialled ahead of time. + // Best-effort and not awaited on the critical path: the flight is entirely + // flyable without them. + const otherIcao = scenario.airport === 'arr' + ? (flightPlan.dep || flightPlan.departure) + : (flightPlan.arr || flightPlan.arrival) + void fetchDestinationFrequencies(otherIcao) // Runway and information letter from the resolved ATIS (the flight plan // carries neither). The report always has a letter, and has a runway