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:
itsrubberduck
2026-07-27 10:17:29 +02:00
parent ac40caeb69
commit d9bc4fcbd9
3 changed files with 131 additions and 5 deletions

View File

@@ -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;