mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
/api/service/tools/* was a second implementation of the OSM geocoding and taxi routing the Python backend already owns — its own Overpass client, alias matching and scoring. It drifted: it still asked Overpass for `out center tags`, so a runway resolved to the middle of the strip rather than a threshold, which is the runway-endpoint bug the backend fixed. Teaching the copy about runway endpoints would mean maintaining the geometry twice, so the copy goes instead. Forwarding exposes origin_runway_point / dest_runway_point and include_connectors, and inherits the backend's Overpass cache and its radius fallback for aerodromes with no generated OSM area (EDDM), which the copy answered with an empty feature list. Failures now carry an HTTP status as well as the error code in the body; the old copy answered every error with 200. The frequencies call sites needed real types: airportGeocode.ts sat under server/api with no default export, so Nitro registered it as a route whose return type collapsed to any and poisoned the whole API type map. The typecheck was green because of it, not despite it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
662 lines
25 KiB
TypeScript
662 lines
25 KiB
TypeScript
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 = {
|
|
type: string
|
|
label: string
|
|
frequency: string
|
|
source: 'vatsim' | 'openaip'
|
|
callsign?: string
|
|
atisCode?: string
|
|
atisText?: string
|
|
lastUpdated?: string
|
|
}
|
|
|
|
/**
|
|
* Shape of `GET /api/airports/:icao/frequencies`.
|
|
*
|
|
* Stated explicitly because `api.get()` is generic over the response and
|
|
* cannot infer it from a template-literal path.
|
|
*/
|
|
type AirportFrequenciesResponse = {
|
|
icao: string
|
|
airportName?: string
|
|
sources: { vatsim: boolean; openaip: boolean }
|
|
frequencies: AirportFrequencyEntry[]
|
|
}
|
|
|
|
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>>
|
|
|
|
export type FrequencyPresetOption = {
|
|
value: string
|
|
label: string
|
|
sublabel: string
|
|
color: string
|
|
sourceLabel: string
|
|
callsign?: string
|
|
}
|
|
|
|
export const FREQUENCY_PLACEHOLDER = '---'
|
|
|
|
const FREQ_ROLE_ORDER = ['ATIS', 'DEL', 'CLD', 'GND', 'TWR', 'DEP', 'APP', 'CTR', 'ACC', 'FSS']
|
|
export const FREQ_ROLE_LABEL: Record<string, string> = {
|
|
ATIS: 'ATIS', DEL: 'Delivery', CLD: 'Delivery', GND: 'Ground', TWR: 'Tower',
|
|
DEP: 'Departure', APP: 'Approach', CTR: 'Center', ACC: 'Center', FSS: 'Radio',
|
|
}
|
|
|
|
const frequencyTypeMap: Record<string, keyof FrequencyVariableUpdate> = {
|
|
ATIS: 'atis_freq',
|
|
DEL: 'delivery_freq',
|
|
CLD: 'delivery_freq',
|
|
GND: 'ground_freq',
|
|
TWR: 'tower_freq',
|
|
DEP: 'departure_freq',
|
|
APP: 'approach_freq',
|
|
CTR: 'handoff_freq',
|
|
ACC: 'handoff_freq',
|
|
FSS: 'handoff_freq',
|
|
}
|
|
|
|
// Maps frequency_name from the flow state to the resolved airport frequency variable.
|
|
const FREQ_NAME_TO_VAR: Record<string, string> = {
|
|
'clearance delivery': 'delivery_freq',
|
|
'delivery': 'delivery_freq',
|
|
'ground': 'ground_freq',
|
|
'tower': 'tower_freq',
|
|
'departure': 'departure_freq',
|
|
'approach': 'approach_freq',
|
|
'centre': 'handoff_freq',
|
|
'center': 'handoff_freq',
|
|
'radar': 'handoff_freq',
|
|
}
|
|
|
|
|
|
/** "Arrival" / "Departure" variant from the station callsign (EDDF_A_ATIS / EDDF_D_ATIS). */
|
|
export const atisVariantLabel = (entry: AirportFrequencyEntry): string => {
|
|
const callsign = (entry.callsign || '').toUpperCase()
|
|
if (callsign.includes('_A_')) return 'Arrival '
|
|
if (callsign.includes('_D_')) return 'Departure '
|
|
// Fallback: published station name (OpenAIP) sometimes carries the variant.
|
|
const label = (entry.label || '').toLowerCase()
|
|
if (label.includes('arr')) return 'Arrival '
|
|
if (label.includes('dep')) return 'Departure '
|
|
return ''
|
|
}
|
|
|
|
export function useFrequencyPresets(
|
|
engine: ReturnType<typeof useCommunicationsEngine>,
|
|
stopCurrentSpeech: () => void,
|
|
) {
|
|
const { currentState, variables: vars, flags, updateFrequencyVariables } = engine
|
|
const api = useApi()
|
|
|
|
const frequencies = ref({
|
|
active: '121.900',
|
|
standby: '118.100',
|
|
})
|
|
|
|
const airportFrequencies = ref<AirportFrequencyEntry[]>([])
|
|
const airportName = ref<string | undefined>(undefined)
|
|
const airportFrequencyLoading = ref(false)
|
|
const frequencySources = ref({ vatsim: false, openaip: false })
|
|
// ICAO of the airport this session operates at (dep for departures, arr for
|
|
// arrivals) — drives ATIS announcements, METAR fallback, and the periodic refetch.
|
|
const activeAirportIcao = ref<string | undefined>(undefined)
|
|
const swapAnimation = ref(false)
|
|
const manualFreqActive = ref('')
|
|
const manualFreqStandby = ref('')
|
|
|
|
// Derived from the raw airport frequency list — always reflects the real airport
|
|
// data regardless of which flow snapshot is active. Used by expectedFrequencyForState
|
|
// so the wrong-frequency check is reliable across all flow transitions.
|
|
const airportFreqMap = computed<Record<string, string>>(() => {
|
|
const result: Record<string, string> = {}
|
|
for (const entry of airportFrequencies.value) {
|
|
const key = frequencyTypeMap[entry.type]
|
|
if (key && entry.frequency && !result[key]) {
|
|
result[key] = entry.frequency
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
|
|
function expectedFrequencyForState(): string | null {
|
|
const freqName = (currentState.value as any)?.frequency_name as string | undefined
|
|
if (!freqName) return null
|
|
const varKey = FREQ_NAME_TO_VAR[freqName.toLowerCase()]
|
|
if (!varKey) return null
|
|
// airportFreqMap is the authoritative source — it comes from the live airport
|
|
// data and is unaffected by flow-snapshot switches. Fall back to the engine
|
|
// variable store for any edge-case where airport data is missing.
|
|
return airportFreqMap.value[varKey] ?? ((vars as any).value[varKey] as string ?? null)
|
|
}
|
|
|
|
// All real frequencies published for a logical position. Some airports list
|
|
// several for the same role (e.g. EDDM has two Tower frequencies, 118.700 and
|
|
// 120.500) — the wrong-frequency gate accepts ANY of them, while
|
|
// expectedFrequencyForState() still returns the primary one for the prompt.
|
|
const airportFreqListMap = computed<Record<string, string[]>>(() => {
|
|
const result: Record<string, string[]> = {}
|
|
for (const entry of airportFrequencies.value) {
|
|
const key = frequencyTypeMap[entry.type]
|
|
if (key && entry.frequency) {
|
|
(result[key] ||= []).push(entry.frequency)
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
|
|
function acceptedFrequenciesForState(): string[] {
|
|
const freqName = (currentState.value as any)?.frequency_name as string | undefined
|
|
if (!freqName) return []
|
|
const varKey = FREQ_NAME_TO_VAR[freqName.toLowerCase()]
|
|
if (!varKey) return []
|
|
const all = [...(airportFreqListMap.value[varKey] ?? [])]
|
|
const fromVars = (vars as any).value[varKey] as string | undefined
|
|
if (fromVars) all.push(fromVars)
|
|
return Array.from(new Set(all.map(normalizedFrequencyValue).filter(Boolean)))
|
|
}
|
|
|
|
// The resolved ATIS for this airport (see server/api/airports/[icao]/atis.get.ts).
|
|
// Always carries an information letter, whether or not VATSIM has a station.
|
|
const atisReport = ref<AtisReport | null>(null)
|
|
|
|
/** The resolved broadcast belonging to a published ATIS frequency, if any. */
|
|
const stationForEntry = (entry: AirportFrequencyEntry): AtisStation | undefined => {
|
|
const stations = atisReport.value?.stations ?? []
|
|
if (!stations.length) return undefined
|
|
const byCallsign = entry.callsign
|
|
&& stations.find(station => station.callsign?.toUpperCase() === entry.callsign!.toUpperCase())
|
|
if (byCallsign) return byCallsign
|
|
const wanted = normalizedFrequencyValue(entry.frequency)
|
|
const byFrequency = stations.find(station => normalizedFrequencyValue(station.frequency) === wanted)
|
|
if (byFrequency) return byFrequency
|
|
// A single broadcast belongs to the airport's single ATIS frequency.
|
|
return stations.length === 1 ? stations[0] : undefined
|
|
}
|
|
|
|
// All ATIS stations at the airport. Large airports broadcast separate
|
|
// Arrival and Departure ATIS on different frequencies (EDDF_A_ATIS /
|
|
// EDDF_D_ATIS on VATSIM), each with its own info letter and text.
|
|
//
|
|
// The published frequency list only carries live text when a VATSIM
|
|
// controller is online, so the resolved report backfills the letter and the
|
|
// broadcast — that is what stops an ATIS from playing with no information
|
|
// letter and no runway.
|
|
const atisEntries = computed<AirportFrequencyEntry[]>(() =>
|
|
airportFrequencies.value
|
|
.filter(entry => entry.type === 'ATIS')
|
|
.map((entry) => {
|
|
const station = stationForEntry(entry)
|
|
if (!station) return entry
|
|
return {
|
|
...entry,
|
|
atisCode: entry.atisCode || station.letter || atisReport.value?.letter || undefined,
|
|
atisText: entry.atisText || station.text || undefined,
|
|
lastUpdated: entry.lastUpdated || station.lastUpdated,
|
|
}
|
|
}))
|
|
|
|
// Primary ATIS entry for the quick-play button — prefer one with live text.
|
|
const atisFrequencyEntry = computed(() =>
|
|
atisEntries.value.find(entry => (entry.atisText || '').trim()) || atisEntries.value[0])
|
|
|
|
/** True when the ATIS is synthesised rather than a live VATSIM broadcast. */
|
|
const atisIsSynthetic = computed(() =>
|
|
Boolean(atisReport.value) && atisReport.value!.source !== 'vatsim')
|
|
|
|
/**
|
|
* Runway in use for the given kind, from the resolved report. Null only when
|
|
* the airport has no usable runway data at all — callers keep their default.
|
|
*/
|
|
const runwayInUse = (kind: 'dep' | 'arr'): string | null =>
|
|
(kind === 'dep' ? atisReport.value?.runwayDep : atisReport.value?.runwayArr) ?? null
|
|
|
|
/** Information letter for the given kind, or null before the report loads. */
|
|
const informationLetter = (kind: 'dep' | 'arr'): string | null =>
|
|
(kind === 'dep' ? atisReport.value?.letterDep : atisReport.value?.letterArr)
|
|
?? atisReport.value?.letter
|
|
?? null
|
|
|
|
const frequencySourceLabels = computed(() => {
|
|
const labels: string[] = []
|
|
if (frequencySources.value.vatsim) labels.push('VATSIM')
|
|
if (frequencySources.value.openaip) labels.push('OpenAIP')
|
|
return labels
|
|
})
|
|
|
|
// The ATIS station matching the currently tuned frequency (if any). With
|
|
// multiple ATIS stations, each frequency carries its own broadcast.
|
|
const tunedAtisEntry = computed<AirportFrequencyEntry | null>(() => {
|
|
const active = normalizedFrequencyValue(frequencies.value.active)
|
|
if (!active) return null
|
|
const matches = atisEntries.value.filter(entry =>
|
|
entry.frequency
|
|
&& entry.frequency !== FREQUENCY_PLACEHOLDER
|
|
&& normalizedFrequencyValue(entry.frequency) === active,
|
|
)
|
|
if (!matches.length) return null
|
|
// Two sources can list the same frequency — prefer the entry with live text.
|
|
return matches.find(entry => (entry.atisText || '').trim()) || matches[0]!
|
|
})
|
|
|
|
const frequencyDisplayKey = (entry: AirportFrequencyEntry) =>
|
|
[
|
|
(entry.type || '').trim().toUpperCase(),
|
|
normalizedFrequencyValue(entry.frequency),
|
|
].join('|')
|
|
|
|
const sourceLabel = (sources: Array<'vatsim' | 'openaip'>) => {
|
|
const hasVatsim = sources.includes('vatsim')
|
|
const hasOpenAip = sources.includes('openaip')
|
|
if (hasVatsim && hasOpenAip) return 'VATSIM + OpenAIP'
|
|
if (hasVatsim) return 'VATSIM'
|
|
if (hasOpenAip) return 'OpenAIP'
|
|
return 'Source'
|
|
}
|
|
|
|
const displayAirportFrequencies = computed<DisplayAirportFrequencyEntry[]>(() => {
|
|
const grouped = new Map<string, DisplayAirportFrequencyEntry>()
|
|
|
|
// 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 = `${icao ?? ''}::${frequencyDisplayKey(entry)}`
|
|
const existing = grouped.get(key)
|
|
|
|
if (!existing) {
|
|
grouped.set(key, {
|
|
...entry,
|
|
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
|
|
}
|
|
|
|
if (!existing.sourceList.includes(entry.source)) {
|
|
existing.sourceList.push(entry.source)
|
|
}
|
|
existing.sourceLabel = sourceLabel(existing.sourceList)
|
|
existing.callsign ||= entry.callsign
|
|
existing.atisCode ||= entry.atisCode
|
|
existing.atisText ||= entry.atisText
|
|
}
|
|
|
|
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
|
|
if (roleDiff !== 0) return roleDiff
|
|
return a.frequency.localeCompare(b.frequency)
|
|
})
|
|
})
|
|
|
|
const toFrequencyVariableUpdate = (entry: AirportFrequencyEntry): FrequencyVariableUpdate | null => {
|
|
if (!entry?.frequency) {
|
|
return null
|
|
}
|
|
|
|
const targetKey = frequencyTypeMap[entry.type]
|
|
if (!targetKey) {
|
|
return null
|
|
}
|
|
|
|
return { [targetKey]: entry.frequency } as FrequencyVariableUpdate
|
|
}
|
|
|
|
const updateEngineFrequencyFromEntry = (entry: AirportFrequencyEntry) => {
|
|
const update = toFrequencyVariableUpdate(entry)
|
|
if (!update) {
|
|
return
|
|
}
|
|
|
|
updateFrequencyVariables(update)
|
|
}
|
|
|
|
const syncLocalFrequenciesWithEngine = (updates: FrequencyVariableUpdate) => {
|
|
const currentUnit = flags.value.current_unit
|
|
if (currentUnit === 'DEL' && updates.delivery_freq) {
|
|
frequencies.value.active = updates.delivery_freq
|
|
} else if (currentUnit === 'GROUND' && updates.ground_freq) {
|
|
frequencies.value.active = updates.ground_freq
|
|
} else if (currentUnit === 'TOWER' && updates.tower_freq) {
|
|
frequencies.value.active = updates.tower_freq
|
|
} else if (currentUnit === 'DEP' && updates.departure_freq) {
|
|
frequencies.value.active = updates.departure_freq
|
|
} else if (currentUnit === 'APP' && updates.approach_freq) {
|
|
frequencies.value.active = updates.approach_freq
|
|
} else if (currentUnit === 'CTR' && updates.handoff_freq) {
|
|
frequencies.value.active = updates.handoff_freq
|
|
}
|
|
|
|
if (updates.ground_freq) {
|
|
frequencies.value.standby = updates.ground_freq
|
|
}
|
|
}
|
|
|
|
const applyFrequencyVariablesFromList = (list: AirportFrequencyEntry[], options: { syncRadio?: boolean } = {}) => {
|
|
if (!Array.isArray(list) || list.length === 0) {
|
|
return
|
|
}
|
|
|
|
const prioritized = [...list].sort((a, b) => {
|
|
if (a.source === b.source) return 0
|
|
return a.source === 'vatsim' ? -1 : 1
|
|
})
|
|
|
|
const updates: FrequencyVariableUpdate = {}
|
|
|
|
for (const entry of prioritized) {
|
|
const targetKey = frequencyTypeMap[entry.type]
|
|
if (!targetKey) continue
|
|
if (updates[targetKey]) continue
|
|
if (!entry.frequency) continue
|
|
updates[targetKey] = entry.frequency
|
|
}
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
updateFrequencyVariables(updates)
|
|
if (options.syncRadio !== false) {
|
|
syncLocalFrequenciesWithEngine(updates)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<AirportFrequenciesResponse>(`/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
|
|
|
|
// Silent mode (background refresh): keep the current list visible while
|
|
// fetching and never retune the user's radio — only swap data when it arrives.
|
|
if (!options.silent) {
|
|
airportFrequencyLoading.value = true
|
|
airportFrequencies.value = []
|
|
airportName.value = undefined
|
|
frequencySources.value = { vatsim: false, openaip: false }
|
|
}
|
|
|
|
try {
|
|
// Both in flight together: the ATIS report backfills the letter and the
|
|
// broadcast text onto the frequency entries, so a partial load would show
|
|
// an ATIS station with no information letter.
|
|
const [response, report] = await Promise.all([
|
|
api.get<AirportFrequenciesResponse>(`/api/airports/${encodeURIComponent(icao)}/frequencies`),
|
|
api.get(`/api/airports/${encodeURIComponent(icao)}/atis`).catch((err: unknown) => {
|
|
console.error('Failed to load ATIS report:', err)
|
|
return null
|
|
}),
|
|
])
|
|
const entries = Array.isArray(response?.frequencies) ? response.frequencies as AirportFrequencyEntry[] : []
|
|
airportFrequencies.value = entries
|
|
atisReport.value = (report as AtisReport | null) ?? null
|
|
airportName.value = typeof response?.airportName === 'string' ? response.airportName : undefined
|
|
frequencySources.value = {
|
|
vatsim: Boolean(response?.sources?.vatsim),
|
|
openaip: Boolean(response?.sources?.openaip),
|
|
}
|
|
|
|
applyFrequencyVariablesFromList(entries, { syncRadio: !options.silent })
|
|
} catch (err) {
|
|
console.error('Failed to load airport frequencies:', err)
|
|
if (!options.silent) {
|
|
airportFrequencies.value = []
|
|
airportName.value = undefined
|
|
atisReport.value = null
|
|
frequencySources.value = { vatsim: false, openaip: false }
|
|
}
|
|
} finally {
|
|
if (!options.silent) {
|
|
airportFrequencyLoading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const setActiveFrequencyFromList = (entry: AirportFrequencyEntry) => {
|
|
if (!entry) return
|
|
const isPlaceholder = !entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER
|
|
|
|
if (!isPlaceholder && frequencies.value.active !== entry.frequency) {
|
|
// Tuning away from the current frequency — cut any in-progress ATC speech
|
|
// so the pilot no longer "hears" the controller on the old channel.
|
|
stopCurrentSpeech()
|
|
frequencies.value.standby = frequencies.value.active
|
|
frequencies.value.active = entry.frequency
|
|
}
|
|
|
|
updateEngineFrequencyFromEntry(entry)
|
|
}
|
|
|
|
const setStandbyFrequencyFromList = (entry: AirportFrequencyEntry) => {
|
|
if (!entry) return
|
|
const isPlaceholder = !entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER
|
|
|
|
if (!isPlaceholder) {
|
|
frequencies.value.standby = entry.frequency
|
|
}
|
|
|
|
updateEngineFrequencyFromEntry(entry)
|
|
}
|
|
|
|
const swapFrequencies = () => {
|
|
swapAnimation.value = true
|
|
|
|
// Swapping away from the active frequency — cut any in-progress speech
|
|
// so the pilot no longer "hears" the controller on the old channel.
|
|
if (frequencies.value.active !== frequencies.value.standby) {
|
|
stopCurrentSpeech()
|
|
}
|
|
|
|
const temp = frequencies.value.active
|
|
frequencies.value.active = frequencies.value.standby
|
|
frequencies.value.standby = temp
|
|
|
|
setTimeout(() => {
|
|
swapAnimation.value = false
|
|
}, 500)
|
|
}
|
|
|
|
// --- Frequency presets (for the hold-to-select gesture) ---------------------
|
|
const frequencyPresets = computed<DisplayAirportFrequencyEntry[]>(() => {
|
|
// Use the grouped overview list so every distinct frequency shows up in the dropdown
|
|
// (deduped by type+frequency, with merged sources for tooltip).
|
|
return displayAirportFrequencies.value
|
|
})
|
|
|
|
const presetKey = (entry: AirportFrequencyEntry | DisplayAirportFrequencyEntry) =>
|
|
'displayKey' in entry ? entry.displayKey : `${entry.type}-${entry.frequency}`
|
|
const presetLabel = (entry: AirportFrequencyEntry) => {
|
|
// Distinguish multiple ATIS stations (Arrival ATIS / Departure ATIS).
|
|
if (entry.type === 'ATIS') {
|
|
const variant = atisVariantLabel(entry)
|
|
if (variant) return `${variant}ATIS`
|
|
}
|
|
return FREQ_ROLE_LABEL[entry.type] || entry.type
|
|
}
|
|
|
|
const presetOptions = computed<FrequencyPresetOption[]>(() =>
|
|
frequencyPresets.value.map((entry) => ({
|
|
value: presetKey(entry),
|
|
label: presetLabel(entry),
|
|
sublabel: entry.type === 'ATIS' && entry.atisCode
|
|
? `${entry.frequency} · Info ${entry.atisCode}`
|
|
: entry.frequency,
|
|
color: entry.type === 'ATIS' ? '#f59e0b' : '#22d3ee',
|
|
// A synthesised ATIS is not a VATSIM broadcast — say so rather than
|
|
// crediting the source the frequency happens to come from.
|
|
sourceLabel: entry.type === 'ATIS' && atisIsSynthetic.value
|
|
? 'Simulated ATIS'
|
|
: entry.sourceLabel,
|
|
callsign: entry.callsign,
|
|
})),
|
|
)
|
|
|
|
const findPreset = (value: string | number) =>
|
|
frequencyPresets.value.find((entry) => presetKey(entry) === value)
|
|
|
|
const onPresetSelectActive = (opt: { value: string | number }) => {
|
|
const entry = findPreset(opt.value)
|
|
if (entry) setActiveFrequencyFromList(entry)
|
|
}
|
|
const onPresetSelectStandby = (opt: { value: string | number }) => {
|
|
const entry = findPreset(opt.value)
|
|
if (entry) setStandbyFrequencyFromList(entry)
|
|
}
|
|
|
|
// --- Manual frequency entry (free-tune any VHF airband channel) --------------
|
|
function applyManualFrequency(target: 'active' | 'standby', close?: () => void) {
|
|
const model = target === 'active' ? manualFreqActive : manualFreqStandby
|
|
const freq = normalizeManualFreq(model.value)
|
|
if (!freq) return
|
|
|
|
if (target === 'active') {
|
|
if (frequencies.value.active !== freq) {
|
|
// Tuning away from the current frequency — cut any in-progress ATC speech
|
|
// so the pilot no longer "hears" the controller on the old channel.
|
|
stopCurrentSpeech()
|
|
frequencies.value.standby = frequencies.value.active
|
|
frequencies.value.active = freq
|
|
}
|
|
} else {
|
|
frequencies.value.standby = freq
|
|
}
|
|
|
|
model.value = ''
|
|
close?.()
|
|
}
|
|
|
|
return {
|
|
frequencies,
|
|
airportFrequencies,
|
|
airportName,
|
|
airportFrequencyLoading,
|
|
frequencySources,
|
|
activeAirportIcao,
|
|
swapAnimation,
|
|
manualFreqActive,
|
|
manualFreqStandby,
|
|
airportFreqMap,
|
|
airportFreqListMap,
|
|
expectedFrequencyForState,
|
|
acceptedFrequenciesForState,
|
|
atisEntries,
|
|
atisFrequencyEntry,
|
|
atisReport,
|
|
atisIsSynthetic,
|
|
runwayInUse,
|
|
informationLetter,
|
|
frequencySourceLabels,
|
|
tunedAtisEntry,
|
|
frequencyDisplayKey,
|
|
displayAirportFrequencies,
|
|
toFrequencyVariableUpdate,
|
|
updateEngineFrequencyFromEntry,
|
|
syncLocalFrequenciesWithEngine,
|
|
applyFrequencyVariablesFromList,
|
|
fetchAirportFrequencies,
|
|
fetchDestinationFrequencies,
|
|
destinationFrequencies,
|
|
applyDerivedPosition,
|
|
setActiveFrequencyFromList,
|
|
setStandbyFrequencyFromList,
|
|
swapFrequencies,
|
|
frequencyPresets,
|
|
presetKey,
|
|
presetLabel,
|
|
presetOptions,
|
|
findPreset,
|
|
onPresetSelectActive,
|
|
onPresetSelectStandby,
|
|
applyManualFrequency,
|
|
}
|
|
}
|