mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-02 06:27:43 +08:00
feat(frequency): offer the destination's stations, and mark what is out of range
The radio only ever held one airport's frequencies, loaded once at the start, so on a departure the destination's Tower never appeared however close you got — there was no way to dial ahead. Both ends of the flight are now listed: the field being flown first and unlabelled, the far end after it, tagged with its ICAO and drawn with a dashed border so it reads as somewhere else. The destination's stations are kept in their own list rather than merged into the primary one, because everything that resolves *which frequency this phase expects* — the frequency map, the expected frequency, the wrong-frequency gate, the ATIS wiring — reads that list, and folding a second airport's Tower into it would let the gate accept the wrong field's frequency. Out-of-range stations are dimmed rather than removed: VHF is line-of-sight, so a station unreachable on the ground is workable from the cruise, and it should come back as you climb. The distances come from the backend, which already derives them and now returns them — the browser does not need its own copy of the airport coordinates for this. With no bridge connected, nothing is dimmed at all. That is the normal case for someone practising without a simulator, and a station that cannot be *proven* out of range must not be taken away.
This commit is contained in:
@@ -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)"
|
||||
>
|
||||
<span class="chan__role">{{ channelLabel(entry) }}</span>
|
||||
<span class="chan__role">
|
||||
{{ channelLabel(entry) }}
|
||||
<span v-if="entry.airportRole === 'destination' && entry.airportIcao" class="chan__airport">
|
||||
{{ entry.airportIcao }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="chan__freq">{{ entry.frequency }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<'atis_freq' | 'delivery_freq' | 'ground_freq' | 'tower_freq' | 'departure_freq' | 'approach_freq' | 'handoff_freq', string>>
|
||||
@@ -254,10 +267,23 @@ export function useFrequencyPresets(
|
||||
const displayAirportFrequencies = computed<DisplayAirportFrequencyEntry[]>(() => {
|
||||
const grouped = new Map<string, DisplayAirportFrequencyEntry>()
|
||||
|
||||
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<string | undefined>(undefined)
|
||||
const destinationFrequencies = ref<AirportFrequencyEntry[]>([])
|
||||
|
||||
// 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<number | undefined>(undefined)
|
||||
const distanceToDestinationNm = ref<number | undefined>(undefined)
|
||||
const altitudeFt = ref<number | undefined>(undefined)
|
||||
|
||||
/** Apply the derived_position block of a backend decision response. */
|
||||
const applyDerivedPosition = (derived: Record<string, number> | 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user