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